Spaces:
Paused
Paused
| 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); | |
| } | |