mirror of
https://github.com/shuaiplus/nodewarden.git
synced 2026-06-20 21:00:41 +00:00
feat: enhance backup process with lease management and attachment deletion
- Implemented a backup runner lease mechanism to prevent concurrent backup executions. - Added `deleteAllAttachmentsForCiphers` function to delete attachments for multiple ciphers efficiently. - Introduced `bulkDeleteAttachmentsByIds` method in storage to handle batch deletion of attachments. - Updated backup execution logic to utilize the new lease management and ensure timely updates during the backup process. - Refactored cipher deletion to handle attachments more effectively. - Improved website icon loading with a dedicated caching mechanism for better performance. - Added new index on `ciphers` table for `folder_id` to optimize queries related to folder management. - Enhanced response handling for CORS policy to allow credentials for specific origins.
This commit is contained in:
@@ -598,6 +598,50 @@ function getBackupSlotStartsForLocalDay(
|
||||
return slots;
|
||||
}
|
||||
|
||||
export function hasBackupSlotBetween(
|
||||
destination: BackupDestinationRecord,
|
||||
startInclusive: Date,
|
||||
endExclusive: Date
|
||||
): boolean {
|
||||
if (!destination.schedule.enabled) return false;
|
||||
const startMs = startInclusive.getTime();
|
||||
const endMs = endExclusive.getTime();
|
||||
if (!Number.isFinite(startMs) || !Number.isFinite(endMs) || endMs <= startMs) return false;
|
||||
|
||||
const lastAttemptAt = destination.runtime.lastAttemptAt ? new Date(destination.runtime.lastAttemptAt) : null;
|
||||
const lastAttemptMs = lastAttemptAt && Number.isFinite(lastAttemptAt.getTime())
|
||||
? lastAttemptAt.getTime()
|
||||
: Number.NEGATIVE_INFINITY;
|
||||
|
||||
const dayCursor = new Date(startMs);
|
||||
dayCursor.setUTCHours(0, 0, 0, 0);
|
||||
const endDay = new Date(endMs);
|
||||
endDay.setUTCHours(0, 0, 0, 0);
|
||||
const checkedLocalDateKeys = new Set<string>();
|
||||
|
||||
while (dayCursor.getTime() <= endDay.getTime() + 24 * 60 * 60 * 1000) {
|
||||
const localDateKey = getBackupLocalDateKey(dayCursor, destination.schedule.timezone);
|
||||
if (!checkedLocalDateKeys.has(localDateKey)) {
|
||||
checkedLocalDateKeys.add(localDateKey);
|
||||
const slotStarts = getBackupSlotStartsForLocalDay(
|
||||
localDateKey,
|
||||
destination.schedule.timezone,
|
||||
destination.schedule.startTime,
|
||||
destination.schedule.intervalHours
|
||||
);
|
||||
for (const slotStart of slotStarts) {
|
||||
const slotStartMs = slotStart.getTime();
|
||||
if (slotStartMs < startMs || slotStartMs >= endMs) continue;
|
||||
if (lastAttemptMs >= slotStartMs) continue;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
dayCursor.setUTCDate(dayCursor.getUTCDate() + 1);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
export function isBackupDueNow(
|
||||
destination: BackupDestinationRecord,
|
||||
now: Date,
|
||||
|
||||
@@ -34,6 +34,22 @@ export async function deleteAttachment(db: D1Database, id: string): Promise<void
|
||||
await db.prepare('DELETE FROM attachments WHERE id = ?').bind(id).run();
|
||||
}
|
||||
|
||||
export async function bulkDeleteAttachmentsByIds(
|
||||
db: D1Database,
|
||||
sqlChunkSize: SqlChunkSize,
|
||||
attachmentIds: string[]
|
||||
): Promise<void> {
|
||||
const uniqueIds = [...new Set(attachmentIds.map((id) => String(id || '').trim()).filter(Boolean))];
|
||||
if (!uniqueIds.length) return;
|
||||
const chunkSize = sqlChunkSize(0);
|
||||
|
||||
for (let i = 0; i < uniqueIds.length; i += chunkSize) {
|
||||
const chunk = uniqueIds.slice(i, i + chunkSize);
|
||||
const placeholders = chunk.map(() => '?').join(',');
|
||||
await db.prepare(`DELETE FROM attachments WHERE id IN (${placeholders})`).bind(...chunk).run();
|
||||
}
|
||||
}
|
||||
|
||||
export async function getAttachmentsByCipher(db: D1Database, cipherId: string): Promise<Attachment[]> {
|
||||
const res = await db
|
||||
.prepare('SELECT id, cipher_id, file_name, size, size_name, key FROM attachments WHERE cipher_id = ?')
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Cipher, Folder } from '../types';
|
||||
import type { Folder } from '../types';
|
||||
|
||||
function mapFolderRow(row: any): Folder {
|
||||
return {
|
||||
@@ -36,26 +36,18 @@ export async function deleteFolder(db: D1Database, id: string, userId: string):
|
||||
export async function clearFolderFromCiphers(
|
||||
db: D1Database,
|
||||
userId: string,
|
||||
folderId: string,
|
||||
saveCipher: (cipher: Cipher) => Promise<void>
|
||||
folderId: string
|
||||
): Promise<void> {
|
||||
const now = new Date().toISOString();
|
||||
const res = await db
|
||||
.prepare('SELECT data FROM ciphers WHERE user_id = ? AND folder_id = ?')
|
||||
.bind(userId, folderId)
|
||||
.all<{ data: string }>();
|
||||
|
||||
for (const row of (res.results || [])) {
|
||||
let cipher: Cipher;
|
||||
try {
|
||||
cipher = JSON.parse(row.data) as Cipher;
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
cipher.folderId = null;
|
||||
cipher.updatedAt = now;
|
||||
await saveCipher(cipher);
|
||||
}
|
||||
const patch = JSON.stringify({ folderId: null, updatedAt: now });
|
||||
await db
|
||||
.prepare(
|
||||
`UPDATE ciphers
|
||||
SET folder_id = NULL, updated_at = ?, data = json_patch(data, ?)
|
||||
WHERE user_id = ? AND folder_id = ?`
|
||||
)
|
||||
.bind(now, patch, userId, folderId)
|
||||
.run();
|
||||
}
|
||||
|
||||
export async function bulkDeleteFolders(
|
||||
@@ -63,34 +55,26 @@ export async function bulkDeleteFolders(
|
||||
userId: string,
|
||||
ids: string[],
|
||||
sqlChunkSize: (fixedBindCount: number) => number,
|
||||
saveCipher: (cipher: Cipher) => Promise<void>,
|
||||
updateRevisionDate: (userId: string) => Promise<string>
|
||||
): Promise<string | null> {
|
||||
const uniqueIds = Array.from(new Set(ids.map((id) => String(id || '').trim()).filter(Boolean)));
|
||||
if (!uniqueIds.length) return null;
|
||||
|
||||
const chunkSize = sqlChunkSize(1);
|
||||
const now = new Date().toISOString();
|
||||
const patch = JSON.stringify({ folderId: null, updatedAt: now });
|
||||
const chunkSize = sqlChunkSize(3);
|
||||
|
||||
for (let i = 0; i < uniqueIds.length; i += chunkSize) {
|
||||
const chunk = uniqueIds.slice(i, i + chunkSize);
|
||||
const placeholders = chunk.map(() => '?').join(',');
|
||||
const res = await db
|
||||
.prepare(`SELECT data FROM ciphers WHERE user_id = ? AND folder_id IN (${placeholders})`)
|
||||
.bind(userId, ...chunk)
|
||||
.all<{ data: string }>();
|
||||
|
||||
for (const row of res.results || []) {
|
||||
let cipher: Cipher;
|
||||
try {
|
||||
cipher = JSON.parse(row.data) as Cipher;
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
cipher.folderId = null;
|
||||
cipher.updatedAt = now;
|
||||
await saveCipher(cipher);
|
||||
}
|
||||
await db
|
||||
.prepare(
|
||||
`UPDATE ciphers
|
||||
SET folder_id = NULL, updated_at = ?, data = json_patch(data, ?)
|
||||
WHERE user_id = ? AND folder_id IN (${placeholders})`
|
||||
)
|
||||
.bind(now, patch, userId, ...chunk)
|
||||
.run();
|
||||
|
||||
await db
|
||||
.prepare(`DELETE FROM folders WHERE user_id = ? AND id IN (${placeholders})`)
|
||||
|
||||
@@ -29,6 +29,7 @@ const SCHEMA_STATEMENTS: readonly string[] = [
|
||||
'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 INDEX IF NOT EXISTS idx_ciphers_user_folder ON ciphers(user_id, folder_id)',
|
||||
|
||||
'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, ' +
|
||||
|
||||
@@ -51,6 +51,7 @@ import {
|
||||
} from './storage-cipher-repo';
|
||||
import {
|
||||
addAttachmentToCipher as attachStoredAttachmentToCipher,
|
||||
bulkDeleteAttachmentsByIds as deleteStoredAttachmentsByIds,
|
||||
deleteAllAttachmentsByCipher as deleteStoredAttachmentsByCipher,
|
||||
deleteAttachment as deleteStoredAttachment,
|
||||
getAttachment as findStoredAttachment,
|
||||
@@ -107,7 +108,7 @@ import {
|
||||
|
||||
const TWO_FACTOR_REMEMBER_TTL_MS = 30 * 24 * 60 * 60 * 1000;
|
||||
const STORAGE_SCHEMA_VERSION_KEY = 'schema.version';
|
||||
const STORAGE_SCHEMA_VERSION = '2026-04-22';
|
||||
const STORAGE_SCHEMA_VERSION = '2026-04-28';
|
||||
|
||||
// D1-backed storage.
|
||||
// Contract:
|
||||
@@ -339,7 +340,6 @@ export class StorageService {
|
||||
userId,
|
||||
ids,
|
||||
this.sqlChunkSize.bind(this),
|
||||
this.saveCipher.bind(this),
|
||||
this.updateRevisionDate.bind(this)
|
||||
);
|
||||
}
|
||||
@@ -347,7 +347,7 @@ export class StorageService {
|
||||
// Clear folder references from all ciphers owned by the user.
|
||||
// Without this, deleting a folder leaves stale folderId values in cipher JSON.
|
||||
async clearFolderFromCiphers(userId: string, folderId: string): Promise<void> {
|
||||
await clearStoredFolderFromCiphers(this.db, userId, folderId, this.saveCipher.bind(this));
|
||||
await clearStoredFolderFromCiphers(this.db, userId, folderId);
|
||||
}
|
||||
|
||||
async getAllFolders(userId: string): Promise<Folder[]> {
|
||||
@@ -372,6 +372,10 @@ export class StorageService {
|
||||
await deleteStoredAttachment(this.db, id);
|
||||
}
|
||||
|
||||
async bulkDeleteAttachmentsByIds(ids: string[]): Promise<void> {
|
||||
await deleteStoredAttachmentsByIds(this.db, this.sqlChunkSize.bind(this), ids);
|
||||
}
|
||||
|
||||
async getAttachmentsByCipher(cipherId: string): Promise<Attachment[]> {
|
||||
return listStoredAttachmentsByCipher(this.db, cipherId);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user