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:
@@ -59,6 +59,7 @@ CREATE TABLE IF NOT EXISTS ciphers (
|
|||||||
CREATE INDEX IF NOT EXISTS idx_ciphers_user_updated ON ciphers(user_id, updated_at);
|
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_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 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 (
|
CREATE TABLE IF NOT EXISTS folders (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
@@ -106,6 +107,7 @@ CREATE TABLE IF NOT EXISTS sends (
|
|||||||
);
|
);
|
||||||
CREATE INDEX IF NOT EXISTS idx_sends_user_updated ON sends(user_id, updated_at);
|
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_deletion ON sends(user_id, deletion_date);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sends_user_updated_id ON sends(user_id, updated_at, id);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS refresh_tokens (
|
CREATE TABLE IF NOT EXISTS refresh_tokens (
|
||||||
token TEXT PRIMARY KEY,
|
token TEXT PRIMARY KEY,
|
||||||
|
|||||||
@@ -130,6 +130,9 @@
|
|||||||
// Max total items (folders + ciphers) allowed in a single import.
|
// Max total items (folders + ciphers) allowed in a single import.
|
||||||
// 单次导入允许的最大条目数(文件夹 + 密码项合计)。
|
// 单次导入允许的最大条目数(文件夹 + 密码项合计)。
|
||||||
importItemLimit: 5000,
|
importItemLimit: 5000,
|
||||||
|
// Small fixed concurrency for blob/attachment batch cleanup work.
|
||||||
|
// 附件 / blob 批量清理时的保守并发数。
|
||||||
|
attachmentDeleteConcurrency: 4,
|
||||||
},
|
},
|
||||||
request: {
|
request: {
|
||||||
// Hard body size limit for JSON API endpoints (bytes). File upload paths are exempt.
|
// 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 {
|
function toProfile(user: User, env: Env): ProfileResponse {
|
||||||
void env;
|
void env;
|
||||||
|
const accountKeys = buildAccountKeys(user);
|
||||||
return {
|
return {
|
||||||
id: user.id,
|
id: user.id,
|
||||||
name: user.name,
|
name: user.name,
|
||||||
@@ -100,7 +101,7 @@ function toProfile(user: User, env: Env): ProfileResponse {
|
|||||||
twoFactorEnabled: !!user.totpSecret,
|
twoFactorEnabled: !!user.totpSecret,
|
||||||
key: user.key,
|
key: user.key,
|
||||||
privateKey: user.privateKey,
|
privateKey: user.privateKey,
|
||||||
accountKeys: buildAccountKeys(user),
|
accountKeys,
|
||||||
securityStamp: user.securityStamp || user.id,
|
securityStamp: user.securityStamp || user.id,
|
||||||
organizations: [],
|
organizations: [],
|
||||||
providers: [],
|
providers: [],
|
||||||
|
|||||||
@@ -38,6 +38,18 @@ function formatSize(bytes: number): string {
|
|||||||
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
|
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(
|
async function processAttachmentUpload(
|
||||||
request: Request,
|
request: Request,
|
||||||
env: Env,
|
env: Env,
|
||||||
@@ -381,10 +393,9 @@ export async function deleteAllAttachmentsForCipher(
|
|||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const storage = new StorageService(env.DB);
|
const storage = new StorageService(env.DB);
|
||||||
const attachments = await storage.getAttachmentsByCipher(cipherId);
|
const attachments = await storage.getAttachmentsByCipher(cipherId);
|
||||||
|
await runWithConcurrency(attachments, LIMITS.performance.attachmentDeleteConcurrency, async (attachment) => {
|
||||||
for (const attachment of attachments) {
|
|
||||||
const path = getAttachmentObjectKey(cipherId, attachment.id);
|
const path = getAttachmentObjectKey(cipherId, attachment.id);
|
||||||
await deleteBlobObject(env, path);
|
await deleteBlobObject(env, path);
|
||||||
await storage.deleteAttachment(attachment.id);
|
await storage.deleteAttachment(attachment.id);
|
||||||
}
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -178,10 +178,12 @@ export async function handleGetCiphers(request: Request, env: Env, userId: strin
|
|||||||
: ciphers.filter(c => !c.deletedAt);
|
: 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
|
// Build responses only for the current page to keep pagination cheap.
|
||||||
const cipherResponses = [];
|
const cipherResponses: CipherResponse[] = [];
|
||||||
for (const cipher of filteredCiphers) {
|
for (const cipher of filteredCiphers) {
|
||||||
const attachments = attachmentsByCipher.get(cipher.id) || [];
|
const attachments = attachmentsByCipher.get(cipher.id) || [];
|
||||||
cipherResponses.push(cipherToResponse(cipher, attachments));
|
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 accessToken = await auth.generateAccessToken(user, deviceSession);
|
||||||
const refreshToken = await auth.generateRefreshToken(user.id, deviceSession);
|
const refreshToken = await auth.generateRefreshToken(user.id, deviceSession);
|
||||||
|
const accountKeys = buildAccountKeys(user);
|
||||||
|
const userDecryptionOptions = buildUserDecryptionOptions(user);
|
||||||
|
|
||||||
const response: TokenResponse = {
|
const response: TokenResponse = {
|
||||||
access_token: accessToken,
|
access_token: accessToken,
|
||||||
@@ -336,8 +338,8 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
|
|||||||
...(trustedTwoFactorTokenToReturn ? { TwoFactorToken: trustedTwoFactorTokenToReturn } : {}),
|
...(trustedTwoFactorTokenToReturn ? { TwoFactorToken: trustedTwoFactorTokenToReturn } : {}),
|
||||||
Key: user.key,
|
Key: user.key,
|
||||||
PrivateKey: user.privateKey,
|
PrivateKey: user.privateKey,
|
||||||
AccountKeys: buildAccountKeys(user),
|
AccountKeys: accountKeys,
|
||||||
accountKeys: buildAccountKeys(user),
|
accountKeys: accountKeys,
|
||||||
Kdf: user.kdfType,
|
Kdf: user.kdfType,
|
||||||
KdfIterations: user.kdfIterations,
|
KdfIterations: user.kdfIterations,
|
||||||
KdfMemory: user.kdfMemory,
|
KdfMemory: user.kdfMemory,
|
||||||
@@ -350,8 +352,8 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
|
|||||||
ApiUseKeyConnector: false,
|
ApiUseKeyConnector: false,
|
||||||
scope: 'api offline_access',
|
scope: 'api offline_access',
|
||||||
unofficialServer: true,
|
unofficialServer: true,
|
||||||
UserDecryptionOptions: buildUserDecryptionOptions(user),
|
UserDecryptionOptions: userDecryptionOptions,
|
||||||
userDecryptionOptions: buildUserDecryptionOptions(user),
|
userDecryptionOptions: userDecryptionOptions,
|
||||||
};
|
};
|
||||||
|
|
||||||
const baseResponse = jsonResponse(response);
|
const baseResponse = jsonResponse(response);
|
||||||
@@ -449,6 +451,8 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
|
|||||||
|
|
||||||
const { accessToken, user, device } = result;
|
const { accessToken, user, device } = result;
|
||||||
const newRefreshToken = await auth.generateRefreshToken(user.id, device);
|
const newRefreshToken = await auth.generateRefreshToken(user.id, device);
|
||||||
|
const accountKeys = buildAccountKeys(user);
|
||||||
|
const userDecryptionOptions = buildUserDecryptionOptions(user);
|
||||||
|
|
||||||
const response: TokenResponse = {
|
const response: TokenResponse = {
|
||||||
access_token: accessToken,
|
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 }),
|
...(shouldUseWebSession(request) ? { web_session: true } : { refresh_token: newRefreshToken }),
|
||||||
Key: user.key,
|
Key: user.key,
|
||||||
PrivateKey: user.privateKey,
|
PrivateKey: user.privateKey,
|
||||||
AccountKeys: buildAccountKeys(user),
|
AccountKeys: accountKeys,
|
||||||
accountKeys: buildAccountKeys(user),
|
accountKeys: accountKeys,
|
||||||
Kdf: user.kdfType,
|
Kdf: user.kdfType,
|
||||||
KdfIterations: user.kdfIterations,
|
KdfIterations: user.kdfIterations,
|
||||||
KdfMemory: user.kdfMemory,
|
KdfMemory: user.kdfMemory,
|
||||||
@@ -471,8 +475,8 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
|
|||||||
ApiUseKeyConnector: false,
|
ApiUseKeyConnector: false,
|
||||||
scope: 'api offline_access',
|
scope: 'api offline_access',
|
||||||
unofficialServer: true,
|
unofficialServer: true,
|
||||||
UserDecryptionOptions: buildUserDecryptionOptions(user),
|
UserDecryptionOptions: userDecryptionOptions,
|
||||||
userDecryptionOptions: buildUserDecryptionOptions(user),
|
userDecryptionOptions: userDecryptionOptions,
|
||||||
};
|
};
|
||||||
|
|
||||||
const baseResponse = jsonResponse(response);
|
const baseResponse = jsonResponse(response);
|
||||||
|
|||||||
@@ -97,8 +97,9 @@ export async function handleGetSends(request: Request, env: Env, userId: string)
|
|||||||
sends = await storage.getAllSends(userId);
|
sends = await storage.getAllSends(userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const sendResponses = sends.map(sendToResponse);
|
||||||
return jsonResponse({
|
return jsonResponse({
|
||||||
data: sends.map(sendToResponse),
|
data: sendResponses,
|
||||||
object: 'list',
|
object: 'list',
|
||||||
continuationToken,
|
continuationToken,
|
||||||
});
|
});
|
||||||
|
|||||||
+48
-111
@@ -10,87 +10,23 @@ import {
|
|||||||
buildUserDecryptionOptions,
|
buildUserDecryptionOptions,
|
||||||
} from '../utils/user-decryption';
|
} from '../utils/user-decryption';
|
||||||
|
|
||||||
interface SyncCacheEntry {
|
function buildSyncCacheRequest(request: Request, userId: string, revisionDate: string, excludeDomains: boolean): Request {
|
||||||
userId: string;
|
const url = new URL(request.url);
|
||||||
revisionDate: string;
|
const cacheUrl = new URL(
|
||||||
body: string;
|
`/__nodewarden/cache/sync/${encodeURIComponent(userId)}/${encodeURIComponent(revisionDate)}/${excludeDomains ? '1' : '0'}`,
|
||||||
expiresAt: number;
|
url.origin
|
||||||
bytes: number;
|
);
|
||||||
|
return new Request(cacheUrl.toString(), { method: 'GET' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const syncResponseCache = new Map<string, SyncCacheEntry>();
|
async function readSyncCache(cacheRequest: Request): Promise<Response | null> {
|
||||||
let syncResponseCacheTotalBytes = 0;
|
const hit = await caches.default.match(cacheRequest);
|
||||||
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);
|
|
||||||
if (!hit) return null;
|
if (!hit) return null;
|
||||||
if (hit.expiresAt <= Date.now()) {
|
return new Response(hit.body, hit);
|
||||||
deleteSyncCacheEntry(key, hit);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return hit.body;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function deleteSyncCacheEntry(key: string, entry?: SyncCacheEntry): void {
|
async function writeSyncCache(cacheRequest: Request, response: Response): Promise<void> {
|
||||||
const existing = entry ?? syncResponseCache.get(key);
|
await caches.default.put(cacheRequest, response.clone());
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// GET /api/sync
|
// 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 url = new URL(request.url);
|
||||||
const excludeDomainsParam = url.searchParams.get('excludeDomains');
|
const excludeDomainsParam = url.searchParams.get('excludeDomains');
|
||||||
const excludeDomains = excludeDomainsParam !== null && /^(1|true|yes)$/i.test(excludeDomainsParam);
|
const excludeDomains = excludeDomainsParam !== null && /^(1|true|yes)$/i.test(excludeDomainsParam);
|
||||||
|
|
||||||
const user = await storage.getUserById(userId);
|
const user = await storage.getUserById(userId);
|
||||||
if (!user) {
|
if (!user) {
|
||||||
return errorResponse('User not found', 404);
|
return errorResponse('User not found', 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
const revisionDate = await storage.getRevisionDate(userId);
|
const revisionDate = await storage.getRevisionDate(userId);
|
||||||
const cacheKey = buildSyncCacheKey(userId, revisionDate, excludeDomains);
|
const cacheRequest = buildSyncCacheRequest(request, userId, revisionDate, excludeDomains);
|
||||||
const cachedBody = readSyncCache(cacheKey);
|
const cachedResponse = await readSyncCache(cacheRequest);
|
||||||
if (cachedBody) {
|
if (cachedResponse) {
|
||||||
return new Response(cachedBody, {
|
return cachedResponse;
|
||||||
status: 200,
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const ciphers = await storage.getAllCiphers(userId);
|
const [ciphers, folders, sends, attachmentsByCipher] = await Promise.all([
|
||||||
const folders = await storage.getAllFolders(userId);
|
storage.getAllCiphers(userId),
|
||||||
const sends = await storage.getAllSends(userId);
|
storage.getAllFolders(userId),
|
||||||
const attachmentsByCipher = await storage.getAttachmentsByUserId(userId);
|
storage.getAllSends(userId),
|
||||||
|
storage.getAttachmentsByUserId(userId),
|
||||||
|
]);
|
||||||
|
const accountKeys = buildAccountKeys(user);
|
||||||
|
const userDecryptionOptions = buildUserDecryptionOptions(user);
|
||||||
|
|
||||||
// Build profile response
|
|
||||||
const profile: ProfileResponse = {
|
const profile: ProfileResponse = {
|
||||||
id: user.id,
|
id: user.id,
|
||||||
name: user.name,
|
name: user.name,
|
||||||
@@ -134,7 +70,7 @@ export async function handleSync(request: Request, env: Env, userId: string): Pr
|
|||||||
twoFactorEnabled: !!user.totpSecret,
|
twoFactorEnabled: !!user.totpSecret,
|
||||||
key: user.key,
|
key: user.key,
|
||||||
privateKey: user.privateKey,
|
privateKey: user.privateKey,
|
||||||
accountKeys: buildAccountKeys(user),
|
accountKeys,
|
||||||
securityStamp: user.securityStamp || user.id,
|
securityStamp: user.securityStamp || user.id,
|
||||||
organizations: [],
|
organizations: [],
|
||||||
providers: [],
|
providers: [],
|
||||||
@@ -146,23 +82,24 @@ export async function handleSync(request: Request, env: Env, userId: string): Pr
|
|||||||
object: 'profile',
|
object: 'profile',
|
||||||
};
|
};
|
||||||
|
|
||||||
// Build cipher responses with attachments
|
|
||||||
const cipherResponses: CipherResponse[] = [];
|
const cipherResponses: CipherResponse[] = [];
|
||||||
for (const cipher of ciphers) {
|
for (const cipher of ciphers) {
|
||||||
const attachments = attachmentsByCipher.get(cipher.id) || [];
|
cipherResponses.push(cipherToResponse(cipher, attachmentsByCipher.get(cipher.id) || []));
|
||||||
cipherResponses.push(cipherToResponse(cipher, attachments));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build folder responses
|
const folderResponses: FolderResponse[] = [];
|
||||||
const folderResponses: FolderResponse[] = folders.map(folder => ({
|
for (const folder of folders) {
|
||||||
id: folder.id,
|
folderResponses.push({
|
||||||
name: folder.name,
|
id: folder.id,
|
||||||
revisionDate: folder.updatedAt,
|
name: folder.name,
|
||||||
object: 'folder',
|
revisionDate: folder.updatedAt,
|
||||||
}));
|
object: 'folder',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const sendResponses = sends.map(sendToResponse);
|
||||||
const syncResponse: SyncResponse = {
|
const syncResponse: SyncResponse = {
|
||||||
profile: profile,
|
profile,
|
||||||
folders: folderResponses,
|
folders: folderResponses,
|
||||||
collections: [],
|
collections: [],
|
||||||
ciphers: cipherResponses,
|
ciphers: cipherResponses,
|
||||||
@@ -174,25 +111,25 @@ export async function handleSync(request: Request, env: Env, userId: string): Pr
|
|||||||
object: 'domains',
|
object: 'domains',
|
||||||
},
|
},
|
||||||
policies: [],
|
policies: [],
|
||||||
sends: sends.map(sendToResponse),
|
sends: sendResponses,
|
||||||
UserDecryption: {
|
UserDecryption: {
|
||||||
MasterPasswordUnlock: buildUserDecryptionOptions(user).MasterPasswordUnlock,
|
MasterPasswordUnlock: userDecryptionOptions.MasterPasswordUnlock,
|
||||||
TrustedDeviceOption: null,
|
TrustedDeviceOption: null,
|
||||||
KeyConnectorOption: null,
|
KeyConnectorOption: null,
|
||||||
Object: 'userDecryption',
|
Object: 'userDecryption',
|
||||||
},
|
},
|
||||||
// PascalCase for desktop/browser clients
|
UserDecryptionOptions: userDecryptionOptions,
|
||||||
UserDecryptionOptions: buildUserDecryptionOptions(user),
|
|
||||||
// camelCase for Android client (SyncResponseJson uses @SerialName("userDecryption"))
|
|
||||||
userDecryption: buildUserDecryptionCompat(user) as SyncResponse['userDecryption'],
|
userDecryption: buildUserDecryptionCompat(user) as SyncResponse['userDecryption'],
|
||||||
object: 'sync',
|
object: 'sync',
|
||||||
};
|
};
|
||||||
|
|
||||||
const body = JSON.stringify(syncResponse);
|
const response = new Response(JSON.stringify(syncResponse), {
|
||||||
writeSyncCache(userId, revisionDate, cacheKey, body);
|
|
||||||
|
|
||||||
return new Response(body, {
|
|
||||||
status: 200,
|
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_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_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 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 (' +
|
'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, ' +
|
'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)',
|
'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_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_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 auth_type INTEGER NOT NULL DEFAULT 2',
|
||||||
'ALTER TABLE sends ADD COLUMN emails TEXT',
|
'ALTER TABLE sends ADD COLUMN emails TEXT',
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { base64ToBytes, bytesToBase64, decryptBw, decryptBwFileData, decryptStr, encryptBw, encryptBwFileData, hkdf, pbkdf2 } from '../crypto';
|
import { base64ToBytes, bytesToBase64, decryptBw, decryptBwFileData, decryptStr, encryptBw, encryptBwFileData, hkdf, pbkdf2 } from '../crypto';
|
||||||
import type { Send, SendDraft, SessionState } from '../types';
|
import type { Send, SendDraft, SessionState } from '../types';
|
||||||
import { chunkArray, createApiError, parseErrorMessage, parseJson, uploadDirectEncryptedPayload, type AuthedFetch } from './shared';
|
import { chunkArray, createApiError, parseErrorMessage, parseJson, uploadDirectEncryptedPayload, type AuthedFetch } from './shared';
|
||||||
|
import { loadVaultSyncSnapshot } from './vault-sync';
|
||||||
|
|
||||||
function toIsoDateFromDays(value: string, required: boolean): string | null {
|
function toIsoDateFromDays(value: string, required: boolean): string | null {
|
||||||
const raw = String(value || '').trim();
|
const raw = String(value || '').trim();
|
||||||
@@ -61,10 +62,8 @@ function parseMaxAccessCountRaw(value: string): number | null {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function getSends(authedFetch: AuthedFetch): Promise<Send[]> {
|
export async function getSends(authedFetch: AuthedFetch): Promise<Send[]> {
|
||||||
const resp = await authedFetch('/api/sends');
|
const body = await loadVaultSyncSnapshot(authedFetch);
|
||||||
if (!resp.ok) throw new Error('Failed to load sends');
|
return body.sends || [];
|
||||||
const body = await parseJson<{ object: 'list'; data: Send[] }>(resp);
|
|
||||||
return body?.data || [];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createSend(
|
export async function createSend(
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import type { Cipher, Folder, Send } from '../types';
|
||||||
|
import { parseJson, type AuthedFetch } from './shared';
|
||||||
|
|
||||||
|
interface VaultSyncResponse {
|
||||||
|
ciphers?: Cipher[];
|
||||||
|
folders?: Folder[];
|
||||||
|
sends?: Send[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const pendingSyncRequests = new WeakMap<AuthedFetch, Promise<VaultSyncResponse>>();
|
||||||
|
|
||||||
|
export async function loadVaultSyncSnapshot(authedFetch: AuthedFetch): Promise<VaultSyncResponse> {
|
||||||
|
const existing = pendingSyncRequests.get(authedFetch);
|
||||||
|
if (existing) return existing;
|
||||||
|
|
||||||
|
const request = (async () => {
|
||||||
|
const resp = await authedFetch('/api/sync');
|
||||||
|
if (!resp.ok) throw new Error('Failed to load vault');
|
||||||
|
const body = await parseJson<VaultSyncResponse>(resp);
|
||||||
|
return body || {};
|
||||||
|
})();
|
||||||
|
|
||||||
|
pendingSyncRequests.set(authedFetch, request);
|
||||||
|
try {
|
||||||
|
return await request;
|
||||||
|
} finally {
|
||||||
|
if (pendingSyncRequests.get(authedFetch) === request) {
|
||||||
|
pendingSyncRequests.delete(authedFetch);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,7 +2,6 @@ import { base64ToBytes, decryptBw, decryptBwFileData, decryptStr, encryptBw, enc
|
|||||||
import type {
|
import type {
|
||||||
Cipher,
|
Cipher,
|
||||||
Folder,
|
Folder,
|
||||||
ListResponse,
|
|
||||||
SessionState,
|
SessionState,
|
||||||
VaultDraft,
|
VaultDraft,
|
||||||
VaultDraftField,
|
VaultDraftField,
|
||||||
@@ -16,12 +15,11 @@ import {
|
|||||||
type AuthedFetch,
|
type AuthedFetch,
|
||||||
} from './shared';
|
} from './shared';
|
||||||
import { readResponseBytesWithProgress } from '../download';
|
import { readResponseBytesWithProgress } from '../download';
|
||||||
|
import { loadVaultSyncSnapshot } from './vault-sync';
|
||||||
|
|
||||||
export async function getFolders(authedFetch: AuthedFetch): Promise<Folder[]> {
|
export async function getFolders(authedFetch: AuthedFetch): Promise<Folder[]> {
|
||||||
const resp = await authedFetch('/api/folders');
|
const body = await loadVaultSyncSnapshot(authedFetch);
|
||||||
if (!resp.ok) throw new Error('Failed to load folders');
|
return body.folders || [];
|
||||||
const body = await parseJson<ListResponse<Folder>>(resp);
|
|
||||||
return body?.data || [];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createFolder(
|
export async function createFolder(
|
||||||
@@ -93,10 +91,8 @@ export async function updateFolder(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function getCiphers(authedFetch: AuthedFetch): Promise<Cipher[]> {
|
export async function getCiphers(authedFetch: AuthedFetch): Promise<Cipher[]> {
|
||||||
const resp = await authedFetch('/api/ciphers?deleted=true');
|
const body = await loadVaultSyncSnapshot(authedFetch);
|
||||||
if (!resp.ok) throw new Error('Failed to load ciphers');
|
return body.ciphers || [];
|
||||||
const body = await parseJson<ListResponse<Cipher>>(resp);
|
|
||||||
return body?.data || [];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CiphersImportPayload {
|
export interface CiphersImportPayload {
|
||||||
|
|||||||
Reference in New Issue
Block a user