// ========== 全局配置与工具函数 ========== const corsHeaders = { "Access-Control-Allow-Methods": "GET, POST, PUT, OPTIONS", "Access-Control-Allow-Headers": "Content-Type, Authorization", "Access-Control-Max-Age": "86400" }; const pageSecurityHeaders = { "Cache-Control": "no-store", "Content-Security-Policy": "default-src 'self'; script-src 'self' 'unsafe-inline' https://static.cloudflareinsights.com; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; connect-src 'self' https://cloudflareinsights.com; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'", "Permissions-Policy": "camera=(), microphone=(), geolocation=()", "Referrer-Policy": "same-origin", "X-Content-Type-Options": "nosniff", "X-Frame-Options": "DENY" }; const APP_ICON_SVG = ``; const CONFIG_CACHE_TTL = 300000; const LOGIN_WINDOW_SECONDS = 900; const LOGIN_MAX_FAILURES = 5; const LOGIN_BLOCK_SECONDS = 900; const MAX_FILE_NAME_BYTES = 255; const DEFAULT_PAGE_SIZE = 20; const MAX_PAGE_SIZE = 100; const MULTIPART_PART_SIZE = 32 * 1024 * 1024; const MAX_MULTIPART_SIZE = 1024 * 1024 * 1024; const MAX_MULTIPART_PARTS = 10000; const MULTIPART_TTL_SECONDS = 7 * 24 * 60 * 60; const LATEST_SCHEMA_VERSION = 2; const SHARE_PATH_PATTERN = /^[A-Za-z0-9_-]{1,64}$/; const RESERVED_SHARE_PATHS = new Set(["api", "init", "app-icon", "favicon", "robots", "sitemap"]); const KNOWN_PREVIEW_TYPES = { jpg: "image/jpeg", jpeg: "image/jpeg", png: "image/png", gif: "image/gif", bmp: "image/bmp", webp: "image/webp", avif: "image/avif", ico: "image/x-icon", svg: "image/svg+xml", pdf: "application/pdf", txt: "text/plain", log: "text/plain", csv: "text/csv", md: "text/markdown", json: "application/json", xml: "application/xml", html: "text/html", htm: "text/html", css: "text/css", js: "text/javascript", mp4: "video/mp4", m4v: "video/mp4", webm: "video/webm", mkv: "video/x-matroska", ogg: "video/ogg", ogv: "video/ogg", mov: "video/quicktime", m3u8: "application/vnd.apple.mpegurl", mp3: "audio/mpeg", wav: "audio/wav", flac: "audio/flac", aac: "audio/aac", m4a: "audio/mp4", oga: "audio/ogg", opus: "audio/ogg", weba: "audio/webm" }; const DEFAULT_PREVIEW_EXTENSIONS = "jpg,jpeg,png,gif,bmp,webp,ico,mp4,webm,ogg,ogv,mov,mp3,wav,flac,aac,weba"; const DEFAULT_APP_CONFIG = { site_name: "langaj.cn 私有云", favicon: "/app-icon.svg", max_multipart_size: String(MAX_MULTIPART_SIZE), max_total_size: String(6 * 1024 * 1024 * 1024), daily_max_a_class: "300", user_download_ttl: "600", preview_extensions: DEFAULT_PREVIEW_EXTENSIONS, preview_mime_types: "{}" }; let configCache = null; let configCacheTime = 0; let configCacheSource = null; let appConfigCache = null; let appConfigCacheTime = 0; let jwtKeyCache = null; let downloadKeyCache = null; class HttpError extends Error { constructor(status, message) { super(message); this.status = status; } } function jsonResponse(data, status = 200, headers = {}) { return new Response(JSON.stringify(data), { status, headers: { "Content-Type": "application/json; charset=utf-8", "Cache-Control": "no-store", "X-Content-Type-Options": "nosniff", ...corsHeaders, ...headers } }); } function textResponse(text, status = 200, headers = {}) { return new Response(text, { status, headers: { "Cache-Control": "no-store", "X-Content-Type-Options": "nosniff", ...corsHeaders, ...headers } }); } function bytesToBase64Url(bytes) { return btoa(String.fromCharCode(...bytes)) .replace(/\+/g, "-") .replace(/\//g, "_") .replace(/=+$/g, ""); } function base64UrlToBytes(value) { const base64 = value.replace(/-/g, "+").replace(/_/g, "/"); const padded = base64 + "=".repeat((4 - base64.length % 4) % 4); return Uint8Array.from(atob(padded), char => char.charCodeAt(0)); } function encodeText(value) { return bytesToBase64Url(new TextEncoder().encode(value)); } function decodeText(value) { return new TextDecoder().decode(base64UrlToBytes(value)); } function encodeCursor(uploadedAt, key) { return encodeText(JSON.stringify({ uploadedAt, key })); } function decodeCursor(value) { if (!value) return null; try { const cursor = JSON.parse(decodeText(value)); if (typeof cursor.uploadedAt !== "string" || typeof cursor.key !== "string") throw new Error(); return cursor; } catch { throw new HttpError(400, "分页游标无效"); } } function parsePositiveInt(value, fallback, max = Number.MAX_SAFE_INTEGER) { const parsed = Number.parseInt(value, 10); return Number.isSafeInteger(parsed) && parsed > 0 ? Math.min(parsed, max) : fallback; } function getFileExtension(fileName) { const index = fileName.lastIndexOf("."); return index > -1 ? fileName.slice(index + 1).toLowerCase() : ""; } function isPreviewable(fileName, previewExts) { const extension = getFileExtension(fileName); return previewExts.includes(extension); } function normalizeContentType(value) { if (typeof value !== "string") return ""; const contentType = value.trim().toLowerCase(); return contentType.length <= 127 && /^[a-z0-9][a-z0-9!#$&^_.+-]*\/[a-z0-9][a-z0-9!#$&^_.+-]*$/.test(contentType) ? contentType : ""; } function getMappedContentType(map, extension) { return map && Object.prototype.hasOwnProperty.call(map, extension) ? map[extension] : ""; } function getPreviewType(fileName, previewExts, previewMimeTypes, storedContentType = "") { if (!isPreviewable(fileName, previewExts)) return null; const extension = getFileExtension(fileName); const contentType = normalizeContentType(storedContentType); return getMappedContentType(previewMimeTypes, extension) || getMappedContentType(KNOWN_PREVIEW_TYPES, extension) || contentType || "application/octet-stream"; } function normalizeFileName(value) { if (typeof value !== "string") throw new HttpError(400, "文件名无效"); const fileName = value.normalize("NFC").trim(); if (!fileName || /[\u0000-\u001f\u007f]/.test(fileName)) throw new HttpError(400, "文件名无效"); if (new TextEncoder().encode(fileName).length > MAX_FILE_NAME_BYTES) throw new HttpError(400, "文件名过长"); return fileName; } function createObjectKey(fileName) { return `pan/${crypto.randomUUID()}~${encodeText(fileName)}`; } function parseFileNameFromKey(key) { const modernIndex = key.indexOf("~"); if (modernIndex > -1) { try { return decodeText(key.slice(modernIndex + 1)); } catch { return key; } } const legacyIndex = key.indexOf("-"); if (legacyIndex === -1) return key; try { return decodeURIComponent(escape(atob(key.slice(legacyIndex + 1)))); } catch { return key; } } function randomToken(byteLength = 16) { const bytes = new Uint8Array(byteLength); crypto.getRandomValues(bytes); return bytesToBase64Url(bytes); } function validateSharePath(value) { if (typeof value !== "string" || !SHARE_PATH_PATTERN.test(value) || RESERVED_SHARE_PATHS.has(value.toLowerCase())) { throw new HttpError(400, "分享路径需为 1-64 位字母、数字、下划线或连字符"); } return value; } function getPathToken(path) { if (path.length <= 1 || path.slice(1).includes("/")) return null; try { return decodeURIComponent(path.slice(1)); } catch { return null; } } function escapeHtml(value) { return String(value) .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """) .replace(/'/g, "'"); } function normalizeFavicon(value) { if (!value || value === "/app-icon.svg") return "/app-icon.svg"; try { const url = new URL(value); if (url.protocol !== "https:") throw new Error(); return url.href; } catch { throw new HttpError(400, "图标链接必须是 HTTPS 地址"); } } async function importHmacKey(secret, currentCache) { if (typeof secret !== "string" || secret.length < 32) throw new HttpError(503, "签名密钥未配置或长度不足"); if (currentCache?.secret === secret) return currentCache; const key = await crypto.subtle.importKey( "raw", new TextEncoder().encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["sign", "verify"] ); return { secret, key }; } async function signValue(value, secret, cacheType) { if (cacheType === "jwt") { jwtKeyCache = await importHmacKey(secret, jwtKeyCache); return bytesToBase64Url(new Uint8Array(await crypto.subtle.sign("HMAC", jwtKeyCache.key, new TextEncoder().encode(value)))); } downloadKeyCache = await importHmacKey(secret, downloadKeyCache); return bytesToBase64Url(new Uint8Array(await crypto.subtle.sign("HMAC", downloadKeyCache.key, new TextEncoder().encode(value)))); } async function verifyValue(value, signature, secret, cacheType) { try { if (cacheType === "jwt") { jwtKeyCache = await importHmacKey(secret, jwtKeyCache); return crypto.subtle.verify("HMAC", jwtKeyCache.key, base64UrlToBytes(signature), new TextEncoder().encode(value)); } downloadKeyCache = await importHmacKey(secret, downloadKeyCache); return crypto.subtle.verify("HMAC", downloadKeyCache.key, base64UrlToBytes(signature), new TextEncoder().encode(value)); } catch (error) { if (error instanceof HttpError) throw error; return false; } } async function createDownloadToken(secret, fileKey, ttlSeconds) { const payload = encodeText(JSON.stringify({ key: fileKey, exp: Math.floor(Date.now() / 1000) + ttlSeconds })); return `${payload}.${await signValue(payload, secret, "download")}`; } async function verifyDownloadToken(token, secret) { const [payloadPart, signature, extra] = token.split("."); if (!payloadPart || !signature || extra) return null; if (!await verifyValue(payloadPart, signature, secret, "download")) return null; try { const payload = JSON.parse(decodeText(payloadPart)); if (typeof payload.key !== "string" || !Number.isFinite(payload.exp) || payload.exp <= Math.floor(Date.now() / 1000)) return null; return payload; } catch { return null; } } function parseStoredSiteConfig(stored) { if (typeof stored === "number" && Number.isSafeInteger(stored) && stored >= 0) return { password: String(stored) }; if (typeof stored !== "string") return stored; const value = stored.trim(); if (!value) return { password: "" }; try { const parsed = JSON.parse(value); return typeof parsed === "string" || (typeof parsed === "number" && Number.isSafeInteger(parsed) && parsed >= 0) ? { password: String(parsed) } : parsed; } catch { return { password: value }; } } async function readStoredSiteConfig(kv) { const stored = await kv.get("sys_config"); return stored === null ? null : parseStoredSiteConfig(stored); } function readConfigPassword(raw, fields) { for (const field of fields) { if (!Object.prototype.hasOwnProperty.call(raw, field)) continue; const stored = raw[field]; const value = typeof stored === "number" && Number.isSafeInteger(stored) && stored >= 0 ? String(stored) : stored; if (typeof value === "string" && value.length >= 6 && value.length <= 128) return value; } return null; } function normalizeSiteConfig(raw) { if (!raw || typeof raw !== "object" || Array.isArray(raw)) throw new HttpError(503, "站点密码配置无效"); // 短链接 Worker 使用 sys_config.password;优先读取它以保持双向改密同步。 const admin = readConfigPassword(raw, ["password", "admin", "adminPassword", "admin_password", "sitePassword"]); const user = readConfigPassword(raw, ["user", "userPassword", "user_password"]); if (!admin) throw new HttpError(503, "站点密码配置无效"); return { admin, user }; } async function readD1SiteConfig(db) { const query = await db.prepare( "SELECT key, value FROM pan_config WHERE key IN ('admin_password', 'user_password')" ).all(); const raw = {}; for (const row of query.results || []) raw[row.key] = row.value; return Object.keys(raw).length ? raw : null; } async function getSiteConfig(env, forceRefresh = false) { const now = Date.now(); const source = env.KV ? "kv" : "d1"; if (!forceRefresh && configCache && configCacheSource === source && now - configCacheTime < CONFIG_CACHE_TTL) { return { ...configCache }; } let raw; try { raw = env.KV ? await readStoredSiteConfig(env.KV) : await readD1SiteConfig(env.DB); } catch (error) { if (error instanceof HttpError) throw error; console.error(`${source.toUpperCase()} password config read failed:`, error.message); throw new HttpError(503, source === "kv" ? "KV 站点密码配置暂不可用" : "D1 站点密码配置暂不可用,请访问 /init"); } if (!raw) { throw new HttpError(503, `${source === "kv" ? "KV" : "D1"} 站点密码尚未初始化,请访问 /init`); } let nextConfig; try { nextConfig = normalizeSiteConfig(raw); } catch { throw new HttpError(503, `${source === "kv" ? "KV 的 sys_config" : "D1 的 pan_config"} 没有有效管理员密码,请访问 /init 修复`); } configCache = nextConfig; configCacheTime = now; configCacheSource = source; return { ...nextConfig }; } async function persistSiteConfig(env, changes) { if (!env.KV) { const stored = await readD1SiteConfig(env.DB); const nextStored = stored && typeof stored === "object" ? { ...stored } : {}; if (Object.prototype.hasOwnProperty.call(changes, "admin")) nextStored.admin_password = changes.admin; if (Object.prototype.hasOwnProperty.call(changes, "user")) nextStored.user_password = changes.user; const normalized = normalizeSiteConfig(nextStored); const statements = [ env.DB.prepare( "INSERT INTO pan_config (key, value, updated_at) VALUES ('admin_password', ?, CURRENT_TIMESTAMP) " + "ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = CURRENT_TIMESTAMP" ).bind(normalized.admin) ]; if (normalized.user) { statements.push(env.DB.prepare( "INSERT INTO pan_config (key, value, updated_at) VALUES ('user_password', ?, CURRENT_TIMESTAMP) " + "ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = CURRENT_TIMESTAMP" ).bind(normalized.user)); } await env.DB.batch(statements); configCache = normalized; configCacheTime = Date.now(); configCacheSource = "d1"; return; } const stored = await readStoredSiteConfig(env.KV); const nextStored = stored && typeof stored === "object" && !Array.isArray(stored) ? { ...stored } : {}; if (Object.prototype.hasOwnProperty.call(changes, "admin")) { nextStored.password = changes.admin; nextStored.admin = changes.admin; if (Object.prototype.hasOwnProperty.call(nextStored, "adminPassword")) nextStored.adminPassword = changes.admin; if (Object.prototype.hasOwnProperty.call(nextStored, "admin_password")) nextStored.admin_password = changes.admin; if (Object.prototype.hasOwnProperty.call(nextStored, "sitePassword")) nextStored.sitePassword = changes.admin; } if (Object.prototype.hasOwnProperty.call(changes, "user")) { nextStored.user = changes.user; if (Object.prototype.hasOwnProperty.call(nextStored, "userPassword")) nextStored.userPassword = changes.user; if (Object.prototype.hasOwnProperty.call(nextStored, "user_password")) nextStored.user_password = changes.user; } if (Object.prototype.hasOwnProperty.call(changes, "siteName")) nextStored.siteName = changes.siteName; if (Object.prototype.hasOwnProperty.call(changes, "favicon")) nextStored.favicon = changes.favicon; await env.KV.put("sys_config", JSON.stringify(nextStored)); configCache = normalizeSiteConfig(nextStored); configCacheTime = Date.now(); configCacheSource = "kv"; } async function getUsedStorage(db) { const row = await db.prepare("SELECT used_bytes FROM pan_storage_usage WHERE id = 1").first(); if (!row) throw new HttpError(503, "存储计数器未初始化,请先执行数据库迁移"); return Number(row.used_bytes) || 0; } async function checkLoginRateLimit(db, ip, now) { const row = await db.prepare("SELECT blocked_until FROM pan_login_attempts WHERE ip = ?").bind(ip).first(); if (row && Number(row.blocked_until) > now) { throw new HttpError(429, `登录尝试过多,请在 ${Number(row.blocked_until) - now} 秒后重试`); } } async function recordLoginFailure(db, ip, now) { const cutoff = now - LOGIN_WINDOW_SECONDS; await db.prepare( `INSERT INTO pan_login_attempts (ip, failures, window_start, blocked_until) VALUES (?, 1, ?, 0) ON CONFLICT(ip) DO UPDATE SET failures = CASE WHEN window_start <= ? THEN 1 ELSE failures + 1 END, window_start = CASE WHEN window_start <= ? THEN ? ELSE window_start END, blocked_until = CASE WHEN window_start > ? AND failures + 1 >= ? THEN ? ELSE 0 END` ).bind(ip, now, cutoff, cutoff, now, cutoff, LOGIN_MAX_FAILURES, now + LOGIN_BLOCK_SECONDS).run(); } async function clearLoginFailures(db, ip) { await db.prepare("DELETE FROM pan_login_attempts WHERE ip = ?").bind(ip).run(); } // ========== JWT 工具 ========== async function generateJWT(secret, role) { const header = encodeText(JSON.stringify({ alg: "HS256", typ: "JWT" })); const now = Math.floor(Date.now() / 1000); const payload = encodeText(JSON.stringify({ role, iat: now, exp: now + 7200 })); const unsigned = `${header}.${payload}`; return `${unsigned}.${await signValue(unsigned, secret, "jwt")}`; } async function verifyJWT(token, secret) { try { const [headerPart, payloadPart, signature, extra] = token.split("."); if (!headerPart || !payloadPart || !signature || extra) return null; const header = JSON.parse(decodeText(headerPart)); if (header.alg !== "HS256" || header.typ !== "JWT") return null; if (!await verifyValue(`${headerPart}.${payloadPart}`, signature, secret, "jwt")) return null; const payload = JSON.parse(decodeText(payloadPart)); if (!Number.isFinite(payload.exp) || payload.exp <= Math.floor(Date.now() / 1000)) return null; if (payload.role !== "admin" && payload.role !== "user") return null; return payload; } catch (error) { if (error instanceof HttpError) throw error; return null; } } async function extractTokenPayload(request, env) { const authHeader = request.headers.get("Authorization"); if (!authHeader || !authHeader.startsWith("Bearer ")) return null; return verifyJWT(authHeader.slice(7), env.JWT_SECRET); } function normalizePreviewExtension(value) { const extension = String(value || "").trim().toLowerCase().replace(/^\.+/, ""); return /^[\p{L}\p{N}][\p{L}\p{N}_+-]{0,63}$/u.test(extension) ? extension : ""; } function parsePreviewExtensions(value) { const extensions = String(value || DEFAULT_PREVIEW_EXTENSIONS) .split(",") .map(normalizePreviewExtension) .filter(Boolean); return [...new Set(extensions.length ? extensions : DEFAULT_PREVIEW_EXTENSIONS.split(","))]; } function parsePreviewMimeTypes(value, previewExtensions) { let input = value; if (typeof input === "string") { try { input = JSON.parse(input); } catch { return {}; } } if (!input || typeof input !== "object" || Array.isArray(input)) return {}; const allowedExtensions = new Set(previewExtensions); const mimeTypes = {}; for (const [key, rawContentType] of Object.entries(input)) { const extension = normalizePreviewExtension(key); const contentType = normalizeContentType(rawContentType); if (extension && allowedExtensions.has(extension) && contentType) mimeTypes[extension] = contentType; } return mimeTypes; } function normalizeAppConfig(values, downloadSecret) { const maxMultipartSize = parsePositiveInt(values.max_multipart_size, Number(DEFAULT_APP_CONFIG.max_multipart_size), MAX_MULTIPART_SIZE); const previewExtensions = parsePreviewExtensions(values.preview_extensions); return { SITE_NAME: typeof values.site_name === "string" && values.site_name.trim() ? values.site_name.trim() : DEFAULT_APP_CONFIG.site_name, FAVICON: normalizeFavicon(values.favicon || DEFAULT_APP_CONFIG.favicon), MAX_MULTIPART_SIZE: maxMultipartSize, MAX_TOTAL_SIZE: parsePositiveInt(values.max_total_size, Number(DEFAULT_APP_CONFIG.max_total_size)), DAILY_MAX_A_CLASS: parsePositiveInt(values.daily_max_a_class, Number(DEFAULT_APP_CONFIG.daily_max_a_class), 100000), USER_DOWNLOAD_TTL: parsePositiveInt(values.user_download_ttl, Number(DEFAULT_APP_CONFIG.user_download_ttl), 3600), DOWNLOAD_SECRET: downloadSecret, PREVIEW_EXTENSIONS: previewExtensions, PREVIEW_MIME_TYPES: parsePreviewMimeTypes(values.preview_mime_types, previewExtensions) }; } async function getAppConfig(db, env, forceRefresh = false) { const now = Date.now(); if (!forceRefresh && appConfigCache && now - appConfigCacheTime < CONFIG_CACHE_TTL) return { ...appConfigCache }; let query; try { query = await db.prepare("SELECT key, value FROM pan_config").all(); } catch { throw new HttpError(503, "数据库尚未初始化,请访问 /init"); } if (!query.results?.length) throw new HttpError(503, "站点配置尚未初始化,请访问 /init"); const values = { ...DEFAULT_APP_CONFIG }; for (const row of query.results) { if (Object.prototype.hasOwnProperty.call(DEFAULT_APP_CONFIG, row.key)) values[row.key] = String(row.value); } appConfigCache = normalizeAppConfig(values, env.DOWNLOAD_SECRET); appConfigCacheTime = now; return { ...appConfigCache }; } async function persistAppConfig(db, changes, env) { const entries = Object.entries(changes).filter(([key]) => Object.prototype.hasOwnProperty.call(DEFAULT_APP_CONFIG, key)); if (!entries.length) return getAppConfig(db, env, true); await db.batch(entries.map(([key, value]) => db.prepare( `INSERT INTO pan_config (key, value, updated_at) VALUES (?, ?, datetime('now')) ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at` ).bind(key, String(value)))); appConfigCache = null; return getAppConfig(db, env, true); } function shouldSandboxPreview(contentType) { const mediaType = String(contentType || "").split(";", 1)[0].trim().toLowerCase(); return mediaType === "text/html" || mediaType === "application/xhtml+xml" || mediaType === "image/svg+xml" || mediaType === "application/xml" || mediaType === "text/xml"; } async function serveR2Object(request, r2, key, fileName, previewExts, previewMimeTypes) { const object = await r2.get(key, { range: request.headers, onlyIf: request.headers }); if (!object) return textResponse("文件不存在", 404); const headers = new Headers({ ...corsHeaders, "Accept-Ranges": "bytes", "Cache-Control": "private, no-store", "X-Content-Type-Options": "nosniff" }); if (typeof object.writeHttpMetadata === "function") object.writeHttpMetadata(headers); const previewType = getPreviewType(fileName, previewExts, previewMimeTypes, headers.get("Content-Type") || ""); headers.set("Content-Type", previewType || "application/octet-stream"); headers.set("Content-Disposition", `${previewType ? "inline" : "attachment"}; filename*=UTF-8''${encodeURIComponent(fileName)}`); if (previewType && shouldSandboxPreview(previewType)) headers.set("Content-Security-Policy", "sandbox"); if (object.httpEtag) headers.set("ETag", object.httpEtag); if (!("body" in object)) { const notModified = request.headers.has("If-None-Match") || request.headers.has("If-Modified-Since"); return new Response(null, { status: notModified ? 304 : 412, headers }); } let status = 200; if (object.range && Number.isFinite(object.range.offset) && Number.isFinite(object.range.length)) { const start = object.range.offset; const end = start + object.range.length - 1; headers.set("Content-Range", `bytes ${start}-${end}/${object.size}`); headers.set("Content-Length", String(object.range.length)); status = 206; } else if (Number.isFinite(object.size)) { headers.set("Content-Length", String(object.size)); } return new Response(request.method === "HEAD" ? null : object.body, { status, headers }); } // ========== D1 初始化与 v2 迁移 ========== const PAN_SCHEMA_STATEMENTS = [ `CREATE TABLE IF NOT EXISTS pan_daily_usage ( date TEXT PRIMARY KEY, a_class INTEGER NOT NULL DEFAULT 0 CHECK (a_class >= 0) )`, `CREATE TABLE IF NOT EXISTS pan_files ( key TEXT PRIMARY KEY, name TEXT NOT NULL, size INTEGER NOT NULL CHECK (size >= 0), uploaded_at TEXT NOT NULL DEFAULT (datetime('now')), hidden INTEGER NOT NULL DEFAULT 0 CHECK (hidden IN (0, 1)) )`, `CREATE TABLE IF NOT EXISTS pan_shares ( token TEXT PRIMARY KEY, file_key TEXT NOT NULL, file_name TEXT NOT NULL, created_at TEXT NOT NULL DEFAULT (datetime('now')), hidden INTEGER NOT NULL DEFAULT 0 CHECK (hidden IN (0, 1)) )`, `CREATE TABLE IF NOT EXISTS pan_storage_usage ( id INTEGER PRIMARY KEY CHECK (id = 1), used_bytes INTEGER NOT NULL DEFAULT 0 CHECK (used_bytes >= 0) )`, `CREATE TABLE IF NOT EXISTS pan_login_attempts ( ip TEXT PRIMARY KEY, failures INTEGER NOT NULL DEFAULT 0, window_start INTEGER NOT NULL DEFAULT 0, blocked_until INTEGER NOT NULL DEFAULT 0 )`, `CREATE TABLE IF NOT EXISTS pan_sync_staging ( run_id TEXT NOT NULL, key TEXT NOT NULL, name TEXT NOT NULL, size INTEGER NOT NULL CHECK (size >= 0), created_at TEXT NOT NULL DEFAULT (datetime('now')), PRIMARY KEY (run_id, key) )`, `CREATE TABLE IF NOT EXISTS pan_object_operations ( id TEXT PRIMARY KEY, operation TEXT NOT NULL CHECK (operation IN ('delete')), file_key TEXT NOT NULL, size INTEGER NOT NULL CHECK (size >= 0), created_at TEXT NOT NULL DEFAULT (datetime('now')) )`, `CREATE TABLE IF NOT EXISTS pan_multipart_uploads ( id TEXT PRIMARY KEY, upload_id TEXT NOT NULL, object_key TEXT NOT NULL UNIQUE, file_name TEXT NOT NULL, total_size INTEGER NOT NULL CHECK (total_size > 0), part_size INTEGER NOT NULL CHECK (part_size >= 5242880), total_parts INTEGER NOT NULL CHECK (total_parts BETWEEN 1 AND 10000), status TEXT NOT NULL DEFAULT 'uploading' CHECK (status IN ('uploading', 'completing')), created_at TEXT NOT NULL DEFAULT (datetime('now')), expires_at INTEGER NOT NULL )`, `CREATE TABLE IF NOT EXISTS pan_multipart_parts ( session_id TEXT NOT NULL, part_number INTEGER NOT NULL CHECK (part_number BETWEEN 1 AND 10000), etag TEXT NOT NULL, size INTEGER NOT NULL CHECK (size > 0), uploaded_at TEXT NOT NULL DEFAULT (datetime('now')), PRIMARY KEY (session_id, part_number), FOREIGN KEY (session_id) REFERENCES pan_multipart_uploads(id) ON DELETE CASCADE )`, `CREATE TABLE IF NOT EXISTS pan_config ( key TEXT PRIMARY KEY, value TEXT NOT NULL, updated_at TEXT NOT NULL DEFAULT (datetime('now')) )`, `CREATE TABLE IF NOT EXISTS pan_schema_migrations ( version INTEGER PRIMARY KEY, name TEXT NOT NULL, applied_at TEXT NOT NULL DEFAULT (datetime('now')) )`, `CREATE TABLE IF NOT EXISTS pan_migration_lock ( id INTEGER PRIMARY KEY CHECK (id = 1), owner TEXT NOT NULL, acquired_at TEXT NOT NULL DEFAULT (datetime('now')) )` ]; const PAN_INDEX_STATEMENTS = [ "CREATE INDEX IF NOT EXISTS pan_idx_files_visibility_page ON pan_files (hidden, uploaded_at DESC, key DESC)", "CREATE INDEX IF NOT EXISTS pan_idx_shares_visibility_page ON pan_shares (hidden, created_at DESC, token DESC)", "CREATE INDEX IF NOT EXISTS pan_idx_shares_file_key ON pan_shares (file_key)", "CREATE INDEX IF NOT EXISTS pan_idx_sync_staging_created ON pan_sync_staging (created_at)", "CREATE UNIQUE INDEX IF NOT EXISTS pan_idx_object_operations_file ON pan_object_operations (file_key)", "CREATE INDEX IF NOT EXISTS pan_idx_object_operations_queue ON pan_object_operations (operation, created_at)", "CREATE INDEX IF NOT EXISTS pan_idx_multipart_uploads_expiry ON pan_multipart_uploads (expires_at)" ]; const PAN_REQUIRED_COLUMNS = { pan_daily_usage: ["date", "a_class"], pan_files: ["key", "name", "size", "uploaded_at", "hidden"], pan_shares: ["token", "file_key", "file_name", "created_at", "hidden"], pan_storage_usage: ["id", "used_bytes"], pan_login_attempts: ["ip", "failures", "window_start", "blocked_until"], pan_sync_staging: ["run_id", "key", "name", "size", "created_at"], pan_object_operations: ["id", "operation", "file_key", "size", "created_at"], pan_multipart_uploads: ["id", "upload_id", "object_key", "file_name", "total_size", "part_size", "total_parts", "status", "created_at", "expires_at"], pan_multipart_parts: ["session_id", "part_number", "etag", "size", "uploaded_at"], pan_config: ["key", "value", "updated_at"], pan_schema_migrations: ["version", "name", "applied_at"] }; const LEGACY_TABLE_NAMES = [ "daily_usage", "files", "shares", "storage_usage", "login_attempts", "sync_staging", "object_operations", "multipart_uploads", "multipart_parts" ]; function quoteIdentifier(value) { if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(value)) throw new Error("Unsafe SQL identifier"); return `"${value}"`; } async function getDatabaseTables(db) { const query = await db.prepare( "SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' ORDER BY name" ).all(); return new Set((query.results || []).map(row => String(row.name))); } async function getTableColumns(db, tableName) { const query = await db.prepare(`PRAGMA table_info(${quoteIdentifier(tableName)})`).all(); return new Set((query.results || []).map(row => String(row.name))); } function findColumn(columns, aliases) { return aliases.find(alias => columns.has(alias)) || null; } function textColumnExpression(column, fallback) { return column ? `COALESCE(NULLIF(TRIM(CAST(${quoteIdentifier(column)} AS TEXT)), ''), ${fallback})` : fallback; } function integerColumnExpression(column, fallback = "0") { return column ? `MAX(0, COALESCE(CAST(${quoteIdentifier(column)} AS INTEGER), ${fallback}))` : fallback; } async function inspectPanDatabase(db) { const tables = await getDatabaseTables(db); const hasLegacy = LEGACY_TABLE_NAMES.some(name => tables.has(name)); const hasPanCore = ["pan_files", "pan_shares", "pan_daily_usage"].some(name => tables.has(name)); const missing = []; for (const [tableName, required] of Object.entries(PAN_REQUIRED_COLUMNS)) { if (!tables.has(tableName)) { missing.push(tableName); continue; } const columns = await getTableColumns(db, tableName); for (const column of required) { if (!columns.has(column)) missing.push(`${tableName}.${column}`); } } let state = "current"; if (!hasLegacy && !hasPanCore) state = "empty"; else if (hasLegacy || missing.length) state = "legacy_v2"; const version = tables.has("pan_schema_migrations") ? Number((await db.prepare("SELECT MAX(version) AS version FROM pan_schema_migrations").first())?.version) || 0 : 0; return { state, version, missing, tables: [...tables] }; } async function runStatements(db, statements) { for (const sql of statements) await db.prepare(sql).run(); } async function acquireMigrationLock(db) { await db.prepare(PAN_SCHEMA_STATEMENTS[PAN_SCHEMA_STATEMENTS.length - 1]).run(); await db.prepare("DELETE FROM pan_migration_lock WHERE acquired_at < datetime('now', '-10 minutes')").run(); const owner = crypto.randomUUID(); await db.prepare("INSERT OR IGNORE INTO pan_migration_lock (id, owner) VALUES (1, ?)").bind(owner).run(); const row = await db.prepare("SELECT owner FROM pan_migration_lock WHERE id = 1").first(); if (!row || row.owner !== owner) throw new HttpError(409, "数据库正在初始化或迁移,请稍后重试"); return owner; } async function releaseMigrationLock(db, owner) { try { await db.prepare("DELETE FROM pan_migration_lock WHERE id = 1 AND owner = ?").bind(owner).run(); } catch {} } async function renameLegacyTables(db, migrated) { const tables = await getDatabaseTables(db); for (const legacyName of LEGACY_TABLE_NAMES) { const panName = `pan_${legacyName}`; if (!tables.has(legacyName) || tables.has(panName)) continue; await db.prepare(`ALTER TABLE ${quoteIdentifier(legacyName)} RENAME TO ${quoteIdentifier(panName)}`).run(); tables.delete(legacyName); tables.add(panName); migrated.push(`${legacyName} -> ${panName}`); } } async function rebuildPanFiles(db, columns, migrated) { const key = findColumn(columns, ["key", "file_key", "object_key", "r2_key"]); if (!key) throw new HttpError(409, "旧版 files 表缺少可识别的文件 key 列,已停止迁移"); const name = findColumn(columns, ["name", "file_name", "filename"]); const size = findColumn(columns, ["size", "file_size", "bytes"]); const uploadedAt = findColumn(columns, ["uploaded_at", "created_at", "upload_time"]); const hidden = findColumn(columns, ["hidden", "is_hidden"]); const keyExpr = `CAST(${quoteIdentifier(key)} AS TEXT)`; const nameExpr = textColumnExpression(name, keyExpr); const dateExpr = textColumnExpression(uploadedAt, "datetime('now')"); const hiddenExpr = hidden ? `CASE WHEN CAST(${quoteIdentifier(hidden)} AS INTEGER) = 1 THEN 1 ELSE 0 END` : "0"; const backup = "pan_files_v2_backup"; const tables = await getDatabaseTables(db); if (tables.has(backup)) throw new HttpError(409, `检测到未完成迁移表 ${backup},请先人工确认后再重试`); await db.batch([ db.prepare(`ALTER TABLE pan_files RENAME TO ${backup}`), db.prepare(PAN_SCHEMA_STATEMENTS[1]), db.prepare( `INSERT OR IGNORE INTO pan_files (key, name, size, uploaded_at, hidden) SELECT ${keyExpr}, ${nameExpr}, ${integerColumnExpression(size)}, ${dateExpr}, ${hiddenExpr} FROM ${backup} WHERE ${quoteIdentifier(key)} IS NOT NULL AND ${keyExpr} <> ''` ), db.prepare(`DROP TABLE ${backup}`) ]); migrated.push("pan_files 列结构升级"); } async function rebuildPanShares(db, columns, migrated) { const token = findColumn(columns, ["token", "share_token", "short_key"]); const fileKey = findColumn(columns, ["file_key", "key", "object_key", "r2_key"]); if (!token || !fileKey) throw new HttpError(409, "旧版 shares 表缺少可识别的 token 或 file_key 列,已停止迁移"); const fileName = findColumn(columns, ["file_name", "name", "filename"]); const createdAt = findColumn(columns, ["created_at", "uploaded_at", "create_time"]); const hidden = findColumn(columns, ["hidden", "is_hidden"]); const tokenExpr = `CAST(${quoteIdentifier(token)} AS TEXT)`; const fileKeyExpr = `CAST(${quoteIdentifier(fileKey)} AS TEXT)`; const fileNameExpr = textColumnExpression(fileName, fileKeyExpr); const dateExpr = textColumnExpression(createdAt, "datetime('now')"); const hiddenExpr = hidden ? `CASE WHEN CAST(${quoteIdentifier(hidden)} AS INTEGER) = 1 THEN 1 ELSE 0 END` : "0"; const backup = "pan_shares_v2_backup"; const tables = await getDatabaseTables(db); if (tables.has(backup)) throw new HttpError(409, `检测到未完成迁移表 ${backup},请先人工确认后再重试`); await db.batch([ db.prepare(`ALTER TABLE pan_shares RENAME TO ${backup}`), db.prepare(PAN_SCHEMA_STATEMENTS[2]), db.prepare( `INSERT OR IGNORE INTO pan_shares (token, file_key, file_name, created_at, hidden) SELECT ${tokenExpr}, ${fileKeyExpr}, ${fileNameExpr}, ${dateExpr}, ${hiddenExpr} FROM ${backup} WHERE ${quoteIdentifier(token)} IS NOT NULL AND ${quoteIdentifier(fileKey)} IS NOT NULL` ), db.prepare(`DROP TABLE ${backup}`) ]); migrated.push("pan_shares 列结构升级"); } async function rebuildPanDailyUsage(db, columns, migrated) { const date = findColumn(columns, ["date", "day", "usage_date"]); const usage = findColumn(columns, ["a_class", "count", "used", "usage"]); if (!date) throw new HttpError(409, "旧版 daily_usage 表缺少可识别的日期列,已停止迁移"); const backup = "pan_daily_usage_v2_backup"; const tables = await getDatabaseTables(db); if (tables.has(backup)) throw new HttpError(409, `检测到未完成迁移表 ${backup},请先人工确认后再重试`); await db.batch([ db.prepare(`ALTER TABLE pan_daily_usage RENAME TO ${backup}`), db.prepare(PAN_SCHEMA_STATEMENTS[0]), db.prepare( `INSERT OR REPLACE INTO pan_daily_usage (date, a_class) SELECT CAST(${quoteIdentifier(date)} AS TEXT), ${integerColumnExpression(usage)} FROM ${backup} WHERE ${quoteIdentifier(date)} IS NOT NULL` ), db.prepare(`DROP TABLE ${backup}`) ]); migrated.push("pan_daily_usage 列结构升级"); } async function normalizeLegacyCoreTables(db, migrated) { const tables = await getDatabaseTables(db); if (tables.has("pan_files")) { const columns = await getTableColumns(db, "pan_files"); if (["key", "name", "size", "uploaded_at"].some(column => !columns.has(column))) { await rebuildPanFiles(db, columns, migrated); } else if (!columns.has("hidden")) { await db.prepare("ALTER TABLE pan_files ADD COLUMN hidden INTEGER NOT NULL DEFAULT 0 CHECK (hidden IN (0, 1))").run(); migrated.push("pan_files.hidden"); } } if (tables.has("pan_shares")) { const columns = await getTableColumns(db, "pan_shares"); if (["token", "file_key", "file_name", "created_at"].some(column => !columns.has(column))) { await rebuildPanShares(db, columns, migrated); } else if (!columns.has("hidden")) { await db.prepare("ALTER TABLE pan_shares ADD COLUMN hidden INTEGER NOT NULL DEFAULT 0 CHECK (hidden IN (0, 1))").run(); migrated.push("pan_shares.hidden"); } } if (tables.has("pan_daily_usage")) { const columns = await getTableColumns(db, "pan_daily_usage"); if (!columns.has("date") || !columns.has("a_class")) await rebuildPanDailyUsage(db, columns, migrated); } } async function mergeRemainingLegacyCoreData(db, migrated) { const tables = await getDatabaseTables(db); if (tables.has("daily_usage") && tables.has("pan_daily_usage")) { const columns = await getTableColumns(db, "daily_usage"); const date = findColumn(columns, ["date", "day", "usage_date"]); const usage = findColumn(columns, ["a_class", "count", "used", "usage"]); if (date) { await db.prepare( `INSERT INTO pan_daily_usage (date, a_class) SELECT CAST(${quoteIdentifier(date)} AS TEXT), ${integerColumnExpression(usage)} FROM daily_usage WHERE ${quoteIdentifier(date)} IS NOT NULL ON CONFLICT(date) DO UPDATE SET a_class = MAX(pan_daily_usage.a_class, excluded.a_class)` ).run(); await db.prepare("DROP TABLE daily_usage").run(); migrated.push("daily_usage 数据合并到 pan_daily_usage"); } } if (tables.has("files") && tables.has("pan_files")) { const columns = await getTableColumns(db, "files"); const key = findColumn(columns, ["key", "file_key", "object_key", "r2_key"]); if (key) { const name = findColumn(columns, ["name", "file_name", "filename"]); const size = findColumn(columns, ["size", "file_size", "bytes"]); const uploadedAt = findColumn(columns, ["uploaded_at", "created_at", "upload_time"]); const hidden = findColumn(columns, ["hidden", "is_hidden"]); const keyExpr = `CAST(${quoteIdentifier(key)} AS TEXT)`; await db.prepare( `INSERT OR IGNORE INTO pan_files (key, name, size, uploaded_at, hidden) SELECT ${keyExpr}, ${textColumnExpression(name, keyExpr)}, ${integerColumnExpression(size)}, ${textColumnExpression(uploadedAt, "datetime('now')")}, ${hidden ? `CASE WHEN CAST(${quoteIdentifier(hidden)} AS INTEGER) = 1 THEN 1 ELSE 0 END` : "0"} FROM files WHERE ${quoteIdentifier(key)} IS NOT NULL` ).run(); await db.prepare("DROP TABLE files").run(); migrated.push("files 数据合并到 pan_files"); } } if (tables.has("shares") && tables.has("pan_shares")) { const columns = await getTableColumns(db, "shares"); const token = findColumn(columns, ["token", "share_token", "short_key"]); const fileKey = findColumn(columns, ["file_key", "key", "object_key", "r2_key"]); if (token && fileKey) { const fileName = findColumn(columns, ["file_name", "name", "filename"]); const createdAt = findColumn(columns, ["created_at", "uploaded_at", "create_time"]); const hidden = findColumn(columns, ["hidden", "is_hidden"]); const fileKeyExpr = `CAST(${quoteIdentifier(fileKey)} AS TEXT)`; await db.prepare( `INSERT OR IGNORE INTO pan_shares (token, file_key, file_name, created_at, hidden) SELECT CAST(${quoteIdentifier(token)} AS TEXT), ${fileKeyExpr}, ${textColumnExpression(fileName, fileKeyExpr)}, ${textColumnExpression(createdAt, "datetime('now')")}, ${hidden ? `CASE WHEN CAST(${quoteIdentifier(hidden)} AS INTEGER) = 1 THEN 1 ELSE 0 END` : "0"} FROM shares WHERE ${quoteIdentifier(token)} IS NOT NULL AND ${quoteIdentifier(fileKey)} IS NOT NULL` ).run(); await db.prepare("DROP TABLE shares").run(); migrated.push("shares 数据合并到 pan_shares"); } } } async function mergeRemainingLegacySupportData(db, migrated) { const tables = await getDatabaseTables(db); const mergeExact = async (legacyName, panName, columns, dropSource = true) => { if (!tables.has(legacyName) || !tables.has(panName)) return; const sourceColumns = await getTableColumns(db, legacyName); const missing = columns.filter(column => !sourceColumns.has(column)); if (missing.length) throw new HttpError(409, `旧版 ${legacyName} 表缺少列:${missing.join(", ")}`); const columnList = columns.map(quoteIdentifier).join(", "); await db.prepare(`INSERT OR IGNORE INTO ${quoteIdentifier(panName)} (${columnList}) SELECT ${columnList} FROM ${quoteIdentifier(legacyName)}`).run(); if (dropSource) await db.prepare(`DROP TABLE ${quoteIdentifier(legacyName)}`).run(); migrated.push(`${legacyName} 数据合并到 ${panName}`); if (dropSource) tables.delete(legacyName); }; await mergeExact("login_attempts", "pan_login_attempts", ["ip", "failures", "window_start", "blocked_until"]); await mergeExact("sync_staging", "pan_sync_staging", ["run_id", "key", "name", "size", "created_at"]); await mergeExact("object_operations", "pan_object_operations", ["id", "operation", "file_key", "size", "created_at"]); await mergeExact( "multipart_uploads", "pan_multipart_uploads", ["id", "upload_id", "object_key", "file_name", "total_size", "part_size", "total_parts", "status", "created_at", "expires_at"], false ); await mergeExact("multipart_parts", "pan_multipart_parts", ["session_id", "part_number", "etag", "size", "uploaded_at"]); if (tables.has("multipart_uploads")) { await db.prepare("DROP TABLE multipart_uploads").run(); tables.delete("multipart_uploads"); } if (tables.has("storage_usage")) { await db.prepare("DROP TABLE storage_usage").run(); migrated.push("storage_usage 已由 pan_storage_usage 重新统计"); } } async function ensureDefaultAppConfig(db) { await db.batch(Object.entries(DEFAULT_APP_CONFIG).map(([key, value]) => db.prepare( "INSERT OR IGNORE INTO pan_config (key, value) VALUES (?, ?)" ).bind(key, value))); } function getProvidedPasswords(input) { const providedAdmin = typeof input.adminPassword === "string" ? input.adminPassword : ""; const providedUser = typeof input.userPassword === "string" ? input.userPassword : ""; if (providedAdmin && (providedAdmin.length < 6 || providedAdmin.length > 128)) { throw new HttpError(400, "管理员密码长度需为 6-128 位"); } if (providedUser && (providedUser.length < 6 || providedUser.length > 128)) { throw new HttpError(400, "普通用户密码长度需为 6-128 位,或留空禁用普通用户登录"); } return { providedAdmin, providedUser }; } async function migrateKvSiteConfig(kv, db, input, env) { let raw = await readStoredSiteConfig(kv); let changed = false; const { providedAdmin, providedUser } = getProvidedPasswords(input); let normalized; try { normalized = raw ? normalizeSiteConfig(raw) : null; } catch { normalized = null; } if (!normalized) { if (!providedAdmin) { throw new HttpError(400, "KV 中没有有效共享密码,请在 /init 输入 6-128 位管理员密码"); } raw = raw && typeof raw === "object" && !Array.isArray(raw) ? { ...raw } : {}; raw.password = providedAdmin; raw.admin = providedAdmin; for (const alias of ["adminPassword", "admin_password", "sitePassword"]) { if (Object.prototype.hasOwnProperty.call(raw, alias)) raw[alias] = providedAdmin; } if (providedUser) raw.user = providedUser; for (const alias of ["userPassword", "user_password"]) { if (providedUser && Object.prototype.hasOwnProperty.call(raw, alias)) raw[alias] = providedUser; } normalized = normalizeSiteConfig(raw); changed = true; } else { raw = raw && typeof raw === "object" && !Array.isArray(raw) ? { ...raw } : {}; if (providedAdmin) normalized.admin = providedAdmin; if (providedUser) normalized.user = providedUser; if (raw.password !== normalized.admin) { raw.password = normalized.admin; changed = true; } if (raw.admin !== normalized.admin) { raw.admin = normalized.admin; changed = true; } for (const alias of ["adminPassword", "admin_password", "sitePassword"]) { if (Object.prototype.hasOwnProperty.call(raw, alias) && raw[alias] !== normalized.admin) { raw[alias] = normalized.admin; changed = true; } } if (normalized.user && raw.user !== normalized.user) { raw.user = normalized.user; changed = true; } for (const alias of ["userPassword", "user_password"]) { if (normalized.user && Object.prototype.hasOwnProperty.call(raw, alias) && raw[alias] !== normalized.user) { raw[alias] = normalized.user; changed = true; } } } const appChanges = {}; if (typeof raw.siteName === "string" && raw.siteName.trim()) appChanges.site_name = raw.siteName.trim(); if (typeof raw.favicon === "string" && raw.favicon.trim()) appChanges.favicon = raw.favicon.trim(); if (Object.keys(appChanges).length) await persistAppConfig(db, appChanges, env); if (changed) await kv.put("sys_config", JSON.stringify(raw)); configCache = normalized; configCacheTime = Date.now(); configCacheSource = "kv"; return { source: "kv", changed, hasUserPassword: !!normalized.user }; } async function initializeD1SiteConfig(db, input) { const { providedAdmin, providedUser } = getProvidedPasswords(input); const raw = await readD1SiteConfig(db); let normalized; try { normalized = raw ? normalizeSiteConfig(raw) : null; } catch { normalized = null; } if (!normalized && !providedAdmin) { throw new HttpError(400, "D1 中没有有效管理员密码,请在 /init 输入 6-128 位管理员密码"); } const changes = {}; if (providedAdmin || !normalized) changes.admin = providedAdmin; if (providedUser) changes.user = providedUser; if (Object.keys(changes).length) { await persistSiteConfig({ DB: db }, changes); normalized = await getSiteConfig({ DB: db }, true); } else { configCache = normalized; configCacheTime = Date.now(); configCacheSource = "d1"; } return { source: "d1", changed: !!Object.keys(changes).length, hasUserPassword: !!normalized.user }; } async function initializeSiteConfig(env, input) { if (!env.KV) return initializeD1SiteConfig(env.DB, input); const result = await migrateKvSiteConfig(env.KV, env.DB, input, env); await env.DB.prepare("DELETE FROM pan_config WHERE key IN ('admin_password', 'user_password')").run(); return result; } async function initializeOrMigrateDatabase(db) { const owner = await acquireMigrationLock(db); try { const before = await inspectPanDatabase(db); const migrated = []; await renameLegacyTables(db, migrated); await normalizeLegacyCoreTables(db, migrated); await runStatements(db, PAN_SCHEMA_STATEMENTS.slice(0, -1)); await mergeRemainingLegacyCoreData(db, migrated); await mergeRemainingLegacySupportData(db, migrated); await runStatements(db, PAN_INDEX_STATEMENTS); await ensureDefaultAppConfig(db); const staleUploadConfig = await db.prepare("DELETE FROM pan_config WHERE key = 'max_size'").run(); if (staleUploadConfig.meta.changes) migrated.push("移除旧版 max_size 单请求上传配置"); await db.prepare("INSERT OR IGNORE INTO pan_storage_usage (id, used_bytes) VALUES (1, 0)").run(); await db.prepare( `UPDATE pan_storage_usage SET used_bytes = (SELECT COALESCE(SUM(size), 0) FROM pan_files) + (SELECT COALESCE(SUM(m.total_size), 0) FROM pan_multipart_uploads m WHERE NOT EXISTS (SELECT 1 FROM pan_files f WHERE f.key = m.object_key)) WHERE id = 1` ).run(); await db.batch([ db.prepare("INSERT OR IGNORE INTO pan_schema_migrations (version, name) VALUES (1, 'pan_v2_prefix_and_hardening')"), db.prepare("INSERT OR IGNORE INTO pan_schema_migrations (version, name) VALUES (2, 'pan_multipart_and_config')") ]); const after = await inspectPanDatabase(db); if (after.missing.length) throw new HttpError(500, `迁移后结构校验失败:${after.missing.join(", ")}`); const fileCount = Number((await db.prepare("SELECT COUNT(*) AS total FROM pan_files").first())?.total) || 0; const shareCount = Number((await db.prepare("SELECT COUNT(*) AS total FROM pan_shares").first())?.total) || 0; const usedBytes = Number((await db.prepare("SELECT used_bytes FROM pan_storage_usage WHERE id = 1").first())?.used_bytes) || 0; return { action: before.state === "empty" ? "initialized" : before.state === "current" && !migrated.length ? "verified" : "migrated", version: LATEST_SCHEMA_VERSION, migrated, fileCount, shareCount, usedBytes }; } finally { await releaseMigrationLock(db, owner); } } async function getInitStatus(env) { const database = await inspectPanDatabase(env.DB); let password = "missing"; let hasUserPassword = false; const passwordSource = env.KV ? "KV" : "D1 pan_config"; try { const raw = env.KV ? await readStoredSiteConfig(env.KV) : database.tables.includes("pan_config") ? await readD1SiteConfig(env.DB) : null; const config = normalizeSiteConfig(raw); password = "ready"; hasUserPassword = !!config.user; } catch {} return { database, password, hasUserPassword, passwordSource }; } function getInitHtml(status) { const stateLabels = { empty: "等待初始化", legacy_v2: "检测到旧版 v2", current: "结构已是最新版" }; const state = stateLabels[status.database.state] || "需要检查"; const passwordState = status.password === "ready" ? `${status.passwordSource} 密码可用` : `${status.passwordSource} 需要设置管理员密码`; return ` PanIndex 初始化

PanIndex 初始化与迁移

数据库${state}
站点密码${passwordState}
`; } function getNotFoundHtml(siteName) { return ` 分享链接无效 | ${siteName}
${siteName}

分享链接无效

当前分享链接不存在、已失效或已过期,请检查链接后重试。

404
当前访问链接 正在读取当前链接
`; } function notFoundResponse(request, runtime) { const html = getNotFoundHtml(escapeHtml(runtime.SITE_NAME)); return new Response(request.method === "HEAD" ? null : html, { status: 404, headers: { "Content-Type": "text/html; charset=utf-8", ...corsHeaders, ...pageSecurityHeaders } }); } async function handleInit(request, env) { if (request.method === "GET" || request.method === "HEAD") { const html = getInitHtml(await getInitStatus(env)); return new Response(request.method === "HEAD" ? null : html, { headers: { "Content-Type": "text/html; charset=utf-8", ...pageSecurityHeaders } }); } if (request.method !== "POST") return jsonResponse({ code: 405, msg: "Method not allowed" }, 405); const body = await readJson(request); if (typeof env.JWT_SECRET !== "string" || env.JWT_SECRET.length < 32 || typeof env.DOWNLOAD_SECRET !== "string" || env.DOWNLOAD_SECRET.length < 32) { throw new HttpError(503, "请先配置长度至少为 32 位的 JWT_SECRET 和 DOWNLOAD_SECRET"); } if (typeof body.initSecret !== "string" || body.initSecret !== env.JWT_SECRET) { throw new HttpError(403, "初始化口令错误"); } const database = await initializeOrMigrateDatabase(env.DB); const credentials = await initializeSiteConfig(env, body); appConfigCache = null; return jsonResponse({ code: 0, data: { ...database, credentials } }); } // ========== 主 Worker ========== export default { async fetch(request, env) { try { const url = new URL(request.url); const path = url.pathname; const DB = env.DB; const R2 = env.R2; const KV = env.KV; if (!DB || !R2) throw new HttpError(503, "缺少 DB 或 R2 绑定"); if (request.method === "OPTIONS") return new Response(null, { status: 204, headers: corsHeaders }); if (path === "/init") return await handleInit(request, env); if (path === "/app-icon.svg" || path === "/favicon.ico") { return new Response(APP_ICON_SVG, { headers: { "Content-Type": "image/svg+xml; charset=utf-8", "Cache-Control": "public, max-age=86400", "X-Content-Type-Options": "nosniff" } }); } if (path === "/api/login") return await handleLogin(request, env); if (path === "/api/verify-token") return await handleVerifyToken(request, env); const runtime = await getAppConfig(DB, env); if (path.startsWith("/api/")) { const payload = await extractTokenPayload(request, env); if (!payload) return jsonResponse({ code: 401, msg: "未授权或令牌失效" }, 401); return await handleApi(path.slice(5), url, payload, R2, DB, KV, request, env, runtime); } const pathToken = getPathToken(path); if (pathToken && (request.method === "GET" || request.method === "HEAD")) { const shareRow = await DB.prepare( "SELECT file_key, file_name FROM pan_shares WHERE token = ?" ).bind(pathToken).first(); if (shareRow) return await serveR2Object( request, R2, shareRow.file_key, shareRow.file_name, runtime.PREVIEW_EXTENSIONS, runtime.PREVIEW_MIME_TYPES ); const downloadPayload = await verifyDownloadToken(pathToken, runtime.DOWNLOAD_SECRET); if (!downloadPayload) return notFoundResponse(request, runtime); const fileRow = await DB.prepare("SELECT name FROM pan_files WHERE key = ? AND hidden = 0").bind(downloadPayload.key).first(); if (!fileRow) return notFoundResponse(request, runtime); return await serveR2Object( request, R2, downloadPayload.key, fileRow.name, runtime.PREVIEW_EXTENSIONS, runtime.PREVIEW_MIME_TYPES ); } if (path !== "/") return notFoundResponse(request, runtime); await getSiteConfig(env); const usedSize = await getUsedStorage(DB); const day = new Date().toISOString().split("T")[0]; const todayRow = await DB.prepare("SELECT a_class FROM pan_daily_usage WHERE date = ?").bind(day).first(); const todayAclass = todayRow ? todayRow.a_class : 0; const pct = (usedSize / runtime.MAX_TOTAL_SIZE * 100).toFixed(1); return new Response( getHtml( todayAclass, usedSize, pct, escapeHtml(runtime.SITE_NAME), escapeHtml(runtime.FAVICON), runtime.MAX_MULTIPART_SIZE, runtime.MAX_TOTAL_SIZE, runtime.DAILY_MAX_A_CLASS, runtime.USER_DOWNLOAD_TTL, runtime.PREVIEW_EXTENSIONS.join(","), runtime.PREVIEW_MIME_TYPES ), { headers: { "Content-Type": "text/html; charset=utf-8", ...corsHeaders, ...pageSecurityHeaders } } ); } catch (error) { const status = error instanceof HttpError ? error.status : 500; const message = error instanceof HttpError ? error.message : "服务异常"; if (!(error instanceof HttpError)) console.error("Worker error:", error); const errorPath = new URL(request.url).pathname; return errorPath.startsWith("/api/") || (errorPath === "/init" && request.method === "POST") ? jsonResponse({ code: status, msg: message }, status) : textResponse(message, status); } } }; // ========== 登录/验证接口 ========== async function handleLogin(request, env) { if (request.method !== "POST") return jsonResponse({ code: 405, error: "Method not allowed" }, 405); const ip = request.headers.get("CF-Connecting-IP") || "unknown"; const now = Math.floor(Date.now() / 1000); await checkLoginRateLimit(env.DB, ip, now); let body; try { body = await request.json(); } catch { throw new HttpError(400, "请求格式无效"); } const pass = typeof body.password === "string" ? body.password : ""; const siteConfig = await getSiteConfig(env, true); let role = null; if (pass === siteConfig.admin) role = "admin"; else if (pass === siteConfig.user) role = "user"; if (!role) { await recordLoginFailure(env.DB, ip, now); return jsonResponse({ code: 403, msg: "密码错误" }, 403); } await clearLoginFailures(env.DB, ip); const token = await generateJWT(env.JWT_SECRET, role); return jsonResponse({ code: 0, token, role }); } async function handleVerifyToken(request, env) { if (request.method !== "POST") return jsonResponse({ code: 405, error: "Method not allowed" }, 405); const payload = await extractTokenPayload(request, env); return jsonResponse({ code: 0, valid: !!payload, role: payload?.role }); } // ========== API 业务处理 ========== function requireMethod(request, method) { if (request.method !== method) throw new HttpError(405, "Method not allowed"); } async function readJson(request) { try { return await request.json(); } catch { throw new HttpError(400, "请求格式无效"); } } async function consumeDailyOperation(db, day, maxOperations) { const result = await db.prepare( `INSERT INTO pan_daily_usage (date, a_class) VALUES (?, 1) ON CONFLICT(date) DO UPDATE SET a_class = a_class + 1 WHERE a_class < ?` ).bind(day, maxOperations).run(); if (result.meta.changes === 0) throw new HttpError(429, "今日 A 类操作已达上限"); } async function rollbackDailyOperation(db, day) { await db.prepare("UPDATE pan_daily_usage SET a_class = MAX(0, a_class - 1) WHERE date = ?").bind(day).run(); } async function reserveStorage(db, size, maxTotalSize) { const result = await db.prepare( "UPDATE pan_storage_usage SET used_bytes = used_bytes + ? WHERE id = 1 AND used_bytes + ? <= ?" ).bind(size, size, maxTotalSize).run(); if (result.meta.changes === 0) throw new HttpError(413, "存储空间已满"); } async function releaseStorage(db, size) { await db.prepare("UPDATE pan_storage_usage SET used_bytes = MAX(0, used_bytes - ?) WHERE id = 1").bind(size).run(); } async function listFilesPage(db, hidden, url) { const limit = parsePositiveInt(url.searchParams.get("limit"), DEFAULT_PAGE_SIZE, MAX_PAGE_SIZE); const cursor = decodeCursor(url.searchParams.get("cursor")); let query; if (cursor) { query = await db.prepare( `SELECT key, name, size, hidden, uploaded_at FROM pan_files WHERE hidden = ? AND (uploaded_at < ? OR (uploaded_at = ? AND key < ?)) ORDER BY uploaded_at DESC, key DESC LIMIT ?` ).bind(hidden, cursor.uploadedAt, cursor.uploadedAt, cursor.key, limit + 1).all(); } else { query = await db.prepare( "SELECT key, name, size, hidden, uploaded_at FROM pan_files WHERE hidden = ? ORDER BY uploaded_at DESC, key DESC LIMIT ?" ).bind(hidden, limit + 1).all(); } const hasMore = query.results.length > limit; const data = query.results.slice(0, limit); const last = data[data.length - 1]; return { data, nextCursor: hasMore && last ? encodeCursor(last.uploaded_at, last.key) : null }; } async function listSharesPage(db, hidden, url) { const limit = parsePositiveInt(url.searchParams.get("limit"), DEFAULT_PAGE_SIZE, MAX_PAGE_SIZE); const cursor = decodeCursor(url.searchParams.get("cursor")); let query; if (cursor) { query = await db.prepare( `SELECT token, file_name, created_at FROM pan_shares WHERE hidden = ? AND (created_at < ? OR (created_at = ? AND token < ?)) ORDER BY created_at DESC, token DESC LIMIT ?` ).bind(hidden, cursor.uploadedAt, cursor.uploadedAt, cursor.key, limit + 1).all(); } else { query = await db.prepare( "SELECT token, file_name, created_at FROM pan_shares WHERE hidden = ? ORDER BY created_at DESC, token DESC LIMIT ?" ).bind(hidden, limit + 1).all(); } const hasMore = query.results.length > limit; const data = query.results.slice(0, limit); const last = data[data.length - 1]; return { data, nextCursor: hasMore && last ? encodeCursor(last.created_at, last.token) : null }; } async function finalizePendingDelete(db, operation) { await db.batch([ db.prepare("DELETE FROM pan_shares WHERE file_key = ?").bind(operation.file_key), db.prepare("DELETE FROM pan_files WHERE key = ?").bind(operation.file_key), db.prepare("UPDATE pan_storage_usage SET used_bytes = MAX(0, used_bytes - ?) WHERE id = 1").bind(operation.size), db.prepare("DELETE FROM pan_object_operations WHERE id = ?").bind(operation.id) ]); } async function drainPendingDeletes(r2, db, limit = 25) { const query = await db.prepare( "SELECT id, file_key, size FROM pan_object_operations WHERE operation = 'delete' ORDER BY created_at LIMIT ?" ).bind(limit).all(); let completed = 0; for (const operation of query.results) { try { await r2.delete(operation.file_key); await finalizePendingDelete(db, operation); completed += 1; } catch (error) { console.error("Pending delete retry failed:", operation.file_key, error); } } return completed; } async function getMultipartSession(db, sessionId) { return db.prepare( `SELECT id, upload_id, object_key, file_name, total_size, part_size, total_parts, status, expires_at FROM pan_multipart_uploads WHERE id = ?` ).bind(sessionId).first(); } function expectedMultipartPartSize(session, partNumber) { const totalParts = Number(session.total_parts); const totalSize = Number(session.total_size); const partSize = Number(session.part_size); return partNumber === totalParts ? totalSize - partSize * (totalParts - 1) : partSize; } async function finalizeMultipartSession(db, session) { await db.batch([ db.prepare("INSERT OR IGNORE INTO pan_files (key, name, size) VALUES (?, ?, ?)") .bind(session.object_key, session.file_name, session.total_size), db.prepare("DELETE FROM pan_multipart_parts WHERE session_id = ?").bind(session.id), db.prepare("DELETE FROM pan_multipart_uploads WHERE id = ?").bind(session.id) ]); } async function discardMultipartSession(db, session) { await db.batch([ db.prepare("DELETE FROM pan_multipart_parts WHERE session_id = ?").bind(session.id), db.prepare("DELETE FROM pan_multipart_uploads WHERE id = ?").bind(session.id), db.prepare("UPDATE pan_storage_usage SET used_bytes = MAX(0, used_bytes - ?) WHERE id = 1").bind(session.total_size) ]); } async function completeMultipartSession(r2, db, session, parts) { await db.prepare("UPDATE pan_multipart_uploads SET status = 'completing' WHERE id = ?").bind(session.id).run(); const upload = r2.resumeMultipartUpload(session.object_key, session.upload_id); try { await upload.complete(parts); } catch (error) { const object = await r2.head(session.object_key).catch(() => null); if (!object || Number(object.size) !== Number(session.total_size)) { await db.prepare("UPDATE pan_multipart_uploads SET status = 'uploading' WHERE id = ?").bind(session.id).run(); throw error; } } await finalizeMultipartSession(db, session); } async function cleanupExpiredMultipart(r2, db, limit = 10) { const now = Math.floor(Date.now() / 1000); const query = await db.prepare( `SELECT id, upload_id, object_key, file_name, total_size, part_size, total_parts, status, expires_at FROM pan_multipart_uploads WHERE expires_at <= ? ORDER BY expires_at LIMIT ?` ).bind(now, limit).all(); let cleaned = 0; for (const session of query.results) { try { const object = await r2.head(session.object_key).catch(() => null); if (object && Number(object.size) === Number(session.total_size)) { await finalizeMultipartSession(db, session); } else { try { await r2.resumeMultipartUpload(session.object_key, session.upload_id).abort(); } catch {} await discardMultipartSession(db, session); } cleaned += 1; } catch (error) { console.error("Expired multipart cleanup failed:", session.id, error); } } return cleaned; } async function syncFiles(r2, db) { const runId = crypto.randomUUID(); let cursor; let totalFiles = 0; await drainPendingDeletes(r2, db, 100); await cleanupExpiredMultipart(r2, db, 100); await db.prepare("DELETE FROM pan_sync_staging WHERE created_at < datetime('now', '-1 day')").run(); try { do { const result = await r2.list(cursor ? { prefix: "pan/", limit: 500, cursor } : { prefix: "pan/", limit: 500 }); totalFiles += result.objects.length; for (let index = 0; index < result.objects.length; index += 50) { const statements = result.objects.slice(index, index + 50).map(object => db.prepare( "INSERT OR REPLACE INTO pan_sync_staging (run_id, key, name, size) VALUES (?, ?, ?, ?)" ).bind(runId, object.key, parseFileNameFromKey(object.key), object.size)); if (statements.length) await db.batch(statements); } cursor = result.truncated ? result.cursor : undefined; } while (cursor); const addedRow = await db.prepare( `SELECT COUNT(*) AS total FROM pan_sync_staging s LEFT JOIN pan_files f ON f.key = s.key WHERE s.run_id = ? AND f.key IS NULL` ).bind(runId).first(); const deletedRow = await db.prepare( `SELECT COUNT(*) AS total FROM pan_files f LEFT JOIN pan_sync_staging s ON s.run_id = ? AND s.key = f.key WHERE f.key LIKE 'pan/%' AND s.key IS NULL` ).bind(runId).first(); await db.batch([ db.prepare( `INSERT OR IGNORE INTO pan_files (key, name, size) SELECT key, name, size FROM pan_sync_staging WHERE run_id = ?` ).bind(runId), db.prepare( `DELETE FROM pan_shares WHERE file_key IN ( SELECT f.key FROM pan_files f LEFT JOIN pan_sync_staging s ON s.run_id = ? AND s.key = f.key WHERE f.key LIKE 'pan/%' AND s.key IS NULL )` ).bind(runId), db.prepare("DELETE FROM pan_files WHERE key LIKE 'pan/%' AND key NOT IN (SELECT key FROM pan_sync_staging WHERE run_id = ?)").bind(runId), db.prepare( `UPDATE pan_storage_usage SET used_bytes = (SELECT COALESCE(SUM(size), 0) FROM pan_files) + (SELECT COALESCE(SUM(m.total_size), 0) FROM pan_multipart_uploads m WHERE NOT EXISTS (SELECT 1 FROM pan_files f WHERE f.key = m.object_key)) WHERE id = 1` ), db.prepare("DELETE FROM pan_sync_staging WHERE run_id = ?").bind(runId) ]); const totalRow = await db.prepare("SELECT used_bytes FROM pan_storage_usage WHERE id = 1").first(); return { added: Number(addedRow?.total) || 0, deleted: Number(deletedRow?.total) || 0, totalFiles, totalSize: Number(totalRow?.used_bytes) || 0 }; } catch (error) { try { await db.prepare("DELETE FROM pan_sync_staging WHERE run_id = ?").bind(runId).run(); } catch {} throw error; } } async function handleApi(action, url, payload, R2, DB, KV, request, env, limits) { const fileKey = url.searchParams.get("key"); const day = new Date().toISOString().split("T")[0]; const isAdmin = payload.role === "admin"; const requireAdminList = [ "delete", "createShare", "listShares", "deleteShare", "updatePass", "updateSite", "updateShareToken", "syncFiles", "hideFile", "unhideFile", "listHidden", "hideShare", "unhideShare", "listHiddenShares" ]; if (requireAdminList.includes(action) && !isAdmin) throw new HttpError(403, "需要管理员权限"); switch (action) { case "multipart/create": { requireMethod(request, "POST"); const body = await readJson(request); const fileName = normalizeFileName(body.name); const extension = getFileExtension(fileName); const uploadContentType = getMappedContentType(limits.PREVIEW_MIME_TYPES, extension) || normalizeContentType(body.type) || getMappedContentType(KNOWN_PREVIEW_TYPES, extension) || "application/octet-stream"; const totalSize = Number(body.size); if (!Number.isSafeInteger(totalSize) || totalSize <= 0) throw new HttpError(400, "文件大小无效"); if (totalSize > limits.MAX_MULTIPART_SIZE) { throw new HttpError(413, `分片上传文件不能超过 ${limits.MAX_MULTIPART_SIZE / 1024 / 1024 / 1024}GB`); } const totalParts = Math.ceil(totalSize / MULTIPART_PART_SIZE); if (totalParts > MAX_MULTIPART_PARTS) throw new HttpError(413, "文件分片数量过多"); await cleanupExpiredMultipart(R2, DB); await reserveStorage(DB, totalSize, limits.MAX_TOTAL_SIZE); const sessionId = crypto.randomUUID(); const objectKey = createObjectKey(fileName); const expiresAt = Math.floor(Date.now() / 1000) + MULTIPART_TTL_SECONDS; let multipartUpload = null; try { await consumeDailyOperation(DB, day, limits.DAILY_MAX_A_CLASS); multipartUpload = await R2.createMultipartUpload(objectKey, { httpMetadata: { contentType: uploadContentType } }); await DB.prepare( `INSERT INTO pan_multipart_uploads (id, upload_id, object_key, file_name, total_size, part_size, total_parts, status, expires_at) VALUES (?, ?, ?, ?, ?, ?, ?, 'uploading', ?)` ).bind( sessionId, multipartUpload.uploadId, objectKey, fileName, totalSize, MULTIPART_PART_SIZE, totalParts, expiresAt ).run(); return jsonResponse({ code: 0, data: { sessionId, partSize: MULTIPART_PART_SIZE, totalParts, uploadedParts: [], expiresAt } }, 201); } catch (error) { if (multipartUpload) { try { await multipartUpload.abort(); } catch (cleanupError) { console.error("Multipart create cleanup failed:", cleanupError); } } await releaseStorage(DB, totalSize); throw error; } } case "multipart/status": { requireMethod(request, "GET"); const sessionId = url.searchParams.get("session"); if (!sessionId || sessionId.length > 64) throw new HttpError(400, "上传会话无效"); const session = await getMultipartSession(DB, sessionId); if (!session) throw new HttpError(404, "上传会话不存在或已结束"); if (Number(session.expires_at) <= Math.floor(Date.now() / 1000)) throw new HttpError(410, "上传会话已过期"); const parts = await DB.prepare( "SELECT part_number, size FROM pan_multipart_parts WHERE session_id = ? ORDER BY part_number" ).bind(sessionId).all(); return jsonResponse({ code: 0, data: { sessionId, name: session.file_name, size: Number(session.total_size), partSize: Number(session.part_size), totalParts: Number(session.total_parts), status: session.status, uploadedParts: parts.results.map(part => ({ partNumber: Number(part.part_number), size: Number(part.size) })), expiresAt: Number(session.expires_at) } }); } case "multipart/part": { requireMethod(request, "PUT"); const sessionId = url.searchParams.get("session"); const partValue = url.searchParams.get("part"); if (!sessionId || sessionId.length > 64 || !partValue || !/^\d+$/.test(partValue)) { throw new HttpError(400, "上传会话或分片编号无效"); } const partNumber = Number(partValue); const session = await getMultipartSession(DB, sessionId); if (!session) throw new HttpError(404, "上传会话不存在或已结束"); if (session.status !== "uploading") throw new HttpError(409, "上传会话正在完成中"); if (Number(session.expires_at) <= Math.floor(Date.now() / 1000)) throw new HttpError(410, "上传会话已过期"); if (!Number.isSafeInteger(partNumber) || partNumber < 1 || partNumber > Number(session.total_parts)) { throw new HttpError(400, "分片编号超出范围"); } const lengthHeader = request.headers.get("Content-Length"); if (!lengthHeader || !/^\d+$/.test(lengthHeader)) throw new HttpError(411, "分片请求必须包含 Content-Length"); const contentLength = Number(lengthHeader); const expectedSize = expectedMultipartPartSize(session, partNumber); if (contentLength !== expectedSize) throw new HttpError(400, `第 ${partNumber} 片大小应为 ${expectedSize} 字节`); if (!request.body) throw new HttpError(400, "缺少分片内容"); await consumeDailyOperation(DB, day, limits.DAILY_MAX_A_CLASS); const upload = R2.resumeMultipartUpload(session.object_key, session.upload_id); const uploadedPart = await upload.uploadPart(partNumber, request.body); await DB.prepare( `INSERT INTO pan_multipart_parts (session_id, part_number, etag, size) VALUES (?, ?, ?, ?) ON CONFLICT(session_id, part_number) DO UPDATE SET etag = excluded.etag, size = excluded.size, uploaded_at = datetime('now')` ).bind(sessionId, partNumber, uploadedPart.etag, contentLength).run(); return jsonResponse({ code: 0, data: { partNumber, size: contentLength } }); } case "multipart/complete": { requireMethod(request, "POST"); const { sessionId } = await readJson(request); if (typeof sessionId !== "string" || !sessionId || sessionId.length > 64) throw new HttpError(400, "上传会话无效"); const session = await getMultipartSession(DB, sessionId); if (!session) throw new HttpError(404, "上传会话不存在或已结束"); if (Number(session.expires_at) <= Math.floor(Date.now() / 1000)) throw new HttpError(410, "上传会话已过期"); const partQuery = await DB.prepare( "SELECT part_number, etag, size FROM pan_multipart_parts WHERE session_id = ? ORDER BY part_number" ).bind(sessionId).all(); if (partQuery.results.length !== Number(session.total_parts)) throw new HttpError(409, "仍有分片未上传完成"); const parts = partQuery.results.map(part => ({ partNumber: Number(part.part_number), etag: part.etag })); for (let index = 0; index < partQuery.results.length; index += 1) { const part = partQuery.results[index]; const partNumber = index + 1; if (Number(part.part_number) !== partNumber || Number(part.size) !== expectedMultipartPartSize(session, partNumber)) { throw new HttpError(409, "分片记录不完整,请重传缺失分片"); } } await consumeDailyOperation(DB, day, limits.DAILY_MAX_A_CLASS); try { await completeMultipartSession(R2, DB, session, parts); } catch (error) { console.error("Multipart complete failed:", sessionId, error); throw new HttpError(503, "合并分片失败,可稍后重试完成操作"); } return jsonResponse({ code: 0, msg: "分片上传完成", data: { key: session.object_key } }); } case "multipart/abort": { requireMethod(request, "POST"); const { sessionId } = await readJson(request); if (typeof sessionId !== "string" || !sessionId || sessionId.length > 64) throw new HttpError(400, "上传会话无效"); const session = await getMultipartSession(DB, sessionId); if (!session) return jsonResponse({ code: 0, msg: "上传会话已结束" }); try { await consumeDailyOperation(DB, day, limits.DAILY_MAX_A_CLASS); } catch (error) { if (!(error instanceof HttpError) || error.status !== 429) throw error; } const object = await R2.head(session.object_key).catch(() => null); if (object && Number(object.size) === Number(session.total_size)) { await finalizeMultipartSession(DB, session); return jsonResponse({ code: 0, msg: "文件已完成上传", data: { completed: true } }); } try { await R2.resumeMultipartUpload(session.object_key, session.upload_id).abort(); } catch (error) { const completedObject = await R2.head(session.object_key).catch(() => null); if (completedObject && Number(completedObject.size) === Number(session.total_size)) { await finalizeMultipartSession(DB, session); return jsonResponse({ code: 0, msg: "文件已完成上传", data: { completed: true } }); } console.error("Multipart abort failed:", sessionId, error); throw new HttpError(503, "取消上传失败,请稍后重试"); } await discardMultipartSession(DB, session); return jsonResponse({ code: 0, msg: "分片上传已取消", data: { completed: false } }); } case "list": { requireMethod(request, "GET"); const page = await listFilesPage(DB, 0, url); return jsonResponse({ code: 0, data: page.data, isAdmin, pagination: { nextCursor: page.nextCursor } }); } case "delete": { requireMethod(request, "POST"); const { key } = await readJson(request); if (typeof key !== "string" || !key) throw new HttpError(400, "缺少 key"); const file = await DB.prepare("SELECT size FROM pan_files WHERE key = ?").bind(key).first(); if (!file) throw new HttpError(404, "文件不存在"); await consumeDailyOperation(DB, day, limits.DAILY_MAX_A_CLASS); const operation = { id: crypto.randomUUID(), file_key: key, size: Number(file.size) || 0 }; let staged = false; try { await DB.batch([ DB.prepare( "INSERT INTO pan_object_operations (id, operation, file_key, size) VALUES (?, 'delete', ?, ?)" ).bind(operation.id, key, operation.size), DB.prepare("UPDATE pan_files SET hidden = 1 WHERE key = ?").bind(key), DB.prepare("DELETE FROM pan_shares WHERE file_key = ?").bind(key) ]); staged = true; await R2.delete(key); await finalizePendingDelete(DB, operation); return jsonResponse({ code: 0, msg: "文件已删除" }); } catch (error) { if (!staged) await rollbackDailyOperation(DB, day); if (staged) throw new HttpError(503, "删除已进入重试队列,请稍后执行列表同步"); throw error; } } case "getDownloadToken": { requireMethod(request, "GET"); if (!fileKey) throw new HttpError(400, "缺少文件 key"); const fileRow = await DB.prepare("SELECT name FROM pan_files WHERE key = ? AND hidden = 0").bind(fileKey).first(); if (!fileRow) throw new HttpError(404, "文件不存在"); const token = await createDownloadToken(limits.DOWNLOAD_SECRET, fileKey, limits.USER_DOWNLOAD_TTL); const previewable = isPreviewable(fileRow.name, limits.PREVIEW_EXTENSIONS); return jsonResponse({ code: 0, token, url: `/${encodeURIComponent(token)}`, previewable }); } case "createShare": { requireMethod(request, "POST"); const { key } = await readJson(request); if (typeof key !== "string" || !key) throw new HttpError(400, "缺少 key"); const fileRow = await DB.prepare("SELECT name FROM pan_files WHERE key = ? AND hidden = 0").bind(key).first(); if (!fileRow) throw new HttpError(404, "文件不存在"); const sharePath = randomToken(); await DB.prepare("INSERT INTO pan_shares (token, file_key, file_name) VALUES (?, ?, ?)").bind(sharePath, key, fileRow.name).run(); return jsonResponse({ code: 0, link: `${url.origin}/${sharePath}` }, 201); } case "listShares": { requireMethod(request, "GET"); const page = await listSharesPage(DB, 0, url); return jsonResponse({ code: 0, data: page.data.map(row => ({ token: row.token, fileName: row.file_name, link: `${url.origin}/${encodeURIComponent(row.token)}`, expireTime: "永久" })), pagination: { nextCursor: page.nextCursor } }); } case "deleteShare": { requireMethod(request, "POST"); const { token } = await readJson(request); if (typeof token !== "string" || !token) throw new HttpError(400, "缺少分享路径"); const result = await DB.prepare("DELETE FROM pan_shares WHERE token = ?").bind(token).run(); if (result.meta.changes === 0) throw new HttpError(404, "分享不存在"); return jsonResponse({ code: 0, msg: "分享已删除" }); } case "updateShareToken": { requireMethod(request, "POST"); const { oldToken, newToken } = await readJson(request); if (typeof oldToken !== "string" || !oldToken) throw new HttpError(400, "缺少原分享路径"); const nextToken = validateSharePath(newToken); if (oldToken === nextToken) throw new HttpError(400, "新旧分享路径相同"); const collision = await DB.prepare("SELECT 1 AS found FROM pan_shares WHERE token = ?").bind(nextToken).first(); if (collision) throw new HttpError(409, "分享路径已被占用"); const result = await DB.prepare("UPDATE pan_shares SET token = ? WHERE token = ?").bind(nextToken, oldToken).run(); if (result.meta.changes === 0) throw new HttpError(404, "原分享不存在"); return jsonResponse({ code: 0, msg: "修改成功", newToken: nextToken }); } case "hideFile": case "unhideFile": { requireMethod(request, "POST"); const { key } = await readJson(request); if (typeof key !== "string" || !key) throw new HttpError(400, "缺少 key"); if (action === "unhideFile") { const pending = await DB.prepare("SELECT 1 AS found FROM pan_object_operations WHERE file_key = ?").bind(key).first(); if (pending) throw new HttpError(409, "文件正在等待删除,暂时不能恢复"); } const hidden = action === "hideFile" ? 1 : 0; const result = await DB.prepare("UPDATE pan_files SET hidden = ? WHERE key = ?").bind(hidden, key).run(); if (result.meta.changes === 0) throw new HttpError(404, "文件不存在"); return jsonResponse({ code: 0, msg: hidden ? "文件已隐藏" : "已取消隐藏" }); } case "listHidden": { requireMethod(request, "GET"); const page = await listFilesPage(DB, 1, url); return jsonResponse({ code: 0, data: page.data, pagination: { nextCursor: page.nextCursor } }); } case "hideShare": case "unhideShare": { requireMethod(request, "POST"); const { token } = await readJson(request); if (typeof token !== "string" || !token) throw new HttpError(400, "缺少分享路径"); const hidden = action === "hideShare" ? 1 : 0; const result = await DB.prepare("UPDATE pan_shares SET hidden = ? WHERE token = ?").bind(hidden, token).run(); if (result.meta.changes === 0) throw new HttpError(404, "分享不存在"); return jsonResponse({ code: 0, msg: hidden ? "分享已移至隐藏列表,原链接仍可访问" : "分享已恢复到常规列表" }); } case "listHiddenShares": { requireMethod(request, "GET"); const page = await listSharesPage(DB, 1, url); return jsonResponse({ code: 0, data: page.data.map(row => ({ token: row.token, fileName: row.file_name, link: `${url.origin}/${encodeURIComponent(row.token)}`, expireTime: "永久" })), pagination: { nextCursor: page.nextCursor } }); } case "updatePass": { requireMethod(request, "POST"); const { type, old: oldPassword, new: newPassword } = await readJson(request); if (type !== "admin" && type !== "user") throw new HttpError(400, "密码类型无效"); if (typeof newPassword !== "string" || newPassword.length < 6 || newPassword.length > 128) { throw new HttpError(400, "新密码长度需为 6-128 位"); } const siteConfig = await getSiteConfig(env, true); const expectedOldPassword = type === "user" && !siteConfig.user ? siteConfig.admin : siteConfig[type]; if (oldPassword !== expectedOldPassword) throw new HttpError(400, "原密码错误"); await persistSiteConfig(env, { [type]: newPassword }); return jsonResponse({ code: 0, msg: "修改成功,已有登录令牌将在到期后失效" }); } case "updateSite": { requireMethod(request, "POST"); const body = await readJson(request); const changes = {}; if (body.siteName !== undefined && body.siteName !== "") { if (typeof body.siteName !== "string" || body.siteName.trim().length > 80) throw new HttpError(400, "网站名称不能超过 80 个字符"); changes.site_name = body.siteName.trim(); } if (body.favicon !== undefined && body.favicon !== "") changes.favicon = normalizeFavicon(body.favicon.trim()); const readNumber = (field, min, max, label) => { if (body[field] === undefined || body[field] === "") return null; const value = Number(body[field]); if (!Number.isFinite(value) || value < min || value > max) throw new HttpError(400, `${label}需在 ${min}-${max} 之间`); return value; }; const maxMultipartSizeMb = readNumber("maxMultipartSizeMb", 5, 1024, "分片上传上限(MiB)"); const maxTotalSizeGb = readNumber("maxTotalSizeGb", 0.1, 10000, "总容量上限(GiB)"); const dailyMaxAClass = readNumber("dailyMaxAClass", 1, 100000, "每日 A 类操作上限"); const userDownloadTtl = readNumber("userDownloadTtl", 60, 3600, "普通用户下载有效期(秒)"); if (maxMultipartSizeMb !== null) changes.max_multipart_size = String(Math.round(maxMultipartSizeMb * 1024 * 1024)); if (maxTotalSizeGb !== null) changes.max_total_size = String(Math.round(maxTotalSizeGb * 1024 * 1024 * 1024)); if (dailyMaxAClass !== null) changes.daily_max_a_class = String(Math.round(dailyMaxAClass)); if (userDownloadTtl !== null) changes.user_download_ttl = String(Math.round(userDownloadTtl)); if (body.previewExtensions !== undefined && body.previewExtensions !== "") { if (typeof body.previewExtensions !== "string") throw new HttpError(400, "预览扩展名格式无效"); changes.preview_extensions = parsePreviewExtensions(body.previewExtensions).join(","); } if (body.previewMimeTypes !== undefined) { const previewExtensions = parsePreviewExtensions(changes.preview_extensions || limits.PREVIEW_EXTENSIONS.join(",")); changes.preview_mime_types = JSON.stringify(parsePreviewMimeTypes(body.previewMimeTypes, previewExtensions)); } const nextMultipartSize = Number(changes.max_multipart_size || limits.MAX_MULTIPART_SIZE); const nextTotalSize = Number(changes.max_total_size || limits.MAX_TOTAL_SIZE); if (nextTotalSize < nextMultipartSize) throw new HttpError(400, "总容量上限不能小于单文件分片上传上限"); await persistAppConfig(DB, changes, env); return jsonResponse({ code: 0, msg: "配置已保存" }); } case "syncFiles": { requireMethod(request, "POST"); try { return jsonResponse({ code: 0, data: await syncFiles(R2, DB) }); } catch (error) { console.error("File sync failed:", error); throw new HttpError(500, "同步失败,请稍后重试"); } } default: throw new HttpError(404, "API 不存在"); } } function getHtml(todayAclass, usedSize, storagePercent, siteName, faviconUrl, MAX_MULTIPART_SIZE, MAX_TOTAL_SIZE, DAILY_MAX_A_CLASS, USER_DOWNLOAD_TTL, PREVIEW_EXTENSIONS, PREVIEW_MIME_TYPES) { const formatSize = (s) => { if (s < 1024) return s + 'B'; if (s < 1024 * 1024) return (s / 1024).toFixed(1) + 'KB'; if (s < 1024 * 1024 * 1024) return (s / 1024 / 1024).toFixed(1) + 'MB'; return (s / 1024 / 1024 / 1024).toFixed(1) + 'GB'; }; const maxMultipartSizeGB = MAX_MULTIPART_SIZE / 1024 / 1024 / 1024; return ` ${siteName}
${siteName}
存储:${formatSize(usedSize)} / ${formatSize(MAX_TOTAL_SIZE)} (${storagePercent}%)
A类:${todayAclass}/${DAILY_MAX_A_CLASS}
B类:按平台额度
单文件 ≤${maxMultipartSizeGB}GB
文件列表
请先登录
`; }