Files
relicario/extension/src/service-worker/vault.ts
adlee-was-taken d2cb6d8461 feat(ext/sw): trash operations — listTrashed, restoreItem, purgeItem, purgeAllTrash
listTrashed filters manifest for trashed_at != null, sorted newest-first.
restoreItem clears trashed_at. purgeItem deletes item + attachments.
purgeAllTrash also scans for orphan blobs in attachments/ directory.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-04-26 15:57:08 -04:00

318 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/// Typed-item vault operations. All calls are handle-keyed — the master key
/// never crosses the WASM boundary.
import type { SessionHandle } from '../../wasm/relicario_wasm';
import type { GitHost } from './git-host';
import type { AttachmentRef, Item, ItemId, Manifest, ManifestEntry, VaultSettings } from '../shared/types';
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let wasm: any = null;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function setWasm(w: any): void { wasm = w; }
function requireWasm(): any {
if (!wasm) throw new Error('WASM module not initialized');
return wasm;
}
export interface VaultMeta {
salt: Uint8Array;
paramsJson: string;
}
export async function fetchVaultMeta(git: GitHost): Promise<VaultMeta> {
const saltBytes = await git.readFile('.relicario/salt');
const paramsRaw = await git.readFile('.relicario/params.json');
const paramsJson = new TextDecoder().decode(paramsRaw);
return { salt: saltBytes, paramsJson };
}
// --- Manifest ---
export async function fetchAndDecryptManifest(
git: GitHost,
handle: SessionHandle,
): Promise<Manifest> {
const w = requireWasm();
const ciphertext = await git.readFile('manifest.enc');
return w.manifest_decrypt(handle, ciphertext) as Manifest;
}
export async function encryptAndWriteManifest(
git: GitHost,
handle: SessionHandle,
manifest: Manifest,
message: string,
): Promise<void> {
const w = requireWasm();
const ciphertext = w.manifest_encrypt(handle, JSON.stringify(manifest));
await git.writeFile('manifest.enc', ciphertext, message);
}
// --- Items ---
export async function fetchAndDecryptItem(
git: GitHost,
handle: SessionHandle,
id: ItemId,
): Promise<Item> {
const w = requireWasm();
const ciphertext = await git.readFile(`items/${id}.enc`);
return w.item_decrypt(handle, ciphertext) as Item;
}
export async function encryptAndWriteItem(
git: GitHost,
handle: SessionHandle,
id: ItemId,
item: Item,
message: string,
): Promise<void> {
const w = requireWasm();
const ciphertext = w.item_encrypt(handle, JSON.stringify(item));
await git.writeFile(`items/${id}.enc`, ciphertext, message);
}
// --- Settings (the α subset the SW reads/writes is autofill_origin_acks) ---
export async function fetchAndDecryptSettings(
git: GitHost,
handle: SessionHandle,
): Promise<VaultSettings> {
const w = requireWasm();
const ciphertext = await git.readFile('settings.enc');
return w.settings_decrypt(handle, ciphertext) as VaultSettings;
}
export async function encryptAndWriteSettings(
git: GitHost,
handle: SessionHandle,
settings: VaultSettings,
message: string,
): Promise<void> {
const w = requireWasm();
const ciphertext = w.settings_encrypt(handle, JSON.stringify(settings));
await git.writeFile('settings.enc', ciphertext, message);
}
// --- In-memory manifest helpers ---
export function listItems(
manifest: Manifest,
group?: string,
): Array<[ItemId, ManifestEntry]> {
const entries = Object.entries(manifest.items) as Array<[ItemId, ManifestEntry]>;
// Hide trashed items from the default list view.
const live = entries.filter(([, e]) => e.trashed_at === undefined);
if (!group) return live;
const g = group.toLowerCase();
return live.filter(([, e]) => e.group?.toLowerCase() === g);
}
export function searchItems(
manifest: Manifest,
query: string,
): Array<[ItemId, ManifestEntry]> {
const q = query.toLowerCase();
return (Object.entries(manifest.items) as Array<[ItemId, ManifestEntry]>)
.filter(([, e]) => e.trashed_at === undefined)
.filter(([, e]) => {
if (e.title.toLowerCase().includes(q)) return true;
if (e.tags.some((t) => t.toLowerCase().includes(q))) return true;
return false;
});
}
/// Match manifest entries against a page hostname.
///
/// icon_hint is derived by the Rust core (crates/relicario-core/src/manifest.rs)
/// from LoginCore.url's hostname, so equality on icon_hint is the cheapest match.
/// α is intentionally coarse: no www.-stripping, no public-suffix matching
/// (`www.github.com` saved items will not match `github.com`, and vice versa).
/// Tighter matching is a 1C-β/γ concern.
export function findByHostname(
manifest: Manifest,
hostname: string,
): Array<[ItemId, ManifestEntry]> {
const h = hostname.toLowerCase();
return (Object.entries(manifest.items) as Array<[ItemId, ManifestEntry]>)
.filter(([, e]) => e.trashed_at === undefined)
.filter(([, e]) => (e.icon_hint ?? '').toLowerCase() === h);
}
// --- Attachment helpers ---
/// Sync the manifest entry's attachment_summaries for a single item.
/// Reads the current manifest + item; if either is missing, does nothing.
async function syncManifestAttachments(
git: GitHost,
handle: SessionHandle,
itemId: ItemId,
item: Item,
message: string,
): Promise<void> {
const manifest = await fetchAndDecryptManifest(git, handle);
const entry = manifest.items[itemId];
if (!entry) return;
entry.attachment_summaries = item.attachments.map((a) => ({
id: a.id,
filename: a.filename,
mime_type: a.mime_type,
size: a.size,
}));
await encryptAndWriteManifest(git, handle, manifest, message);
}
/// Add an AttachmentRef to an existing item.
/// Reads + decrypts the item, appends to attachments[], re-encrypts and
/// writes both items/<id>.enc and the manifest entry's attachment_summaries.
/// Throws if the item is not found.
export async function addAttachmentToItem(
git: GitHost,
handle: SessionHandle,
itemId: ItemId,
ref: AttachmentRef,
): Promise<void> {
let item: Item;
try {
item = await fetchAndDecryptItem(git, handle, itemId);
} catch {
throw new Error(`addAttachmentToItem: item ${itemId} not found`);
}
item.attachments = [...item.attachments, ref];
item.modified = Math.floor(Date.now() / 1000);
const commitMsg = `attach ${ref.filename} to item ${itemId}`;
await encryptAndWriteItem(git, handle, itemId, item, commitMsg);
await syncManifestAttachments(git, handle, itemId, item, `manifest: sync attachments for ${itemId}`);
}
// --- Trash operations ---
export function listTrashed(manifest: Manifest): Array<[ItemId, ManifestEntry]> {
return Object.entries(manifest.items)
.filter(([, entry]) => entry.trashed_at != null)
.sort(([, a], [, b]) => (b.trashed_at ?? 0) - (a.trashed_at ?? 0));
}
export async function restoreItem(
git: GitHost,
handle: SessionHandle,
manifest: Manifest,
itemId: ItemId,
): Promise<void> {
const item = await fetchAndDecryptItem(git, handle, itemId);
const now = Math.floor(Date.now() / 1000);
const restored: Item = { ...item, trashed_at: undefined, modified: now };
await encryptAndWriteItem(git, handle, itemId, restored, `restore: ${item.title}`);
manifest.items[itemId] = { ...manifest.items[itemId], trashed_at: undefined, modified: now };
await encryptAndWriteManifest(git, handle, manifest, `manifest: restore ${item.title}`);
}
export async function purgeItem(
git: GitHost,
itemId: ItemId,
manifest: Manifest,
): Promise<string[]> {
const entry = manifest.items[itemId];
const deletedBlobs: string[] = [];
// Delete attachments
for (const att of entry?.attachment_summaries ?? []) {
try {
await git.deleteBlob(`attachments/${att.id}.bin`, `purge attachment: ${att.filename}`);
deletedBlobs.push(att.id);
} catch { /* blob may not exist */ }
}
// Delete item file
await git.deleteFile(`items/${itemId}.enc`, `purge: ${entry?.title ?? itemId}`);
// Update manifest
delete manifest.items[itemId];
return deletedBlobs;
}
export async function purgeAllTrash(
git: GitHost,
handle: SessionHandle,
manifest: Manifest,
): Promise<{ itemCount: number; orphanCount: number }> {
const trashed = listTrashed(manifest);
const allDeletedBlobs = new Set<string>();
// Purge each trashed item
for (const [id] of trashed) {
const deleted = await purgeItem(git, id, manifest);
deleted.forEach((b) => allDeletedBlobs.add(b));
}
// Collect all referenced attachment IDs from remaining items
const referenced = new Set<string>();
for (const entry of Object.values(manifest.items)) {
for (const att of entry.attachment_summaries ?? []) {
referenced.add(att.id);
}
}
// Scan for orphan blobs
let orphanCount = 0;
try {
const blobFiles = await git.listDir('attachments');
for (const filename of blobFiles) {
const id = filename.replace(/\.bin$/, '');
if (!referenced.has(id) && !allDeletedBlobs.has(id)) {
try {
await git.deleteBlob(`attachments/${filename}`, `purge orphan: ${id}`);
orphanCount++;
} catch { /* ignore */ }
}
}
} catch { /* attachments dir may not exist */ }
// Write manifest once
if (trashed.length > 0 || orphanCount > 0) {
await encryptAndWriteManifest(
git, handle, manifest,
`trash: purge ${trashed.length} items + ${orphanCount} orphan blobs`,
);
}
return { itemCount: trashed.length, orphanCount };
}
/// Remove attachments matching the given IDs from the item.
/// Returns the removed AttachmentRefs (so the caller can deleteBlob the
/// underlying bytes). IDs not present in the item's attachments are
/// silently ignored.
export async function removeAttachmentsFromItem(
git: GitHost,
handle: SessionHandle,
itemId: ItemId,
idsToRemove: string[],
): Promise<AttachmentRef[]> {
let item: Item;
try {
item = await fetchAndDecryptItem(git, handle, itemId);
} catch {
throw new Error(`removeAttachmentsFromItem: item ${itemId} not found`);
}
const removeSet = new Set(idsToRemove);
const removed: AttachmentRef[] = [];
const kept: AttachmentRef[] = [];
for (const att of item.attachments) {
if (removeSet.has(att.id)) {
removed.push(att);
} else {
kept.push(att);
}
}
item.attachments = kept;
item.modified = Math.floor(Date.now() / 1000);
const commitMsg = `remove ${removed.length} attachment(s) from item ${itemId}`;
await encryptAndWriteItem(git, handle, itemId, item, commitMsg);
await syncManifestAttachments(git, handle, itemId, item, `manifest: sync attachments for ${itemId}`);
return removed;
}