// ========== 全局配置与工具函数 ========== 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 `