K-VAULT / functions /utils /github.js
spongyicybulk's picture
feat: 适配 HuggingFace Space 单容器部署
c8cfaf5 verified
Raw
History Blame Contribute Delete
16.5 kB
function normalizeToken(value) {
if (!value) return '';
return String(value).replace(/^Bearer\s+/i, '').trim();
}
function normalizeRepo(value) {
if (!value) return '';
const cleaned = String(value)
.trim()
.replace(/^https?:\/\/github\.com\//i, '')
.replace(/^\/+|\/+$/g, '');
const [owner, repo] = cleaned.split('/');
if (!owner || !repo) return '';
return `${owner}/${repo}`;
}
function normalizeApiBase(value) {
if (!value) return 'https://api.github.com';
try {
return new URL(String(value)).toString().replace(/\/+$/, '');
} catch {
return 'https://api.github.com';
}
}
function normalizeMode(value) {
const mode = String(value || 'releases').trim().toLowerCase();
return mode === 'contents' ? 'contents' : 'releases';
}
function normalizePath(value) {
const normalized = String(value || '').replace(/\\/g, '/').trim();
const output = [];
for (const part of normalized.split('/')) {
const piece = part.trim();
if (!piece || piece === '.') continue;
if (piece === '..') {
output.pop();
continue;
}
output.push(piece);
}
return output.join('/');
}
function encodePath(path) {
const normalized = normalizePath(path);
if (!normalized) return '';
return normalized.split('/').map((segment) => encodeURIComponent(segment)).join('/');
}
function arrayBufferToBase64(arrayBuffer) {
const bytes = new Uint8Array(arrayBuffer);
const chunkSize = 0x8000;
let binary = '';
for (let i = 0; i < bytes.length; i += chunkSize) {
const chunk = bytes.subarray(i, i + chunkSize);
binary += String.fromCharCode(...chunk);
}
return btoa(binary);
}
async function parseErrorBody(response) {
const contentType = response.headers.get('content-type') || '';
if (contentType.includes('application/json')) {
const json = await response.json().catch(() => ({}));
return json.message || json.error || JSON.stringify(json);
}
return response.text().catch(() => '');
}
function authHeaders(config, extra = {}, accept = null) {
return {
Authorization: `Bearer ${config.token}`,
'User-Agent': 'k-vault-cloudflare-functions',
Accept: accept || 'application/vnd.github+json',
...extra,
};
}
function repoApi(config, pathname) {
return `${config.apiBase}/repos/${config.repo}${pathname}`;
}
function contentsStoragePath(config, storageKey = '', fallbackName = '') {
const keyPath = normalizePath(storageKey || fallbackName || `file_${Date.now()}`);
if (!config.prefix) return keyPath;
return keyPath ? `${config.prefix}/${keyPath}` : config.prefix;
}
function releaseAssetName(config, storageKey = '', fallbackName = '') {
const path = contentsStoragePath(config, storageKey, fallbackName);
return String(path || `file_${Date.now()}`).replace(/\//g, '__');
}
async function getContentsMetadata(config, pathInRepo) {
const response = await fetch(repoApi(config, `/contents/${encodePath(pathInRepo)}`), {
headers: authHeaders(config),
});
if (response.status === 404) return null;
if (!response.ok) {
const detail = await parseErrorBody(response);
throw new Error(`GitHub contents lookup failed (${response.status}): ${detail}`);
}
return response.json();
}
async function uploadViaContents(config, { arrayBuffer, storageKey, fileName }) {
const maxSize = 20 * 1024 * 1024;
if (arrayBuffer.byteLength > maxSize) {
throw new Error('GitHub Contents mode is limited to 20MB.');
}
const pathInRepo = contentsStoragePath(config, storageKey, fileName);
if (!pathInRepo) {
throw new Error('GitHub Contents mode requires a valid storage path.');
}
const existing = await getContentsMetadata(config, pathInRepo);
const payload = {
message: `k-vault upload: ${pathInRepo}`,
content: arrayBufferToBase64(arrayBuffer),
};
if (config.branch) payload.branch = config.branch;
if (existing?.sha) payload.sha = existing.sha;
const response = await fetch(repoApi(config, `/contents/${encodePath(pathInRepo)}`), {
method: 'PUT',
headers: authHeaders(config, { 'Content-Type': 'application/json' }),
body: JSON.stringify(payload),
});
const json = await response.json().catch(() => ({}));
if (!response.ok) {
throw new Error(`GitHub contents upload failed (${response.status}): ${json.message || 'Unknown error'}`);
}
return {
storagePath: pathInRepo,
metadata: {
githubMode: 'contents',
githubPath: pathInRepo,
githubSha: json.content?.sha || null,
githubRepo: config.repo,
},
};
}
async function getReleaseByTag(config, tag, createIfMissing = false) {
const response = await fetch(repoApi(config, `/releases/tags/${encodeURIComponent(tag)}`), {
headers: authHeaders(config),
});
if (response.status === 404) {
if (!createIfMissing) return null;
return createRelease(config, tag);
}
if (!response.ok) {
const detail = await parseErrorBody(response);
throw new Error(`GitHub release lookup failed (${response.status}): ${detail}`);
}
return response.json();
}
async function getLatestRelease(config, createIfMissing = false) {
const response = await fetch(repoApi(config, '/releases/latest'), {
headers: authHeaders(config),
});
if (response.status === 404) {
if (!createIfMissing) return null;
return createRelease(config, 'k-vault-storage');
}
if (!response.ok) {
const detail = await parseErrorBody(response);
throw new Error(`GitHub latest release lookup failed (${response.status}): ${detail}`);
}
return response.json();
}
async function createRelease(config, tag) {
const response = await fetch(repoApi(config, '/releases'), {
method: 'POST',
headers: authHeaders(config, { 'Content-Type': 'application/json' }),
body: JSON.stringify({
tag_name: tag,
name: tag,
draft: false,
prerelease: false,
}),
});
const json = await response.json().catch(() => ({}));
if (!response.ok) {
throw new Error(`GitHub create release failed (${response.status}): ${json.message || 'Unknown error'}`);
}
return json;
}
async function ensureRelease(config) {
if (config.releaseTag) {
return getReleaseByTag(config, config.releaseTag, true);
}
return getLatestRelease(config, true);
}
async function listReleaseAssets(config, releaseId) {
const response = await fetch(repoApi(config, `/releases/${releaseId}/assets?per_page=100`), {
headers: authHeaders(config),
});
if (!response.ok) {
const detail = await parseErrorBody(response);
throw new Error(`GitHub release assets list failed (${response.status}): ${detail}`);
}
return response.json();
}
async function findReleaseAsset(config, releaseId, assetName) {
const assets = await listReleaseAssets(config, releaseId);
return assets.find((asset) => asset.name === assetName) || null;
}
async function deleteReleaseAssetById(config, assetId) {
const response = await fetch(repoApi(config, `/releases/assets/${assetId}`), {
method: 'DELETE',
headers: authHeaders(config),
});
if (response.ok || response.status === 404) return true;
const detail = await parseErrorBody(response);
throw new Error(`GitHub release asset delete failed (${response.status}): ${detail}`);
}
async function uploadViaReleases(config, { arrayBuffer, storageKey, fileName, contentType }) {
const release = await ensureRelease(config);
const assetName = releaseAssetName(config, storageKey, fileName);
const existing = await findReleaseAsset(config, release.id, assetName);
if (existing?.id) {
await deleteReleaseAssetById(config, existing.id);
}
const uploadUrl = new URL(String(release.upload_url || '').replace(/\{.+\}$/, ''));
uploadUrl.searchParams.set('name', assetName);
const response = await fetch(uploadUrl.toString(), {
method: 'POST',
headers: {
...authHeaders(config, {
'Content-Type': contentType || 'application/octet-stream',
'Content-Length': String(arrayBuffer.byteLength),
}),
},
body: arrayBuffer,
});
const json = await response.json().catch(() => ({}));
if (!response.ok) {
throw new Error(`GitHub release upload failed (${response.status}): ${json.message || 'Unknown error'}`);
}
return {
storagePath: contentsStoragePath(config, storageKey, fileName),
metadata: {
githubMode: 'releases',
githubRepo: config.repo,
githubReleaseId: release.id,
githubAssetId: json.id || null,
githubAssetName: json.name || assetName,
githubDownloadUrl: json.browser_download_url || '',
},
};
}
async function resolveReleaseAsset(config, storageKey, metadata = {}) {
if (metadata.githubAssetId) {
const response = await fetch(repoApi(config, `/releases/assets/${metadata.githubAssetId}`), {
headers: authHeaders(config),
});
if (response.ok) {
return response.json();
}
if (response.status !== 404) {
const detail = await parseErrorBody(response);
throw new Error(`GitHub release asset lookup failed (${response.status}): ${detail}`);
}
}
const releaseId = metadata.githubReleaseId || (await ensureRelease(config)).id;
const assetName = metadata.githubAssetName || releaseAssetName(config, storageKey);
return findReleaseAsset(config, releaseId, assetName);
}
async function downloadViaContents(config, storageKey, metadata = {}, range = '') {
const pathInRepo = metadata.githubPath || contentsStoragePath(config, storageKey);
if (!pathInRepo) return null;
const headers = {};
if (range) headers.Range = range;
const response = await fetch(repoApi(config, `/contents/${encodePath(pathInRepo)}`), {
headers: authHeaders(config, headers, 'application/vnd.github.raw'),
redirect: 'follow',
});
if (!response.ok && response.status !== 206) {
if (response.status === 404) return null;
const detail = await parseErrorBody(response);
throw new Error(`GitHub contents download failed (${response.status}): ${detail}`);
}
return response;
}
async function downloadViaReleases(config, storageKey, metadata = {}, range = '') {
const asset = await resolveReleaseAsset(config, storageKey, metadata);
if (!asset?.id) return null;
const headers = {};
if (range) headers.Range = range;
const assetApiResponse = await fetch(repoApi(config, `/releases/assets/${asset.id}`), {
headers: authHeaders(config, headers, 'application/octet-stream'),
redirect: 'manual',
});
if (assetApiResponse.status === 404) return null;
if (assetApiResponse.status === 301 || assetApiResponse.status === 302) {
const redirectUrl = assetApiResponse.headers.get('location');
if (!redirectUrl) {
throw new Error('GitHub release download redirect URL is missing.');
}
const response = await fetch(redirectUrl, {
headers: range ? { Range: range } : {},
redirect: 'follow',
});
if (!response.ok && response.status !== 206) {
throw new Error(`GitHub release download failed (${response.status}).`);
}
return response;
}
if (!assetApiResponse.ok && assetApiResponse.status !== 206) {
const detail = await parseErrorBody(assetApiResponse);
throw new Error(`GitHub release download failed (${assetApiResponse.status}): ${detail}`);
}
return assetApiResponse;
}
async function deleteViaContents(config, storageKey, metadata = {}) {
const pathInRepo = metadata.githubPath || contentsStoragePath(config, storageKey);
if (!pathInRepo) return false;
const existing = await getContentsMetadata(config, pathInRepo);
if (!existing?.sha) return true;
const payload = {
message: `k-vault delete: ${pathInRepo}`,
sha: existing.sha,
};
if (config.branch) payload.branch = config.branch;
const response = await fetch(repoApi(config, `/contents/${encodePath(pathInRepo)}`), {
method: 'DELETE',
headers: authHeaders(config, { 'Content-Type': 'application/json' }),
body: JSON.stringify(payload),
});
if (response.ok || response.status === 404) return true;
const detail = await parseErrorBody(response);
throw new Error(`GitHub contents delete failed (${response.status}): ${detail}`);
}
async function deleteViaReleases(config, storageKey, metadata = {}) {
const asset = await resolveReleaseAsset(config, storageKey, metadata);
if (!asset?.id) return true;
return deleteReleaseAssetById(config, asset.id);
}
export function getGitHubConfig(env = {}) {
return {
repo: normalizeRepo(env.GITHUB_REPO || ''),
token: normalizeToken(env.GITHUB_TOKEN || ''),
mode: normalizeMode(env.GITHUB_MODE || 'releases'),
prefix: normalizePath(env.GITHUB_PREFIX || env.GITHUB_PATH || ''),
releaseTag: String(env.GITHUB_RELEASE_TAG || '').trim(),
branch: String(env.GITHUB_BRANCH || '').trim(),
apiBase: normalizeApiBase(env.GITHUB_API_BASE || 'https://api.github.com'),
};
}
export function hasGitHubConfig(env = {}) {
const config = getGitHubConfig(env);
return Boolean(config.repo && config.token);
}
export async function uploadToGitHub(arrayBuffer, storageKey, fileName, contentType, env = {}) {
const config = getGitHubConfig(env);
if (!config.repo || !config.token) {
throw new Error('GitHub storage is not configured.');
}
if (config.mode === 'contents') {
return uploadViaContents(config, { arrayBuffer, storageKey, fileName, contentType });
}
return uploadViaReleases(config, { arrayBuffer, storageKey, fileName, contentType });
}
export async function getGitHubFile(storageKey, metadata = {}, env = {}, options = {}) {
const config = getGitHubConfig(env);
if (!config.repo || !config.token) {
throw new Error('GitHub storage is not configured.');
}
const mode = String(metadata.githubMode || config.mode || 'releases').toLowerCase();
if (mode === 'contents') {
return downloadViaContents(config, storageKey, metadata, options.range || '');
}
return downloadViaReleases(config, storageKey, metadata, options.range || '');
}
export async function deleteGitHubFile(storageKey, metadata = {}, env = {}) {
const config = getGitHubConfig(env);
if (!config.repo || !config.token) {
return false;
}
const mode = String(metadata.githubMode || config.mode || 'releases').toLowerCase();
if (mode === 'contents') {
return deleteViaContents(config, storageKey, metadata);
}
return deleteViaReleases(config, storageKey, metadata);
}
export async function checkGitHubConnection(env = {}) {
if (!hasGitHubConfig(env)) {
return {
connected: false,
configured: false,
message: 'Not configured',
};
}
const config = getGitHubConfig(env);
try {
const repoResponse = await fetch(repoApi(config, ''), {
headers: authHeaders(config),
});
if (!repoResponse.ok) {
const detail = await parseErrorBody(repoResponse);
return {
connected: false,
configured: true,
status: repoResponse.status,
message: detail || 'Repository access failed',
detail: detail || undefined,
};
}
if (config.mode === 'contents') {
return {
connected: true,
configured: true,
mode: 'contents',
message: 'Connected',
};
}
if (config.releaseTag) {
const release = await getReleaseByTag(config, config.releaseTag, false);
if (!release) {
return {
connected: false,
configured: true,
mode: 'releases',
message: `Release tag "${config.releaseTag}" does not exist`,
};
}
}
return {
connected: true,
configured: true,
mode: 'releases',
message: 'Connected',
};
} catch (error) {
return {
connected: false,
configured: true,
message: error.message || 'Connection failed',
detail: error.message || 'Connection failed',
};
}
}
export function normalizeGitHubStoragePath(value = '') {
return normalizePath(value);
}