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:
+86
-24
@@ -27,6 +27,8 @@ import {
|
||||
createAuthedFetch,
|
||||
createInvite,
|
||||
downloadCipherAttachmentDecrypted,
|
||||
exportAdminBackup,
|
||||
importAdminBackup,
|
||||
importCiphers,
|
||||
createSend,
|
||||
deleteAllInvites,
|
||||
@@ -109,7 +111,12 @@ function asText(value: unknown): string {
|
||||
|
||||
function summarizeImportResult(
|
||||
ciphers: Array<Record<string, unknown>>,
|
||||
folderCount: number
|
||||
folderCount: number,
|
||||
attachmentSummary?: {
|
||||
total: number;
|
||||
imported: number;
|
||||
failed: Array<{ fileName: string; reason: string }>;
|
||||
}
|
||||
): ImportResultSummary {
|
||||
const typeLabel = (type: number): string => {
|
||||
if (type === 1) return t('txt_login');
|
||||
@@ -136,6 +143,9 @@ function summarizeImportResult(
|
||||
totalItems: ciphers.length,
|
||||
folderCount: Math.max(0, folderCount),
|
||||
typeCounts,
|
||||
attachmentCount: Math.max(0, attachmentSummary?.total || 0),
|
||||
importedAttachmentCount: Math.max(0, attachmentSummary?.imported || 0),
|
||||
failedAttachments: attachmentSummary?.failed || [],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1122,13 +1132,16 @@ export default function App() {
|
||||
async function uploadImportedAttachments(
|
||||
attachments: ImportAttachmentFile[],
|
||||
idMaps: { byIndex: Map<number, string>; bySourceId: Map<string, string> }
|
||||
): Promise<void> {
|
||||
if (!attachments.length) return;
|
||||
): Promise<{ total: number; imported: number; failed: Array<{ fileName: string; reason: string }> }> {
|
||||
if (!attachments.length) {
|
||||
return { total: 0, imported: 0, failed: [] };
|
||||
}
|
||||
if (!session?.symEncKey || !session?.symMacKey) throw new Error(t('txt_vault_key_unavailable'));
|
||||
|
||||
const initialCiphers = (await ciphersQuery.refetch()).data || [];
|
||||
const cipherById = new Map(initialCiphers.map((cipher) => [String(cipher.id || ''), cipher]));
|
||||
const unresolved: ImportAttachmentFile[] = [];
|
||||
const failed: Array<{ fileName: string; reason: string }> = [];
|
||||
let imported = 0;
|
||||
|
||||
for (const attachment of attachments) {
|
||||
const sourceId = String(attachment.sourceCipherId || '').trim();
|
||||
@@ -1137,7 +1150,10 @@ export default function App() {
|
||||
const byIndex = Number.isFinite(sourceIndex) ? idMaps.byIndex.get(sourceIndex) : null;
|
||||
const targetCipherId = byId || byIndex || null;
|
||||
if (!targetCipherId) {
|
||||
unresolved.push(attachment);
|
||||
failed.push({
|
||||
fileName: String(attachment.fileName || '').trim() || 'attachment.bin',
|
||||
reason: t('txt_import_attachment_target_not_found'),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -1145,14 +1161,23 @@ export default function App() {
|
||||
const fileBytes = Uint8Array.from(attachment.bytes);
|
||||
const file = new File([fileBytes], name, { type: 'application/octet-stream' });
|
||||
const cipher = cipherById.get(targetCipherId) || null;
|
||||
await uploadCipherAttachment(authedFetch, session, targetCipherId, file, cipher);
|
||||
}
|
||||
|
||||
if (unresolved.length) {
|
||||
throw new Error(t('txt_failed_to_map_attachments', { count: unresolved.length }));
|
||||
try {
|
||||
await uploadCipherAttachment(authedFetch, session, targetCipherId, file, cipher);
|
||||
imported += 1;
|
||||
} catch (error) {
|
||||
failed.push({
|
||||
fileName: name,
|
||||
reason: error instanceof Error ? error.message : t('txt_upload_attachment_failed'),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await ciphersQuery.refetch();
|
||||
return {
|
||||
total: attachments.length,
|
||||
imported,
|
||||
failed,
|
||||
};
|
||||
}
|
||||
|
||||
function toImportedCipherMapsFromResponse(
|
||||
@@ -1252,10 +1277,10 @@ export default function App() {
|
||||
const idMaps = buildImportedCipherMaps(payload.ciphers, createdCipherIdsByIndex);
|
||||
await foldersQuery.refetch();
|
||||
await ciphersQuery.refetch();
|
||||
if (attachments.length) {
|
||||
await uploadImportedAttachments(attachments, idMaps);
|
||||
}
|
||||
return summarizeImportResult(payload.ciphers, mode === 'original' ? createdFolderCount : 0);
|
||||
const attachmentSummary = attachments.length
|
||||
? await uploadImportedAttachments(attachments, idMaps)
|
||||
: undefined;
|
||||
return summarizeImportResult(payload.ciphers, mode === 'original' ? createdFolderCount : 0, attachmentSummary);
|
||||
}
|
||||
|
||||
async function handleImportEncryptedRawAction(
|
||||
@@ -1280,11 +1305,14 @@ export default function App() {
|
||||
returnCipherMap: attachments.length > 0,
|
||||
});
|
||||
await Promise.all([ciphersQuery.refetch(), foldersQuery.refetch()]);
|
||||
if (attachments.length) {
|
||||
const idMaps = toImportedCipherMapsFromResponse(importedCipherMap);
|
||||
await uploadImportedAttachments(attachments, idMaps);
|
||||
}
|
||||
return summarizeImportResult(nextPayload.ciphers, mode === 'original' ? nextPayload.folders.length : 0);
|
||||
const attachmentSummary = attachments.length
|
||||
? await uploadImportedAttachments(attachments, toImportedCipherMapsFromResponse(importedCipherMap))
|
||||
: undefined;
|
||||
return summarizeImportResult(
|
||||
nextPayload.ciphers,
|
||||
mode === 'original' ? nextPayload.folders.length : 0,
|
||||
attachmentSummary
|
||||
);
|
||||
}
|
||||
|
||||
async function handleExportAction(request: ExportRequest) {
|
||||
@@ -1519,6 +1547,30 @@ export default function App() {
|
||||
throw new Error(t('txt_unsupported_export_format'));
|
||||
}
|
||||
|
||||
function downloadBytesAsFile(bytes: Uint8Array, fileName: string, mimeType: string) {
|
||||
const blob = new Blob([bytes], { type: mimeType || 'application/octet-stream' });
|
||||
const objectUrl = URL.createObjectURL(blob);
|
||||
const anchor = document.createElement('a');
|
||||
anchor.href = objectUrl;
|
||||
anchor.download = fileName || 'download.bin';
|
||||
document.body.appendChild(anchor);
|
||||
anchor.click();
|
||||
anchor.remove();
|
||||
window.setTimeout(() => URL.revokeObjectURL(objectUrl), 0);
|
||||
}
|
||||
|
||||
async function handleBackupExportAction() {
|
||||
const payload = await exportAdminBackup(authedFetch);
|
||||
downloadBytesAsFile(payload.bytes, payload.fileName, payload.mimeType);
|
||||
}
|
||||
|
||||
async function handleBackupImportAction(file: File, replaceExisting: boolean = false) {
|
||||
await importAdminBackup(authedFetch, file, replaceExisting);
|
||||
window.setTimeout(() => {
|
||||
logoutNow();
|
||||
}, 200);
|
||||
}
|
||||
|
||||
const hashPathRaw = typeof window !== 'undefined' ? window.location.hash || '' : '';
|
||||
const hashPath = hashPathRaw.startsWith('#') ? hashPathRaw.slice(1) : hashPathRaw;
|
||||
const hashPathOnly = String(hashPath || '').split('?')[0].split('#')[0];
|
||||
@@ -1540,6 +1592,12 @@ export default function App() {
|
||||
}
|
||||
}, [phase, isImportHashRoute, location, navigate]);
|
||||
|
||||
useEffect(() => {
|
||||
if (phase === 'app' && profile?.role !== 'admin' && location === '/help') {
|
||||
navigate('/vault');
|
||||
}
|
||||
}, [phase, profile?.role, location, navigate]);
|
||||
|
||||
if (jwtWarning) {
|
||||
return <JwtWarningPage reason={jwtWarning.reason} minLength={jwtWarning.minLength} />;
|
||||
}
|
||||
@@ -1695,10 +1753,12 @@ export default function App() {
|
||||
<Shield size={16} />
|
||||
<span>{t('nav_device_management')}</span>
|
||||
</Link>
|
||||
<Link href="/help" className={`side-link ${location === '/help' ? 'active' : ''}`}>
|
||||
<Cloud size={16} />
|
||||
<span>{t('nav_backup_strategy')}</span>
|
||||
</Link>
|
||||
{profile?.role === 'admin' && (
|
||||
<Link href="/help" className={`side-link ${location === '/help' ? 'active' : ''}`}>
|
||||
<Cloud size={16} />
|
||||
<span>{t('nav_backup_strategy')}</span>
|
||||
</Link>
|
||||
)}
|
||||
<Link href={IMPORT_ROUTE} className={`side-link ${isImportRoute ? 'active' : ''}`}>
|
||||
<ArrowUpDown size={14} />
|
||||
<span>{t('nav_import_export')}</span>
|
||||
@@ -1913,7 +1973,9 @@ export default function App() {
|
||||
/>
|
||||
</Route>
|
||||
<Route path="/help">
|
||||
<HelpPage />
|
||||
{profile?.role === 'admin' ? (
|
||||
<HelpPage onExport={handleBackupExportAction} onImport={handleBackupImportAction} onNotify={pushToast} />
|
||||
) : null}
|
||||
</Route>
|
||||
</Switch>
|
||||
</main>
|
||||
|
||||
@@ -1,18 +1,139 @@
|
||||
import { Cloud } from 'lucide-preact';
|
||||
import { useRef, useState } from 'preact/hooks';
|
||||
import { Download, FileUp } from 'lucide-preact';
|
||||
import ConfirmDialog from '@/components/ConfirmDialog';
|
||||
import { t } from '@/lib/i18n';
|
||||
|
||||
export default function HelpPage() {
|
||||
interface HelpPageProps {
|
||||
onExport: () => Promise<void>;
|
||||
onImport: (file: File, replaceExisting?: boolean) => Promise<void>;
|
||||
onNotify: (type: 'success' | 'error', text: string) => void;
|
||||
}
|
||||
|
||||
export default function HelpPage(props: HelpPageProps) {
|
||||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
||||
const [exporting, setExporting] = useState(false);
|
||||
const [importing, setImporting] = useState(false);
|
||||
const [localError, setLocalError] = useState('');
|
||||
const [confirmReplaceOpen, setConfirmReplaceOpen] = useState(false);
|
||||
|
||||
function isReplaceRequiredError(error: unknown): boolean {
|
||||
const message = error instanceof Error ? String(error.message || '') : '';
|
||||
return message.toLowerCase().includes('fresh instance');
|
||||
}
|
||||
|
||||
async function handleExport() {
|
||||
setLocalError('');
|
||||
setExporting(true);
|
||||
try {
|
||||
await props.onExport();
|
||||
props.onNotify('success', t('txt_backup_export_success'));
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : t('txt_backup_export_failed');
|
||||
setLocalError(message);
|
||||
props.onNotify('error', message);
|
||||
} finally {
|
||||
setExporting(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function runImport(replaceExisting: boolean) {
|
||||
if (!selectedFile) {
|
||||
const message = t('txt_backup_file_required');
|
||||
setLocalError(message);
|
||||
props.onNotify('error', message);
|
||||
return;
|
||||
}
|
||||
|
||||
setLocalError('');
|
||||
setImporting(true);
|
||||
try {
|
||||
await props.onImport(selectedFile, replaceExisting);
|
||||
props.onNotify('success', t('txt_backup_import_success_relogin'));
|
||||
setSelectedFile(null);
|
||||
if (fileInputRef.current) fileInputRef.current.value = '';
|
||||
setConfirmReplaceOpen(false);
|
||||
} catch (error) {
|
||||
if (!replaceExisting && isReplaceRequiredError(error)) {
|
||||
setConfirmReplaceOpen(true);
|
||||
return;
|
||||
}
|
||||
const message = error instanceof Error ? error.message : t('txt_backup_import_failed');
|
||||
setLocalError(message);
|
||||
props.onNotify('error', message);
|
||||
} finally {
|
||||
setImporting(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleImport() {
|
||||
await runImport(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="stack">
|
||||
<section className="card">
|
||||
<h3>{t('backup_strategy_title')}</h3>
|
||||
<div className="empty" style={{ minHeight: 180 }}>
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<Cloud size={34} style={{ color: '#64748b', marginBottom: 8 }} />
|
||||
<div>{t('backup_strategy_under_construction')}</div>
|
||||
<div className="stack backup-page">
|
||||
<div className="import-export-panels">
|
||||
<section className="card backup-panel">
|
||||
<div className="section-head">
|
||||
<h3>{t('txt_backup_export')}</h3>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<p className="backup-inline-note">{t('txt_backup_export_description')}</p>
|
||||
<div className="actions">
|
||||
<button type="button" className="btn btn-primary" disabled={exporting || importing} onClick={() => void handleExport()}>
|
||||
<Download size={14} className="btn-icon" />
|
||||
{exporting ? t('txt_backup_exporting') : t('txt_backup_export')}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="card backup-panel">
|
||||
<div className="section-head">
|
||||
<h3>{t('txt_backup_import')}</h3>
|
||||
</div>
|
||||
<p className="backup-inline-note">{t('txt_backup_import_description')}</p>
|
||||
<label className="field">
|
||||
<span>{t('txt_backup_file')}</span>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
className="input"
|
||||
type="file"
|
||||
accept=".zip,application/zip"
|
||||
disabled={importing || exporting}
|
||||
onChange={(event) => {
|
||||
const nextFile = (event.currentTarget as HTMLInputElement).files?.[0] || null;
|
||||
setSelectedFile(nextFile);
|
||||
setLocalError('');
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<div className="backup-file-meta">
|
||||
{selectedFile ? (
|
||||
<span>{t('txt_backup_selected_file_name', { name: selectedFile.name })}</span>
|
||||
) : (
|
||||
<span>{t('txt_backup_no_file_selected')}</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="backup-inline-note">{t('txt_backup_restore_note')}</p>
|
||||
<div className="actions">
|
||||
<button type="button" className="btn btn-primary" disabled={importing || exporting} onClick={() => void handleImport()}>
|
||||
<FileUp size={14} className="btn-icon" />
|
||||
{importing ? t('txt_backup_importing') : t('txt_backup_import')}
|
||||
</button>
|
||||
</div>
|
||||
{localError && <div className="local-error">{localError}</div>}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<ConfirmDialog
|
||||
open={confirmReplaceOpen}
|
||||
title={t('txt_backup_replace_confirm_title')}
|
||||
message={t('txt_backup_replace_confirm_message')}
|
||||
confirmText={t('txt_backup_clear_and_import')}
|
||||
cancelText={t('txt_cancel')}
|
||||
danger
|
||||
onConfirm={() => void runImport(true)}
|
||||
onCancel={() => setConfirmReplaceOpen(false)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { argon2idAsync } from '@noble/hashes/argon2.js';
|
||||
import { strFromU8, unzipSync } from 'fflate';
|
||||
import { BlobReader, Uint8ArrayWriter, ZipReader, configure as configureZipJs } from '@zip.js/zip.js';
|
||||
import { Archive, ArrowLeftRight, Download, FileJson, FileUp } from 'lucide-preact';
|
||||
import { Download, FileUp } from 'lucide-preact';
|
||||
import ConfirmDialog from '@/components/ConfirmDialog';
|
||||
import type { CiphersImportPayload } from '@/lib/api';
|
||||
import {
|
||||
@@ -55,6 +55,9 @@ export interface ImportResultSummary {
|
||||
totalItems: number;
|
||||
folderCount: number;
|
||||
typeCounts: Array<{ label: string; count: number }>;
|
||||
attachmentCount: number;
|
||||
importedAttachmentCount: number;
|
||||
failedAttachments: Array<{ fileName: string; reason: string }>;
|
||||
}
|
||||
|
||||
interface BitwardenPasswordProtectedInput extends BitwardenJsonInput {
|
||||
@@ -582,46 +585,10 @@ export default function ImportPage({ onImport, onImportEncryptedRaw, accountKeys
|
||||
|
||||
return (
|
||||
<div className="import-export-page">
|
||||
<section className="card import-export-hero">
|
||||
<h3>{t('txt_import_export_title')}</h3>
|
||||
<p className="import-export-hero-sub">{t('txt_import_export_feature_intro')}</p>
|
||||
<div className="import-export-feature-grid">
|
||||
<article className="import-export-feature-item">
|
||||
<span className="import-export-feature-icon">
|
||||
<Archive size={16} />
|
||||
</span>
|
||||
<div>
|
||||
<strong>{t('txt_import_export_feature_bw_zip_title')}</strong>
|
||||
<p>{t('txt_import_export_feature_bw_zip_desc')}</p>
|
||||
</div>
|
||||
</article>
|
||||
<article className="import-export-feature-item">
|
||||
<span className="import-export-feature-icon">
|
||||
<FileJson size={16} />
|
||||
</span>
|
||||
<div>
|
||||
<strong>{t('txt_import_export_feature_nodewarden_json_title')}</strong>
|
||||
<p>{t('txt_import_export_feature_nodewarden_json_desc')}</p>
|
||||
</div>
|
||||
</article>
|
||||
<article className="import-export-feature-item">
|
||||
<span className="import-export-feature-icon">
|
||||
<ArrowLeftRight size={16} />
|
||||
</span>
|
||||
<div>
|
||||
<strong>{t('txt_import_export_feature_compat_title')}</strong>
|
||||
<p>{t('txt_import_export_feature_compat_desc')}</p>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="import-export-panels">
|
||||
<section className="card import-export-panel">
|
||||
<h3>{t('txt_import')}</h3>
|
||||
<p className="muted" style={{ textAlign: 'left', marginBottom: 12 }}>
|
||||
{t('txt_import_vault_data_hint')}
|
||||
</p>
|
||||
<p className="backup-inline-note">{t('txt_import_vault_data_hint')}</p>
|
||||
<div className="field-grid">
|
||||
<label className="field field-span-2">
|
||||
<span>{t('txt_format')}</span>
|
||||
@@ -702,9 +669,7 @@ export default function ImportPage({ onImport, onImportEncryptedRaw, accountKeys
|
||||
|
||||
<section className="card import-export-panel">
|
||||
<h3>{t('txt_export')}</h3>
|
||||
<p className="muted" style={{ textAlign: 'left', marginBottom: 12 }}>
|
||||
{t('txt_export_vault_data_hint')}
|
||||
</p>
|
||||
<p className="backup-inline-note">{t('txt_export_vault_data_hint')}</p>
|
||||
<div className="field-grid">
|
||||
<label className="field field-span-2">
|
||||
<span>{t('txt_format')}</span>
|
||||
@@ -862,6 +827,29 @@ export default function ImportPage({ onImport, onImportEncryptedRaw, accountKeys
|
||||
</button>
|
||||
<h3 className="dialog-title">{t('txt_import_success')}</h3>
|
||||
<div className="dialog-message">{t('txt_import_success_number_of_items', { count: importSummary.totalItems })}</div>
|
||||
{importSummary.attachmentCount > 0 && (
|
||||
<div className="dialog-message">
|
||||
{t('txt_import_attachment_summary', {
|
||||
imported: String(importSummary.importedAttachmentCount),
|
||||
total: String(importSummary.attachmentCount),
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{importSummary.failedAttachments.length > 0 && (
|
||||
<div className="import-summary-failed-list">
|
||||
<div className="import-summary-failed-title">
|
||||
{t('txt_import_failed_attachments_title', { count: String(importSummary.failedAttachments.length) })}
|
||||
</div>
|
||||
<ul>
|
||||
{importSummary.failedAttachments.map((row, index) => (
|
||||
<li key={`${row.fileName}-${index}`}>
|
||||
<strong>{row.fileName}</strong>
|
||||
{`: ${row.reason}`}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
<div className="import-summary-table-wrap">
|
||||
<table className="import-summary-table">
|
||||
<thead>
|
||||
|
||||
+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 = '删除文件夹';
|
||||
|
||||
@@ -1178,6 +1178,35 @@ input[type='file'].input::file-selector-button:hover {
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.backup-panel {
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
.backup-list {
|
||||
margin: 12px 0 14px;
|
||||
padding-left: 18px;
|
||||
color: #475467;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.backup-list li + li {
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.backup-file-meta {
|
||||
margin: -2px 0 12px;
|
||||
color: #64748b;
|
||||
font-size: 13px;
|
||||
line-height: 1.45;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.backup-inline-note {
|
||||
margin: 0 0 12px;
|
||||
color: #64748b;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.import-export-panel h3 {
|
||||
margin: 0 0 6px 0;
|
||||
}
|
||||
@@ -1532,6 +1561,30 @@ input[type='file'].input::file-selector-button:hover {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.import-summary-failed-list {
|
||||
margin-top: 10px;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid #fecaca;
|
||||
border-radius: 10px;
|
||||
background: #fef2f2;
|
||||
color: #991b1b;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.import-summary-failed-title {
|
||||
font-weight: 700;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.import-summary-failed-list ul {
|
||||
margin: 0;
|
||||
padding-left: 18px;
|
||||
}
|
||||
|
||||
.import-summary-failed-list li + li {
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.settings-twofactor-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
|
||||
Reference in New Issue
Block a user