mirror of
https://github.com/shuaiplus/nodewarden.git
synced 2026-06-20 13:00:39 +00:00
feat: enhance database indexing and optimize sync response handling
This commit is contained in:
@@ -130,6 +130,9 @@
|
||||
// Max total items (folders + ciphers) allowed in a single import.
|
||||
// 单次导入允许的最大条目数(文件夹 + 密码项合计)。
|
||||
importItemLimit: 5000,
|
||||
// Small fixed concurrency for blob/attachment batch cleanup work.
|
||||
// 附件 / blob 批量清理时的保守并发数。
|
||||
attachmentDeleteConcurrency: 4,
|
||||
},
|
||||
request: {
|
||||
// Hard body size limit for JSON API endpoints (bytes). File upload paths are exempt.
|
||||
|
||||
@@ -87,6 +87,7 @@ async function verifyUserSecret(
|
||||
|
||||
function toProfile(user: User, env: Env): ProfileResponse {
|
||||
void env;
|
||||
const accountKeys = buildAccountKeys(user);
|
||||
return {
|
||||
id: user.id,
|
||||
name: user.name,
|
||||
@@ -100,7 +101,7 @@ function toProfile(user: User, env: Env): ProfileResponse {
|
||||
twoFactorEnabled: !!user.totpSecret,
|
||||
key: user.key,
|
||||
privateKey: user.privateKey,
|
||||
accountKeys: buildAccountKeys(user),
|
||||
accountKeys,
|
||||
securityStamp: user.securityStamp || user.id,
|
||||
organizations: [],
|
||||
providers: [],
|
||||
|
||||
@@ -38,6 +38,18 @@ function formatSize(bytes: number): string {
|
||||
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
|
||||
}
|
||||
|
||||
async function runWithConcurrency<T>(
|
||||
items: T[],
|
||||
concurrency: number,
|
||||
worker: (item: T) => Promise<void>
|
||||
): Promise<void> {
|
||||
if (items.length === 0) return;
|
||||
const limit = Math.max(1, concurrency);
|
||||
for (let index = 0; index < items.length; index += limit) {
|
||||
await Promise.all(items.slice(index, index + limit).map(worker));
|
||||
}
|
||||
}
|
||||
|
||||
async function processAttachmentUpload(
|
||||
request: Request,
|
||||
env: Env,
|
||||
@@ -381,10 +393,9 @@ export async function deleteAllAttachmentsForCipher(
|
||||
): Promise<void> {
|
||||
const storage = new StorageService(env.DB);
|
||||
const attachments = await storage.getAttachmentsByCipher(cipherId);
|
||||
|
||||
for (const attachment of attachments) {
|
||||
await runWithConcurrency(attachments, LIMITS.performance.attachmentDeleteConcurrency, async (attachment) => {
|
||||
const path = getAttachmentObjectKey(cipherId, attachment.id);
|
||||
await deleteBlobObject(env, path);
|
||||
await storage.deleteAttachment(attachment.id);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -178,10 +178,12 @@ export async function handleGetCiphers(request: Request, env: Env, userId: strin
|
||||
: ciphers.filter(c => !c.deletedAt);
|
||||
}
|
||||
|
||||
const attachmentsByCipher = await storage.getAttachmentsByUserId(userId);
|
||||
const attachmentsByCipher = await storage.getAttachmentsByCipherIds(
|
||||
filteredCiphers.map((cipher) => cipher.id)
|
||||
);
|
||||
|
||||
// Get attachments for all ciphers
|
||||
const cipherResponses = [];
|
||||
// Build responses only for the current page to keep pagination cheap.
|
||||
const cipherResponses: CipherResponse[] = [];
|
||||
for (const cipher of filteredCiphers) {
|
||||
const attachments = attachmentsByCipher.get(cipher.id) || [];
|
||||
cipherResponses.push(cipherToResponse(cipher, attachments));
|
||||
|
||||
@@ -327,6 +327,8 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
|
||||
|
||||
const accessToken = await auth.generateAccessToken(user, deviceSession);
|
||||
const refreshToken = await auth.generateRefreshToken(user.id, deviceSession);
|
||||
const accountKeys = buildAccountKeys(user);
|
||||
const userDecryptionOptions = buildUserDecryptionOptions(user);
|
||||
|
||||
const response: TokenResponse = {
|
||||
access_token: accessToken,
|
||||
@@ -336,8 +338,8 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
|
||||
...(trustedTwoFactorTokenToReturn ? { TwoFactorToken: trustedTwoFactorTokenToReturn } : {}),
|
||||
Key: user.key,
|
||||
PrivateKey: user.privateKey,
|
||||
AccountKeys: buildAccountKeys(user),
|
||||
accountKeys: buildAccountKeys(user),
|
||||
AccountKeys: accountKeys,
|
||||
accountKeys: accountKeys,
|
||||
Kdf: user.kdfType,
|
||||
KdfIterations: user.kdfIterations,
|
||||
KdfMemory: user.kdfMemory,
|
||||
@@ -350,8 +352,8 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
|
||||
ApiUseKeyConnector: false,
|
||||
scope: 'api offline_access',
|
||||
unofficialServer: true,
|
||||
UserDecryptionOptions: buildUserDecryptionOptions(user),
|
||||
userDecryptionOptions: buildUserDecryptionOptions(user),
|
||||
UserDecryptionOptions: userDecryptionOptions,
|
||||
userDecryptionOptions: userDecryptionOptions,
|
||||
};
|
||||
|
||||
const baseResponse = jsonResponse(response);
|
||||
@@ -449,6 +451,8 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
|
||||
|
||||
const { accessToken, user, device } = result;
|
||||
const newRefreshToken = await auth.generateRefreshToken(user.id, device);
|
||||
const accountKeys = buildAccountKeys(user);
|
||||
const userDecryptionOptions = buildUserDecryptionOptions(user);
|
||||
|
||||
const response: TokenResponse = {
|
||||
access_token: accessToken,
|
||||
@@ -457,8 +461,8 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
|
||||
...(shouldUseWebSession(request) ? { web_session: true } : { refresh_token: newRefreshToken }),
|
||||
Key: user.key,
|
||||
PrivateKey: user.privateKey,
|
||||
AccountKeys: buildAccountKeys(user),
|
||||
accountKeys: buildAccountKeys(user),
|
||||
AccountKeys: accountKeys,
|
||||
accountKeys: accountKeys,
|
||||
Kdf: user.kdfType,
|
||||
KdfIterations: user.kdfIterations,
|
||||
KdfMemory: user.kdfMemory,
|
||||
@@ -471,8 +475,8 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
|
||||
ApiUseKeyConnector: false,
|
||||
scope: 'api offline_access',
|
||||
unofficialServer: true,
|
||||
UserDecryptionOptions: buildUserDecryptionOptions(user),
|
||||
userDecryptionOptions: buildUserDecryptionOptions(user),
|
||||
UserDecryptionOptions: userDecryptionOptions,
|
||||
userDecryptionOptions: userDecryptionOptions,
|
||||
};
|
||||
|
||||
const baseResponse = jsonResponse(response);
|
||||
|
||||
@@ -97,8 +97,9 @@ export async function handleGetSends(request: Request, env: Env, userId: string)
|
||||
sends = await storage.getAllSends(userId);
|
||||
}
|
||||
|
||||
const sendResponses = sends.map(sendToResponse);
|
||||
return jsonResponse({
|
||||
data: sends.map(sendToResponse),
|
||||
data: sendResponses,
|
||||
object: 'list',
|
||||
continuationToken,
|
||||
});
|
||||
|
||||
+48
-111
@@ -10,87 +10,23 @@ import {
|
||||
buildUserDecryptionOptions,
|
||||
} from '../utils/user-decryption';
|
||||
|
||||
interface SyncCacheEntry {
|
||||
userId: string;
|
||||
revisionDate: string;
|
||||
body: string;
|
||||
expiresAt: number;
|
||||
bytes: number;
|
||||
function buildSyncCacheRequest(request: Request, userId: string, revisionDate: string, excludeDomains: boolean): Request {
|
||||
const url = new URL(request.url);
|
||||
const cacheUrl = new URL(
|
||||
`/__nodewarden/cache/sync/${encodeURIComponent(userId)}/${encodeURIComponent(revisionDate)}/${excludeDomains ? '1' : '0'}`,
|
||||
url.origin
|
||||
);
|
||||
return new Request(cacheUrl.toString(), { method: 'GET' });
|
||||
}
|
||||
|
||||
const syncResponseCache = new Map<string, SyncCacheEntry>();
|
||||
let syncResponseCacheTotalBytes = 0;
|
||||
const textEncoder = new TextEncoder();
|
||||
|
||||
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);
|
||||
async function readSyncCache(cacheRequest: Request): Promise<Response | null> {
|
||||
const hit = await caches.default.match(cacheRequest);
|
||||
if (!hit) return null;
|
||||
if (hit.expiresAt <= Date.now()) {
|
||||
deleteSyncCacheEntry(key, hit);
|
||||
return null;
|
||||
}
|
||||
return hit.body;
|
||||
return new Response(hit.body, hit);
|
||||
}
|
||||
|
||||
function deleteSyncCacheEntry(key: string, entry?: SyncCacheEntry): void {
|
||||
const existing = entry ?? syncResponseCache.get(key);
|
||||
if (!existing) return;
|
||||
syncResponseCache.delete(key);
|
||||
syncResponseCacheTotalBytes = Math.max(0, syncResponseCacheTotalBytes - existing.bytes);
|
||||
}
|
||||
|
||||
function pruneExpiredSyncCache(nowMs: number = Date.now()): void {
|
||||
for (const [key, entry] of syncResponseCache.entries()) {
|
||||
if (entry.expiresAt <= nowMs) {
|
||||
deleteSyncCacheEntry(key, entry);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function pruneStaleUserSyncCache(userId: string, revisionDate: string): void {
|
||||
for (const [key, entry] of syncResponseCache.entries()) {
|
||||
if (entry.userId === userId && entry.revisionDate !== revisionDate) {
|
||||
deleteSyncCacheEntry(key, entry);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function writeSyncCache(userId: string, revisionDate: string, key: string, body: string): void {
|
||||
const nowMs = Date.now();
|
||||
pruneExpiredSyncCache(nowMs);
|
||||
pruneStaleUserSyncCache(userId, revisionDate);
|
||||
|
||||
const bodyBytes = textEncoder.encode(body).byteLength;
|
||||
if (bodyBytes > LIMITS.cache.syncResponseMaxBodyBytes) {
|
||||
return;
|
||||
}
|
||||
|
||||
const existing = syncResponseCache.get(key);
|
||||
if (existing) {
|
||||
deleteSyncCacheEntry(key, existing);
|
||||
}
|
||||
|
||||
while (
|
||||
syncResponseCache.size >= LIMITS.cache.syncResponseMaxEntries ||
|
||||
syncResponseCacheTotalBytes + bodyBytes > LIMITS.cache.syncResponseMaxTotalBytes
|
||||
) {
|
||||
const oldestKey = syncResponseCache.keys().next().value as string | undefined;
|
||||
if (!oldestKey) break;
|
||||
deleteSyncCacheEntry(oldestKey);
|
||||
}
|
||||
|
||||
syncResponseCache.set(key, {
|
||||
userId,
|
||||
revisionDate,
|
||||
body,
|
||||
expiresAt: nowMs + LIMITS.cache.syncResponseTtlMs,
|
||||
bytes: bodyBytes,
|
||||
});
|
||||
syncResponseCacheTotalBytes += bodyBytes;
|
||||
async function writeSyncCache(cacheRequest: Request, response: Response): Promise<void> {
|
||||
await caches.default.put(cacheRequest, response.clone());
|
||||
}
|
||||
|
||||
// GET /api/sync
|
||||
@@ -99,28 +35,28 @@ export async function handleSync(request: Request, env: Env, userId: string): Pr
|
||||
const url = new URL(request.url);
|
||||
const excludeDomainsParam = url.searchParams.get('excludeDomains');
|
||||
const excludeDomains = excludeDomainsParam !== null && /^(1|true|yes)$/i.test(excludeDomainsParam);
|
||||
|
||||
|
||||
const user = await storage.getUserById(userId);
|
||||
if (!user) {
|
||||
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 cacheRequest = buildSyncCacheRequest(request, userId, revisionDate, excludeDomains);
|
||||
const cachedResponse = await readSyncCache(cacheRequest);
|
||||
if (cachedResponse) {
|
||||
return cachedResponse;
|
||||
}
|
||||
|
||||
const ciphers = await storage.getAllCiphers(userId);
|
||||
const folders = await storage.getAllFolders(userId);
|
||||
const sends = await storage.getAllSends(userId);
|
||||
const attachmentsByCipher = await storage.getAttachmentsByUserId(userId);
|
||||
const [ciphers, folders, sends, attachmentsByCipher] = await Promise.all([
|
||||
storage.getAllCiphers(userId),
|
||||
storage.getAllFolders(userId),
|
||||
storage.getAllSends(userId),
|
||||
storage.getAttachmentsByUserId(userId),
|
||||
]);
|
||||
const accountKeys = buildAccountKeys(user);
|
||||
const userDecryptionOptions = buildUserDecryptionOptions(user);
|
||||
|
||||
// Build profile response
|
||||
const profile: ProfileResponse = {
|
||||
id: user.id,
|
||||
name: user.name,
|
||||
@@ -134,7 +70,7 @@ export async function handleSync(request: Request, env: Env, userId: string): Pr
|
||||
twoFactorEnabled: !!user.totpSecret,
|
||||
key: user.key,
|
||||
privateKey: user.privateKey,
|
||||
accountKeys: buildAccountKeys(user),
|
||||
accountKeys,
|
||||
securityStamp: user.securityStamp || user.id,
|
||||
organizations: [],
|
||||
providers: [],
|
||||
@@ -146,23 +82,24 @@ export async function handleSync(request: Request, env: Env, userId: string): Pr
|
||||
object: 'profile',
|
||||
};
|
||||
|
||||
// Build cipher responses with attachments
|
||||
const cipherResponses: CipherResponse[] = [];
|
||||
for (const cipher of ciphers) {
|
||||
const attachments = attachmentsByCipher.get(cipher.id) || [];
|
||||
cipherResponses.push(cipherToResponse(cipher, attachments));
|
||||
cipherResponses.push(cipherToResponse(cipher, attachmentsByCipher.get(cipher.id) || []));
|
||||
}
|
||||
|
||||
// Build folder responses
|
||||
const folderResponses: FolderResponse[] = folders.map(folder => ({
|
||||
id: folder.id,
|
||||
name: folder.name,
|
||||
revisionDate: folder.updatedAt,
|
||||
object: 'folder',
|
||||
}));
|
||||
const folderResponses: FolderResponse[] = [];
|
||||
for (const folder of folders) {
|
||||
folderResponses.push({
|
||||
id: folder.id,
|
||||
name: folder.name,
|
||||
revisionDate: folder.updatedAt,
|
||||
object: 'folder',
|
||||
});
|
||||
}
|
||||
|
||||
const sendResponses = sends.map(sendToResponse);
|
||||
const syncResponse: SyncResponse = {
|
||||
profile: profile,
|
||||
profile,
|
||||
folders: folderResponses,
|
||||
collections: [],
|
||||
ciphers: cipherResponses,
|
||||
@@ -174,25 +111,25 @@ export async function handleSync(request: Request, env: Env, userId: string): Pr
|
||||
object: 'domains',
|
||||
},
|
||||
policies: [],
|
||||
sends: sends.map(sendToResponse),
|
||||
sends: sendResponses,
|
||||
UserDecryption: {
|
||||
MasterPasswordUnlock: buildUserDecryptionOptions(user).MasterPasswordUnlock,
|
||||
MasterPasswordUnlock: userDecryptionOptions.MasterPasswordUnlock,
|
||||
TrustedDeviceOption: null,
|
||||
KeyConnectorOption: null,
|
||||
Object: 'userDecryption',
|
||||
},
|
||||
// PascalCase for desktop/browser clients
|
||||
UserDecryptionOptions: buildUserDecryptionOptions(user),
|
||||
// camelCase for Android client (SyncResponseJson uses @SerialName("userDecryption"))
|
||||
UserDecryptionOptions: userDecryptionOptions,
|
||||
userDecryption: buildUserDecryptionCompat(user) as SyncResponse['userDecryption'],
|
||||
object: 'sync',
|
||||
};
|
||||
|
||||
const body = JSON.stringify(syncResponse);
|
||||
writeSyncCache(userId, revisionDate, cacheKey, body);
|
||||
|
||||
return new Response(body, {
|
||||
const response = new Response(JSON.stringify(syncResponse), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Cache-Control': `private, max-age=${Math.max(1, Math.floor(LIMITS.cache.syncResponseTtlMs / 1000))}`,
|
||||
},
|
||||
});
|
||||
await writeSyncCache(cacheRequest, response);
|
||||
return response;
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ const SCHEMA_STATEMENTS: readonly string[] = [
|
||||
'CREATE INDEX IF NOT EXISTS idx_ciphers_user_updated ON ciphers(user_id, updated_at)',
|
||||
'CREATE INDEX IF NOT EXISTS idx_ciphers_user_archived ON ciphers(user_id, archived_at)',
|
||||
'CREATE INDEX IF NOT EXISTS idx_ciphers_user_deleted ON ciphers(user_id, deleted_at)',
|
||||
'CREATE INDEX IF NOT EXISTS idx_ciphers_user_deleted_updated ON ciphers(user_id, deleted_at, updated_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, ' +
|
||||
@@ -47,6 +48,7 @@ const SCHEMA_STATEMENTS: readonly string[] = [
|
||||
'FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)',
|
||||
'CREATE INDEX IF NOT EXISTS idx_sends_user_updated ON sends(user_id, updated_at)',
|
||||
'CREATE INDEX IF NOT EXISTS idx_sends_user_deletion ON sends(user_id, deletion_date)',
|
||||
'CREATE INDEX IF NOT EXISTS idx_sends_user_updated_id ON sends(user_id, updated_at, id)',
|
||||
'ALTER TABLE sends ADD COLUMN auth_type INTEGER NOT NULL DEFAULT 2',
|
||||
'ALTER TABLE sends ADD COLUMN emails TEXT',
|
||||
|
||||
|
||||
Reference in New Issue
Block a user