mirror of
https://github.com/shuaiplus/nodewarden.git
synced 2026-06-20 21:00:41 +00:00
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.
This commit is contained in:
@@ -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;
|
||||
@@ -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<Respon
|
||||
if (!privateKey || !publicKey) {
|
||||
return errorResponse('Private key and public key are required', 400);
|
||||
}
|
||||
if (!looksLikeEncString(key)) {
|
||||
return errorResponse('key is not a valid encrypted string', 400);
|
||||
}
|
||||
if (!looksLikeEncString(privateKey)) {
|
||||
return errorResponse('encryptedPrivateKey is not a valid encrypted string', 400);
|
||||
}
|
||||
|
||||
// Create user
|
||||
const user: User = {
|
||||
@@ -74,7 +91,7 @@ export async function handleRegister(request: Request, env: Env): Promise<Respon
|
||||
privateKey: privateKey,
|
||||
publicKey: publicKey,
|
||||
kdfType: body.kdf ?? 0,
|
||||
kdfIterations: body.kdfIterations ?? 600000,
|
||||
kdfIterations: body.kdfIterations ?? LIMITS.auth.defaultKdfIterations,
|
||||
kdfMemory: body.kdfMemory,
|
||||
kdfParallelism: body.kdfParallelism,
|
||||
securityStamp: generateUUID(),
|
||||
@@ -178,6 +195,12 @@ export async function handleSetKeys(request: Request, env: Env, userId: string):
|
||||
if (body.key) user.key = body.key;
|
||||
if (body.encryptedPrivateKey) user.privateKey = body.encryptedPrivateKey;
|
||||
if (body.publicKey) user.publicKey = body.publicKey;
|
||||
if (body.key && !looksLikeEncString(body.key)) {
|
||||
return errorResponse('key is not a valid encrypted string', 400);
|
||||
}
|
||||
if (body.encryptedPrivateKey && !looksLikeEncString(body.encryptedPrivateKey)) {
|
||||
return errorResponse('encryptedPrivateKey is not a valid encrypted string', 400);
|
||||
}
|
||||
user.updatedAt = new Date().toISOString();
|
||||
|
||||
await storage.saveUser(user);
|
||||
|
||||
@@ -4,6 +4,7 @@ import { jsonResponse, errorResponse } from '../utils/response';
|
||||
import { generateUUID } from '../utils/uuid';
|
||||
import { createFileDownloadToken, verifyFileDownloadToken } from '../utils/jwt';
|
||||
import { cipherToResponse } from './ciphers';
|
||||
import { LIMITS } from '../config/limits';
|
||||
|
||||
// Format file size to human readable
|
||||
function formatSize(bytes: number): string {
|
||||
@@ -86,7 +87,7 @@ export async function handleCreateAttachment(
|
||||
}
|
||||
|
||||
// Maximum file size: 100MB
|
||||
const MAX_FILE_SIZE = 100 * 1024 * 1024;
|
||||
const MAX_FILE_SIZE = LIMITS.attachment.maxFileSizeBytes;
|
||||
|
||||
// POST /api/ciphers/{cipherId}/attachment/{attachmentId}
|
||||
// Upload attachment file content
|
||||
@@ -211,7 +212,7 @@ export async function handlePublicDownloadAttachment(
|
||||
attachmentId: string
|
||||
): Promise<Response> {
|
||||
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': '*',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
+22
-8
@@ -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<Response> {
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
+15
-2
@@ -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<Response> {
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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<Response> {
|
||||
@@ -74,7 +75,7 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
|
||||
|
||||
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<Response>
|
||||
|
||||
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<Respon
|
||||
|
||||
// Return default KDF settings even if user doesn't exist (to prevent user enumeration)
|
||||
const kdfType = user?.kdfType ?? 0;
|
||||
const kdfIterations = user?.kdfIterations ?? 600000;
|
||||
const kdfIterations = user?.kdfIterations ?? LIMITS.auth.defaultKdfIterations;
|
||||
const kdfMemory = user?.kdfMemory;
|
||||
const kdfParallelism = user?.kdfParallelism;
|
||||
|
||||
|
||||
+13
-3
@@ -1,7 +1,8 @@
|
||||
import { Env, DEFAULT_DEV_SECRET } from '../types';
|
||||
import { StorageService } from '../services/storage';
|
||||
import { jsonResponse, errorResponse } from '../utils/response';
|
||||
import { handleRegisterPage } from './setupPage';
|
||||
import { jsonResponse, errorResponse, htmlResponse } from '../utils/response';
|
||||
import { renderRegisterPageHTML } from '../setup/pageTemplate';
|
||||
import { LIMITS } from '../config/limits';
|
||||
|
||||
type JwtSecretState = 'missing' | 'default' | 'too_short';
|
||||
|
||||
@@ -10,10 +11,19 @@ function getJwtSecretState(env: Env): JwtSecretState | null {
|
||||
if (!secret) return 'missing';
|
||||
// Block common "forgot to change" sample value (matches .dev.vars.example)
|
||||
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;
|
||||
}
|
||||
|
||||
async function handleRegisterPage(request: Request, env: Env, jwtState: JwtSecretState | null): Promise<Response> {
|
||||
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<Response> {
|
||||
const storage = new StorageService(env.DB);
|
||||
|
||||
+51
-2
@@ -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<string, SyncCacheEntry>();
|
||||
|
||||
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<Response> {
|
||||
@@ -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' },
|
||||
});
|
||||
}
|
||||
|
||||
+23
-5
@@ -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<Response> {
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
|
||||
+34
-11
@@ -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<Respons
|
||||
|
||||
// Handle CORS preflight
|
||||
if (method === 'OPTIONS') {
|
||||
return handleCors();
|
||||
return handleCors(request);
|
||||
}
|
||||
|
||||
// Route matching
|
||||
@@ -252,7 +255,7 @@ export async function handleRequest(request: Request, env: Env): Promise<Respons
|
||||
// Keep this aligned with Vaultwarden's reported version to maintain compatibility.
|
||||
// When Vaultwarden bumps their version, update this value accordingly.
|
||||
// Vaultwarden source: src/api/core/mod.rs → fn config()
|
||||
version: '2025.12.0',
|
||||
version: LIMITS.compatibility.bitwardenServerVersion,
|
||||
gitHash: 'nodewarden',
|
||||
server: null,
|
||||
environment: {
|
||||
@@ -277,7 +280,7 @@ export async function handleRequest(request: Request, env: Env): Promise<Respons
|
||||
|
||||
// Version endpoint (some clients probe this to validate the server)
|
||||
if (path === '/api/version' && method === 'GET') {
|
||||
return jsonResponse('2025.12.0'); // Keep in sync with config.version above
|
||||
return jsonResponse(LIMITS.compatibility.bitwardenServerVersion); // Always same value as /config.version
|
||||
}
|
||||
|
||||
// Registration endpoint (no auth required, but only works once)
|
||||
@@ -290,7 +293,7 @@ export async function handleRequest(request: Request, env: Env): Promise<Respons
|
||||
|
||||
// If JWT_SECRET is not safely configured, block any other endpoints.
|
||||
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: JWT_SECRET is not set or too weak', 500);
|
||||
}
|
||||
|
||||
@@ -304,12 +307,32 @@ export async function handleRequest(request: Request, env: Env): Promise<Respons
|
||||
}
|
||||
|
||||
const userId = payload.sub;
|
||||
const clientId = getClientIdentifier(request);
|
||||
|
||||
// Dedicated read rate limiting for heavy sync endpoint.
|
||||
if (path === '/api/sync' && method === 'GET') {
|
||||
const rateLimit = new RateLimitService(env.DB);
|
||||
const rateLimitCheck = await rateLimit.consumeSyncReadBudget(userId + ':' + clientId + ':sync');
|
||||
|
||||
if (!rateLimitCheck.allowed) {
|
||||
return new Response(JSON.stringify({
|
||||
error: 'Too many requests',
|
||||
error_description: `Sync rate limit exceeded. Try again in ${rateLimitCheck.retryAfterSeconds} seconds.`,
|
||||
}), {
|
||||
status: 429,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Retry-After': rateLimitCheck.retryAfterSeconds!.toString(),
|
||||
'X-RateLimit-Remaining': '0',
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// API rate limiting only for write operations (keep reads frictionless)
|
||||
const isWriteMethod = method === 'POST' || method === 'PUT' || method === 'DELETE' || method === 'PATCH';
|
||||
if (isWriteMethod) {
|
||||
const rateLimit = new RateLimitService(env.DB);
|
||||
const clientId = getClientIdentifier(request);
|
||||
const rateLimitCheck = await rateLimit.consumeApiWriteBudget(userId + ':' + clientId + ':write');
|
||||
|
||||
if (!rateLimitCheck.allowed) {
|
||||
|
||||
+97
-18
@@ -1,3 +1,5 @@
|
||||
import { LIMITS } from '../config/limits';
|
||||
|
||||
// D1-backed rate limiting.
|
||||
// Notes:
|
||||
// - Login attempts are tracked per client IP.
|
||||
@@ -6,19 +8,60 @@
|
||||
// Rate limit configuration
|
||||
const CONFIG = {
|
||||
// Friendly default: short cooldown instead of long lockouts.
|
||||
LOGIN_MAX_ATTEMPTS: 5,
|
||||
LOGIN_LOCKOUT_MINUTES: 2,
|
||||
LOGIN_MAX_ATTEMPTS: LIMITS.rateLimit.loginMaxAttempts,
|
||||
LOGIN_LOCKOUT_MINUTES: LIMITS.rateLimit.loginLockoutMinutes,
|
||||
|
||||
// Write operations only (POST/PUT/DELETE/PATCH) should use this budget.
|
||||
API_WRITE_REQUESTS_PER_MINUTE: 120,
|
||||
API_WINDOW_SECONDS: 60,
|
||||
API_WRITE_REQUESTS_PER_MINUTE: LIMITS.rateLimit.apiWriteRequestsPerMinute,
|
||||
// Dedicated budget for GET /api/sync reads.
|
||||
SYNC_READ_REQUESTS_PER_MINUTE: LIMITS.rateLimit.syncReadRequestsPerMinute,
|
||||
API_WINDOW_SECONDS: LIMITS.rateLimit.apiWindowSeconds,
|
||||
};
|
||||
|
||||
export class RateLimitService {
|
||||
private static loginIpTableReady = false;
|
||||
private static lastLoginIpCleanupAt = 0;
|
||||
private static lastApiWindowCleanupAt = 0;
|
||||
|
||||
private static readonly PERIODIC_CLEANUP_PROBABILITY = LIMITS.rateLimit.cleanupProbability;
|
||||
private static readonly LOGIN_IP_CLEANUP_INTERVAL_MS = LIMITS.rateLimit.loginIpCleanupIntervalMs;
|
||||
private static readonly API_WINDOW_CLEANUP_INTERVAL_MS = LIMITS.rateLimit.apiWindowCleanupIntervalMs;
|
||||
private static readonly LOGIN_IP_RETENTION_MS = LIMITS.rateLimit.loginIpRetentionMs;
|
||||
private static readonly API_WINDOW_RETENTION_WINDOWS = LIMITS.rateLimit.apiWindowRetentionWindows;
|
||||
|
||||
constructor(private db: D1Database) {}
|
||||
|
||||
private shouldRunCleanup(lastRunAt: number, intervalMs: number): boolean {
|
||||
const now = Date.now();
|
||||
if (now - lastRunAt < intervalMs) return false;
|
||||
return Math.random() < RateLimitService.PERIODIC_CLEANUP_PROBABILITY;
|
||||
}
|
||||
|
||||
private async maybeCleanupLoginAttemptsIp(nowMs: number): Promise<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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 {
|
||||
|
||||
+154
-141
@@ -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<void> {
|
||||
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<void> {
|
||||
// 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<Cipher[]> {
|
||||
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<Cipher[]> {
|
||||
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<void> {
|
||||
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<Folder[]> {
|
||||
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<any>();
|
||||
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<Attachment | null> {
|
||||
@@ -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<void> {
|
||||
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<string | null> {
|
||||
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<string> {
|
||||
@@ -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(
|
||||
|
||||
@@ -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 `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
@@ -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 {
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
export async function handleRegisterPage(request: Request, env: Env, jwtState: JwtSecretState | null): Promise<Response> {
|
||||
const storage = new StorageService(env.DB);
|
||||
const disabled = await storage.isSetupDisabled();
|
||||
if (disabled) {
|
||||
return new Response(null, { status: 404 });
|
||||
}
|
||||
return htmlResponse(renderRegisterPageHTML(jwtState));
|
||||
}
|
||||
+4
-3
@@ -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<JWTPayload, 'iat' | 'exp' | 'iss' | 'premium' | 'email_verified' | 'amr'>, secret: string, expiresIn: number = 7200): Promise<string> {
|
||||
export async function createJWT(payload: Omit<JWTPayload, 'iat' | 'exp' | 'iss' | 'premium' | 'email_verified' | 'amr'>, secret: string, expiresIn: number = LIMITS.auth.accessTokenTtlSeconds): Promise<string> {
|
||||
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<JWTPaylo
|
||||
|
||||
// Create refresh token (simple random string)
|
||||
export function createRefreshToken(): string {
|
||||
const bytes = new Uint8Array(32);
|
||||
const bytes = new Uint8Array(LIMITS.auth.refreshTokenRandomBytes);
|
||||
crypto.getRandomValues(bytes);
|
||||
return base64UrlEncode(bytes);
|
||||
}
|
||||
@@ -116,7 +117,7 @@ export async function createFileDownloadToken(
|
||||
cipherId,
|
||||
attachmentId,
|
||||
jti: createRefreshToken(),
|
||||
exp: now + 300, // 5 minutes
|
||||
exp: now + LIMITS.auth.fileDownloadTokenTtlSeconds, // 5 minutes
|
||||
};
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { LIMITS } from '../config/limits';
|
||||
|
||||
const MAX_PAGE_SIZE = LIMITS.pagination.maxPageSize;
|
||||
|
||||
export interface PaginationRequest {
|
||||
limit: number;
|
||||
offset: number;
|
||||
}
|
||||
|
||||
export function parsePagination(url: URL): PaginationRequest | null {
|
||||
const pageSizeRaw = url.searchParams.get('pageSize');
|
||||
const continuationToken = url.searchParams.get('continuationToken');
|
||||
if (!pageSizeRaw && !continuationToken) return null;
|
||||
|
||||
const pageSize = pageSizeRaw ? Number(pageSizeRaw) : LIMITS.pagination.defaultPageSize;
|
||||
if (!Number.isInteger(pageSize) || pageSize <= 0) return null;
|
||||
|
||||
const limit = Math.min(pageSize, MAX_PAGE_SIZE);
|
||||
const offset = decodeContinuationToken(continuationToken);
|
||||
|
||||
return { limit, offset };
|
||||
}
|
||||
|
||||
export function encodeContinuationToken(offset: number): string {
|
||||
return btoa(String(offset));
|
||||
}
|
||||
|
||||
export function decodeContinuationToken(token: string | null): number {
|
||||
if (!token) return 0;
|
||||
try {
|
||||
const decoded = atob(token);
|
||||
const offset = Number(decoded);
|
||||
if (!Number.isInteger(offset) || offset < 0) return 0;
|
||||
return offset;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
+69
-14
@@ -1,10 +1,68 @@
|
||||
import { LIMITS } from '../config/limits';
|
||||
|
||||
const CORS_METHODS = 'GET, POST, PUT, DELETE, PATCH, OPTIONS';
|
||||
const CORS_HEADERS = 'Content-Type, Authorization, Accept, Device-Type, Bitwarden-Client-Name, Bitwarden-Client-Version';
|
||||
|
||||
function isTrustedClientOrigin(origin: string): boolean {
|
||||
// Official browser extension / desktop-webview common origins.
|
||||
if (origin === 'null') return true;
|
||||
if (origin.startsWith('chrome-extension://')) return true;
|
||||
if (origin.startsWith('moz-extension://')) return true;
|
||||
if (origin.startsWith('safari-web-extension://')) return true;
|
||||
if (origin.startsWith('app://')) return true;
|
||||
if (origin.startsWith('capacitor://')) return true;
|
||||
if (origin.startsWith('ionic://')) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
function getAllowedOrigin(request: Request): string | null {
|
||||
const origin = request.headers.get('Origin');
|
||||
if (!origin) return null;
|
||||
|
||||
const targetOrigin = new URL(request.url).origin;
|
||||
if (origin === targetOrigin) return origin;
|
||||
if (isTrustedClientOrigin(origin)) return origin;
|
||||
return null;
|
||||
}
|
||||
|
||||
function buildCorsHeaders(request: Request): Record<string, string> {
|
||||
const headers: Record<string, string> = {
|
||||
'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<string, string> = {}): 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<string, string> {
|
||||
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(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user