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:
shuaiplus
2026-02-18 20:59:46 +08:00
parent c53819e178
commit b6d4113e21
17 changed files with 668 additions and 232 deletions
+25 -2
View File
@@ -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);
+3 -3
View File
@@ -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
View File
@@ -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
View File
@@ -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,
});
}
+4 -3
View File
@@ -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
View File
@@ -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);
File diff suppressed because it is too large Load Diff
+51 -2
View File
@@ -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' },
});
}