feat: recall-on-restore, logout, and mail/contact export-import
This commit is contained in:
parent
a3378def50
commit
3576e48b99
4 changed files with 216 additions and 16 deletions
105
web/js/store.js
105
web/js/store.js
|
|
@ -234,3 +234,108 @@ export async function unreadCount() {
|
|||
const read = load().read || {};
|
||||
return ids.filter(id => !read[id]).length;
|
||||
}
|
||||
|
||||
// --- export / import: mail, contacts, pins — never the seed ---------------------
|
||||
|
||||
// btoa/atob work on JS's UTF-16 "binary string" convention (one code unit per
|
||||
// byte); this just bridges that to Uint8Array. Browser-only, like the rest of
|
||||
// this file — no Node test exercises store.js, unlike crypto.js/proto.js.
|
||||
const toBase64 = (bytes) => btoa(String.fromCharCode(...bytes));
|
||||
const fromBase64 = (text) => Uint8Array.from(atob(text), c => c.charCodeAt(0));
|
||||
|
||||
// Deliberately excludes the seed: it already has its own reveal-and-copy flow
|
||||
// in settings, meant for a password manager, not a downloadable file. This
|
||||
// covers what fetching makes irreplaceable (SPEC.md §5 deletes mail from the
|
||||
// server once acknowledged) plus the trust state that took real verification
|
||||
// (RESOLVE, key comparison) to build up — pins and contacts.
|
||||
export async function exportData() {
|
||||
const [inbox, sent] = await Promise.all([listMessages("inbox"), listMessages("sent")]);
|
||||
return {
|
||||
gsmolExport: 1,
|
||||
exportedAt: Date.now(),
|
||||
servers: Object.fromEntries(allPins().map(({ host, key }) => [host, key])),
|
||||
contacts: Object.fromEntries(allContacts().map(({ address, key, verified, history }) =>
|
||||
[address, { key, verified, history }])),
|
||||
inbox: inbox.map(row => ({ id: row.id, receivedAt: row.receivedAt, envelope: toBase64(row.envelope) })),
|
||||
sent: sent.map(row =>
|
||||
({ id: row.id, recipient: row.recipient, sentAt: row.sentAt, envelope: toBase64(row.envelope) })),
|
||||
};
|
||||
}
|
||||
|
||||
// Never overwrites a trust binding that already differs locally — the same
|
||||
// rule refreshContact()/saveReplyAddress() apply elsewhere: an existing pin
|
||||
// or contact key changes only by explicit user action, never silently. A
|
||||
// malformed entry (hand-edited file, corruption) is skipped, not fatal — one
|
||||
// bad record cannot abort the rest of the import, matching describe()'s
|
||||
// per-message fail-open elsewhere in the app.
|
||||
// A plain object, as JSON.parse would produce for `{...}`; Object.entries()
|
||||
// on a string iterates its characters rather than failing, which is exactly
|
||||
// the kind of malformed input this rejects as one unit instead of one per char.
|
||||
const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
|
||||
export async function importData(data) {
|
||||
if (!data || data.gsmolExport !== 1) throw new Error("not a gsmol export file");
|
||||
const summary = { pinsAdded: 0, pinsConflicted: 0, contactsAdded: 0, contactsConflicted: 0,
|
||||
mailAdded: 0, malformed: 0 };
|
||||
|
||||
update(state => {
|
||||
const servers = { ...(state.servers || {}) };
|
||||
if (data.servers !== undefined && !isRecord(data.servers)) summary.malformed++;
|
||||
for (const [host, key] of Object.entries(isRecord(data.servers) ? data.servers : {})) {
|
||||
try {
|
||||
if (b32decode(key).length !== 32) throw new Error("bad length");
|
||||
} catch { summary.malformed++; continue; }
|
||||
if (!(host in servers)) { servers[host] = key; summary.pinsAdded++; }
|
||||
else if (servers[host] !== key) summary.pinsConflicted++;
|
||||
}
|
||||
state.servers = servers;
|
||||
|
||||
const contacts = { ...(state.contacts || {}) };
|
||||
if (data.contacts !== undefined && !isRecord(data.contacts)) summary.malformed++;
|
||||
for (const [address, c] of Object.entries(isRecord(data.contacts) ? data.contacts : {})) {
|
||||
try {
|
||||
if (typeof c.key !== "string" || b32decode(c.key).length !== 32) throw new Error("bad key");
|
||||
} catch { summary.malformed++; continue; }
|
||||
if (!(address in contacts)) {
|
||||
contacts[address] = { key: c.key, verified: Boolean(c.verified), seenAt: Date.now(),
|
||||
...(Array.isArray(c.history) && c.history.length && { history: c.history }) };
|
||||
summary.contactsAdded++;
|
||||
} else if (contacts[address].key !== c.key) {
|
||||
summary.contactsConflicted++;
|
||||
}
|
||||
}
|
||||
state.contacts = contacts;
|
||||
});
|
||||
|
||||
for (const [box, rows] of [["inbox", data.inbox], ["sent", data.sent]]) {
|
||||
if (rows !== undefined && !Array.isArray(rows)) summary.malformed++;
|
||||
for (const row of Array.isArray(rows) ? rows : []) {
|
||||
try {
|
||||
const record = box === "inbox"
|
||||
? { id: row.id, receivedAt: row.receivedAt, envelope: fromBase64(row.envelope) }
|
||||
: { id: row.id, recipient: row.recipient, sentAt: row.sentAt, envelope: fromBase64(row.envelope) };
|
||||
if (await storeIfNew(box, record)) summary.mailAdded++;
|
||||
} catch { summary.malformed++; }
|
||||
}
|
||||
}
|
||||
|
||||
return summary;
|
||||
}
|
||||
|
||||
// Logout: erases the seed, pins, contacts and every cached message from this
|
||||
// browser. Closes the held connection first so the delete isn't left
|
||||
// "blocked" waiting for a handle that never closes on its own.
|
||||
export async function clearAll() {
|
||||
localStorage.removeItem(KEY);
|
||||
cached = derived = null;
|
||||
if (dbPromise) {
|
||||
(await dbPromise).close();
|
||||
dbPromise = null;
|
||||
}
|
||||
await new Promise((resolve, reject) => {
|
||||
const request = indexedDB.deleteDatabase(DB_NAME);
|
||||
request.onsuccess = () => resolve();
|
||||
request.onerror = () => reject(request.error);
|
||||
request.onblocked = () => resolve(); // still completes once the reload closes every handle
|
||||
});
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue