// functions/api/submit.ts interface Env { GITHUB_APP_ID: string; GITHUB_INSTALLATION_ID: string; GITHUB_PRIVATE_KEY: string; GITHUB_OWNER: string; GITHUB_REPO: string; GITHUB_BASE_BRANCH?: string; // 默认 main CONTENT_DIR?: string; // 默认 src/content(对齐 Pages CMS 的 path) MEDIA_DIR?: string; // 默认 public/pages/static MEDIA_URL_PREFIX?: string; // 默认 /pages/static SONGS_DIR?: string; // 默认 src/songs(音乐投稿的目标目录) TURNSTILE_SECRET_KEY?: string; // Turnstile 私钥;未设置则跳过校验(本地开发) } const enc = new TextEncoder(); // ---------- 编码/加密工具 ---------- function b64url(bytes: Uint8Array | ArrayBuffer): string { const arr = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes); let bin = ''; const chunk = 0x8000; for (let i = 0; i < arr.length; i += chunk) { bin += String.fromCharCode(...arr.subarray(i, i + chunk)); } return btoa(bin).replace(/=/g, '').replace(/\+/g, '-').replace(/\//g, '_'); } const b64utf8 = (s: string) => b64url(enc.encode(s)); function abToBase64(buf: ArrayBuffer): string { const arr = new Uint8Array(buf); let bin = ''; const chunk = 0x8000; for (let i = 0; i < arr.length; i += chunk) bin += String.fromCharCode(...arr.subarray(i, i + chunk)); return btoa(bin); } function pemToArrayBuffer(pem: string): ArrayBuffer { const fixed = pem.includes('\n') ? pem : pem.replace(/-----BEGIN [^-]+-----\s*/, '-----BEGIN PRIVATE KEY-----\n') .replace(/\s*-----END PRIVATE KEY-----/, '\n-----END PRIVATE KEY-----'); const b64 = fixed .replace(/-----BEGIN [^-]+-----/, '') .replace(/-----END [^-]+-----/, '') .replace(/\s+/g, ''); const bin = atob(b64); const buf = new Uint8Array(bin.length); for (let i = 0; i < bin.length; i++) buf[i] = bin.charCodeAt(i); return buf.buffer; } async function makeAppJwt(appId: string, pem: string) { const now = Math.floor(Date.now() / 1000); const head = b64utf8(JSON.stringify({ alg: 'RS256', typ: 'JWT' })); const body = b64utf8(JSON.stringify({ iat: now - 60, exp: now + 540, iss: appId })); const data = `${head}.${body}`; const key = await crypto.subtle.importKey( 'pkcs8', pemToArrayBuffer(pem), { name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' }, false, ['sign'], ); const sig = await crypto.subtle.sign('RSASSA-PKCS1-v1_5', key, enc.encode(data)); return `${data}.${b64url(sig)}`; } async function getInstallationToken(env: Env): Promise { const jwt = await makeAppJwt(env.GITHUB_APP_ID, env.GITHUB_PRIVATE_KEY); const res = await fetch( `https://api.github.com/app/installations/${env.GITHUB_INSTALLATION_ID}/access_tokens`, { method: 'POST', headers: { authorization: `Bearer ${jwt}`, accept: 'application/vnd.github+json', 'user-agent': 'astro-chronicle-fn', }, }, ); if (!res.ok) throw new Error(`installation token failed: ${res.status} ${await res.text()}`); return (await res.json()).token as string; } // ---------- Turnstile ---------- async function verifyTurnstile( secret: string, token: string, remoteIp?: string, ): Promise { const body = new URLSearchParams({ secret, response: token }); if (remoteIp) body.set('remoteip', remoteIp); const res = await fetch( 'https://challenges.cloudflare.com/turnstile/v0/siteverify', { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' }, body, }, ); if (!res.ok) return false; const data = (await res.json()) as { success?: boolean }; return data.success === true; } // ---------- GitHub REST ---------- function gh(token: string) { return async function api(path: string, init: RequestInit = {}): Promise { const res = await fetch(`https://api.github.com${path}`, { ...init, headers: { authorization: `Bearer ${token}`, accept: 'application/vnd.github+json', 'user-agent': 'astro-chronicle-fn', 'content-type': 'application/json', ...(init.headers || {}), }, }); if (res.status === 404) return null; // 用于探测文件是否存在 if (!res.ok) throw new Error(`GitHub ${path} → ${res.status}: ${await res.text()}`); return res.json() as Promise; }; } // ---------- 业务辅助 ---------- const yyyymmdd = (iso: string) => iso.replace(/-/g, ''); function extFromMime(mime: string): string { const m = mime.toLowerCase(); if (m.includes('png')) return 'png'; if (m.includes('jpeg') || m.includes('jpg')) return 'jpg'; if (m.includes('webp')) return 'webp'; if (m.includes('gif')) return 'gif'; if (m.includes('avif')) return 'avif'; // SVG 刻意不支持:内含的