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:
@@ -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);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+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' },
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user