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>
318 lines
10 KiB
TypeScript
318 lines
10 KiB
TypeScript
/// 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;
|
||
}
|