feat: harden bridge origin/CSP, cache decryption, and overhaul the web client UI
This commit is contained in:
parent
f637d5be57
commit
80133da100
10 changed files with 975 additions and 352 deletions
|
|
@ -7,18 +7,32 @@ import { b32decode, b32encode, identityFromSeed } from "./proto.js";
|
|||
|
||||
const KEY = "gsmol";
|
||||
|
||||
// Every getter reads the whole blob and some are called per message row, so the
|
||||
// parse is cached. `derived` holds the identities that follow from it, which
|
||||
// cost an Ed25519 scalar multiplication each.
|
||||
let cached = null, derived = null;
|
||||
|
||||
function load() {
|
||||
if (cached) return cached;
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem(KEY) || "{}");
|
||||
cached = JSON.parse(localStorage.getItem(KEY) || "{}");
|
||||
} catch {
|
||||
return {};
|
||||
cached = {};
|
||||
}
|
||||
return cached;
|
||||
}
|
||||
|
||||
function save(state) {
|
||||
localStorage.setItem(KEY, JSON.stringify(state));
|
||||
cached = state;
|
||||
derived = null;
|
||||
}
|
||||
|
||||
// Another tab writing this key leaves our copy stale.
|
||||
window.addEventListener("storage", (event) => {
|
||||
if (event.key === KEY || event.key === null) cached = derived = null;
|
||||
});
|
||||
|
||||
function update(fn) {
|
||||
const state = load();
|
||||
const next = fn(state);
|
||||
|
|
@ -33,8 +47,7 @@ export function seed() {
|
|||
}
|
||||
|
||||
export function identity() {
|
||||
const raw = seed();
|
||||
return raw ? identityFromSeed(raw) : null;
|
||||
return identities()[0] ?? null;
|
||||
}
|
||||
|
||||
export function setIdentity(newSeed) {
|
||||
|
|
@ -56,10 +69,12 @@ export function rotateIdentity(newSeed) {
|
|||
// §7: seeds rotated away from are retained, since mail sealed to a superseded
|
||||
// key is readable with nothing else.
|
||||
export function identities() {
|
||||
const me = identity();
|
||||
if (!me) return [];
|
||||
if (derived) return derived;
|
||||
const raw = seed();
|
||||
if (!raw) return [];
|
||||
const retired = (load().retired || []).map(({ seed: s }) => identityFromSeed(unhex(s)));
|
||||
return [me, ...retired];
|
||||
derived = [identityFromSeed(raw), ...retired];
|
||||
return derived;
|
||||
}
|
||||
|
||||
// --- account and server pins ---------------------------------------------------
|
||||
|
|
@ -100,15 +115,22 @@ export function allPins() {
|
|||
|
||||
export function contact(address) {
|
||||
const c = (load().contacts || {})[address];
|
||||
return c ? { key: b32decode(c.key), verified: c.verified } : null;
|
||||
return c ? { key: b32decode(c.key), verified: c.verified, history: c.history ?? [] } : null;
|
||||
}
|
||||
|
||||
// A key that displaces another is kept: it is the only local record that the
|
||||
// contact rotated, and §8 turns on the user being able to notice such changes.
|
||||
// Re-saving the same key is not a rotation and must not add an entry.
|
||||
export function saveContact(address, keyBytes, verified) {
|
||||
const key = b32encode(keyBytes);
|
||||
update(state => {
|
||||
state.contacts = {
|
||||
...(state.contacts || {}),
|
||||
[address]: { key: b32encode(keyBytes), verified, seenAt: Date.now() },
|
||||
};
|
||||
const contacts = { ...(state.contacts || {}) };
|
||||
const previous = contacts[address];
|
||||
const history = previous && previous.key !== key
|
||||
? [...(previous.history ?? []), { key: previous.key, until: Date.now() }]
|
||||
: previous?.history ?? [];
|
||||
contacts[address] = { key, verified, seenAt: Date.now(), ...(history.length && { history }) };
|
||||
state.contacts = contacts;
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -119,7 +141,7 @@ export function addressForKey(keyBytes) {
|
|||
|
||||
export function allContacts() {
|
||||
return Object.entries(load().contacts || {})
|
||||
.map(([address, c]) => ({ address, key: c.key, verified: c.verified }));
|
||||
.map(([address, c]) => ({ address, key: c.key, verified: c.verified, history: c.history ?? [] }));
|
||||
}
|
||||
|
||||
// --- read markers ---------------------------------------------------------------
|
||||
|
|
@ -134,12 +156,30 @@ export function isRead(idHex) {
|
|||
return Boolean((load().read || {})[idHex]);
|
||||
}
|
||||
|
||||
// --- appearance ------------------------------------------------------------
|
||||
|
||||
// "light" or "dark" to pin a side; null to follow the system.
|
||||
export function theme() {
|
||||
return load().theme ?? null;
|
||||
}
|
||||
|
||||
export function setTheme(value) {
|
||||
update(state => {
|
||||
if (value) state.theme = value;
|
||||
else delete state.theme;
|
||||
});
|
||||
}
|
||||
|
||||
// --- sealed mail (IndexedDB) ------------------------------------------------------
|
||||
|
||||
const DB_NAME = "gsmol", DB_VERSION = 1;
|
||||
|
||||
// Held open: reopening per read cost more than the reads themselves.
|
||||
let dbPromise = null;
|
||||
|
||||
function openDb() {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (dbPromise) return dbPromise;
|
||||
dbPromise = new Promise((resolve, reject) => {
|
||||
const request = indexedDB.open(DB_NAME, DB_VERSION);
|
||||
request.onupgradeneeded = () => {
|
||||
const db = request.result;
|
||||
|
|
@ -149,7 +189,8 @@ function openDb() {
|
|||
};
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
}).catch(error => { dbPromise = null; throw error; });
|
||||
return dbPromise;
|
||||
}
|
||||
|
||||
function tx(db, box, mode, fn) {
|
||||
|
|
@ -163,29 +204,33 @@ function tx(db, box, mode, fn) {
|
|||
export async function storeMessage(box, record) {
|
||||
const db = await openDb();
|
||||
await tx(db, box, "readwrite", store => store.put(record));
|
||||
db.close();
|
||||
}
|
||||
|
||||
// Returns null when the id already exists, so fetch can leave server state alone.
|
||||
export async function storeIfNew(box, record) {
|
||||
const db = await openDb();
|
||||
const existing = await tx(db, box, "readonly", store => store.get(record.id));
|
||||
if (existing) { db.close(); return null; }
|
||||
if (existing) return null;
|
||||
await tx(db, box, "readwrite", store => store.put(record));
|
||||
db.close();
|
||||
return record;
|
||||
}
|
||||
|
||||
export async function listMessages(box) {
|
||||
const db = await openDb();
|
||||
const rows = await tx(db, box, "readonly", store => store.getAll());
|
||||
db.close();
|
||||
return rows.sort((a, b) => (a.receivedAt ?? a.sentAt) - (b.receivedAt ?? b.sentAt));
|
||||
return rows.sort((a, b) => (b.receivedAt ?? b.sentAt) - (a.receivedAt ?? a.sentAt)); // newest first
|
||||
}
|
||||
|
||||
export async function getMessage(box, id) {
|
||||
const db = await openDb();
|
||||
const row = await tx(db, box, "readonly", store => store.get(id));
|
||||
db.close();
|
||||
return row ?? null;
|
||||
return await tx(db, box, "readonly", store => store.get(id)) ?? null;
|
||||
}
|
||||
|
||||
// The unread badge must survive leaving the inbox, so it is counted from ids
|
||||
// and read markers alone — no envelope is opened.
|
||||
export async function unreadCount() {
|
||||
const db = await openDb();
|
||||
const ids = await tx(db, "inbox", "readonly", store => store.getAllKeys());
|
||||
const read = load().read || {};
|
||||
return ids.filter(id => !read[id]).length;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue