97a19836a0
Third slice of the app.js split. Moves the encrypted export container, CSV/JSON import parsing (parseCSV, findColumn, parseEntriesFromCSV, parseEntriesFromJSON), and the doImport/doExport/doExportCSV flows to js/app.import.js. encryptImportEntry moves here too (also called by app.sync.js — resolved via shared global scope at call time). - Byte-for-byte identical to the extracted block; no duplicate const; no top-level import ref left in app.js. - Load order: BEFORE app.js (pure declarations, no top-level side effects), alongside app.crypto.js. Full order: argon2 → crypto → import → app → sync. - index.html + BuildAssets whitelist + harness APP_PARTS updated. - Safety net: the 14 CSV tests exercise parseCSV/parseEntriesFromCSV from the extracted file and stay green (42/42). app.js: 11936 → 10253 lines (crypto + sync + import now separate, ~1700 lines moved into 3 modules). NOTE: assets.res not regenerated here (needs brcc32/Delphi) — run BuildAssets before the next Delphi build to embed js/app.import.js. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1021 lines
47 KiB
JavaScript
1021 lines
47 KiB
JavaScript
// ============================================================
|
||
// app.import.js — IMPORT / EXPORT module (extracted from app.js, §3.1)
|
||
// ============================================================
|
||
//
|
||
// Encrypted export container, CSV/JSON import parsing, and the import/export
|
||
// UI flows. Pure declarations, no top-level side effects → loads BEFORE
|
||
// app.js (alongside app.crypto.js). Cross-file refs (state, api, crypto
|
||
// helpers, parseTags…) resolve via the shared global lexical environment at
|
||
// call time. NOTE: encryptImportEntry lives here and is also called by
|
||
// app.sync.js (applyRemoteSnapshot) — works via shared scope. See CLAUDE.md
|
||
// "Découpage frontend".
|
||
//
|
||
// ============================================================
|
||
// ENCRYPTED EXPORT CONTAINER
|
||
// ============================================================
|
||
//
|
||
// File format (JSON):
|
||
// {
|
||
// "format": "pm-encrypted-export-v1",
|
||
// "kdf": "pbkdf2-sha256",
|
||
// "kdf_iterations": 600000,
|
||
// "kdf_salt": "<base64 random 32 bytes>",
|
||
// "iv": "<base64 random 12 bytes>",
|
||
// "ciphertext":"<base64 AES-GCM ciphertext of JSON.stringify(payload)>",
|
||
// "created_at": "<ISO timestamp>"
|
||
// }
|
||
// payload = same shape produced by the plaintext exporter (entries array).
|
||
//
|
||
// The encryption password is INDEPENDENT of the master password — the
|
||
// user picks it at export time and provides it again at import time.
|
||
// Decoupling means a master-password change doesn't brick old backups,
|
||
// and the backup can be shared without revealing the master pw.
|
||
|
||
function bytesToBase64(arr) {
|
||
if (arr instanceof ArrayBuffer) arr = new Uint8Array(arr);
|
||
let s = '';
|
||
for (let i = 0; i < arr.length; i++) s += String.fromCharCode(arr[i]);
|
||
return btoa(s);
|
||
}
|
||
function base64ToBytes(b64) {
|
||
return Uint8Array.from(atob(b64), c => c.charCodeAt(0));
|
||
}
|
||
|
||
// Derive an AES-GCM key from a user-chosen export password + random salt.
|
||
// Uses the same 600k iteration PBKDF2 as the rest of the app.
|
||
async function deriveExportKey(password, saltBytes, iterations) {
|
||
const km = await crypto.subtle.importKey(
|
||
'raw', new TextEncoder().encode(password),
|
||
'PBKDF2', false, ['deriveKey']);
|
||
return crypto.subtle.deriveKey(
|
||
{ name: 'PBKDF2', salt: saltBytes, iterations: iterations, hash: 'SHA-256' },
|
||
km,
|
||
{ name: 'AES-GCM', length: 256 },
|
||
false, ['encrypt', 'decrypt']);
|
||
}
|
||
|
||
async function encryptExportPayload(payloadObj, exportPwd) {
|
||
const plaintext = new TextEncoder().encode(JSON.stringify(payloadObj));
|
||
const salt = crypto.getRandomValues(new Uint8Array(32));
|
||
const iv = crypto.getRandomValues(new Uint8Array(12));
|
||
const key = await deriveExportKey(exportPwd, salt, 600000);
|
||
const ct = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, plaintext);
|
||
return {
|
||
format: 'pm-encrypted-export-v1',
|
||
kdf: 'pbkdf2-sha256',
|
||
kdf_iterations: 600000,
|
||
kdf_salt: bytesToBase64(salt),
|
||
iv: bytesToBase64(iv),
|
||
ciphertext: bytesToBase64(ct),
|
||
created_at: new Date().toISOString(),
|
||
};
|
||
}
|
||
|
||
async function decryptExportContainer(container, exportPwd) {
|
||
const salt = base64ToBytes(container.kdf_salt);
|
||
const iv = base64ToBytes(container.iv);
|
||
const ct = base64ToBytes(container.ciphertext);
|
||
const key = await deriveExportKey(exportPwd, salt, container.kdf_iterations || 600000);
|
||
const plainBuf = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, key, ct);
|
||
return JSON.parse(new TextDecoder().decode(plainBuf));
|
||
}
|
||
|
||
// ============================================================
|
||
// IMPORT — JSON (native round-trip) + CSV (universal)
|
||
// ============================================================
|
||
//
|
||
// Two supported input formats:
|
||
// 1. Native JSON: the same shape produced by doExport() above
|
||
// { version, exported_at, username, entries: [
|
||
// { site, username, password, folder, tags, favorite, ... }
|
||
// ]}
|
||
// 2. CSV: with a header row. Column names are mapped heuristically so
|
||
// exports from Bitwarden / KeePass / Chrome / 1Password generally
|
||
// "just work" without manual column mapping.
|
||
//
|
||
// Each parsed entry is encrypted client-side with the vault key (same
|
||
// flow as a single-entry add), then sent to /entries/bulk-import as one
|
||
// transactional batch.
|
||
|
||
// Minimal RFC 4180-ish CSV parser. Handles quoted fields, escaped quotes
|
||
// (""), commas inside quotes, and CRLF line endings. Returns an array of
|
||
// arrays (rows × columns). No streaming — fine for the ~MB-scale imports
|
||
// a password manager realistically deals with.
|
||
function parseCSV(text) {
|
||
const rows = [];
|
||
let row = [], field = '', inQuotes = false;
|
||
for (let i = 0; i < text.length; i++) {
|
||
const c = text[i];
|
||
if (inQuotes) {
|
||
if (c === '"') {
|
||
if (text[i + 1] === '"') { field += '"'; i++; } // escaped ""
|
||
else inQuotes = false;
|
||
} else field += c;
|
||
} else {
|
||
if (c === '"') inQuotes = true;
|
||
else if (c === ',') { row.push(field); field = ''; }
|
||
else if (c === '\n' || c === '\r') {
|
||
if (c === '\r' && text[i + 1] === '\n') i++; // CRLF
|
||
row.push(field); field = '';
|
||
if (row.length > 1 || (row.length === 1 && row[0] !== '')) rows.push(row);
|
||
row = [];
|
||
} else field += c;
|
||
}
|
||
}
|
||
// Flush trailing field/row (file without final newline)
|
||
if (field !== '' || row.length > 0) { row.push(field); rows.push(row); }
|
||
return rows;
|
||
}
|
||
|
||
// Header heuristics: pick the first matching column name (case-insensitive,
|
||
// underscore/space-tolerant). Returns null if no candidate header matches.
|
||
function findColumn(headers, candidates) {
|
||
const norm = s => String(s || '').toLowerCase().replace(/[\s_-]+/g, '');
|
||
const cand = candidates.map(norm);
|
||
for (let i = 0; i < headers.length; i++) {
|
||
if (cand.indexOf(norm(headers[i])) >= 0) return i;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
// Parse a CSV text into an array of plaintext entries
|
||
// ({ site, username, password, folder, tags, totp_secret }). Returns
|
||
// { entries, skipped, columns } so the preview can show what was matched.
|
||
function parseEntriesFromCSV(text) {
|
||
const rows = parseCSV(text);
|
||
if (rows.length < 2) {
|
||
throw new Error('CSV needs a header row and at least one data row');
|
||
}
|
||
const headers = rows[0];
|
||
// Candidate names per format observed in real exports:
|
||
// Bitwarden CSV : folder, name, login_uri, login_username, login_password, login_totp, notes
|
||
// KeePass CSV : Title, URL, Username, Password, Group, Notes
|
||
// Chrome/Edge : name, url, username, password
|
||
// 1Password CSV : Title, URL, Username, Password, Notes
|
||
// findColumn normalizes (lowercase, strip _ and -) so 'login_uri' and
|
||
// 'loginuri' both match the same candidate.
|
||
// Prefer URL-shaped columns for `site` and human-readable name for
|
||
// `title` so KeePass/Bitwarden exports keep both. Fall back: if only
|
||
// one is present, reuse it for the other.
|
||
const colTitle = findColumn(headers, ['name', 'title', 'item_name', 'entry_name']);
|
||
const colSite = findColumn(headers, ['url', 'login_uri', 'login_url', 'site', 'website', 'web_site']);
|
||
const colUser = findColumn(headers, ['login_username', 'username', 'user', 'login', 'email', 'user_name']);
|
||
const colPwd = findColumn(headers, ['login_password', 'password', 'pass', 'pwd']);
|
||
const colFolder = findColumn(headers, ['folder', 'group', 'category', 'path', 'collection']);
|
||
const colTags = findColumn(headers, ['tags', 'labels']);
|
||
const colNotes = findColumn(headers, ['notes', 'note', 'comment', 'comments']);
|
||
const colTotp = findColumn(headers, ['login_totp', 'totp', 'totp_secret', 'otp', 'otpauth', 'authenticator', 'two_factor', 'twofa']);
|
||
// Our own CSV export carries an explicit `kind` column. When absent we
|
||
// fall back to a heuristic (empty site + non-empty notes = a note).
|
||
const colKind = findColumn(headers, ['kind', 'type', 'item_type']);
|
||
const colTemplate = findColumn(headers, ['template', 'subtype']);
|
||
const colCustom = findColumn(headers, ['custom_fields', 'custom', 'fields']);
|
||
// Bitwarden card columns — only used when type=card. Each maps to a
|
||
// custom field on a credit-card-template note.
|
||
const colCardHolder = findColumn(headers, ['card_cardholdername', 'card_holder', 'cardholder']);
|
||
const colCardBrand = findColumn(headers, ['card_brand', 'card_type']);
|
||
const colCardNumber = findColumn(headers, ['card_number', 'cardnumber']);
|
||
const colCardExpM = findColumn(headers, ['card_expmonth', 'card_exp_month']);
|
||
const colCardExpY = findColumn(headers, ['card_expyear', 'card_exp_year']);
|
||
const colCardCode = findColumn(headers, ['card_code', 'card_cvv', 'card_cvc']);
|
||
// Bitwarden identity columns — mapped to identity-template note.
|
||
const colIdFirst = findColumn(headers, ['identity_firstname']);
|
||
const colIdLast = findColumn(headers, ['identity_lastname']);
|
||
const colIdEmail = findColumn(headers, ['identity_email']);
|
||
const colIdPhone = findColumn(headers, ['identity_phone']);
|
||
|
||
if (colSite === null && colTitle === null && colUser === null)
|
||
throw new Error('No recognizable title/url or username column in CSV header');
|
||
if (colPwd === null)
|
||
throw new Error('No recognizable password column in CSV header');
|
||
|
||
const entries = [];
|
||
let skipped = 0;
|
||
for (let i = 1; i < rows.length; i++) {
|
||
const r = rows[i];
|
||
const titleRaw = (colTitle !== null ? r[colTitle] : '').trim();
|
||
const siteRaw = (colSite !== null ? r[colSite] : '').trim();
|
||
const pwd = (colPwd !== null ? r[colPwd] : '');
|
||
const notesRaw = (colNotes !== null ? r[colNotes] : '').trim();
|
||
// Resolve kind: explicit column wins. Heuristic fallback for foreign
|
||
// CSVs (Bitwarden/KeePass) — when site+user+pwd are all empty but
|
||
// notes/title is set, that's a secure-note row.
|
||
let kindRaw = (colKind !== null ? String(r[colKind] || '').toLowerCase().trim() : '');
|
||
let kind = (kindRaw === 'note' || kindRaw === 'secure_note') ? 'note' :
|
||
(kindRaw === 'card' || kindRaw === 'identity') ? 'note' : 'login';
|
||
if (kindRaw === '' && !siteRaw && !pwd && notesRaw) kind = 'note';
|
||
let templateRaw = (colTemplate !== null ? String(r[colTemplate] || '').trim() : '');
|
||
// Bitwarden type=card / type=identity → note kind + appropriate
|
||
// template. The card/identity columns become custom fields below.
|
||
if (kindRaw === 'card') templateRaw = templateRaw || 'credit-card';
|
||
if (kindRaw === 'identity') templateRaw = templateRaw || 'identity';
|
||
|
||
// Notes legitimately have no site; their body is in `notes` (or in
|
||
// `password` when round-tripping our own CSV — we wrote the body
|
||
// into the password column for the export).
|
||
// Custom fields cell carries a JSON-stringified array (our own
|
||
// export shape). Parse defensively — a malformed cell drops to
|
||
// [] rather than failing the row.
|
||
let cf = [];
|
||
if (colCustom !== null) {
|
||
const raw = String(r[colCustom] || '').trim();
|
||
if (raw) {
|
||
try {
|
||
// Our own export: JSON array of {label, value, is_secret}
|
||
const arr = JSON.parse(raw);
|
||
if (Array.isArray(arr))
|
||
cf = arr.filter(f => f && typeof f === 'object' && f.label);
|
||
} catch {
|
||
// Bitwarden / Chrome / KeePass CSV: newline-separated
|
||
// "label: value" lines (sometimes "label=value"). Split,
|
||
// pick the FIRST separator only so values can contain
|
||
// ":" or "=" without being mangled.
|
||
raw.split(/\r?\n/).forEach(line => {
|
||
line = line.trim();
|
||
if (!line) return;
|
||
const sep = line.search(/[:=]/);
|
||
if (sep <= 0) return;
|
||
const label = line.slice(0, sep).trim();
|
||
const value = line.slice(sep + 1).trim();
|
||
if (label) cf.push({ label, value, is_secret: false });
|
||
});
|
||
}
|
||
}
|
||
}
|
||
|
||
if (kind === 'note') {
|
||
// Pull Bitwarden card/identity columns into custom_fields so
|
||
// the type=card / type=identity rows survive the import.
|
||
const push = (label, val, is_secret) => {
|
||
if (val) cf.push({ label, value: val, is_secret: !!is_secret });
|
||
};
|
||
if (kindRaw === 'card') {
|
||
push('Cardholder', colCardHolder !== null ? String(r[colCardHolder] || '').trim() : '');
|
||
push('Brand', colCardBrand !== null ? String(r[colCardBrand] || '').trim() : '');
|
||
push('Number', colCardNumber !== null ? String(r[colCardNumber] || '').trim() : '', true);
|
||
const expM = colCardExpM !== null ? String(r[colCardExpM] || '').trim() : '';
|
||
const expY = colCardExpY !== null ? String(r[colCardExpY] || '').trim() : '';
|
||
if (expM || expY) push('Expires', (expM && expY) ? (expM + '/' + expY) : (expM || expY));
|
||
push('CVV', colCardCode !== null ? String(r[colCardCode] || '').trim() : '', true);
|
||
}
|
||
if (kindRaw === 'identity') {
|
||
const f = colIdFirst !== null ? String(r[colIdFirst] || '').trim() : '';
|
||
const l = colIdLast !== null ? String(r[colIdLast] || '').trim() : '';
|
||
if (f || l) push('Name', (f && l) ? (f + ' ' + l) : (f || l));
|
||
push('Email', colIdEmail !== null ? String(r[colIdEmail] || '').trim() : '');
|
||
push('Phone', colIdPhone !== null ? String(r[colIdPhone] || '').trim() : '');
|
||
}
|
||
const body = pwd || notesRaw || ' '; // template carries data via cf
|
||
if (!body && cf.length === 0) { skipped++; continue; }
|
||
const tagsArr = [];
|
||
if (colTags !== null) {
|
||
String(r[colTags] || '').split(/[,;]/).forEach(t => {
|
||
t = t.trim(); if (t) tagsArr.push(t);
|
||
});
|
||
}
|
||
entries.push({
|
||
site: '',
|
||
title: titleRaw,
|
||
username: '',
|
||
password: body,
|
||
folder: (colFolder !== null ? r[colFolder] : '').trim() || 'All',
|
||
tags: tagsArr.join(','),
|
||
totp_secret: '',
|
||
kind: 'note',
|
||
template: templateRaw,
|
||
custom_fields: cf,
|
||
});
|
||
continue;
|
||
}
|
||
// Fall through chain: site → title → username so we always have
|
||
// something to display. The unused string becomes the title for
|
||
// browser-style cards.
|
||
const site = siteRaw || titleRaw ||
|
||
(colUser !== null ? r[colUser] : '').trim();
|
||
const title = titleRaw || '';
|
||
if (!site || !pwd) { skipped++; continue; }
|
||
|
||
// Tags: only the explicit tags column. Free-form notes are
|
||
// surfaced as a custom "Notes" field below — putting prose into
|
||
// the tag chip strip turned it into noise (and lost line breaks).
|
||
let tagsArr = [];
|
||
if (colTags !== null) {
|
||
String(r[colTags] || '').split(/[,;]/).forEach(t => {
|
||
t = t.trim();
|
||
if (t) tagsArr.push(t);
|
||
});
|
||
}
|
||
// Bitwarden / KeePass / Chrome login rows carry per-entry notes
|
||
// in a `notes` column. Preserve them as a non-secret custom field
|
||
// so the body survives round-trip without polluting tags.
|
||
if (colNotes !== null) {
|
||
const n = String(r[colNotes] || '').trim();
|
||
if (n) cf.push({ label: 'Notes', value: n, is_secret: false });
|
||
}
|
||
|
||
// TOTP: support raw base32 OR full otpauth:// URI in the cell.
|
||
let totp = '';
|
||
if (colTotp !== null) {
|
||
const raw = String(r[colTotp] || '').trim();
|
||
totp = parseOtpAuthUri(raw) || raw;
|
||
}
|
||
|
||
entries.push({
|
||
site: site,
|
||
title: title,
|
||
username: (colUser !== null ? r[colUser] : '').trim(),
|
||
password: pwd,
|
||
folder: (colFolder !== null ? r[colFolder] : '').trim() || 'All',
|
||
tags: tagsArr.join(','),
|
||
totp_secret: totp,
|
||
kind: 'login',
|
||
template: templateRaw,
|
||
custom_fields: cf,
|
||
});
|
||
}
|
||
return { entries, skipped, columns: {
|
||
title: colTitle, site: colSite, username: colUser, password: colPwd,
|
||
folder: colFolder, tags: colTags, notes: colNotes, totp: colTotp,
|
||
kind: colKind, custom_fields: colCustom,
|
||
} };
|
||
}
|
||
|
||
// Parse a native JSON export. Forgiving: accepts both our own format and
|
||
// a flat array of entry objects.
|
||
function parseEntriesFromJSON(text) {
|
||
let data;
|
||
try { data = JSON.parse(text); }
|
||
catch (e) { throw new Error('Invalid JSON: ' + e.message); }
|
||
const raw = Array.isArray(data) ? data : (data.entries || []);
|
||
if (!Array.isArray(raw) || raw.length === 0)
|
||
throw new Error('No entries in JSON file');
|
||
// Folders metadata (color, icon) — only present on payloads produced
|
||
// by our own JSON exporter from 2026-06 onward. Silently absent for
|
||
// older backups or foreign formats; the per-entry `folder` name is
|
||
// still respected either way.
|
||
const folders = Array.isArray(data.folders)
|
||
? data.folders.filter(f => f && f.name && f.name !== 'All')
|
||
.map(f => ({
|
||
name: String(f.name).trim(),
|
||
color: String(f.color || '').trim(),
|
||
icon: String(f.icon || '').trim(),
|
||
}))
|
||
: [];
|
||
|
||
const entries = [];
|
||
let skipped = 0;
|
||
for (const e of raw) {
|
||
if (!e || typeof e !== 'object') { skipped++; continue; }
|
||
const kind = (e.kind === 'note') ? 'note' : 'login';
|
||
const site = String(e.site || e.url || e.name || '').trim();
|
||
const pwd = String(e.password || '');
|
||
// Notes legitimately have no `site` — their "content" lives in
|
||
// password (the note body). Logins still need both site + pwd.
|
||
if (kind === 'login' && (!site || !pwd)) { skipped++; continue; }
|
||
// Notes with a template (credit-card, ssh-key, etc.) carry data
|
||
// in custom_fields — an empty body is legitimate as long as at
|
||
// least one custom field has content. Only skip a note if BOTH
|
||
// the body AND every custom field are empty.
|
||
if (kind === 'note' && !pwd) {
|
||
const cfList = Array.isArray(e.custom_fields) ? e.custom_fields : [];
|
||
const anyFieldFilled = cfList.some(f =>
|
||
f && (String(f.value || '').trim() !== ''));
|
||
if (!anyFieldFilled) { skipped++; continue; }
|
||
}
|
||
|
||
const tagsVal = e.tags;
|
||
const tagsStr = Array.isArray(tagsVal) ? tagsVal.join(',')
|
||
: String(tagsVal || '');
|
||
// Custom fields: array of {label, value, is_secret}. Tolerate
|
||
// missing / malformed gracefully — drop the field rather than
|
||
// failing the entry.
|
||
let cf = [];
|
||
if (Array.isArray(e.custom_fields)) {
|
||
cf = e.custom_fields.filter(f => f && typeof f === 'object' && f.label);
|
||
}
|
||
// Attachments: pass through as-is; the import side re-encrypts
|
||
// the base64 content with the current vault key and POSTs each.
|
||
let atts = [];
|
||
if (Array.isArray(e.attachments)) {
|
||
atts = e.attachments.filter(a =>
|
||
a && typeof a === 'object' && a.filename && a.content_b64);
|
||
}
|
||
entries.push({
|
||
uuid: String(e.uuid || '').trim(),
|
||
site: site,
|
||
title: String(e.title || '').trim(),
|
||
username: String(e.username || e.user || e.login || '').trim(),
|
||
password: pwd,
|
||
folder: String(e.folder || e.group || 'All').trim() || 'All',
|
||
tags: tagsStr,
|
||
totp_secret: String(e.totp || e.totp_secret || e.otpauth || '').trim(),
|
||
kind: kind,
|
||
template: String(e.template || '').trim(),
|
||
custom_fields: cf,
|
||
attachments: atts,
|
||
icon_b64: String(e.icon_b64 || '').trim(),
|
||
});
|
||
}
|
||
return { entries, skipped, columns: null, folders,
|
||
avatar_b64: typeof data.avatar_b64 === 'string' ? data.avatar_b64 : '' };
|
||
}
|
||
|
||
// Encrypt one parsed entry (plaintext password + optional TOTP) into the
|
||
// shape the bulk-import endpoint expects. Reuses encryptPwd which already
|
||
// generates a fresh IV per call.
|
||
async function encryptImportEntry(plain) {
|
||
const pw = await encryptPwd(plain.password);
|
||
let totpEnc = '', totpIv = '';
|
||
if (plain.totp_secret) {
|
||
try {
|
||
base32Decode(plain.totp_secret); // validate before encrypting
|
||
const t = await encryptPwd(plain.totp_secret);
|
||
totpEnc = t.encrypted;
|
||
totpIv = t.iv;
|
||
} catch (e) {
|
||
// Bad TOTP secret in source file — keep the entry but drop the
|
||
// 2FA silently. The user can fix it later via the slide-over.
|
||
}
|
||
}
|
||
// Custom fields: encrypt the same way the slideover does so the row
|
||
// round-trips through the regular GET path.
|
||
let cfEnc = '', cfIv = '';
|
||
if (Array.isArray(plain.custom_fields) && plain.custom_fields.length > 0) {
|
||
try {
|
||
const c = await encryptCustomFields(plain.custom_fields);
|
||
cfEnc = c.encrypted;
|
||
cfIv = c.iv;
|
||
} catch (e) { /* drop silently */ }
|
||
}
|
||
// tags may arrive as a comma-separated string (CSV / our own JSON
|
||
// export) or as a real array (buildSyncSnapshot uses parseTags → []).
|
||
// The server's HandleCreateEntry does GetValue<string> which throws
|
||
// "TJSONArray → string non supporté" on an array — normalise here.
|
||
const tagsStr = Array.isArray(plain.tags)
|
||
? plain.tags.filter(Boolean).join(',')
|
||
: (plain.tags || '');
|
||
return {
|
||
uuid: plain.uuid || '',
|
||
site: plain.site,
|
||
title: plain.title || '',
|
||
username: plain.username || '',
|
||
encrypted_password: pw.encrypted,
|
||
iv: pw.iv,
|
||
folder: plain.folder || 'All',
|
||
tags: tagsStr,
|
||
totp_secret: totpEnc,
|
||
totp_iv: totpIv,
|
||
kind: plain.kind === 'note' ? 'note' : 'login',
|
||
custom_fields: cfEnc,
|
||
custom_fields_iv: cfIv,
|
||
icon_b64: plain.icon_b64 || '',
|
||
template: plain.template || '',
|
||
};
|
||
}
|
||
|
||
// Open a hidden file picker, route the result through the right parser,
|
||
// show a preview confirmation, then bulk-encrypt + POST.
|
||
async function doImport() {
|
||
const fileInput = el('input', {
|
||
type: 'file',
|
||
accept: '.json,.csv,application/json,text/csv',
|
||
style: 'display:none',
|
||
});
|
||
document.body.appendChild(fileInput);
|
||
|
||
fileInput.addEventListener('change', async () => {
|
||
const file = fileInput.files && fileInput.files[0];
|
||
fileInput.remove();
|
||
if (!file) return;
|
||
|
||
let text;
|
||
try { text = await file.text(); }
|
||
catch (e) { return toast('Cannot read file: ' + e.message, 'error'); }
|
||
|
||
const isJSON = /\.json$/i.test(file.name) || text.trim().startsWith('{') || text.trim().startsWith('[');
|
||
|
||
// If the JSON is an encrypted-export container, prompt for the
|
||
// backup password and decrypt before handing the plaintext payload
|
||
// to the regular JSON parser.
|
||
if (isJSON) {
|
||
let raw;
|
||
try { raw = JSON.parse(text); } catch (e) { raw = null; }
|
||
if (raw && raw.format === 'pm-encrypted-export-v1') {
|
||
const pw = await promptDialog({
|
||
title: 'Encrypted backup',
|
||
message: 'This backup is encrypted. Enter the password ' +
|
||
'you set when you exported it.',
|
||
placeholder: 'Backup encryption password',
|
||
okText: 'Decrypt',
|
||
password: true,
|
||
});
|
||
if (!pw) return;
|
||
try {
|
||
const payload = await decryptExportContainer(raw, pw);
|
||
// Hand the decrypted payload back to parseEntriesFromJSON
|
||
// via JSON.stringify — keeps the parser code path single.
|
||
text = JSON.stringify(payload);
|
||
} catch (e) {
|
||
return toast('Decryption failed — wrong password or corrupted file', 'error');
|
||
}
|
||
}
|
||
}
|
||
|
||
let parsed;
|
||
try {
|
||
parsed = isJSON ? parseEntriesFromJSON(text) : parseEntriesFromCSV(text);
|
||
} catch (e) {
|
||
return toast('Parse error: ' + e.message, 'error');
|
||
}
|
||
|
||
if (parsed.entries.length === 0) {
|
||
return toast('No valid entries found in file', 'warning');
|
||
}
|
||
|
||
// Build the preview message (innerHTML target → escape user data)
|
||
const esc = s => String(s).replace(/[&<>"]/g,
|
||
c => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]));
|
||
const parts = [];
|
||
parts.push('<b>' + parsed.entries.length + '</b> entries detected in <code>' +
|
||
esc(file.name) + '</code>');
|
||
if (parsed.skipped > 0)
|
||
parts.push('<span style="color:var(--text-dim)">' + parsed.skipped +
|
||
' rows skipped (missing site or password)</span>');
|
||
const sample = parsed.entries.slice(0, 3).map(e =>
|
||
'• ' + esc(e.site || '?') +
|
||
(e.username ? ' <span style="color:var(--text-dim)">(' + esc(e.username) + ')</span>' : '')
|
||
).join('<br>');
|
||
parts.push('<div style="margin-top:8px;font-size:12px">' + sample +
|
||
(parsed.entries.length > 3 ? '<br>…' : '') + '</div>');
|
||
parts.push('<div style="margin-top:10px">Import now? This adds the entries to your existing vault.</div>');
|
||
|
||
const confirmed = await confirmDialog({
|
||
title: 'Import vault',
|
||
message: parts.join('<br>'),
|
||
okText: 'Import',
|
||
});
|
||
if (!confirmed) return;
|
||
|
||
// parsedAtt is computed AFTER dedup — see below where
|
||
// dedupedEntries is defined.
|
||
let parsedAtt = 0;
|
||
|
||
// Apply folder customisation (color, icon) from the payload —
|
||
// additive only: existing local folders are left untouched so the
|
||
// user's current customisation isn't overwritten by an older
|
||
// backup. Folders referenced by entries but absent from the
|
||
// folders[] block will still be auto-created with defaults during
|
||
// the bulk-import step server-side.
|
||
if (Array.isArray(parsed.folders) && parsed.folders.length > 0) {
|
||
const existing = new Set((state.folders || [])
|
||
.filter(f => f && f.name).map(f => f.name));
|
||
let createdFolders = 0;
|
||
for (const f of parsed.folders) {
|
||
if (!f.name || existing.has(f.name)) continue;
|
||
try {
|
||
await api('/folders', {
|
||
method: 'POST',
|
||
headers: authHeaders({ 'Content-Type': 'application/json' }),
|
||
body: JSON.stringify({
|
||
name: f.name,
|
||
color: f.color || '',
|
||
icon: f.icon || '',
|
||
}),
|
||
});
|
||
createdFolders++;
|
||
} catch (_) { /* duplicate or invalid — skip silently */ }
|
||
}
|
||
if (createdFolders > 0) {
|
||
await loadFolders();
|
||
toast(createdFolders + ' folder(s) added');
|
||
}
|
||
}
|
||
|
||
// Restore the profile picture from the backup — only when the
|
||
// current account has none, so an import doesn't clobber a
|
||
// picture the user already set on this device.
|
||
if (parsed.avatar_b64 && !state.avatarDataUri) {
|
||
try {
|
||
await api('/avatar', {
|
||
method: 'POST',
|
||
headers: authHeaders({ 'Content-Type': 'application/json' }),
|
||
body: JSON.stringify({ avatar_b64: parsed.avatar_b64 }),
|
||
});
|
||
state.avatarDataUri = parsed.avatar_b64;
|
||
renderUserAvatar();
|
||
} catch (_) { /* non-critical */ }
|
||
}
|
||
|
||
// CSV imports (Bitwarden / KeePass / Chrome) don't carry a
|
||
// folders[] block — they just stamp a folder name on each row.
|
||
// Bulk-import stores the name but never creates the folders
|
||
// table row, so the sidebar wouldn't show the new folder.
|
||
// Auto-create any referenced folder that doesn't exist yet.
|
||
const referenced = new Set();
|
||
for (const e of parsed.entries) {
|
||
const f = (e.folder || '').trim();
|
||
if (f && f !== 'All') referenced.add(f);
|
||
}
|
||
if (referenced.size > 0) {
|
||
const localNames = new Set((state.folders || [])
|
||
.filter(f => f && f.name).map(f => f.name));
|
||
let createdMissing = 0;
|
||
for (const name of referenced) {
|
||
if (localNames.has(name)) continue;
|
||
try {
|
||
await api('/folders', {
|
||
method: 'POST',
|
||
headers: authHeaders({ 'Content-Type': 'application/json' }),
|
||
body: JSON.stringify({ name, color: '', icon: '' }),
|
||
});
|
||
createdMissing++;
|
||
} catch (_) { /* duplicate or invalid — skip silently */ }
|
||
}
|
||
if (createdMissing > 0) await loadFolders();
|
||
}
|
||
|
||
// Dedupe by uuid: split parsed rows into "fresh" (uuid absent
|
||
// locally, safe to bulk-insert) and "overlapping" (uuid already
|
||
// exists — the user is either re-importing a backup or rolling
|
||
// back to an earlier version). Ask what to do with overlapping
|
||
// entries so a restore isn't silently blocked by the dedup.
|
||
const localByUuid = new Map();
|
||
for (const e of state.entries) if (e && e.uuid) localByUuid.set(e.uuid, e);
|
||
const fresh = [];
|
||
const overlaps = [];
|
||
for (const e of parsed.entries) {
|
||
if (e.uuid && localByUuid.has(e.uuid)) overlaps.push(e);
|
||
else fresh.push(e);
|
||
}
|
||
let overwriteOverlaps = false;
|
||
if (overlaps.length > 0) {
|
||
overwriteOverlaps = await confirmDialog({
|
||
title: overlaps.length + ' entries already in vault',
|
||
message: '<b>' + overlaps.length + '</b> entries in this file ' +
|
||
'already exist locally (same UUID).<br><br>' +
|
||
'Choose <b>Overwrite</b> to replace the local version with the ' +
|
||
'file\'s (rolls back edits made since the backup was taken).<br><br>' +
|
||
'Choose <b>Skip</b> to keep the current local version and only ' +
|
||
'import genuinely new entries.',
|
||
okText: 'Overwrite',
|
||
cancelText: 'Skip',
|
||
danger: true,
|
||
});
|
||
}
|
||
if (fresh.length === 0 && !overwriteOverlaps) {
|
||
return toast('Nothing new to import', 'warning');
|
||
}
|
||
parsedAtt = fresh.reduce(
|
||
(n, e) => n + (Array.isArray(e.attachments) ? e.attachments.length : 0), 0);
|
||
|
||
toast('Encrypting ' + fresh.length + ' entries…');
|
||
|
||
const encrypted = [];
|
||
for (const e of fresh) {
|
||
encrypted.push(await encryptImportEntry(e));
|
||
}
|
||
|
||
// Overwriting overlaps: PUT each existing entry with the file's
|
||
// content. Attachments on the local entry stay in place — the
|
||
// user typically wants to roll back credentials, not lose
|
||
// manually-uploaded files. Adjust if that assumption changes.
|
||
let overwritten = 0;
|
||
if (overwriteOverlaps) {
|
||
for (const src of overlaps) {
|
||
try {
|
||
const local = localByUuid.get(src.uuid);
|
||
if (!local) continue;
|
||
const enc = await encryptImportEntry(src);
|
||
await api('/entries/' + local.id, {
|
||
method: 'PUT',
|
||
headers: authHeaders({ 'Content-Type': 'application/json' }),
|
||
body: JSON.stringify(enc),
|
||
});
|
||
overwritten++;
|
||
} catch (_) { /* skip the single row on failure */ }
|
||
}
|
||
}
|
||
// Use dedupedEntries as an alias for fresh so downstream code
|
||
// (attachments loop) keeps working without a second rename.
|
||
const dedupedEntries = fresh;
|
||
|
||
try {
|
||
let r = { imported: 0, ids: [] };
|
||
if (encrypted.length > 0) {
|
||
r = await api('/entries/bulk-import', {
|
||
method: 'POST',
|
||
headers: authHeaders({ 'Content-Type': 'application/json' }),
|
||
body: JSON.stringify({ entries: encrypted }),
|
||
});
|
||
}
|
||
const tailMsg = overwritten > 0
|
||
? ' · ' + overwritten + ' overwritten'
|
||
: '';
|
||
toast('Imported ' + r.imported + ' entries' + tailMsg);
|
||
|
||
// Restore attachments. ids[] is parallel-indexed with the
|
||
// input (-1 = server skipped this row), so we can map back
|
||
// from each parsed entry to its newly-created server id.
|
||
const ids = Array.isArray(r.ids) ? r.ids : [];
|
||
if (parsedAtt > 0 && ids.length === 0) {
|
||
toast(parsedAtt + ' attachment(s) skipped — server missing /ids response', 'warning');
|
||
}
|
||
let attachCount = 0;
|
||
for (let i = 0; i < dedupedEntries.length; i++) {
|
||
const newId = ids[i];
|
||
const atts = dedupedEntries[i].attachments;
|
||
if (typeof newId !== 'number' || newId <= 0) continue;
|
||
if (!Array.isArray(atts) || atts.length === 0) continue;
|
||
for (const a of atts) {
|
||
try {
|
||
const bytes = base64ToBytes(a.content_b64 || '');
|
||
// Re-encrypt under the CURRENT vault key — the
|
||
// export stored plaintext bytes so a cross-
|
||
// account / post-rotation restore still works.
|
||
const { encrypted: blob, iv } = await encryptBlobBytes(bytes);
|
||
await api('/entries/' + newId + '/attachments', {
|
||
method: 'POST',
|
||
headers: authHeaders({ 'Content-Type': 'application/json' }),
|
||
body: JSON.stringify({
|
||
filename: a.filename,
|
||
mime: a.mime || 'application/octet-stream',
|
||
encrypted_blob: blob,
|
||
iv,
|
||
size_bytes: a.size_bytes || bytes.length,
|
||
}),
|
||
});
|
||
attachCount++;
|
||
} catch (e) { /* skip the single attachment */ }
|
||
}
|
||
}
|
||
if (attachCount > 0) toast(attachCount + ' attachment(s) restored');
|
||
|
||
await loadEntries();
|
||
render();
|
||
if (state.hibpEnabled) hibpCheckAllEntries(); // scan the new entries too
|
||
} catch (err) {
|
||
toast('Import failed: ' + err.message, 'error');
|
||
}
|
||
});
|
||
|
||
fileInput.click();
|
||
}
|
||
|
||
async function doExport() {
|
||
// Step 1: reauth — verifies the human in front of the screen is the
|
||
// vault owner before we hand them every plaintext password. Defense
|
||
// against a stranger reaching the open laptop and exfiltrating data.
|
||
// Up to 5 retries with inline error in the modal — beyond that the
|
||
// server-side rate limiter takes over (429 with retry_after).
|
||
let lastError = null;
|
||
let attempts = 0;
|
||
const MAX_ATTEMPTS = 5;
|
||
let reauthed = false;
|
||
while (!reauthed) {
|
||
const masterPwd = await askReauth(
|
||
'Enter your master password to start an encrypted export.',
|
||
{ error: lastError });
|
||
if (!masterPwd) return; // user cancelled
|
||
try {
|
||
const verifier = await computeVerifier(
|
||
masterPwd, state.salt, state.kdfIterations || 100000, state.hashAlgo, state.argon2Params);
|
||
await api('/reauth', {
|
||
method: 'POST',
|
||
headers: authHeaders({ 'Content-Type': 'application/json' }),
|
||
body: JSON.stringify({ verifier: verifier }),
|
||
});
|
||
reauthed = true;
|
||
} catch (err) {
|
||
if (err.status === 429 && err.body && err.body.retry_after) {
|
||
return toast('Account locked. Try again in ' +
|
||
Math.ceil(err.body.retry_after / 60) + ' min', 'warning');
|
||
}
|
||
attempts++;
|
||
if (attempts >= MAX_ATTEMPTS) {
|
||
return toast('Too many wrong attempts — try again later', 'error');
|
||
}
|
||
lastError = 'Wrong master password. Attempt ' +
|
||
attempts + ' / ' + MAX_ATTEMPTS + '.';
|
||
}
|
||
}
|
||
|
||
// Step 2: ask for an INDEPENDENT export password. Decoupled from the
|
||
// master pw so a master-pw change later doesn't invalidate the backup,
|
||
// and so the backup can be shared without revealing the master pw.
|
||
let exportPwd = null;
|
||
{
|
||
let pwdErr = '';
|
||
let pwdAttempts = 0;
|
||
const PWD_MAX = 5;
|
||
for (;;) {
|
||
const v = await promptDialog({
|
||
title: 'Encrypted export',
|
||
message: 'Choose a password to encrypt the backup file.<br>' +
|
||
'<small style="color:var(--text-dim)">' +
|
||
'You will need this password to restore the file. ' +
|
||
'It is independent of your master password.</small>',
|
||
placeholder: 'Backup encryption password',
|
||
okText: 'Export',
|
||
password: true,
|
||
error: pwdErr,
|
||
});
|
||
if (!v) return;
|
||
if (v.length >= 6) { exportPwd = v; break; }
|
||
pwdAttempts++;
|
||
if (pwdAttempts >= PWD_MAX) {
|
||
return toast('Too many invalid attempts', 'error');
|
||
}
|
||
pwdErr = 'Use at least 6 characters (attempt ' +
|
||
pwdAttempts + ' / ' + PWD_MAX + ').';
|
||
}
|
||
}
|
||
|
||
// Show the spinner BEFORE the heavy work — the entry-decrypt +
|
||
// attachment-fetch loop below is the real cost on big vaults, not
|
||
// just the final encrypt/save. A 0ms yield lets the overlay paint
|
||
// before we block the thread.
|
||
showBusy('Reading vault…');
|
||
await new Promise(r => setTimeout(r, 0));
|
||
try {
|
||
// Step 3: assemble the plaintext payload (same shape as the legacy
|
||
// plaintext exporter — round-trips with the existing JSON importer
|
||
// after decryption).
|
||
const payload = {
|
||
version: 1,
|
||
exported_at: new Date().toISOString(),
|
||
username: state.username,
|
||
// Profile picture (data URI) so a restore brings the avatar
|
||
// back. Empty string when none set.
|
||
avatar_b64: state.avatarDataUri || '',
|
||
// Folder customisation (color, icon) so restoring on a fresh
|
||
// install brings the sidebar back the way the user had it,
|
||
// not the default gray + folder-icon. 'All' is synthetic and
|
||
// never persisted, skip it.
|
||
folders: (state.folders || [])
|
||
.filter(f => f && f.name && f.name !== 'All')
|
||
.map(f => ({
|
||
name: f.name,
|
||
color: f.color || '',
|
||
icon: f.icon || '',
|
||
})),
|
||
entries: [],
|
||
};
|
||
let _expDone = 0;
|
||
const _expTotal = state.entries.length;
|
||
for (const e of state.entries) {
|
||
_expDone++;
|
||
if (_expTotal > 10 && (_expDone % 5 === 0 || _expDone === _expTotal))
|
||
updateBusy('Reading vault… ' + _expDone + '/' + _expTotal);
|
||
const plain = await decryptPwd(e.encrypted_password, e.iv);
|
||
let plainTotp = '';
|
||
if (e.totp_secret && e.totp_iv) {
|
||
plainTotp = await decryptTotpSecret(e.totp_secret, e.totp_iv);
|
||
if (plainTotp === '[ERROR]') plainTotp = '';
|
||
}
|
||
let plainCustom = [];
|
||
if (e.custom_fields && e.custom_fields_iv) {
|
||
try {
|
||
plainCustom = await decryptCustomFields(
|
||
e.custom_fields, e.custom_fields_iv);
|
||
} catch (_) { plainCustom = []; }
|
||
}
|
||
// Attachments are fetched separately (one extra GET per entry
|
||
// that has them) so this branch stays cheap on vaults without.
|
||
let attachments = [];
|
||
try {
|
||
const metas = await api('/entries/' + e.id + '/attachments',
|
||
{ headers: authHeaders() });
|
||
for (const m of (metas || [])) {
|
||
const full = await api('/attachments/' + m.id,
|
||
{ headers: authHeaders() });
|
||
const bytes = await decryptBlobBytes(
|
||
full.encrypted_blob, full.iv);
|
||
attachments.push({
|
||
filename: m.filename,
|
||
mime: m.mime,
|
||
size_bytes: m.size_bytes,
|
||
content_b64: bytesToBase64(bytes),
|
||
});
|
||
}
|
||
} catch (_) { /* partial export beats a failed one */ }
|
||
|
||
payload.entries.push({
|
||
uuid: e.uuid || '',
|
||
site: e.site,
|
||
title: e.title || '',
|
||
username: e.username,
|
||
password: plain,
|
||
folder: e.folder,
|
||
tags: parseTags(e.tags),
|
||
favorite: !!e.favorite,
|
||
totp_secret: plainTotp,
|
||
kind: e.kind || 'login',
|
||
template: e.template || '',
|
||
custom_fields: plainCustom,
|
||
attachments: attachments,
|
||
icon_b64: e.icon_b64 || '',
|
||
created_at: e.created_at,
|
||
updated_at: e.updated_at,
|
||
});
|
||
}
|
||
|
||
const attachTotal = payload.entries.reduce(
|
||
(n, e) => n + (Array.isArray(e.attachments) ? e.attachments.length : 0), 0);
|
||
|
||
// Step 4: encrypt + save via native dialog. Big vaults (many
|
||
// attachments) take a few seconds — show a spinner so the app
|
||
// doesn't look frozen while the Save dialog is being prepared.
|
||
showBusy('Encrypting export…');
|
||
let res;
|
||
try {
|
||
const container = await encryptExportPayload(payload, exportPwd);
|
||
const json = JSON.stringify(container, null, 2);
|
||
const fname = 'vault-export-' + new Date().toISOString().slice(0, 10) + '.json';
|
||
updateBusy('Preparing file…');
|
||
res = await Bridge.saveFile(fname, json, pct =>
|
||
updateBusy('Preparing file… ' + pct + '%'));
|
||
} finally {
|
||
hideBusy();
|
||
}
|
||
if (res.ok) {
|
||
const tail = attachTotal > 0
|
||
? ' + ' + attachTotal + ' attachment(s)' : '';
|
||
toast(payload.entries.length + ' entries' + tail +
|
||
' exported to ' + res.path);
|
||
}
|
||
else if (res.error) toast('Export failed: ' + res.error, 'error');
|
||
} catch (err) {
|
||
hideBusy();
|
||
toast('Export failed: ' + (err && err.message ? err.message : err), 'error');
|
||
}
|
||
}
|
||
|
||
// CSV escape: wrap in quotes if the value contains comma / quote / newline.
|
||
// Inner quotes doubled per RFC 4180.
|
||
function csvEscape(s) {
|
||
if (s == null) return '';
|
||
s = String(s);
|
||
if (/[",\n\r]/.test(s)) return '"' + s.replace(/"/g, '""') + '"';
|
||
return s;
|
||
}
|
||
|
||
async function doExportCSV() {
|
||
const ok = await confirmDialog({
|
||
title: 'Export to CSV?',
|
||
message:
|
||
'The CSV file is <b>NOT encrypted</b>. Passwords, note bodies and ' +
|
||
'custom field values will be written in plaintext, readable by ' +
|
||
'anyone who opens the file.<br><br>' +
|
||
'<b>Not included in CSV</b>: encrypted attachments and custom ' +
|
||
'favicons. Use <i>Encrypted JSON</i> export for a complete ' +
|
||
'backup that round-trips everything.<br><br>' +
|
||
'Use this format only for migration to another password ' +
|
||
'manager — delete the file as soon as the import is done.',
|
||
okText: 'Export plaintext',
|
||
danger: true,
|
||
});
|
||
if (!ok) return;
|
||
|
||
// Decrypt everything client-side (server never sees plaintext).
|
||
const rows = [[
|
||
'kind', 'template', 'title', 'site', 'username', 'password',
|
||
'totp_secret', 'folder', 'tags',
|
||
'custom_fields', 'created_at', 'updated_at',
|
||
]];
|
||
for (const e of state.entries) {
|
||
const pwd = await decryptPwd(e.encrypted_password, e.iv);
|
||
let totp = '';
|
||
if (e.totp_secret && e.totp_iv) {
|
||
totp = await decryptTotpSecret(e.totp_secret, e.totp_iv);
|
||
if (totp === '[ERROR]') totp = '';
|
||
}
|
||
let cf = '';
|
||
if (e.custom_fields && e.custom_fields_iv) {
|
||
const arr = await decryptCustomFields(e.custom_fields, e.custom_fields_iv);
|
||
if (arr.length) cf = JSON.stringify(arr);
|
||
}
|
||
rows.push([
|
||
e.kind || 'login',
|
||
e.template || '',
|
||
e.title || '',
|
||
e.site || '',
|
||
e.username || '',
|
||
pwd === '[ERROR]' ? '' : pwd,
|
||
totp,
|
||
e.folder || '',
|
||
e.tags || '',
|
||
cf,
|
||
e.created_at || '',
|
||
e.updated_at || '',
|
||
]);
|
||
}
|
||
const csv = rows.map(r => r.map(csvEscape).join(',')).join('\r\n');
|
||
// Prepend UTF-8 BOM so Excel reads accented chars correctly.
|
||
const body = '' + csv;
|
||
const fname = 'vault-export-' + new Date().toISOString().slice(0, 10) + '.csv';
|
||
const res = await Bridge.saveFile(fname, body);
|
||
if (res.ok) toast(state.entries.length + ' entries exported to ' + res.path, 'warning');
|
||
else if (res.error) toast('Export failed: ' + res.error, 'error');
|
||
}
|
||
|