mirror of
https://github.com/shuaiplus/nodewarden.git
synced 2026-06-20 21:00:41 +00:00
f0ace28bf2
- Introduced `shared.ts` with utility functions for API interactions, including JSON parsing, error handling, and content disposition parsing. - Added `vault.ts` to manage vault-related operations such as folder and cipher management, including creation, deletion, and bulk operations. - Implemented encryption and decryption methods for secure data handling within the vault. - Created `backup-settings-repair.ts` to automatically repair backup settings for admin profiles if needed.
466 lines
17 KiB
TypeScript
466 lines
17 KiB
TypeScript
import type { Env, User } from '../types';
|
|
import { errorResponse, jsonResponse } from '../utils/response';
|
|
import { generateUUID } from '../utils/uuid';
|
|
import { type BackupArchiveBundle, buildBackupArchive } from '../services/backup-archive';
|
|
import {
|
|
type BackupDestinationRecord,
|
|
type BackupSettingsInput,
|
|
BACKUP_SCHEDULER_WINDOW_MINUTES,
|
|
getBackupLocalDateKey,
|
|
getDefaultBackupSettings,
|
|
getBackupSettingsRepairState,
|
|
isBackupDueNow,
|
|
loadBackupSettings,
|
|
normalizeBackupSettingsInput,
|
|
normalizeImportedBackupSettings,
|
|
repairBackupSettings,
|
|
requireBackupDestination,
|
|
saveBackupSettings,
|
|
} from '../services/backup-config';
|
|
import { type BackupImportExecutionResult, importBackupArchiveBytes } from '../services/backup-import';
|
|
import {
|
|
deleteRemoteBackupFile,
|
|
downloadRemoteBackupFile,
|
|
ensureRemoteRestoreCandidate,
|
|
listRemoteBackupEntries,
|
|
pruneRemoteBackupArchives,
|
|
uploadBackupArchive,
|
|
} from '../services/backup-uploader';
|
|
import { StorageService } from '../services/storage';
|
|
|
|
function isAdmin(user: User): boolean {
|
|
return user.role === 'admin' && user.status === 'active';
|
|
}
|
|
|
|
async function writeAuditLog(
|
|
storage: StorageService,
|
|
actorUserId: string | null,
|
|
action: string,
|
|
targetType: string | null,
|
|
targetId: string | null,
|
|
metadata: Record<string, unknown> | null
|
|
): Promise<void> {
|
|
await storage.createAuditLog({
|
|
id: generateUUID(),
|
|
actorUserId,
|
|
action,
|
|
targetType,
|
|
targetId,
|
|
metadata: metadata ? JSON.stringify(metadata) : null,
|
|
createdAt: new Date().toISOString(),
|
|
});
|
|
}
|
|
|
|
function getBackupDestinationSummary(destination: BackupDestinationRecord | null): Record<string, unknown> {
|
|
if (!destination) {
|
|
return {
|
|
destinationId: null,
|
|
destinationName: null,
|
|
destinationType: null,
|
|
};
|
|
}
|
|
return {
|
|
destinationId: destination.id,
|
|
destinationName: destination.name,
|
|
destinationType: destination.type,
|
|
};
|
|
}
|
|
|
|
async function executeConfiguredBackup(
|
|
env: Env,
|
|
storage: StorageService,
|
|
actorUserId: string | null,
|
|
trigger: 'manual' | 'scheduled',
|
|
destinationId?: string | null
|
|
): Promise<{ fileName: string; fileSize: number; remotePath: string; provider: string }> {
|
|
const currentSettings = await loadBackupSettings(storage, env, 'UTC');
|
|
const destination = requireBackupDestination(currentSettings, destinationId);
|
|
|
|
const now = new Date();
|
|
destination.runtime.lastAttemptAt = now.toISOString();
|
|
destination.runtime.lastAttemptLocalDate = getBackupLocalDateKey(now, destination.schedule.timezone);
|
|
destination.runtime.lastErrorAt = null;
|
|
destination.runtime.lastErrorMessage = null;
|
|
await saveBackupSettings(storage, env, currentSettings);
|
|
|
|
try {
|
|
const archive = await buildBackupArchive(env, now);
|
|
const upload = await uploadBackupArchive(destination, archive.bytes, archive.fileName);
|
|
let prunedFileCount = 0;
|
|
let pruneErrorMessage: string | null = null;
|
|
try {
|
|
prunedFileCount = await pruneRemoteBackupArchives(destination, destination.schedule.retentionCount, archive.fileName);
|
|
} catch (error) {
|
|
pruneErrorMessage = error instanceof Error ? error.message : 'Old backup cleanup failed';
|
|
}
|
|
|
|
destination.runtime.lastSuccessAt = new Date().toISOString();
|
|
destination.runtime.lastErrorAt = null;
|
|
destination.runtime.lastErrorMessage = null;
|
|
destination.runtime.lastUploadedFileName = archive.fileName;
|
|
destination.runtime.lastUploadedSizeBytes = archive.bytes.byteLength;
|
|
destination.runtime.lastUploadedDestination = upload.remotePath;
|
|
await saveBackupSettings(storage, env, currentSettings);
|
|
|
|
await writeAuditLog(storage, actorUserId, `admin.backup.remote.${trigger}`, 'backup', null, {
|
|
...getBackupDestinationSummary(destination),
|
|
provider: upload.provider,
|
|
remotePath: upload.remotePath,
|
|
fileName: archive.fileName,
|
|
fileBytes: archive.bytes.byteLength,
|
|
prunedFileCount,
|
|
pruneError: pruneErrorMessage,
|
|
});
|
|
|
|
return {
|
|
fileName: archive.fileName,
|
|
fileSize: archive.bytes.byteLength,
|
|
remotePath: upload.remotePath,
|
|
provider: upload.provider,
|
|
};
|
|
} catch (error) {
|
|
destination.runtime.lastErrorAt = new Date().toISOString();
|
|
destination.runtime.lastErrorMessage = error instanceof Error ? error.message : 'Backup upload failed';
|
|
await saveBackupSettings(storage, env, currentSettings);
|
|
|
|
await writeAuditLog(storage, actorUserId, `admin.backup.remote.${trigger}.failed`, 'backup', null, {
|
|
...getBackupDestinationSummary(destination),
|
|
error: destination.runtime.lastErrorMessage,
|
|
});
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
function toImportStatusCode(message: string): number {
|
|
const lower = message.toLowerCase();
|
|
if (lower.includes('invalid backup') || lower.includes('invalid json')) return 400;
|
|
if (lower.includes('fresh instance')) return 409;
|
|
if (lower.includes('not configured') || lower.includes('kv')) return 409;
|
|
return 500;
|
|
}
|
|
|
|
async function runImportAndAudit(
|
|
env: Env,
|
|
actorUser: User,
|
|
archiveBytes: Uint8Array,
|
|
replaceExisting: boolean,
|
|
metadata: Record<string, unknown>
|
|
): Promise<BackupImportExecutionResult> {
|
|
const storage = new StorageService(env.DB);
|
|
const imported = await importBackupArchiveBytes(archiveBytes, env, actorUser.id, replaceExisting);
|
|
await writeAuditLog(storage, imported.auditActorUserId, 'admin.backup.import', 'backup', null, {
|
|
users: imported.result.imported.users,
|
|
ciphers: imported.result.imported.ciphers,
|
|
attachments: imported.result.imported.attachmentFiles,
|
|
sendFiles: imported.result.imported.sendFiles,
|
|
replaceExisting,
|
|
...metadata,
|
|
});
|
|
return imported;
|
|
}
|
|
|
|
export async function runScheduledBackupIfDue(env: Env): Promise<void> {
|
|
const storage = new StorageService(env.DB);
|
|
const settings = await loadBackupSettings(storage, env, 'UTC');
|
|
const now = new Date();
|
|
for (const destination of settings.destinations) {
|
|
if (!isBackupDueNow(destination, now, BACKUP_SCHEDULER_WINDOW_MINUTES)) continue;
|
|
await executeConfiguredBackup(env, storage, null, 'scheduled', destination.id);
|
|
}
|
|
}
|
|
|
|
export async function handleGetAdminBackupSettings(request: Request, env: Env, actorUser: User): Promise<Response> {
|
|
void request;
|
|
if (!isAdmin(actorUser)) return errorResponse('Forbidden', 403);
|
|
|
|
const storage = new StorageService(env.DB);
|
|
try {
|
|
const settings = await loadBackupSettings(storage, env, 'UTC');
|
|
return jsonResponse(settings);
|
|
} catch (error) {
|
|
return errorResponse(error instanceof Error ? error.message : 'Backup settings could not be loaded', 409);
|
|
}
|
|
}
|
|
|
|
export async function handleUpdateAdminBackupSettings(request: Request, env: Env, actorUser: User): Promise<Response> {
|
|
if (!isAdmin(actorUser)) return errorResponse('Forbidden', 403);
|
|
|
|
let body: BackupSettingsInput;
|
|
try {
|
|
body = await request.json<BackupSettingsInput>();
|
|
} catch {
|
|
return errorResponse('Backup settings payload is invalid', 400);
|
|
}
|
|
|
|
const storage = new StorageService(env.DB);
|
|
let previous;
|
|
try {
|
|
previous = await loadBackupSettings(storage, env, 'UTC');
|
|
} catch {
|
|
previous = getDefaultBackupSettings('UTC');
|
|
}
|
|
|
|
let next;
|
|
try {
|
|
next = normalizeBackupSettingsInput(body, previous);
|
|
} catch (error) {
|
|
return errorResponse(error instanceof Error ? error.message : 'Backup settings are invalid', 400);
|
|
}
|
|
|
|
await saveBackupSettings(storage, env, next);
|
|
await writeAuditLog(storage, actorUser.id, 'admin.backup.settings.update', 'backup', null, {
|
|
destinationCount: next.destinations.length,
|
|
scheduledDestinationCount: next.destinations.filter((destination) => destination.schedule.enabled).length,
|
|
});
|
|
return jsonResponse(next);
|
|
}
|
|
|
|
export async function handleGetAdminBackupSettingsRepairState(request: Request, env: Env, actorUser: User): Promise<Response> {
|
|
void request;
|
|
if (!isAdmin(actorUser)) return errorResponse('Forbidden', 403);
|
|
|
|
const storage = new StorageService(env.DB);
|
|
try {
|
|
const state = await getBackupSettingsRepairState(storage, env, 'UTC');
|
|
return jsonResponse({
|
|
object: 'backup-settings-repair',
|
|
needsRepair: state.needsRepair,
|
|
portable: state.portable,
|
|
});
|
|
} catch (error) {
|
|
return errorResponse(error instanceof Error ? error.message : 'Backup settings repair state could not be loaded', 409);
|
|
}
|
|
}
|
|
|
|
export async function handleRepairAdminBackupSettings(request: Request, env: Env, actorUser: User): Promise<Response> {
|
|
if (!isAdmin(actorUser)) return errorResponse('Forbidden', 403);
|
|
|
|
let body: BackupSettingsInput;
|
|
try {
|
|
body = await request.json<BackupSettingsInput>();
|
|
} catch {
|
|
return errorResponse('Backup settings repair payload is invalid', 400);
|
|
}
|
|
|
|
const storage = new StorageService(env.DB);
|
|
let previous;
|
|
try {
|
|
previous = await loadBackupSettings(storage, env, 'UTC');
|
|
} catch {
|
|
previous = getDefaultBackupSettings('UTC');
|
|
}
|
|
|
|
let next;
|
|
try {
|
|
next = normalizeBackupSettingsInput(body, previous);
|
|
} catch (error) {
|
|
return errorResponse(error instanceof Error ? error.message : 'Backup settings repair payload is invalid', 400);
|
|
}
|
|
|
|
await repairBackupSettings(storage, env, next);
|
|
await writeAuditLog(storage, actorUser.id, 'admin.backup.settings.repair', 'backup', null, {
|
|
destinationCount: next.destinations.length,
|
|
scheduledDestinationCount: next.destinations.filter((destination) => destination.schedule.enabled).length,
|
|
});
|
|
return jsonResponse(next);
|
|
}
|
|
|
|
export async function handleRunAdminConfiguredBackup(request: Request, env: Env, actorUser: User): Promise<Response> {
|
|
if (!isAdmin(actorUser)) return errorResponse('Forbidden', 403);
|
|
|
|
const storage = new StorageService(env.DB);
|
|
try {
|
|
let body: { destinationId?: string } | null = null;
|
|
try {
|
|
if ((request.headers.get('Content-Type') || '').includes('application/json')) {
|
|
body = await request.json<{ destinationId?: string }>();
|
|
}
|
|
} catch {
|
|
return errorResponse('Backup run payload is invalid', 400);
|
|
}
|
|
|
|
const result = await executeConfiguredBackup(env, storage, actorUser.id, 'manual', body?.destinationId || null);
|
|
const settings = await loadBackupSettings(storage, env, 'UTC');
|
|
return jsonResponse({
|
|
object: 'backup-run',
|
|
result: {
|
|
fileName: result.fileName,
|
|
fileSize: result.fileSize,
|
|
provider: result.provider,
|
|
remotePath: result.remotePath,
|
|
},
|
|
settings,
|
|
});
|
|
} catch (error) {
|
|
return errorResponse(error instanceof Error ? error.message : 'Backup run failed', 500);
|
|
}
|
|
}
|
|
|
|
export async function handleListAdminRemoteBackups(request: Request, env: Env, actorUser: User): Promise<Response> {
|
|
if (!isAdmin(actorUser)) return errorResponse('Forbidden', 403);
|
|
|
|
const storage = new StorageService(env.DB);
|
|
try {
|
|
const settings = await loadBackupSettings(storage, env, 'UTC');
|
|
const url = new URL(request.url);
|
|
const destination = requireBackupDestination(settings, url.searchParams.get('destinationId') || null);
|
|
const listing = await listRemoteBackupEntries(destination, url.searchParams.get('path') || '');
|
|
return jsonResponse({
|
|
object: 'backup-remote-browser',
|
|
destinationId: destination.id,
|
|
destinationName: destination.name,
|
|
...listing,
|
|
});
|
|
} catch (error) {
|
|
return errorResponse(error instanceof Error ? error.message : 'Remote backup listing failed', 409);
|
|
}
|
|
}
|
|
|
|
export async function handleDownloadAdminRemoteBackup(request: Request, env: Env, actorUser: User): Promise<Response> {
|
|
if (!isAdmin(actorUser)) return errorResponse('Forbidden', 403);
|
|
|
|
const storage = new StorageService(env.DB);
|
|
try {
|
|
const settings = await loadBackupSettings(storage, env, 'UTC');
|
|
const url = new URL(request.url);
|
|
const path = ensureRemoteRestoreCandidate(url.searchParams.get('path') || '');
|
|
const destination = requireBackupDestination(settings, url.searchParams.get('destinationId') || null);
|
|
const remoteFile = await downloadRemoteBackupFile(destination, path);
|
|
return new Response(remoteFile.bytes, {
|
|
status: 200,
|
|
headers: {
|
|
'Content-Type': remoteFile.contentType || 'application/zip',
|
|
'Content-Disposition': `attachment; filename="${remoteFile.fileName}"`,
|
|
'Cache-Control': 'no-store',
|
|
},
|
|
});
|
|
} catch (error) {
|
|
return errorResponse(error instanceof Error ? error.message : 'Remote backup download failed', 409);
|
|
}
|
|
}
|
|
|
|
export async function handleDeleteAdminRemoteBackup(request: Request, env: Env, actorUser: User): Promise<Response> {
|
|
if (!isAdmin(actorUser)) return errorResponse('Forbidden', 403);
|
|
|
|
const storage = new StorageService(env.DB);
|
|
try {
|
|
const settings = await loadBackupSettings(storage, env, 'UTC');
|
|
const url = new URL(request.url);
|
|
const path = ensureRemoteRestoreCandidate(url.searchParams.get('path') || '');
|
|
const destination = requireBackupDestination(settings, url.searchParams.get('destinationId') || null);
|
|
await deleteRemoteBackupFile(destination, path);
|
|
await writeAuditLog(storage, actorUser.id, 'admin.backup.remote.delete', 'backup', null, {
|
|
...getBackupDestinationSummary(destination),
|
|
remotePath: path,
|
|
});
|
|
return jsonResponse({ object: 'backup-remote-delete', deleted: true, path });
|
|
} catch (error) {
|
|
return errorResponse(error instanceof Error ? error.message : 'Remote backup delete failed', 409);
|
|
}
|
|
}
|
|
|
|
export async function handleRestoreAdminRemoteBackup(request: Request, env: Env, actorUser: User): Promise<Response> {
|
|
if (!isAdmin(actorUser)) return errorResponse('Forbidden', 403);
|
|
|
|
let body: { destinationId?: string; path?: string; replaceExisting?: boolean };
|
|
try {
|
|
body = await request.json<{ destinationId?: string; path?: string; replaceExisting?: boolean }>();
|
|
} catch {
|
|
return errorResponse('Remote restore payload is invalid', 400);
|
|
}
|
|
|
|
const storage = new StorageService(env.DB);
|
|
try {
|
|
const settings = await loadBackupSettings(storage, env, 'UTC');
|
|
const destination = requireBackupDestination(settings, body.destinationId || null);
|
|
const path = ensureRemoteRestoreCandidate(String(body.path || ''));
|
|
const remoteFile = await downloadRemoteBackupFile(destination, path);
|
|
const imported = await runImportAndAudit(env, actorUser, remoteFile.bytes, !!body.replaceExisting, {
|
|
...getBackupDestinationSummary(destination),
|
|
remotePath: path,
|
|
bytes: remoteFile.bytes.byteLength,
|
|
trigger: 'remote',
|
|
});
|
|
return jsonResponse(imported.result);
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : 'Remote backup restore failed';
|
|
return errorResponse(message, toImportStatusCode(message));
|
|
}
|
|
}
|
|
|
|
export async function handleAdminExportBackup(request: Request, env: Env, actorUser: User): Promise<Response> {
|
|
void request;
|
|
if (!isAdmin(actorUser)) return errorResponse('Forbidden', 403);
|
|
|
|
const storage = new StorageService(env.DB);
|
|
let archive: BackupArchiveBundle;
|
|
try {
|
|
archive = await buildBackupArchive(env);
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : 'Backup export failed';
|
|
return errorResponse(message, message.includes('blob missing') ? 409 : 500);
|
|
}
|
|
|
|
await writeAuditLog(storage, actorUser.id, 'admin.backup.export', 'backup', null, {
|
|
users: archive.manifest.tableCounts.users,
|
|
ciphers: archive.manifest.tableCounts.ciphers,
|
|
attachments: archive.manifest.tableCounts.attachments,
|
|
sends: archive.manifest.tableCounts.sends,
|
|
compressedBytes: archive.bytes.byteLength,
|
|
});
|
|
|
|
return new Response(archive.bytes, {
|
|
status: 200,
|
|
headers: {
|
|
'Content-Type': 'application/zip',
|
|
'Content-Disposition': `attachment; filename="${archive.fileName}"`,
|
|
'Cache-Control': 'no-store',
|
|
},
|
|
});
|
|
}
|
|
|
|
export async function handleAdminImportBackup(request: Request, env: Env, actorUser: User): Promise<Response> {
|
|
if (!isAdmin(actorUser)) return errorResponse('Forbidden', 403);
|
|
|
|
let formData: FormData;
|
|
try {
|
|
formData = await request.formData();
|
|
} catch {
|
|
return errorResponse('Content-Type must be multipart/form-data', 400);
|
|
}
|
|
|
|
const file = formData.get('file');
|
|
if (!file || typeof file !== 'object' || !('arrayBuffer' in file)) {
|
|
return errorResponse('Backup file is required', 400);
|
|
}
|
|
|
|
const replaceExisting = String(formData.get('replaceExisting') || '').trim() === '1';
|
|
let archiveBytes: Uint8Array;
|
|
try {
|
|
archiveBytes = new Uint8Array(await (file as { arrayBuffer(): Promise<ArrayBuffer> }).arrayBuffer());
|
|
} catch {
|
|
return errorResponse('Unable to read backup file', 400);
|
|
}
|
|
|
|
try {
|
|
const imported = await runImportAndAudit(env, actorUser, archiveBytes, replaceExisting, {
|
|
trigger: 'local',
|
|
bytes: archiveBytes.byteLength,
|
|
});
|
|
return jsonResponse(imported.result);
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : 'Backup import failed';
|
|
return errorResponse(message, toImportStatusCode(message));
|
|
}
|
|
}
|
|
|
|
export async function seedDefaultBackupSettings(env: Env): Promise<void> {
|
|
const storage = new StorageService(env.DB);
|
|
const current = await storage.getConfigValue('backup.settings.v1');
|
|
if (current) {
|
|
await normalizeImportedBackupSettings(storage, env, 'UTC');
|
|
return;
|
|
}
|
|
await saveBackupSettings(storage, env, getDefaultBackupSettings('UTC'));
|
|
}
|