mirror of
https://github.com/shuaiplus/nodewarden.git
synced 2026-06-20 21:00:41 +00:00
feat: Implement admin backup export and import functionality
- Added new endpoints for exporting and importing instance-level backups. - Introduced user interface components for backup management in the web app. - Enhanced import/export logic to handle attachments and provide detailed summaries. - Updated localization files to include new strings related to backup features. - Improved styling for backup-related UI elements.
This commit is contained in:
+116
-25
@@ -63,6 +63,25 @@ async function parseJson<T>(response: Response): Promise<T | null> {
|
||||
}
|
||||
}
|
||||
|
||||
function parseContentDispositionFileName(response: Response, fallback: string): string {
|
||||
const header = String(response.headers.get('Content-Disposition') || '').trim();
|
||||
if (!header) return fallback;
|
||||
|
||||
const utf8Match = header.match(/filename\*\s*=\s*UTF-8''([^;]+)/i);
|
||||
if (utf8Match?.[1]) {
|
||||
try {
|
||||
return decodeURIComponent(utf8Match[1]);
|
||||
} catch {
|
||||
// Ignore malformed filename*= values and fall back to the plain filename.
|
||||
}
|
||||
}
|
||||
|
||||
const plainMatch = header.match(/filename\s*=\s*"([^"]+)"|filename\s*=\s*([^;]+)/i);
|
||||
const raw = plainMatch?.[1] || plainMatch?.[2] || '';
|
||||
const normalized = String(raw).trim().replace(/^"+|"+$/g, '');
|
||||
return normalized || fallback;
|
||||
}
|
||||
|
||||
export async function getSetupStatus(): Promise<SetupStatusResponse> {
|
||||
const resp = await fetch('/setup/status');
|
||||
const body = await parseJson<SetupStatusResponse>(resp);
|
||||
@@ -804,6 +823,63 @@ export async function deleteUser(authedFetch: (input: string, init?: RequestInit
|
||||
if (!resp.ok) throw new Error('Delete user failed');
|
||||
}
|
||||
|
||||
export interface AdminBackupImportCounts {
|
||||
config: number;
|
||||
users: number;
|
||||
userRevisions: number;
|
||||
folders: number;
|
||||
ciphers: number;
|
||||
attachments: number;
|
||||
sends: number;
|
||||
attachmentFiles: number;
|
||||
sendFiles: number;
|
||||
}
|
||||
|
||||
export interface AdminBackupImportResponse {
|
||||
object: 'instance-backup-import';
|
||||
imported: AdminBackupImportCounts;
|
||||
}
|
||||
|
||||
export interface AdminBackupExportPayload {
|
||||
fileName: string;
|
||||
mimeType: string;
|
||||
bytes: Uint8Array;
|
||||
}
|
||||
|
||||
export async function exportAdminBackup(
|
||||
authedFetch: (input: string, init?: RequestInit) => Promise<Response>
|
||||
): Promise<AdminBackupExportPayload> {
|
||||
const resp = await authedFetch('/api/admin/backup/export', { method: 'POST' });
|
||||
if (!resp.ok) throw new Error(await parseErrorMessage(resp, 'Backup export failed'));
|
||||
|
||||
const mimeType = String(resp.headers.get('Content-Type') || 'application/zip').trim() || 'application/zip';
|
||||
const fileName = parseContentDispositionFileName(resp, 'nodewarden_instance_backup.zip');
|
||||
const bytes = new Uint8Array(await resp.arrayBuffer());
|
||||
return { fileName, mimeType, bytes };
|
||||
}
|
||||
|
||||
export async function importAdminBackup(
|
||||
authedFetch: (input: string, init?: RequestInit) => Promise<Response>,
|
||||
file: File,
|
||||
replaceExisting: boolean = false
|
||||
): Promise<AdminBackupImportResponse> {
|
||||
const formData = new FormData();
|
||||
formData.set('file', file, file.name || 'nodewarden_instance_backup.zip');
|
||||
if (replaceExisting) {
|
||||
formData.set('replaceExisting', '1');
|
||||
}
|
||||
|
||||
const resp = await authedFetch('/api/admin/backup/import', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
if (!resp.ok) throw new Error(await parseErrorMessage(resp, 'Backup import failed'));
|
||||
|
||||
const body = await parseJson<AdminBackupImportResponse>(resp);
|
||||
if (!body?.imported) throw new Error('Invalid backup import response');
|
||||
return body;
|
||||
}
|
||||
|
||||
function asNullable(v: string): string | null {
|
||||
const s = String(v || '').trim();
|
||||
return s ? s : null;
|
||||
@@ -851,16 +927,6 @@ async function encryptUris(uris: string[], enc: Uint8Array, mac: Uint8Array): Pr
|
||||
return out;
|
||||
}
|
||||
|
||||
function asFidoString(value: unknown, fallback = ''): string {
|
||||
const normalized = String(value ?? '').trim();
|
||||
return normalized || fallback;
|
||||
}
|
||||
|
||||
function asNullableFidoString(value: unknown): string | null {
|
||||
const normalized = String(value ?? '').trim();
|
||||
return normalized || null;
|
||||
}
|
||||
|
||||
function toIsoDateOrNow(value: unknown): string {
|
||||
const raw = String(value ?? '').trim();
|
||||
if (!raw) return new Date().toISOString();
|
||||
@@ -869,26 +935,51 @@ function toIsoDateOrNow(value: unknown): string {
|
||||
return parsed.toISOString();
|
||||
}
|
||||
|
||||
function normalizeFido2Credentials(
|
||||
async function encryptMaybeFidoValue(
|
||||
value: unknown,
|
||||
enc: Uint8Array,
|
||||
mac: Uint8Array,
|
||||
fallback = ''
|
||||
): Promise<string> {
|
||||
const normalized = String(value ?? '').trim() || fallback;
|
||||
if (looksLikeCipherString(normalized)) return normalized;
|
||||
return encryptBw(new TextEncoder().encode(normalized), enc, mac);
|
||||
}
|
||||
|
||||
async function encryptMaybeNullableFidoValue(
|
||||
value: unknown,
|
||||
enc: Uint8Array,
|
||||
mac: Uint8Array
|
||||
): Promise<string | null> {
|
||||
const normalized = String(value ?? '').trim();
|
||||
if (!normalized) return null;
|
||||
if (looksLikeCipherString(normalized)) return normalized;
|
||||
return encryptBw(new TextEncoder().encode(normalized), enc, mac);
|
||||
}
|
||||
|
||||
async function normalizeFido2Credentials(
|
||||
credentials: Array<Record<string, unknown>> | null | undefined
|
||||
,
|
||||
enc: Uint8Array,
|
||||
mac: Uint8Array
|
||||
): Array<Record<string, unknown>> | null {
|
||||
if (!Array.isArray(credentials) || credentials.length === 0) return null;
|
||||
const out: Array<Record<string, unknown>> = [];
|
||||
for (const credential of credentials) {
|
||||
if (!credential || typeof credential !== 'object') continue;
|
||||
out.push({
|
||||
credentialId: asFidoString(credential.credentialId),
|
||||
keyType: asFidoString(credential.keyType, 'public-key'),
|
||||
keyAlgorithm: asFidoString(credential.keyAlgorithm, 'ECDSA'),
|
||||
keyCurve: asFidoString(credential.keyCurve, 'P-256'),
|
||||
keyValue: asFidoString(credential.keyValue),
|
||||
rpId: asFidoString(credential.rpId),
|
||||
rpName: asNullableFidoString(credential.rpName),
|
||||
userHandle: asNullableFidoString(credential.userHandle),
|
||||
userName: asNullableFidoString(credential.userName),
|
||||
userDisplayName: asNullableFidoString(credential.userDisplayName),
|
||||
counter: asFidoString(credential.counter, '0'),
|
||||
discoverable: asFidoString(credential.discoverable, 'false'),
|
||||
credentialId: await encryptMaybeFidoValue(credential.credentialId, enc, mac),
|
||||
keyType: await encryptMaybeFidoValue(credential.keyType, enc, mac, 'public-key'),
|
||||
keyAlgorithm: await encryptMaybeFidoValue(credential.keyAlgorithm, enc, mac, 'ECDSA'),
|
||||
keyCurve: await encryptMaybeFidoValue(credential.keyCurve, enc, mac, 'P-256'),
|
||||
keyValue: await encryptMaybeFidoValue(credential.keyValue, enc, mac),
|
||||
rpId: await encryptMaybeFidoValue(credential.rpId, enc, mac),
|
||||
rpName: await encryptMaybeNullableFidoValue(credential.rpName, enc, mac),
|
||||
userHandle: await encryptMaybeNullableFidoValue(credential.userHandle, enc, mac),
|
||||
userName: await encryptMaybeNullableFidoValue(credential.userName, enc, mac),
|
||||
userDisplayName: await encryptMaybeNullableFidoValue(credential.userDisplayName, enc, mac),
|
||||
counter: await encryptMaybeFidoValue(credential.counter, enc, mac, '0'),
|
||||
discoverable: await encryptMaybeFidoValue(credential.discoverable, enc, mac, 'false'),
|
||||
creationDate: toIsoDateOrNow(credential.creationDate),
|
||||
});
|
||||
}
|
||||
@@ -937,7 +1028,7 @@ export async function createCipher(
|
||||
username: await encryptTextValue(draft.loginUsername, enc, mac),
|
||||
password: await encryptTextValue(draft.loginPassword, enc, mac),
|
||||
totp: await encryptTextValue(draft.loginTotp, enc, mac),
|
||||
fido2Credentials: normalizeFido2Credentials(draft.loginFido2Credentials),
|
||||
fido2Credentials: await normalizeFido2Credentials(draft.loginFido2Credentials, enc, mac),
|
||||
uris: await encryptUris(draft.loginUris || [], enc, mac),
|
||||
};
|
||||
} else if (type === 3) {
|
||||
@@ -1032,7 +1123,7 @@ export async function updateCipher(
|
||||
username: await encryptTextValue(draft.loginUsername, keys.enc, keys.mac),
|
||||
password: await encryptTextValue(draft.loginPassword, keys.enc, keys.mac),
|
||||
totp: await encryptTextValue(draft.loginTotp, keys.enc, keys.mac),
|
||||
fido2Credentials: normalizeFido2Credentials(existingFido2),
|
||||
fido2Credentials: await normalizeFido2Credentials(existingFido2, keys.enc, keys.mac),
|
||||
uris: await encryptUris(draft.loginUris || [], keys.enc, keys.mac),
|
||||
};
|
||||
} else if (type === 3) {
|
||||
|
||||
+42
-14
@@ -15,6 +15,23 @@ const messages: Record<Locale, Record<string, string>> = {
|
||||
backup_strategy_under_construction: "Under construction.",
|
||||
import_export_title: "Import & Export",
|
||||
import_export_under_construction: "Under construction.",
|
||||
txt_backup_export: "Backup Export",
|
||||
txt_backup_import: "Backup Import",
|
||||
txt_backup_export_description: "Download a full instance backup ZIP for manual safekeeping.",
|
||||
txt_backup_import_description: "Upload a previously exported backup ZIP and restore it into a fresh instance shell.",
|
||||
txt_backup_exporting: "Exporting...",
|
||||
txt_backup_importing: "Importing...",
|
||||
txt_backup_export_success: "Backup exported",
|
||||
txt_backup_import_success_relogin: "Backup imported. Please sign in again.",
|
||||
txt_backup_export_failed: "Backup export failed",
|
||||
txt_backup_import_failed: "Backup import failed",
|
||||
txt_backup_file: "Backup File",
|
||||
txt_backup_file_required: "Please select a backup file",
|
||||
txt_backup_no_file_selected: "No backup file selected",
|
||||
txt_backup_selected_file_name: "Selected file: {name}",
|
||||
txt_backup_replace_confirm_title: "Replace Current Instance Data",
|
||||
txt_backup_replace_confirm_message: "The current instance already contains data. Clear it and import the new backup?",
|
||||
txt_backup_clear_and_import: "Clear and Import",
|
||||
txt_access_count: "Access Count",
|
||||
txt_accessed_count_times: "Accessed {count} times",
|
||||
txt_actions: "Actions",
|
||||
@@ -385,6 +402,23 @@ const zhCNOverrides: Record<string, string> = {
|
||||
backup_strategy_under_construction: '正在搭建中',
|
||||
import_export_title: '导入导出',
|
||||
import_export_under_construction: '正在搭建中',
|
||||
txt_backup_export: '备份导出',
|
||||
txt_backup_import: '备份导入',
|
||||
txt_backup_export_description: '下载一个完整的实例备份 ZIP,手动保管即可。',
|
||||
txt_backup_import_description: '上传之前导出的备份 ZIP,并恢复到全新实例空壳。',
|
||||
txt_backup_exporting: '正在导出...',
|
||||
txt_backup_importing: '正在导入...',
|
||||
txt_backup_export_success: '备份已导出',
|
||||
txt_backup_import_success_relogin: '备份已导入,请重新登录',
|
||||
txt_backup_export_failed: '备份导出失败',
|
||||
txt_backup_import_failed: '备份导入失败',
|
||||
txt_backup_file: '备份文件',
|
||||
txt_backup_file_required: '请选择备份文件',
|
||||
txt_backup_no_file_selected: '尚未选择备份文件',
|
||||
txt_backup_selected_file_name: '已选择文件:{name}',
|
||||
txt_backup_replace_confirm_title: '替换当前实例数据',
|
||||
txt_backup_replace_confirm_message: '当前实例里已经有数据。要先清空当前数据库和文件,再导入新的备份吗?',
|
||||
txt_backup_clear_and_import: '清空后导入',
|
||||
txt_sign_out: '退出登录',
|
||||
txt_log_in: '登录',
|
||||
txt_log_out: '退出',
|
||||
@@ -759,13 +793,6 @@ messages.en.txt_select_folder_placeholder = '-- Select folder --';
|
||||
messages.en.txt_import_vault_data_hint = 'Import vault data into your current account.';
|
||||
messages.en.txt_export_vault_data_hint = 'Export vault data from your current account.';
|
||||
messages.en.txt_import_export_title = 'Import & Export';
|
||||
messages.en.txt_import_export_feature_intro = 'Provides standardized vault migration across clients, including attachment-aware and encrypted workflows.';
|
||||
messages.en.txt_import_export_feature_bw_zip_title = 'Bitwarden vault + attachments ZIP';
|
||||
messages.en.txt_import_export_feature_bw_zip_desc = 'Supports both import and export for Bitwarden ZIP archives containing vault data and attachments.';
|
||||
messages.en.txt_import_export_feature_nodewarden_json_title = 'NodeWarden vault + attachments JSON';
|
||||
messages.en.txt_import_export_feature_nodewarden_json_desc = 'Supports NodeWarden JSON import/export with vault and attachment payloads in a single document. Exported vault data remains importable by Bitwarden clients.';
|
||||
messages.en.txt_import_export_feature_compat_title = 'Cross-client compatibility';
|
||||
messages.en.txt_import_export_feature_compat_desc = 'Supports Bitwarden JSON/CSV and mainstream migration formats with consistent field normalization and import mapping.';
|
||||
messages.en.txt_encrypted_mode = 'Encrypted mode';
|
||||
messages.en.txt_account_verification = 'Account verification';
|
||||
messages.en.txt_password_verification = 'Password verification';
|
||||
@@ -776,6 +803,10 @@ messages.en.txt_close = 'Close';
|
||||
messages.en.txt_total = 'Total';
|
||||
messages.en.txt_import_success = 'Import successful';
|
||||
messages.en.txt_import_success_number_of_items = 'Imported {count} item(s) in total.';
|
||||
messages.en.txt_import_attachment_summary = 'Imported {imported} of {total} attachment(s).';
|
||||
messages.en.txt_import_failed_attachments_title = '{count} attachment(s) were not imported:';
|
||||
messages.en.txt_import_attachment_target_not_found = 'Matching imported item not found.';
|
||||
messages.en.txt_upload_attachment_failed = 'Attachment upload failed.';
|
||||
messages.en.txt_import_file_password_required = 'Please enter file password.';
|
||||
messages.en.txt_import_invalid_zip_password = 'Invalid ZIP password.';
|
||||
messages.en.txt_export_completed = 'Export completed';
|
||||
@@ -832,6 +863,10 @@ zhCNOverrides.txt_close = '关闭';
|
||||
zhCNOverrides.txt_total = '总计';
|
||||
zhCNOverrides.txt_import_success = '数据导入成功';
|
||||
zhCNOverrides.txt_import_success_number_of_items = '一共导入了 {count} 个项目。';
|
||||
zhCNOverrides.txt_import_attachment_summary = '附件已导入 {imported}/{total} 个。';
|
||||
zhCNOverrides.txt_import_failed_attachments_title = '以下 {count} 个附件未导入:';
|
||||
zhCNOverrides.txt_import_attachment_target_not_found = '没有找到对应的导入项目。';
|
||||
zhCNOverrides.txt_upload_attachment_failed = '附件上传失败。';
|
||||
zhCNOverrides.txt_import_file_password_required = '请输入文件密码。';
|
||||
zhCNOverrides.txt_import_invalid_zip_password = 'ZIP 密码错误。';
|
||||
zhCNOverrides.txt_export_completed = '导出完成';
|
||||
@@ -850,13 +885,6 @@ zhCNOverrides.txt_import_encrypted_zip_title = '导入加密 ZIP';
|
||||
zhCNOverrides.txt_import_encrypted_zip_message = '该 ZIP 压缩包已加密,请输入 ZIP 密码继续。';
|
||||
|
||||
zhCNOverrides.txt_import_export_title = '导入导出';
|
||||
zhCNOverrides.txt_import_export_feature_intro = '提供标准化的数据迁移能力,覆盖附件与加密场景。';
|
||||
zhCNOverrides.txt_import_export_feature_bw_zip_title = 'Bitwarden 密码库 + 附件 ZIP';
|
||||
zhCNOverrides.txt_import_export_feature_bw_zip_desc = '支持导入与导出包含密码库和附件的 Bitwarden ZIP 压缩包。';
|
||||
zhCNOverrides.txt_import_export_feature_nodewarden_json_title = 'NodeWarden 密码库 + 附件 JSON';
|
||||
zhCNOverrides.txt_import_export_feature_nodewarden_json_desc = '支持 NodeWarden JSON 导入导出,单文件包含密码库与附件;导出的密码库数据可被 Bitwarden 客户端导入。';
|
||||
zhCNOverrides.txt_import_export_feature_compat_title = '跨客户端兼容';
|
||||
zhCNOverrides.txt_import_export_feature_compat_desc = '支持 Bitwarden JSON/CSV 与主流迁移格式,统一字段映射与导入行为。';
|
||||
zhCNOverrides.txt_new_type_header = '新建{type}';
|
||||
zhCNOverrides.txt_edit_type_header = '编辑{type}';
|
||||
zhCNOverrides.txt_delete_folder = '删除文件夹';
|
||||
|
||||
Reference in New Issue
Block a user