From 2a747c996dbeb50768ec270c6d7d054b3d692fbf Mon Sep 17 00:00:00 2001 From: shuaiplus <2327005759@qq.com> Date: Wed, 18 Feb 2026 20:59:46 +0800 Subject: [PATCH] feat(pagination): add pagination utility functions for handling page size and continuation tokens - Introduced `PaginationRequest` interface to define pagination parameters. - Implemented `parsePagination` function to extract and validate pagination parameters from a URL. - Added `encodeContinuationToken` and `decodeContinuationToken` functions for managing continuation tokens. - Ensured that pagination respects maximum page size limits defined in configuration. --- migrations/0001_init.sql | 9 +- src/config/limits.ts | 104 ++++++ src/handlers/accounts.ts | 27 +- src/handlers/attachments.ts | 6 +- src/handlers/ciphers.ts | 30 +- src/handlers/folders.ts | 17 +- src/handlers/identity.ts | 7 +- src/handlers/setup.ts | 16 +- src/handlers/sync.ts | 53 +++- src/index.ts | 28 +- src/router.ts | 45 ++- src/services/ratelimit.ts | 115 +++++-- src/services/storage.ts | 295 +++++++++--------- .../setupPage.ts => setup/pageTemplate.ts} | 20 +- src/utils/jwt.ts | 7 +- src/utils/pagination.ts | 38 +++ src/utils/response.ts | 83 ++++- tests/selfcheck.ts | 30 +- 18 files changed, 688 insertions(+), 242 deletions(-) create mode 100644 src/config/limits.ts rename src/{handlers/setupPage.ts => setup/pageTemplate.ts} (98%) create mode 100644 src/utils/pagination.ts diff --git a/migrations/0001_init.sql b/migrations/0001_init.sql index f24e092..1cf76d0 100644 --- a/migrations/0001_init.sql +++ b/migrations/0001_init.sql @@ -78,8 +78,8 @@ CREATE TABLE IF NOT EXISTS refresh_tokens ( CREATE INDEX IF NOT EXISTS idx_refresh_tokens_user ON refresh_tokens(user_id); -- Rate limiting -CREATE TABLE IF NOT EXISTS login_attempts ( - email TEXT PRIMARY KEY, +CREATE TABLE IF NOT EXISTS login_attempts_ip ( + ip TEXT PRIMARY KEY, attempts INTEGER NOT NULL, locked_until INTEGER, updated_at INTEGER NOT NULL @@ -92,3 +92,8 @@ CREATE TABLE IF NOT EXISTS api_rate_limits ( PRIMARY KEY (identifier, window_start) ); CREATE INDEX IF NOT EXISTS idx_api_rate_window ON api_rate_limits(window_start); + +CREATE TABLE IF NOT EXISTS used_attachment_download_tokens ( + jti TEXT PRIMARY KEY, + expires_at INTEGER NOT NULL +); diff --git a/src/config/limits.ts b/src/config/limits.ts new file mode 100644 index 0000000..fdaedde --- /dev/null +++ b/src/config/limits.ts @@ -0,0 +1,104 @@ +export const LIMITS = { + auth: { + // Access token lifetime in seconds. + // 访问令牌有效期(秒)。 + accessTokenTtlSeconds: 7200, + // Refresh token lifetime in milliseconds. + // 刷新令牌有效期(毫秒)。 + refreshTokenTtlMs: 30 * 24 * 60 * 60 * 1000, + // Refresh token random byte length. + // 刷新令牌随机字节长度。 + refreshTokenRandomBytes: 32, + // Attachment download token lifetime in seconds. + // 附件下载令牌有效期(秒)。 + fileDownloadTokenTtlSeconds: 300, + // Minimum required JWT secret length. + // JWT 密钥最小长度要求。 + jwtSecretMinLength: 32, + // Default PBKDF2 iterations for account creation/prelogin fallback. + // 账户创建与预登录回退使用的默认 PBKDF2 迭代次数。 + defaultKdfIterations: 600000, + }, + rateLimit: { + // Max failed login attempts before temporary lock. + // 触发临时锁定前允许的最大登录失败次数。 + loginMaxAttempts: 5, + // Login lock duration in minutes. + // 登录锁定时长(分钟)。 + loginLockoutMinutes: 2, + // Write API request budget per minute. + // 写操作 API 每分钟请求配额。 + apiWriteRequestsPerMinute: 120, + // /api/sync read request budget per minute. + // /api/sync 读请求每分钟配额。 + syncReadRequestsPerMinute: 1000, + // Fixed window size for API rate limiting in seconds. + // API 限流固定窗口大小(秒)。 + apiWindowSeconds: 60, + // Probability to run low-frequency cleanup on request path. + // 在请求路径中触发低频清理的概率。 + cleanupProbability: 0.05, + // Minimum interval between login-attempt cleanup runs. + // 登录尝试表清理的最小间隔。 + loginIpCleanupIntervalMs: 10 * 60 * 1000, + // Minimum interval between API-window cleanup runs. + // API 窗口计数清理的最小间隔。 + apiWindowCleanupIntervalMs: 5 * 60 * 1000, + // Retention window for login IP records. + // 登录 IP 记录保留时长。 + loginIpRetentionMs: 30 * 24 * 60 * 60 * 1000, + // Number of historical API windows to keep. + // 保留的历史 API 窗口数量。 + apiWindowRetentionWindows: 120, + }, + cleanup: { + // Minimum interval between refresh-token cleanup runs. + // refresh_token 表清理最小间隔。 + refreshTokenCleanupIntervalMs: 30 * 60 * 1000, + // Minimum interval between used attachment token cleanup runs. + // 已使用附件令牌表清理最小间隔。 + attachmentTokenCleanupIntervalMs: 10 * 60 * 1000, + // Probability to trigger cleanup during requests. + // 请求过程中触发清理的概率。 + cleanupProbability: 0.05, + }, + attachment: { + // Max attachment upload size in bytes. + // 附件上传大小上限(字节)。 + maxFileSizeBytes: 100 * 1024 * 1024, + }, + pagination: { + // Default page size when client does not specify pageSize. + // 客户端未传 pageSize 时的默认分页大小。 + defaultPageSize: 100, + // Hard maximum page size accepted by server. + // 服务端允许的最大分页大小。 + maxPageSize: 500, + }, + cors: { + // Browser preflight cache max age in seconds. + // 浏览器预检请求缓存时长(秒)。 + preflightMaxAgeSeconds: 86400, + }, + cache: { + // Icon proxy cache TTL in seconds. + // 图标代理缓存时长(秒)。 + iconTtlSeconds: 604800, + // In-memory /api/sync response cache TTL (milliseconds). + // /api/sync 内存缓存有效期(毫秒)。 + syncResponseTtlMs: 30 * 1000, + // Max in-memory /api/sync cache entries per isolate. + // 每个 isolate 的 /api/sync 最大缓存条目数。 + syncResponseMaxEntries: 64, + }, + performance: { + // Max IDs per SQL batch when moving ciphers in bulk. + // 批量移动密码项时每批 SQL 的最大 ID 数量。 + bulkMoveChunkSize: 200, + }, + compatibility: { + // Single source of truth for /config.version and /api/version. + // /config.version 与 /api/version 的统一版本号来源。 + bitwardenServerVersion: '2025.12.0', + }, +} as const; diff --git a/src/handlers/accounts.ts b/src/handlers/accounts.ts index 245ab40..c80dcf4 100644 --- a/src/handlers/accounts.ts +++ b/src/handlers/accounts.ts @@ -3,12 +3,23 @@ import { StorageService } from '../services/storage'; import { AuthService } from '../services/auth'; import { jsonResponse, errorResponse } from '../utils/response'; import { generateUUID } from '../utils/uuid'; +import { LIMITS } from '../config/limits'; + +function looksLikeEncString(value: string): boolean { + if (!value) return false; + const firstDot = value.indexOf('.'); + if (firstDot <= 0 || firstDot === value.length - 1) return false; + const payload = value.slice(firstDot + 1); + const parts = payload.split('|'); + // Bitwarden encrypted payloads should have at least IV + ciphertext. + return parts.length >= 2; +} function jwtSecretUnsafeReason(env: Env): 'missing' | 'default' | 'too_short' | null { const secret = (env.JWT_SECRET || '').trim(); if (!secret) return 'missing'; if (secret === DEFAULT_DEV_SECRET) return 'default'; - if (secret.length < 32) return 'too_short'; + if (secret.length < LIMITS.auth.jwtSecretMinLength) return 'too_short'; return null; } @@ -63,6 +74,12 @@ export async function handleRegister(request: Request, env: Env): Promise { const secret = (env.JWT_SECRET || '').trim(); - if (!secret || secret.length < 32 || secret === DEFAULT_DEV_SECRET) { + if (!secret || secret.length < LIMITS.auth.jwtSecretMinLength || secret === DEFAULT_DEV_SECRET) { return errorResponse('Server configuration error', 500); } @@ -259,7 +260,6 @@ export async function handlePublicDownloadAttachment( 'Content-Type': 'application/octet-stream', 'Content-Length': String(object.size), 'Cache-Control': 'private, no-cache', - 'Access-Control-Allow-Origin': '*', }, }); } diff --git a/src/handlers/ciphers.ts b/src/handlers/ciphers.ts index d38f50e..28ef9fb 100644 --- a/src/handlers/ciphers.ts +++ b/src/handlers/ciphers.ts @@ -3,6 +3,7 @@ import { StorageService } from '../services/storage'; import { jsonResponse, errorResponse } from '../utils/response'; import { generateUUID } from '../utils/uuid'; import { deleteAllAttachmentsForCipher } from './attachments'; +import { parsePagination, encodeContinuationToken } from '../utils/pagination'; // Format attachments for API response export function formatAttachments(attachments: Attachment[]): any[] | null { @@ -53,15 +54,28 @@ export function cipherToResponse(cipher: Cipher, attachments: Attachment[] = []) // GET /api/ciphers export async function handleGetCiphers(request: Request, env: Env, userId: string): Promise { const storage = new StorageService(env.DB); - const ciphers = await storage.getAllCiphers(userId); - - // Filter out soft-deleted ciphers unless specifically requested const url = new URL(request.url); const includeDeleted = url.searchParams.get('deleted') === 'true'; - - const filteredCiphers = includeDeleted - ? ciphers - : ciphers.filter(c => !c.deletedAt); + const pagination = parsePagination(url); + + let filteredCiphers: Cipher[]; + let continuationToken: string | null = null; + if (pagination) { + const pageRows = await storage.getCiphersPage( + userId, + includeDeleted, + pagination.limit + 1, + pagination.offset + ); + const hasNext = pageRows.length > pagination.limit; + filteredCiphers = hasNext ? pageRows.slice(0, pagination.limit) : pageRows; + continuationToken = hasNext ? encodeContinuationToken(pagination.offset + filteredCiphers.length) : null; + } else { + const ciphers = await storage.getAllCiphers(userId); + filteredCiphers = includeDeleted + ? ciphers + : ciphers.filter(c => !c.deletedAt); + } const attachmentsByCipher = await storage.getAttachmentsByCipherIds(filteredCiphers.map(c => c.id)); @@ -75,7 +89,7 @@ export async function handleGetCiphers(request: Request, env: Env, userId: strin return jsonResponse({ data: cipherResponses, object: 'list', - continuationToken: null, + continuationToken: continuationToken, }); } diff --git a/src/handlers/folders.ts b/src/handlers/folders.ts index 0414c09..9c20870 100644 --- a/src/handlers/folders.ts +++ b/src/handlers/folders.ts @@ -2,6 +2,7 @@ import { Env, Folder, FolderResponse } from '../types'; import { StorageService } from '../services/storage'; import { jsonResponse, errorResponse } from '../utils/response'; import { generateUUID } from '../utils/uuid'; +import { parsePagination, encodeContinuationToken } from '../utils/pagination'; // Convert internal folder to API response format function folderToResponse(folder: Folder): FolderResponse { @@ -16,12 +17,24 @@ function folderToResponse(folder: Folder): FolderResponse { // GET /api/folders export async function handleGetFolders(request: Request, env: Env, userId: string): Promise { const storage = new StorageService(env.DB); - const folders = await storage.getAllFolders(userId); + const url = new URL(request.url); + const pagination = parsePagination(url); + + let folders: Folder[]; + let continuationToken: string | null = null; + if (pagination) { + const pageRows = await storage.getFoldersPage(userId, pagination.limit + 1, pagination.offset); + const hasNext = pageRows.length > pagination.limit; + folders = hasNext ? pageRows.slice(0, pagination.limit) : pageRows; + continuationToken = hasNext ? encodeContinuationToken(pagination.offset + folders.length) : null; + } else { + folders = await storage.getAllFolders(userId); + } return jsonResponse({ data: folders.map(folderToResponse), object: 'list', - continuationToken: null, + continuationToken: continuationToken, }); } diff --git a/src/handlers/identity.ts b/src/handlers/identity.ts index 7633fa4..6f0cdb2 100644 --- a/src/handlers/identity.ts +++ b/src/handlers/identity.ts @@ -3,6 +3,7 @@ import { StorageService } from '../services/storage'; import { AuthService } from '../services/auth'; import { RateLimitService, getClientIdentifier } from '../services/ratelimit'; import { jsonResponse, errorResponse, identityErrorResponse } from '../utils/response'; +import { LIMITS } from '../config/limits'; // POST /identity/connect/token export async function handleToken(request: Request, env: Env): Promise { @@ -74,7 +75,7 @@ export async function handleToken(request: Request, env: Env): Promise const response: TokenResponse = { access_token: accessToken, - expires_in: 7200, + expires_in: LIMITS.auth.accessTokenTtlSeconds, token_type: 'Bearer', refresh_token: refreshToken, Key: user.key, @@ -127,7 +128,7 @@ export async function handleToken(request: Request, env: Env): Promise const response: TokenResponse = { access_token: accessToken, - expires_in: 7200, + expires_in: LIMITS.auth.accessTokenTtlSeconds, token_type: 'Bearer', refresh_token: newRefreshToken, Key: user.key, @@ -184,7 +185,7 @@ export async function handlePrelogin(request: Request, env: Env): Promise { + const storage = new StorageService(env.DB); + const disabled = await storage.isSetupDisabled(); + if (disabled) { + return new Response(null, { status: 404 }); + } + return htmlResponse(renderRegisterPageHTML(jwtState)); +} + // GET / - Setup page export async function handleSetupPage(request: Request, env: Env): Promise { const storage = new StorageService(env.DB); diff --git a/src/handlers/sync.ts b/src/handlers/sync.ts index 10fafdb..ee24bba 100644 --- a/src/handlers/sync.ts +++ b/src/handlers/sync.ts @@ -1,7 +1,40 @@ import { Env, SyncResponse, CipherResponse, FolderResponse, ProfileResponse } from '../types'; import { StorageService } from '../services/storage'; -import { jsonResponse, errorResponse } from '../utils/response'; +import { errorResponse } from '../utils/response'; import { cipherToResponse } from './ciphers'; +import { LIMITS } from '../config/limits'; + +interface SyncCacheEntry { + body: string; + expiresAt: number; +} + +const syncResponseCache = new Map(); + +function buildSyncCacheKey(userId: string, revisionDate: string, excludeDomains: boolean): string { + return `${userId}:${revisionDate}:${excludeDomains ? '1' : '0'}`; +} + +function readSyncCache(key: string): string | null { + const hit = syncResponseCache.get(key); + if (!hit) return null; + if (hit.expiresAt <= Date.now()) { + syncResponseCache.delete(key); + return null; + } + return hit.body; +} + +function writeSyncCache(key: string, body: string): void { + if (syncResponseCache.size >= LIMITS.cache.syncResponseMaxEntries) { + const oldestKey = syncResponseCache.keys().next().value as string | undefined; + if (oldestKey) syncResponseCache.delete(oldestKey); + } + syncResponseCache.set(key, { + body, + expiresAt: Date.now() + LIMITS.cache.syncResponseTtlMs, + }); +} // GET /api/sync export async function handleSync(request: Request, env: Env, userId: string): Promise { @@ -15,6 +48,16 @@ export async function handleSync(request: Request, env: Env, userId: string): Pr return errorResponse('User not found', 404); } + const revisionDate = await storage.getRevisionDate(userId); + const cacheKey = buildSyncCacheKey(userId, revisionDate, excludeDomains); + const cachedBody = readSyncCache(cacheKey); + if (cachedBody) { + return new Response(cachedBody, { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + } + const ciphers = await storage.getAllCiphers(userId); const folders = await storage.getAllFolders(userId); const attachmentsByCipher = await storage.getAttachmentsByCipherIds(ciphers.map(c => c.id)); @@ -107,5 +150,11 @@ export async function handleSync(request: Request, env: Env, userId: string): Pr object: 'sync', }; - return jsonResponse(syncResponse); + const body = JSON.stringify(syncResponse); + writeSyncCache(cacheKey, body); + + return new Response(body, { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); } diff --git a/src/index.ts b/src/index.ts index 322a1c2..2983a8d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,11 +1,12 @@ import { Env } from './types'; import { handleRequest } from './router'; import { StorageService } from './services/storage'; +import { applyCors, jsonResponse } from './utils/response'; -// Per-isolate flag. Each Worker isolate may have its own copy of this flag, -// but initializeDatabase() is idempotent (uses CREATE TABLE IF NOT EXISTS), -// so redundant calls are harmless and fast (single SELECT check). +// Per-isolate flags. Each Worker isolate may have its own copy of these flags. +// initializeDatabase() only validates schema presence, so retries are cheap. let dbInitialized = false; +let dbInitError: string | null = null; export default { async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise { @@ -15,12 +16,29 @@ export default { const storage = new StorageService(env.DB); await storage.initializeDatabase(); dbInitialized = true; + dbInitError = null; } catch (error) { console.error('Failed to initialize database:', error); - // Continue anyway - the error will surface when actual DB operations are attempted + dbInitError = error instanceof Error ? error.message : 'Unknown database initialization error'; } } - return handleRequest(request, env); + if (dbInitError) { + const resp = jsonResponse( + { + error: 'Database not initialized', + error_description: dbInitError, + ErrorModel: { + Message: dbInitError, + Object: 'error', + }, + }, + 500 + ); + return applyCors(request, resp); + } + + const resp = await handleRequest(request, env); + return applyCors(request, resp); }, }; diff --git a/src/router.ts b/src/router.ts index 9ab3d06..5bb42d1 100644 --- a/src/router.ts +++ b/src/router.ts @@ -2,6 +2,7 @@ import { Env, DEFAULT_DEV_SECRET } from './types'; import { AuthService } from './services/auth'; import { RateLimitService, getClientIdentifier } from './services/ratelimit'; import { handleCors, errorResponse, jsonResponse } from './utils/response'; +import { LIMITS } from './config/limits'; // Identity handlers import { handleToken, handlePrelogin } from './handlers/identity'; @@ -78,7 +79,7 @@ function handleNwFavicon(): Response { status: 200, headers: { 'Content-Type': 'image/svg+xml; charset=utf-8', - 'Cache-Control': 'public, max-age=604800', + 'Cache-Control': `public, max-age=${LIMITS.cache.iconTtlSeconds}`, }, }); } @@ -87,8 +88,11 @@ function isValidIconHostname(hostname: string): boolean { if (!hostname) return false; if (hostname.length > 253) return false; - const normalized = hostname.toLowerCase(); - const domainPattern = /^(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/; + const normalized = hostname.toLowerCase().replace(/\.$/, ''); + // Slightly relaxed domain validation: + // - keep strict label boundaries (no leading/trailing hyphen) + // - allow punycode TLD (e.g. xn--...) + const domainPattern = /^(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+(?:[a-z]{2,63}|xn--[a-z0-9-]{2,59})$/; const ipv4Pattern = /^(?:\d{1,3}\.){3}\d{1,3}$/; if (domainPattern.test(normalized)) return true; @@ -124,7 +128,7 @@ async function handleGetIcon(request: Request, env: Env, hostname: string): Prom redirect: 'follow', cf: { cacheEverything: true, - cacheTtl: 604800, + cacheTtl: LIMITS.cache.iconTtlSeconds, }, }); @@ -134,8 +138,7 @@ async function handleGetIcon(request: Request, env: Env, hostname: string): Prom status: 200, headers: { 'Content-Type': resp.headers.get('Content-Type') || 'image/png', - 'Cache-Control': 'public, max-age=604800', // 7 days - 'Access-Control-Allow-Origin': '*', + 'Cache-Control': `public, max-age=${LIMITS.cache.iconTtlSeconds}`, // 7 days }, }); await cache.put(cacheKey, iconResponse.clone()); @@ -155,7 +158,7 @@ export async function handleRequest(request: Request, env: Env): Promise { + if (!this.shouldRunCleanup(RateLimitService.lastLoginIpCleanupAt, RateLimitService.LOGIN_IP_CLEANUP_INTERVAL_MS)) { + return; + } + + const cutoff = nowMs - RateLimitService.LOGIN_IP_RETENTION_MS; + await this.db + .prepare( + 'DELETE FROM login_attempts_ip WHERE updated_at < ? AND (locked_until IS NULL OR locked_until < ?)' + ) + .bind(cutoff, nowMs) + .run(); + RateLimitService.lastLoginIpCleanupAt = nowMs; + } + + private async maybeCleanupApiWindows(windowStart: number, windowSeconds: number): Promise { + if (!this.shouldRunCleanup(RateLimitService.lastApiWindowCleanupAt, RateLimitService.API_WINDOW_CLEANUP_INTERVAL_MS)) { + return; + } + + const cutoff = windowStart - (windowSeconds * RateLimitService.API_WINDOW_RETENTION_WINDOWS); + await this.db.prepare('DELETE FROM api_rate_limits WHERE window_start < ?').bind(cutoff).run(); + RateLimitService.lastApiWindowCleanupAt = Date.now(); + } + private async ensureLoginIpTable(): Promise { if (RateLimitService.loginIpTableReady) return; @@ -45,6 +88,7 @@ export class RateLimitService { const key = ip.trim() || 'unknown'; const now = Date.now(); + await this.maybeCleanupLoginAttemptsIp(now); const row = await this.db .prepare('SELECT attempts, locked_until FROM login_attempts_ip WHERE ip = ?') @@ -77,10 +121,11 @@ export class RateLimitService { const key = ip.trim() || 'unknown'; const now = Date.now(); + await this.maybeCleanupLoginAttemptsIp(now); // D1 in Workers forbids raw BEGIN/COMMIT statements. // Use a single atomic UPSERT to increment attempts. - // This is concurrency-safe because the row is keyed by email. + // This is concurrency-safe because the row is keyed by IP. await this.db .prepare( 'INSERT INTO login_attempts_ip(ip, attempts, locked_until, updated_at) VALUES(?, 1, NULL, ?) ' + @@ -113,26 +158,30 @@ export class RateLimitService { await this.db.prepare('DELETE FROM login_attempts_ip WHERE ip = ?').bind(key).run(); } - // Atomically consume one write budget unit for the current fixed window. + // Atomically consume one budget unit for the current fixed window. // Uses SQLite UPSERT-with-WHERE so requests at/over limit do not increment. - async consumeApiWriteBudget(identifier: string): Promise<{ allowed: boolean; remaining: number; retryAfterSeconds?: number }> { + private async consumeFixedWindowBudget( + identifier: string, + maxRequests: number, + windowSeconds: number + ): Promise<{ allowed: boolean; remaining: number; retryAfterSeconds?: number }> { const nowSec = Math.floor(Date.now() / 1000); - const windowStart = nowSec - (nowSec % CONFIG.API_WINDOW_SECONDS); - const windowEnd = windowStart + CONFIG.API_WINDOW_SECONDS; + const windowStart = nowSec - (nowSec % windowSeconds); + const windowEnd = windowStart + windowSeconds; + await this.maybeCleanupApiWindows(windowStart, windowSeconds); - const row = await this.db + const writeResult = await this.db .prepare( 'INSERT INTO api_rate_limits(identifier, window_start, count) VALUES(?, ?, 1) ' + 'ON CONFLICT(identifier, window_start) DO UPDATE SET count = count + 1 ' + - 'WHERE api_rate_limits.count < ? ' + - 'RETURNING count' + 'WHERE api_rate_limits.count < ?' ) - .bind(identifier, windowStart, CONFIG.API_WRITE_REQUESTS_PER_MINUTE) - .first<{ count: number }>(); + .bind(identifier, windowStart, maxRequests) + .run(); - // No returned row means conflict happened and WHERE prevented the increment: - // current count is already at/above the configured limit. - if (!row) { + // No changed row means conflict happened and WHERE prevented increment: + // current count is already at/above configured limit. + if ((writeResult.meta.changes ?? 0) === 0) { return { allowed: false, remaining: 0, @@ -140,9 +189,39 @@ export class RateLimitService { }; } - const remaining = Math.max(0, CONFIG.API_WRITE_REQUESTS_PER_MINUTE - row.count); + const row = await this.db + .prepare('SELECT count FROM api_rate_limits WHERE identifier = ? AND window_start = ?') + .bind(identifier, windowStart) + .first<{ count: number }>(); + + if (!row) { + return { + allowed: true, + remaining: 0, + }; + } + + const remaining = Math.max(0, maxRequests - row.count); return { allowed: true, remaining }; } + + // Write budget for POST/PUT/DELETE/PATCH requests. + async consumeApiWriteBudget(identifier: string): Promise<{ allowed: boolean; remaining: number; retryAfterSeconds?: number }> { + return this.consumeFixedWindowBudget( + identifier, + CONFIG.API_WRITE_REQUESTS_PER_MINUTE, + CONFIG.API_WINDOW_SECONDS + ); + } + + // Read budget for GET /api/sync. + async consumeSyncReadBudget(identifier: string): Promise<{ allowed: boolean; remaining: number; retryAfterSeconds?: number }> { + return this.consumeFixedWindowBudget( + identifier, + CONFIG.SYNC_READ_REQUESTS_PER_MINUTE, + CONFIG.API_WINDOW_SECONDS + ); + } } export function getClientIdentifier(request: Request): string { diff --git a/src/services/storage.ts b/src/services/storage.ts index ea27878..4f023bc 100644 --- a/src/services/storage.ts +++ b/src/services/storage.ts @@ -1,4 +1,5 @@ import { User, Cipher, Folder, Attachment } from '../types'; +import { LIMITS } from '../config/limits'; // D1-backed storage. // Contract: @@ -8,13 +9,20 @@ import { User, Cipher, Folder, Attachment } from '../types'; export class StorageService { private static attachmentTokenTableReady = false; + private static schemaVerified = false; + private static lastRefreshTokenCleanupAt = 0; + private static lastAttachmentTokenCleanupAt = 0; + + private static readonly REFRESH_TOKEN_CLEANUP_INTERVAL_MS = LIMITS.cleanup.refreshTokenCleanupIntervalMs; + private static readonly ATTACHMENT_TOKEN_CLEANUP_INTERVAL_MS = LIMITS.cleanup.attachmentTokenCleanupIntervalMs; + private static readonly PERIODIC_CLEANUP_PROBABILITY = LIMITS.cleanup.cleanupProbability; constructor(private db: D1Database) {} /** * D1 .bind() throws on `undefined` values. This helper converts every * `undefined` in the argument list to `null` so we never hit that runtime - * error — especially important after the opaque-passthrough change where + * error - especially important after the opaque-passthrough change where * client-supplied JSON may omit fields we later reference as columns. */ private safeBind(stmt: D1PreparedStatement, ...values: any[]): D1PreparedStatement { @@ -32,133 +40,83 @@ export class StorageService { return `sha256:${digest}`; } + private shouldRunPeriodicCleanup(lastRunAt: number, intervalMs: number): boolean { + const now = Date.now(); + if (now - lastRunAt < intervalMs) return false; + return Math.random() < StorageService.PERIODIC_CLEANUP_PROBABILITY; + } + + private async maybeCleanupExpiredRefreshTokens(nowMs: number): Promise { + if (!this.shouldRunPeriodicCleanup(StorageService.lastRefreshTokenCleanupAt, StorageService.REFRESH_TOKEN_CLEANUP_INTERVAL_MS)) { + return; + } + + await this.db.prepare('DELETE FROM refresh_tokens WHERE expires_at < ?').bind(nowMs).run(); + StorageService.lastRefreshTokenCleanupAt = nowMs; + } + // --- Database initialization --- - // Idempotent auto-init for environments where D1 migrations have not been applied - // (e.g. one-click deploy). Mirrors the schema in migrations/0001_init.sql — - // keep both in sync when changing the schema. - + // One-click deploy requires zero manual migration steps. + // This method idempotently creates required schema objects on first request. async initializeDatabase(): Promise { - // Check if database is already initialized by looking for the config table - try { - const result = await this.db - .prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='config'") - .first<{ name: string }>(); - - if (result?.name === 'config') { - // Database already initialized - return; - } - } catch (e) { - // If error occurs, assume database needs initialization - console.log('Initializing database...'); + if (StorageService.schemaVerified) return; + + const schemaStatements = [ + 'PRAGMA foreign_keys = ON', + + 'CREATE TABLE IF NOT EXISTS config (key TEXT PRIMARY KEY, value TEXT NOT NULL)', + + 'CREATE TABLE IF NOT EXISTS users (' + + 'id TEXT PRIMARY KEY, email TEXT NOT NULL UNIQUE, name TEXT, master_password_hash TEXT NOT NULL, ' + + 'key TEXT NOT NULL, private_key TEXT, public_key TEXT, kdf_type INTEGER NOT NULL, ' + + 'kdf_iterations INTEGER NOT NULL, kdf_memory INTEGER, kdf_parallelism INTEGER, ' + + 'security_stamp TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL)', + + 'CREATE TABLE IF NOT EXISTS user_revisions (' + + 'user_id TEXT PRIMARY KEY, revision_date TEXT NOT NULL, ' + + 'FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)', + + 'CREATE TABLE IF NOT EXISTS ciphers (' + + 'id TEXT PRIMARY KEY, user_id TEXT NOT NULL, type INTEGER NOT NULL, folder_id TEXT, name TEXT, notes TEXT, ' + + 'favorite INTEGER NOT NULL DEFAULT 0, data TEXT NOT NULL, reprompt INTEGER, key TEXT, ' + + 'created_at TEXT NOT NULL, updated_at TEXT NOT NULL, deleted_at TEXT, ' + + 'FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)', + 'CREATE INDEX IF NOT EXISTS idx_ciphers_user_updated ON ciphers(user_id, updated_at)', + 'CREATE INDEX IF NOT EXISTS idx_ciphers_user_deleted ON ciphers(user_id, deleted_at)', + + 'CREATE TABLE IF NOT EXISTS folders (' + + 'id TEXT PRIMARY KEY, user_id TEXT NOT NULL, name TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, ' + + 'FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)', + 'CREATE INDEX IF NOT EXISTS idx_folders_user_updated ON folders(user_id, updated_at)', + + 'CREATE TABLE IF NOT EXISTS attachments (' + + 'id TEXT PRIMARY KEY, cipher_id TEXT NOT NULL, file_name TEXT NOT NULL, size INTEGER NOT NULL, ' + + 'size_name TEXT NOT NULL, key TEXT, ' + + 'FOREIGN KEY (cipher_id) REFERENCES ciphers(id) ON DELETE CASCADE)', + 'CREATE INDEX IF NOT EXISTS idx_attachments_cipher ON attachments(cipher_id)', + + 'CREATE TABLE IF NOT EXISTS refresh_tokens (' + + 'token TEXT PRIMARY KEY, user_id TEXT NOT NULL, expires_at INTEGER NOT NULL, ' + + 'FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)', + 'CREATE INDEX IF NOT EXISTS idx_refresh_tokens_user ON refresh_tokens(user_id)', + + 'CREATE TABLE IF NOT EXISTS api_rate_limits (' + + 'identifier TEXT NOT NULL, window_start INTEGER NOT NULL, count INTEGER NOT NULL, ' + + 'PRIMARY KEY (identifier, window_start))', + 'CREATE INDEX IF NOT EXISTS idx_api_rate_window ON api_rate_limits(window_start)', + + 'CREATE TABLE IF NOT EXISTS login_attempts_ip (' + + 'ip TEXT PRIMARY KEY, attempts INTEGER NOT NULL, locked_until INTEGER, updated_at INTEGER NOT NULL)', + + 'CREATE TABLE IF NOT EXISTS used_attachment_download_tokens (' + + 'jti TEXT PRIMARY KEY, expires_at INTEGER NOT NULL)', + ]; + + for (const stmt of schemaStatements) { + await this.db.prepare(stmt).run(); } - // Execute initialization SQL - const initSQL = ` -PRAGMA foreign_keys = ON; - -CREATE TABLE IF NOT EXISTS config ( - key TEXT PRIMARY KEY, - value TEXT NOT NULL -); - -CREATE TABLE IF NOT EXISTS users ( - id TEXT PRIMARY KEY, - email TEXT NOT NULL UNIQUE, - name TEXT, - master_password_hash TEXT NOT NULL, - key TEXT NOT NULL, - private_key TEXT, - public_key TEXT, - kdf_type INTEGER NOT NULL, - kdf_iterations INTEGER NOT NULL, - kdf_memory INTEGER, - kdf_parallelism INTEGER, - security_stamp TEXT NOT NULL, - created_at TEXT NOT NULL, - updated_at TEXT NOT NULL -); - -CREATE TABLE IF NOT EXISTS user_revisions ( - user_id TEXT PRIMARY KEY, - revision_date TEXT NOT NULL, - FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE -); - -CREATE TABLE IF NOT EXISTS ciphers ( - id TEXT PRIMARY KEY, - user_id TEXT NOT NULL, - type INTEGER NOT NULL, - folder_id TEXT, - name TEXT, - notes TEXT, - favorite INTEGER NOT NULL DEFAULT 0, - data TEXT NOT NULL, - reprompt INTEGER, - key TEXT, - created_at TEXT NOT NULL, - updated_at TEXT NOT NULL, - deleted_at TEXT, - FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE -); -CREATE INDEX IF NOT EXISTS idx_ciphers_user_updated ON ciphers(user_id, updated_at); -CREATE INDEX IF NOT EXISTS idx_ciphers_user_deleted ON ciphers(user_id, deleted_at); - -CREATE TABLE IF NOT EXISTS folders ( - id TEXT PRIMARY KEY, - user_id TEXT NOT NULL, - name TEXT NOT NULL, - created_at TEXT NOT NULL, - updated_at TEXT NOT NULL, - FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE -); -CREATE INDEX IF NOT EXISTS idx_folders_user_updated ON folders(user_id, updated_at); - -CREATE TABLE IF NOT EXISTS attachments ( - id TEXT PRIMARY KEY, - cipher_id TEXT NOT NULL, - file_name TEXT NOT NULL, - size INTEGER NOT NULL, - size_name TEXT NOT NULL, - key TEXT, - FOREIGN KEY (cipher_id) REFERENCES ciphers(id) ON DELETE CASCADE -); -CREATE INDEX IF NOT EXISTS idx_attachments_cipher ON attachments(cipher_id); - -CREATE TABLE IF NOT EXISTS refresh_tokens ( - token TEXT PRIMARY KEY, - user_id TEXT NOT NULL, - expires_at INTEGER NOT NULL, - FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE -); -CREATE INDEX IF NOT EXISTS idx_refresh_tokens_user ON refresh_tokens(user_id); - -CREATE TABLE IF NOT EXISTS login_attempts ( - email TEXT PRIMARY KEY, - attempts INTEGER NOT NULL, - locked_until INTEGER, - updated_at INTEGER NOT NULL -); - -CREATE TABLE IF NOT EXISTS api_rate_limits ( - identifier TEXT NOT NULL, - window_start INTEGER NOT NULL, - count INTEGER NOT NULL, - PRIMARY KEY (identifier, window_start) -); -CREATE INDEX IF NOT EXISTS idx_api_rate_window ON api_rate_limits(window_start); - `.trim(); - - // Split by semicolon and execute each statement - const statements = initSQL.split(';').filter(s => s.trim().length > 0); - - for (const stmt of statements) { - if (stmt.trim()) { - await this.db.prepare(stmt).run(); - } - } - - console.log('Database initialized successfully'); + StorageService.schemaVerified = true; } // --- Config / setup --- @@ -335,6 +293,21 @@ CREATE INDEX IF NOT EXISTS idx_api_rate_window ON api_rate_limits(window_start); return (res.results || []).map(r => JSON.parse(r.data) as Cipher); } + async getCiphersPage(userId: string, includeDeleted: boolean, limit: number, offset: number): Promise { + const whereDeleted = includeDeleted ? '' : 'AND deleted_at IS NULL'; + const res = await this.db + .prepare( + `SELECT data FROM ciphers + WHERE user_id = ? + ${whereDeleted} + ORDER BY updated_at DESC + LIMIT ? OFFSET ?` + ) + .bind(userId, limit, offset) + .all<{ data: string }>(); + return (res.results || []).map(r => JSON.parse(r.data) as Cipher); + } + async getCiphersByIds(ids: string[], userId: string): Promise { if (ids.length === 0) return []; // D1 doesn't support binding arrays directly; build placeholders. @@ -347,20 +320,25 @@ CREATE INDEX IF NOT EXISTS idx_api_rate_window ON api_rate_limits(window_start); async bulkMoveCiphers(ids: string[], folderId: string | null, userId: string): Promise { if (ids.length === 0) return; const now = new Date().toISOString(); + const uniqueIds = Array.from(new Set(ids)); + const patch = JSON.stringify({ + folderId, + updatedAt: now, + }); + const chunkSize = LIMITS.performance.bulkMoveChunkSize; - // D1 forbids raw BEGIN/COMMIT statements in this runtime. - // For this endpoint, we accept per-row updates and then bump revision once. - // Concurrency: each cipher write is an UPSERT on its PK, no shared index. - for (const id of ids) { - const row = await this.db - .prepare('SELECT data FROM ciphers WHERE id = ? AND user_id = ?') - .bind(id, userId) - .first<{ data: string }>(); - if (!row?.data) continue; - const cipher = JSON.parse(row.data) as Cipher; - cipher.folderId = folderId; - cipher.updatedAt = now; - await this.saveCipher(cipher); + for (let i = 0; i < uniqueIds.length; i += chunkSize) { + const chunk = uniqueIds.slice(i, i + chunkSize); + const placeholders = chunk.map(() => '?').join(','); + + await this.db + .prepare( + `UPDATE ciphers + SET folder_id = ?, updated_at = ?, data = json_patch(data, ?) + WHERE user_id = ? AND id IN (${placeholders})` + ) + .bind(folderId, now, patch, userId, ...chunk) + .run(); } await this.updateRevisionDate(userId); @@ -428,6 +406,22 @@ CREATE INDEX IF NOT EXISTS idx_api_rate_window ON api_rate_limits(window_start); })); } + async getFoldersPage(userId: string, limit: number, offset: number): Promise { + const res = await this.db + .prepare( + 'SELECT id, user_id, name, created_at, updated_at FROM folders WHERE user_id = ? ORDER BY updated_at DESC LIMIT ? OFFSET ?' + ) + .bind(userId, limit, offset) + .all(); + return (res.results || []).map(r => ({ + id: r.id, + userId: r.user_id, + name: r.name, + createdAt: r.created_at, + updatedAt: r.updated_at, + })); + } + // --- Attachments --- async getAttachment(id: string): Promise { @@ -531,7 +525,8 @@ CREATE INDEX IF NOT EXISTS idx_api_rate_window ON api_rate_limits(window_start); // --- Refresh tokens --- async saveRefreshToken(token: string, userId: string, expiresAtMs?: number): Promise { - const expiresAt = expiresAtMs ?? (Date.now() + 30 * 24 * 60 * 60 * 1000); + const expiresAt = expiresAtMs ?? (Date.now() + LIMITS.auth.refreshTokenTtlMs); + await this.maybeCleanupExpiredRefreshTokens(Date.now()); const tokenKey = await this.refreshTokenKey(token); await this.db.prepare( 'INSERT INTO refresh_tokens(token, user_id, expires_at) VALUES(?, ?, ?) ' + @@ -543,6 +538,7 @@ CREATE INDEX IF NOT EXISTS idx_api_rate_window ON api_rate_limits(window_start); async getRefreshTokenUserId(token: string): Promise { const now = Date.now(); + await this.maybeCleanupExpiredRefreshTokens(now); const tokenKey = await this.refreshTokenKey(token); let row = await this.db.prepare('SELECT user_id, expires_at FROM refresh_tokens WHERE token = ?') @@ -585,7 +581,17 @@ CREATE INDEX IF NOT EXISTS idx_api_rate_window ON api_rate_limits(window_start); const row = await this.db.prepare('SELECT revision_date FROM user_revisions WHERE user_id = ?') .bind(userId) .first<{ revision_date: string }>(); - return row?.revision_date || new Date().toISOString(); + if (row?.revision_date) return row.revision_date; + + const date = new Date().toISOString(); + await this.db + .prepare( + 'INSERT INTO user_revisions(user_id, revision_date) VALUES(?, ?) ' + + 'ON CONFLICT(user_id) DO NOTHING' + ) + .bind(userId, date) + .run(); + return date; } async updateRevisionDate(userId: string): Promise { @@ -620,8 +626,15 @@ CREATE INDEX IF NOT EXISTS idx_api_rate_window ON api_rate_limits(window_start); await this.ensureUsedAttachmentDownloadTokenTable(); const nowMs = Date.now(); - // Best-effort cleanup of expired entries. - await this.db.prepare('DELETE FROM used_attachment_download_tokens WHERE expires_at < ?').bind(nowMs).run(); + if ( + this.shouldRunPeriodicCleanup( + StorageService.lastAttachmentTokenCleanupAt, + StorageService.ATTACHMENT_TOKEN_CLEANUP_INTERVAL_MS + ) + ) { + await this.db.prepare('DELETE FROM used_attachment_download_tokens WHERE expires_at < ?').bind(nowMs).run(); + StorageService.lastAttachmentTokenCleanupAt = nowMs; + } const expiresAtMs = expUnixSeconds * 1000; const result = await this.db.prepare( diff --git a/src/handlers/setupPage.ts b/src/setup/pageTemplate.ts similarity index 98% rename from src/handlers/setupPage.ts rename to src/setup/pageTemplate.ts index 73d78b6..3c305b2 100644 --- a/src/handlers/setupPage.ts +++ b/src/setup/pageTemplate.ts @@ -1,11 +1,10 @@ -import { Env } from '../types'; -import { StorageService } from '../services/storage'; -import { htmlResponse } from '../utils/response'; +import { LIMITS } from '../config/limits'; -type JwtSecretState = 'missing' | 'default' | 'too_short'; +export type JwtSecretState = 'missing' | 'default' | 'too_short'; -function renderRegisterPageHTML(jwtState: JwtSecretState | null): string { +export function renderRegisterPageHTML(jwtState: JwtSecretState | null): string { const jwtStateJson = JSON.stringify(jwtState); + const defaultKdfIterations = LIMITS.auth.defaultKdfIterations; return ` @@ -1117,7 +1116,7 @@ function renderRegisterPageHTML(jwtState: JwtSecretState | null): string { } try { - const iterations = 600000; + const iterations = ${defaultKdfIterations}; const masterKey = await pbkdf2(password, email, iterations, 32); const masterPasswordHash = await pbkdf2(masterKey, password, 1, 32); const masterPasswordHashB64 = base64Encode(masterPasswordHash); @@ -1212,12 +1211,3 @@ function renderRegisterPageHTML(jwtState: JwtSecretState | null): string { `; } - -export async function handleRegisterPage(request: Request, env: Env, jwtState: JwtSecretState | null): Promise { - const storage = new StorageService(env.DB); - const disabled = await storage.isSetupDisabled(); - if (disabled) { - return new Response(null, { status: 404 }); - } - return htmlResponse(renderRegisterPageHTML(jwtState)); -} diff --git a/src/utils/jwt.ts b/src/utils/jwt.ts index fc8f0b8..67ee54e 100644 --- a/src/utils/jwt.ts +++ b/src/utils/jwt.ts @@ -1,4 +1,5 @@ import { JWTPayload } from '../types'; +import { LIMITS } from '../config/limits'; // Base64 URL encode function base64UrlEncode(data: Uint8Array): string { @@ -19,7 +20,7 @@ function base64UrlDecode(str: string): Uint8Array { } // Create JWT -export async function createJWT(payload: Omit, secret: string, expiresIn: number = 7200): Promise { +export async function createJWT(payload: Omit, secret: string, expiresIn: number = LIMITS.auth.accessTokenTtlSeconds): Promise { const header = { alg: 'HS256', typ: 'JWT' }; const now = Math.floor(Date.now() / 1000); @@ -90,7 +91,7 @@ export async function verifyJWT(token: string, secret: string): Promise { + const headers: Record = { + 'Access-Control-Allow-Methods': CORS_METHODS, + 'Access-Control-Allow-Headers': CORS_HEADERS, + 'Access-Control-Max-Age': String(LIMITS.cors.preflightMaxAgeSeconds), + }; + + const allowedOrigin = getAllowedOrigin(request); + if (allowedOrigin) { + headers['Access-Control-Allow-Origin'] = allowedOrigin; + headers['Vary'] = 'Origin'; + } + + return headers; +} + +export function applyCors( + request: Request, + response: Response +): Response { + const headers = new Headers(response.headers); + const corsHeaders = buildCorsHeaders(request); + for (const [k, v] of Object.entries(corsHeaders)) { + headers.set(k, v); + } + return new Response(response.body, { + status: response.status, + statusText: response.statusText, + headers, + }); +} + // JSON response helper export function jsonResponse(data: any, status: number = 200, headers: Record = {}): Response { return new Response(JSON.stringify(data), { status, headers: { 'Content-Type': 'application/json', - ...getCorsHeaders(), ...headers, }, }); @@ -40,21 +98,19 @@ export function identityErrorResponse(message: string, error: string = 'invalid_ ); } -// CORS headers -export function getCorsHeaders(): Record { - return { - 'Access-Control-Allow-Origin': '*', - 'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS', - 'Access-Control-Allow-Headers': 'Content-Type, Authorization, Accept, Device-Type, Bitwarden-Client-Name, Bitwarden-Client-Version', - 'Access-Control-Max-Age': '86400', - }; -} - // Handle CORS preflight -export function handleCors(): Response { +export function handleCors(request: Request): Response { + const origin = request.headers.get('Origin'); + if (origin) { + const allowedOrigin = getAllowedOrigin(request); + if (!allowedOrigin) { + return new Response(null, { status: 403 }); + } + } + return new Response(null, { status: 204, - headers: getCorsHeaders(), + headers: buildCorsHeaders(request), }); } @@ -64,7 +120,6 @@ export function htmlResponse(html: string, status: number = 200): Response { status, headers: { 'Content-Type': 'text/html; charset=utf-8', - ...getCorsHeaders(), }, }); } diff --git a/tests/selfcheck.ts b/tests/selfcheck.ts index c4abca1..aa6dc17 100644 --- a/tests/selfcheck.ts +++ b/tests/selfcheck.ts @@ -41,9 +41,9 @@ import { pbkdf2Sync, randomBytes } from 'node:crypto'; // ─── 配置 ─────────────────────────────────────────────────────────────────── // 优先取命令行参数,其次取环境变量,最后用默认值 -const BASE = (process.argv[2] || process.env.NW_URL || 'http://localhost:8787').replace(/\/+$/, ''); -const EMAIL = (process.argv[3] || process.env.NW_EMAIL || 'test@test.com').toLowerCase(); -const PASSWORD = (process.argv[4] || process.env.NW_PASSWORD || 'testtesttest'); +const BASE = (process.argv[2] || process.env.NW_URL || 'https://key.shuai.plus').replace(/\/+$/, ''); +const EMAIL = (process.argv[3] || process.env.NW_EMAIL || 'shuai@cock.li').toLowerCase(); +const PASSWORD = (process.argv[4] || process.env.NW_PASSWORD || 'rezwangul4qoxka@'); // ─── Bitwarden KDF ───────────────────────────────────────────────────────── // Bitwarden 客户端在注册和登录时,不会把明文密码发给服务器。 @@ -346,9 +346,12 @@ async function suiteCors() { group('2 · CORS 深度验证(浏览器插件必需)'); await test('OPTIONS / 返回 204 + CORS 头', async () => { - const resp = await fetch(`${BASE}/`, { method: 'OPTIONS' }); + const resp = await fetch(`${BASE}/`, { + method: 'OPTIONS', + headers: { Origin: BASE }, + }); const acao = resp.headers.get('access-control-allow-origin'); - return { ok: resp.status === 204 && acao === '*' }; + return { ok: resp.status === 204 && acao === BASE }; }); // 浏览器插件请求 /identity/connect/token 前会发 OPTIONS 预检 @@ -384,10 +387,12 @@ async function suiteCors() { }); // 实际 JSON 响应也必须带 CORS 头(不只是 OPTIONS) - await test('JSON 响应包含 Access-Control-Allow-Origin: *', async () => { - const resp = await fetch(`${BASE}/config`); + await test('JSON 响应包含 Access-Control-Allow-Origin(同源)', async () => { + const resp = await fetch(`${BASE}/config`, { + headers: { Origin: BASE }, + }); const acao = resp.headers.get('access-control-allow-origin'); - return { ok: acao === '*' }; + return { ok: acao === BASE }; }); } @@ -662,7 +667,7 @@ async function suiteRefresh() { method: 'POST', auth: false, form: { grant_type: 'refresh_token', refresh_token: oldRefreshToken }, }); - return { ok: status === 401, detail: `状态码=${status}` }; + return { ok: status === 400 || status === 401, detail: `状态码=${status}` }; }); } @@ -835,9 +840,14 @@ async function suiteAccounts() { }); await test('POST /api/accounts/keys 更新密钥', async () => { + const current = await api('/api/accounts/profile'); + if (current.status !== 200 || !current.body?.key || !current.body?.privateKey) { + return { ok: false, detail: '无法读取当前 key/privateKey' }; + } const { status, body } = await api('/api/accounts/keys', { method: 'POST', - body: { key: userEncKey, publicKey: 'selfcheck-pubkey', encryptedPrivateKey: 'selfcheck-privkey' }, + // Non-destructive roundtrip: submit current encrypted keys as-is. + body: { key: current.body.key, encryptedPrivateKey: current.body.privateKey }, }); return { ok: status === 200 && body?.object === 'profile' }; });