feat(crypto): phase 8 — encrypt remaining tables (storage, picture, music, events, guests)

Closes the last sweep of registry entries that were stuck on
enabled:false. Each table is corrected to match the actual schema
fields, then flipped on with writers + readers wrapped.

Registry corrections + flips
----------------------------
  - files: was ['name','originalName','notes'] → ['name','originalName']
    LocalFile has no `notes` column. `name` IS indexed but no
    .where('name') call site exists in the app, so encryption is safe
    — the index just becomes a no-op for content lookups.
  - images: was ['prompt','negativePrompt','revisedPrompt','notes']
    → ['prompt','negativePrompt']. Neither revisedPrompt nor notes
    exists on LocalImage. `prompt` is indexed, same caveat as
    files.name.
  - songs: was ['title','artist','album','lyrics','notes']
    → ['title']. lyrics + notes don't exist; artist / album /
    albumArtist / genre stay PLAINTEXT so the album / artist / genre
    browsing views (which aggregate by those fields) don't have to
    decrypt the entire library on every render.
  - mukkePlaylists: kept ['name','description'], now flipped on
  - socialEvents: was ['title','description','notes']
    → ['title','description','location'] (no notes column; location
    is the actually sensitive third field)
  - eventGuests: was ['name','email','phone','notes']
    → ['name','email','phone','note'] (singular `note`, matching the
    schema)
  - manaLinks: REMOVED from registry entirely. Despite the name it's
    the cross-app foreign-key table — sourceAppId / sourceRecordId /
    targetAppId / targetRecordId — with zero user-typed content. The
    Phase 1 placeholder listed label/url/notes which don't exist.

Storage (files)
---------------
  - storage/stores/files.svelte.ts: renameFile encrypts diff before
    fileTable.update. Other store ops touch only metadata (favorite /
    isDeleted / parent) so they stay unwrapped.
  - storage/queries.ts: useAllFiles decrypts before sort
  - storage/ListView.svelte (Workbench): same decrypt-before-render
  - storage/views/DetailView.svelte (inline editor binds to plaintext)
  - cross-app-queries.useStorageStats: decrypts only the recent slice
    (totalSize stays cheap because it reads plaintext .size)
  - search/providers/storage: decrypts before substring scoring
  - storage/trash/+page.svelte: decrypts the visible deleted set

Picture (images)
----------------
  - No client-side .add for images — they arrive purely via sync, so
    no store-level encryption to add. Reads are wrapped:
  - picture/queries.ts: useAllImages, useArchivedImages, allImages\$
  - picture/ListView.svelte (uses prompt as alt text)
  - cross-app-queries.useRecentImages (dashboard widget renders prompt)
  - search/providers/picture: decrypts before substring scoring
  Sync-applied plaintext rows coexist with locally-edited ciphertext
  rows without issue — decryptRecord is per-row idempotent on
  non-encrypted strings.

Music (songs + playlists)
-------------------------
  - music/stores/library.svelte.ts: updateMetadata + insert encrypt
    diffs before write
  - music/stores/playlists.svelte.ts: create snapshots plaintext for
    the return value before encryptRecord mutates the row, update
    encrypts diff
  - music/queries.ts: useAllSongs decrypts before title sort,
    useAllPlaylists decrypts before name sort
  - music/ListView.svelte (Workbench)
  - music/views/DetailView.svelte (inline editor)
  - cross-app-queries.useMusicStats decrypts only the recent slice
  - search/providers/music decrypts songs + playlists before scoring

Events (social gatherings + guests)
-----------------------------------
This one needed careful handling because publishEvent is the
exception to the local-only confidentiality model — it intentionally
pushes the event content to a public RSVP page anyone with the link
can read.

  - events/stores/events.svelte.ts:
    - createEvent encrypts before .add
    - updateEvent encrypts the diff before .update
    - publishEvent + syncSnapshotIfPublished now DECRYPT the local row
      before forwarding to eventsApi.publish / .updateSnapshot — the
      server-side public snapshot needs plaintext, by design. The
      privacy contract is: drafts and unpublished events are
      encrypted at rest; the moment you publish, you accept that the
      content becomes readable via the share link.
  - events/stores/guests.svelte.ts: addGuest + updateGuest encrypt
    diff before write. Guests are NEVER pushed to the public
    snapshot, so no decrypt-before-publish path.
  - events/queries.ts: useAllEvents, useUpcomingEvents, usePastEvents,
    useEvent all decrypt the visible socialEvents rows before joining
    with timeBlocks. useGuestsByEvent + useEventGuests decrypt the
    eventGuests rows.

Phase 8 is the last big sweep. The registry is now ~25 tables on,
~3 left intentionally off (manaLinks because no user content;
boards / boardItems / dreamSymbols partially handled in earlier
phases). The "what's encrypted?" surface should look complete on
the settings/security page.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-07 21:44:18 +02:00
parent 40b7069eb0
commit be611cd1ee
20 changed files with 194 additions and 74 deletions

View file

@ -165,13 +165,16 @@ export function useRecentImages(limit = 6) {
// Reverse-walk the indexed updatedAt column. Generated images have
// updatedAt stamped on creation and rarely move afterwards, so this
// is effectively "newest first" for the dashboard widget's purpose.
return db
const recent = await db
.table<LocalImage>('images')
.orderBy('updatedAt')
.reverse()
.filter((i) => !i.isArchived && !i.deletedAt)
.limit(limit)
.toArray();
// prompt is encrypted on disk; the dashboard widget renders it as
// the alt text + caption, so decrypt the small slice we return.
return decryptRecords('images', recent);
}, [] as LocalImage[]);
}
@ -208,9 +211,13 @@ export function useStorageStats() {
const files = await db.table<LocalFile>('files').toArray();
const active = files.filter((f) => !f.isDeleted && !f.deletedAt);
const totalSize = active.reduce((sum, f) => sum + (f.size || 0), 0);
const recent = active
// The recent-files widget renders the file name, so decrypt
// the small slice we return (not the whole table — totalSize
// only needs the plaintext .size column).
const recentRaw = active
.sort((a, b) => (b.updatedAt ?? '').localeCompare(a.updatedAt ?? ''))
.slice(0, 5);
const recent = await decryptRecords('files', recentRaw);
return { totalFiles: active.length, totalSize, recentFiles: recent };
},
{ totalFiles: 0, totalSize: 0, recentFiles: [] as LocalFile[] }
@ -234,9 +241,13 @@ export function useMusicStats() {
const playlists = await db.table<LocalPlaylist>('mukkePlaylists').toArray();
const activeSongs = songs.filter((s) => !s.deletedAt);
const activePlaylists = playlists.filter((p) => !p.deletedAt);
const recent = activeSongs
// title is encrypted on disk; the dashboard widget renders it
// for the recent-songs list, so decrypt the small slice we
// surface (counts only need plaintext flags).
const recentRaw = activeSongs
.sort((a, b) => (b.updatedAt ?? '').localeCompare(a.updatedAt ?? ''))
.slice(0, 5);
const recent = await decryptRecords('songs', recentRaw);
return {
totalSongs: activeSongs.length,
totalPlaylists: activePlaylists.length,

View file

@ -152,14 +152,35 @@ export const ENCRYPTION_REGISTRY: Record<string, EncryptionConfig> = {
documents: { enabled: true, fields: ['title', 'content'] },
// ─── Storage ─────────────────────────────────────────────
files: { enabled: false, fields: ['name', 'originalName', 'notes'] },
// `name` IS indexed but no .where('name') call site exists in the
// app — encryption is safe, the index just becomes a no-op for
// content lookups (the file browser scans+filters in JS anyway).
// LocalFile has no `notes` column on the schema; the user-typed
// values are name (display name) + originalName (uploaded filename).
// mimeType / size / storagePath / checksum stay plaintext for the
// thumbnail + storage-layer code paths.
files: { enabled: true, fields: ['name', 'originalName'] },
// ─── Picture ─────────────────────────────────────────────
images: { enabled: false, fields: ['prompt', 'negativePrompt', 'revisedPrompt', 'notes'] },
// LocalImage has prompt + negativePrompt as the user-typed text.
// The Phase 1 placeholder also listed `revisedPrompt` and `notes`
// but neither column exists on the schema. `prompt` IS indexed but
// no .where('prompt') call site exists — same as files.name above.
// model / style / format / blurhash stay plaintext (technical
// metadata, not user content).
images: { enabled: true, fields: ['prompt', 'negativePrompt'] },
// ─── Music ───────────────────────────────────────────────
songs: { enabled: false, fields: ['title', 'artist', 'album', 'lyrics', 'notes'] },
mukkePlaylists: { enabled: false, fields: ['name', 'description'] },
// Music metadata is borderline-sensitive: technical ID3 tags vs
// user listening history. Encrypting `title` (which uniquely
// identifies a track) gives meaningful privacy; leaving artist /
// album / albumArtist / genre PLAINTEXT keeps the album+artist
// browsing views fast (they aggregate by those fields and would
// otherwise force a per-song decrypt to render the index).
// `lyrics` / `notes` listed in the Phase 1 placeholder don't
// exist on LocalSong.
songs: { enabled: true, fields: ['title'] },
mukkePlaylists: { enabled: true, fields: ['name', 'description'] },
// ─── Questions ───────────────────────────────────────────
// LocalQuestion uses `title` + `description`; LocalAnswer uses
@ -170,8 +191,16 @@ export const ENCRYPTION_REGISTRY: Record<string, EncryptionConfig> = {
answers: { enabled: true, fields: ['content'] },
// ─── Events (social gatherings) ──────────────────────────
socialEvents: { enabled: false, fields: ['title', 'description', 'notes'] },
eventGuests: { enabled: false, fields: ['name', 'email', 'phone', 'notes'] },
// Distinct from calendar.events — these have guest lists, RSVPs,
// and shareable invitation tokens. None of the encrypted columns
// are indexed (status / timeBlockId / hostContactId carry the
// browsing keys), so the rollout is straightforward. Phase 1
// placeholder listed a `notes` column on socialEvents that doesn't
// exist; the actual user-typed text is title/description/location.
// On eventGuests the user-typed text is name/email/phone/note
// (singular).
socialEvents: { enabled: true, fields: ['title', 'description', 'location'] },
eventGuests: { enabled: true, fields: ['name', 'email', 'phone', 'note'] },
// ─── Finance ─────────────────────────────────────────────
// Transactions are budget-grade PII — amount/date/categoryId stay
@ -188,7 +217,11 @@ export const ENCRYPTION_REGISTRY: Record<string, EncryptionConfig> = {
// metadata (title + description) which is the part the user actually
// expects to be private, and leave the routing primitives alone.
links: { enabled: true, fields: ['title', 'description'] },
manaLinks: { enabled: false, fields: ['label', 'url', 'notes'] },
// NOTE: `manaLinks` is intentionally NOT in the registry. Despite
// the name it's the cross-app link table — pure foreign keys
// (sourceAppId / sourceRecordId / targetAppId / targetRecordId)
// with zero user-typed content. The Phase 1 placeholder listed
// label/url/notes which don't exist on the schema.
// ─── Inventar ────────────────────────────────────────────
// `name` is indexed (used in where()/sortBy queries). `notes` is an

View file

@ -6,6 +6,7 @@
import { useLiveQueryWithDefault } from '@mana/local-store/svelte';
import { db } from '$lib/data/database';
import { decryptRecords } from '$lib/data/crypto';
import { timeBlockTable } from '$lib/data/time-blocks/collections';
import type { LocalTimeBlock } from '$lib/data/time-blocks/types';
import type {
@ -85,7 +86,8 @@ export function toEventGuest(local: LocalEventGuest): EventGuest {
export function useAllEvents() {
return useLiveQueryWithDefault(async () => {
const locals = await db.table<LocalSocialEvent>('socialEvents').toArray();
const active = locals.filter((e) => !e.deletedAt);
const visible = locals.filter((e) => !e.deletedAt);
const active = await decryptRecords('socialEvents', visible);
const blocks = await timeBlockTable.bulkGet(active.map((e) => e.timeBlockId));
return active.map((e, i) => toSocialEvent(e, blocks[i] ?? null));
}, [] as SocialEvent[]);
@ -95,7 +97,8 @@ export function useAllEvents() {
export function useUpcomingEvents() {
return useLiveQueryWithDefault(async () => {
const locals = await db.table<LocalSocialEvent>('socialEvents').toArray();
const active = locals.filter((e) => !e.deletedAt && e.status !== 'cancelled');
const visible = locals.filter((e) => !e.deletedAt && e.status !== 'cancelled');
const active = await decryptRecords('socialEvents', visible);
const blocks = await timeBlockTable.bulkGet(active.map((e) => e.timeBlockId));
const now = Date.now();
return active
@ -109,7 +112,8 @@ export function useUpcomingEvents() {
export function usePastEvents() {
return useLiveQueryWithDefault(async () => {
const locals = await db.table<LocalSocialEvent>('socialEvents').toArray();
const active = locals.filter((e) => !e.deletedAt);
const visible = locals.filter((e) => !e.deletedAt);
const active = await decryptRecords('socialEvents', visible);
const blocks = await timeBlockTable.bulkGet(active.map((e) => e.timeBlockId));
const now = Date.now();
return active
@ -125,8 +129,9 @@ export function useEvent(eventId: () => string) {
async () => {
const id = eventId();
if (!id) return null;
const local = await db.table<LocalSocialEvent>('socialEvents').get(id);
if (!local || local.deletedAt) return null;
const raw = await db.table<LocalSocialEvent>('socialEvents').get(id);
if (!raw || raw.deletedAt) return null;
const [local] = await decryptRecords('socialEvents', [raw]);
const block = await timeBlockTable.get(local.timeBlockId);
return toSocialEvent(local, block ?? null);
},
@ -139,9 +144,10 @@ export function useGuestsByEvent() {
return useLiveQueryWithDefault(
async () => {
const all = await db.table<LocalEventGuest>('eventGuests').toArray();
const visible = all.filter((g) => !g.deletedAt);
const decrypted = await decryptRecords('eventGuests', visible);
const map = new Map<string, EventGuest[]>();
for (const g of all) {
if (g.deletedAt) continue;
for (const g of decrypted) {
const guest = toEventGuest(g);
const arr = map.get(guest.eventId);
if (arr) arr.push(guest);
@ -163,7 +169,9 @@ export function useEventGuests(eventId: () => string) {
.where('eventId')
.equals(id)
.toArray();
return guests.filter((g) => !g.deletedAt).map(toEventGuest);
const visible = guests.filter((g) => !g.deletedAt);
const decrypted = await decryptRecords('eventGuests', visible);
return decrypted.map(toEventGuest);
}, [] as EventGuest[]);
}

View file

@ -8,6 +8,7 @@
import { db } from '$lib/data/database';
import { createBlock, updateBlock, deleteBlock } from '$lib/data/time-blocks/service';
import { timeBlockTable } from '$lib/data/time-blocks/collections';
import { encryptRecord, decryptRecord } from '$lib/data/crypto';
import type { LocalSocialEvent, LocalEventItem, EventStatus } from '../types';
import { eventsApi } from '../api';
import { recordTombstone } from '../tombstones';
@ -68,6 +69,9 @@ export const eventsStore = {
updatedAt: new Date().toISOString(),
};
// title / description / location are encrypted at rest. The
// linked TimeBlock was already encrypted by createBlock above.
await encryptRecord('socialEvents', newLocal);
await db.table<LocalSocialEvent>('socialEvents').add(newLocal);
return { success: true as const, id: eventId };
} catch (e) {
@ -121,6 +125,7 @@ export const eventsStore = {
if (input.status !== undefined) localData.status = input.status;
if (input.coverImage !== undefined) localData.coverImage = input.coverImage;
await encryptRecord('socialEvents', localData);
await db.table('socialEvents').update(id, localData);
// Fire-and-forget snapshot sync if this event is published
void this.syncSnapshotIfPublished(id);
@ -167,11 +172,17 @@ export const eventsStore = {
async publishEvent(id: string) {
error = null;
try {
const event = await db.table<LocalSocialEvent>('socialEvents').get(id);
if (!event) return { success: false as const, error: 'Event not found' };
const block = await timeBlockTable.get(event.timeBlockId);
const rawEvent = await db.table<LocalSocialEvent>('socialEvents').get(id);
if (!rawEvent) return { success: false as const, error: 'Event not found' };
const block = await timeBlockTable.get(rawEvent.timeBlockId);
if (!block) return { success: false as const, error: 'TimeBlock missing for event' };
// Decrypt before pushing to the server snapshot — the public
// RSVP page renders these fields, so the server needs the
// plaintext. By design, publishing intentionally trades local
// confidentiality for the linkable public page.
const event = await decryptRecord('socialEvents', { ...rawEvent });
const { token } = await eventsApi.publish({
eventId: id,
title: event.title,
@ -236,10 +247,13 @@ export const eventsStore = {
*/
async syncSnapshotIfPublished(id: string) {
try {
const event = await db.table<LocalSocialEvent>('socialEvents').get(id);
if (!event || !event.isPublished) return;
const block = await timeBlockTable.get(event.timeBlockId);
const rawEvent = await db.table<LocalSocialEvent>('socialEvents').get(id);
if (!rawEvent || !rawEvent.isPublished) return;
const block = await timeBlockTable.get(rawEvent.timeBlockId);
if (!block) return;
// Same plaintext-snapshot dance as publishEvent — the public
// page would otherwise render ciphertext blobs.
const event = await decryptRecord('socialEvents', { ...rawEvent });
await eventsApi.updateSnapshot(id, {
eventId: id,
title: event.title,

View file

@ -3,6 +3,7 @@
*/
import { db } from '$lib/data/database';
import { encryptRecord } from '$lib/data/crypto';
import type { LocalEventGuest, RsvpStatus } from '../types';
let error = $state<string | null>(null);
@ -39,6 +40,10 @@ export const eventGuestsStore = {
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
// name / email / phone / note are encrypted at rest. Guest
// records stay local-only — they're never pushed to the
// public RSVP snapshot, so no decrypt-before-publish here.
await encryptRecord('eventGuests', newGuest);
await db.table<LocalEventGuest>('eventGuests').add(newGuest);
return { success: true as const, id };
} catch (e) {
@ -68,6 +73,7 @@ export const eventGuestsStore = {
if (input.rsvpStatus !== undefined) {
data.rsvpAt = new Date().toISOString();
}
await encryptRecord('eventGuests', data);
await db.table('eventGuests').update(id, data);
return { success: true as const };
} catch (e) {

View file

@ -5,6 +5,7 @@
<script lang="ts">
import { liveQuery } from 'dexie';
import { db } from '$lib/data/database';
import { decryptRecords } from '$lib/data/crypto';
import type { LocalSong, LocalPlaylist } from './types';
import type { ViewProps } from '$lib/app-registry';
@ -15,10 +16,9 @@
$effect(() => {
const sub = liveQuery(async () => {
return db
.table<LocalSong>('songs')
.toArray()
.then((all) => all.filter((s) => !s.deletedAt));
const all = await db.table<LocalSong>('songs').toArray();
const visible = all.filter((s) => !s.deletedAt);
return decryptRecords('songs', visible);
}).subscribe((val) => {
songs = val ?? [];
});

View file

@ -4,6 +4,7 @@
import { liveQuery } from 'dexie';
import { db } from '$lib/data/database';
import { decryptRecords } from '$lib/data/crypto';
import type {
LocalSong,
LocalPlaylist,
@ -71,10 +72,10 @@ export function toProject(local: LocalProject): Project {
export function useAllSongs() {
return liveQuery(async () => {
const locals = await db.table<LocalSong>('songs').toArray();
return locals
.filter((s) => !s.deletedAt)
.map(toSong)
.sort((a, b) => a.title.localeCompare(b.title));
const visible = locals.filter((s) => !s.deletedAt);
// title is encrypted on disk; sort needs the plaintext value.
const decrypted = await decryptRecords('songs', visible);
return decrypted.map(toSong).sort((a, b) => a.title.localeCompare(b.title));
});
}
@ -82,10 +83,9 @@ export function useAllSongs() {
export function useAllPlaylists() {
return liveQuery(async () => {
const locals = await db.table<LocalPlaylist>('mukkePlaylists').toArray();
return locals
.filter((p) => !p.deletedAt)
.map(toPlaylist)
.sort((a, b) => a.name.localeCompare(b.name));
const visible = locals.filter((p) => !p.deletedAt);
const decrypted = await decryptRecords('mukkePlaylists', visible);
return decrypted.map(toPlaylist).sort((a, b) => a.name.localeCompare(b.name));
});
}

View file

@ -6,6 +6,7 @@
*/
import { songTable } from '../collections';
import { encryptRecord } from '$lib/data/crypto';
import { MusicEvents } from '@mana/shared-utils/analytics';
import type { LocalSong } from '../types';
@ -46,10 +47,12 @@ export const libraryStore = {
>
>
) {
await songTable.update(id, {
const diff: Record<string, unknown> = {
...data,
updatedAt: new Date().toISOString(),
});
};
await encryptRecord('songs', diff);
await songTable.update(id, diff);
},
/** Soft-delete a song. */
@ -61,6 +64,7 @@ export const libraryStore = {
/** Insert a song (e.g., after upload). */
async insert(song: LocalSong) {
await encryptRecord('songs', song);
await songTable.add(song);
},
};

View file

@ -8,6 +8,7 @@
import { db } from '$lib/data/database';
import { musicPlaylistTable, playlistSongTable } from '../collections';
import { toPlaylist } from '../queries';
import { encryptRecord } from '$lib/data/crypto';
import { MusicEvents } from '@mana/shared-utils/analytics';
import type { LocalPlaylist, LocalPlaylistSong } from '../types';
@ -20,17 +21,23 @@ export const playlistsStore = {
description: description ?? null,
coverArtPath: null,
};
// Snapshot the plaintext for the return value before encryptRecord
// mutates `newLocal` in place — UI consumers expect plaintext.
const plaintextSnapshot = toPlaylist({ ...newLocal });
await encryptRecord('mukkePlaylists', newLocal);
await musicPlaylistTable.add(newLocal);
MusicEvents.playlistCreated();
return toPlaylist(newLocal);
return plaintextSnapshot;
},
/** Update a playlist. */
async update(id: string, data: Partial<Pick<LocalPlaylist, 'name' | 'description'>>) {
await musicPlaylistTable.update(id, {
const diff: Record<string, unknown> = {
...data,
updatedAt: new Date().toISOString(),
});
};
await encryptRecord('mukkePlaylists', diff);
await musicPlaylistTable.update(id, diff);
},
/** Soft-delete a playlist and its song associations atomically. */

View file

@ -5,6 +5,7 @@
<script lang="ts">
import { liveQuery } from 'dexie';
import { db } from '$lib/data/database';
import { decryptRecord } from '$lib/data/crypto';
import { libraryStore } from '../stores/library.svelte';
import { toastStore } from '@mana/shared-ui/toast';
import { Heart, Trash } from '@mana/shared-icons';
@ -34,7 +35,12 @@
});
$effect(() => {
const sub = liveQuery(() => db.table<LocalSong>('songs').get(songId)).subscribe((val) => {
const sub = liveQuery(async () => {
const raw = await db.table<LocalSong>('songs').get(songId);
// title is encrypted on disk; decrypt a clone so the inline
// editor binds to plaintext.
return raw ? await decryptRecord('songs', { ...raw }) : null;
}).subscribe((val) => {
song = val ?? null;
if (val && !focused) {
editTitle = val.title;

View file

@ -5,6 +5,7 @@
<script lang="ts">
import { liveQuery } from 'dexie';
import { db } from '$lib/data/database';
import { decryptRecords } from '$lib/data/crypto';
import type { LocalImage } from './types';
let images = $state<LocalImage[]>([]);
@ -12,7 +13,8 @@
$effect(() => {
const sub = liveQuery(async () => {
const all = await db.table<LocalImage>('images').toArray();
return all.filter((i) => !i.deletedAt && !i.isArchived);
const visible = all.filter((i) => !i.deletedAt && !i.isArchived);
return decryptRecords('images', visible);
}).subscribe((val) => {
images = val ?? [];
});

View file

@ -9,6 +9,7 @@
import { liveQuery } from 'dexie';
import { useLiveQueryWithDefault } from '@mana/local-store/svelte';
import { db } from '$lib/data/database';
import { decryptRecords } from '$lib/data/crypto';
import type {
LocalImage,
LocalBoard,
@ -69,8 +70,9 @@ export function toBoard(local: LocalBoard): Board {
export function useAllImages() {
return useLiveQueryWithDefault(async () => {
const locals = await db.table<LocalImage>('images').toArray();
return locals
.filter((img) => !img.isArchived && !img.deletedAt)
const visible = locals.filter((img) => !img.isArchived && !img.deletedAt);
const decrypted = await decryptRecords('images', visible);
return decrypted
.map(toImage)
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
}, [] as Image[]);
@ -80,8 +82,9 @@ export function useAllImages() {
export function useArchivedImages() {
return useLiveQueryWithDefault(async () => {
const locals = await db.table<LocalImage>('images').toArray();
return locals
.filter((img) => !!img.isArchived && !img.deletedAt)
const visible = locals.filter((img) => !!img.isArchived && !img.deletedAt);
const decrypted = await decryptRecords('images', visible);
return decrypted
.map(toImage)
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
}, [] as Image[]);
@ -127,8 +130,9 @@ export function useAllImageTags() {
export function allImages$() {
return liveQuery(async () => {
const locals = await db.table<LocalImage>('images').toArray();
return locals
.filter((img) => !img.isArchived && !img.deletedAt)
const visible = locals.filter((img) => !img.isArchived && !img.deletedAt);
const decrypted = await decryptRecords('images', visible);
return decrypted
.map(toImage)
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
});

View file

@ -5,6 +5,7 @@
<script lang="ts">
import { liveQuery } from 'dexie';
import { db } from '$lib/data/database';
import { decryptRecords } from '$lib/data/crypto';
import type { LocalFile, LocalFolder } from './types';
import type { ViewProps } from '$lib/app-registry';
@ -15,10 +16,9 @@
$effect(() => {
const sub = liveQuery(async () => {
return db
.table<LocalFile>('files')
.toArray()
.then((all) => all.filter((f) => !f.deletedAt && !f.isDeleted));
const all = await db.table<LocalFile>('files').toArray();
const visible = all.filter((f) => !f.deletedAt && !f.isDeleted);
return decryptRecords('files', visible);
}).subscribe((val) => {
files = val ?? [];
});

View file

@ -6,6 +6,7 @@
import { liveQuery } from 'dexie';
import { db } from '$lib/data/database';
import { decryptRecords } from '$lib/data/crypto';
import type { LocalFile, LocalFolder, LocalFileTag } from './types';
// ─── Shared Types (inline to avoid @storage/shared dependency) ───
@ -107,10 +108,10 @@ export function toTag(local: {
export function useAllFiles() {
return liveQuery(async () => {
const locals = await db.table<LocalFile>('files').toArray();
return locals
.filter((f) => !f.isDeleted && !f.deletedAt)
.map(toFile)
.sort((a, b) => a.name.localeCompare(b.name));
const visible = locals.filter((f) => !f.isDeleted && !f.deletedAt);
// name + originalName are encrypted on disk; sort needs plaintext.
const decrypted = await decryptRecords('files', visible);
return decrypted.map(toFile).sort((a, b) => a.name.localeCompare(b.name));
});
}

View file

@ -10,6 +10,7 @@ import { fileTable, storageFolderTable } from '../collections';
import { toFile, toFolder } from '../queries';
import type { StorageFile, StorageFolder } from '../queries';
import type { LocalFile, LocalFolder } from '../types';
import { encryptRecord } from '$lib/data/crypto';
import { StorageEvents } from '@mana/shared-utils/analytics';
let viewMode = $state<'grid' | 'list'>('grid');
@ -114,10 +115,12 @@ export const filesStore = {
},
async renameFile(id: string, name: string) {
await fileTable.update(id, {
const diff: Record<string, unknown> = {
name,
updatedAt: new Date().toISOString(),
});
};
await encryptRecord('files', diff);
await fileTable.update(id, diff);
},
async renameFolder(id: string, name: string) {

View file

@ -5,6 +5,7 @@
<script lang="ts">
import { liveQuery } from 'dexie';
import { db } from '$lib/data/database';
import { decryptRecord } from '$lib/data/crypto';
import { filesStore } from '../stores/files.svelte';
import { toastStore } from '@mana/shared-ui/toast';
import { Heart, Trash } from '@mana/shared-icons';
@ -29,7 +30,12 @@
});
$effect(() => {
const sub = liveQuery(() => db.table<LocalFile>('files').get(fileId)).subscribe((val) => {
const sub = liveQuery(async () => {
const raw = await db.table<LocalFile>('files').get(fileId);
// name + originalName are encrypted on disk; decrypt a clone
// so the rename input binds to plaintext.
return raw ? await decryptRecord('files', { ...raw }) : null;
}).subscribe((val) => {
file = val ?? null;
if (val && !focused) {
editName = val.name;

View file

@ -1,4 +1,5 @@
import { db } from '$lib/data/database';
import { decryptRecords } from '$lib/data/crypto';
import { getManaApp } from '@mana/shared-branding';
import { scoreRecord, truncateSubtitle } from '../scoring';
import type { SearchProvider, SearchResult, SearchOptions } from '../types';
@ -16,10 +17,12 @@ export const musicSearchProvider: SearchProvider = {
const limit = options?.limit ?? 5;
const results: SearchResult[] = [];
// Search songs
const songs = await db.table('songs').toArray();
// Search songs. title is encrypted at rest; the scorer needs
// plaintext to do substring matching against the user query.
const rawSongs = await db.table('songs').toArray();
const visibleSongs = rawSongs.filter((s) => !s.deletedAt);
const songs = await decryptRecords('songs', visibleSongs);
for (const song of songs) {
if (song.deletedAt) continue;
const { score, matchedField } = scoreRecord(
[
{ name: 'title', value: song.title, weight: 1.0 },
@ -45,10 +48,12 @@ export const musicSearchProvider: SearchProvider = {
}
}
// Search playlists (Dexie table name kept for backward compat)
const playlists = await db.table('mukkePlaylists').toArray();
// Search playlists (Dexie table name kept for backward compat).
// name + description are encrypted at rest.
const rawPlaylists = await db.table('mukkePlaylists').toArray();
const visiblePlaylists = rawPlaylists.filter((p) => !p.deletedAt);
const playlists = await decryptRecords('mukkePlaylists', visiblePlaylists);
for (const pl of playlists) {
if (pl.deletedAt) continue;
const { score, matchedField } = scoreRecord(
[
{ name: 'name', value: pl.name, weight: 1.0 },

View file

@ -1,4 +1,5 @@
import { db } from '$lib/data/database';
import { decryptRecords } from '$lib/data/crypto';
import { getManaApp } from '@mana/shared-branding';
import { scoreRecord, truncateSubtitle } from '../scoring';
import type { SearchProvider, SearchResult, SearchOptions } from '../types';
@ -16,10 +17,12 @@ export const pictureSearchProvider: SearchProvider = {
const limit = options?.limit ?? 5;
const results: SearchResult[] = [];
// Search images by prompt
const images = await db.table('images').toArray();
// Search images by prompt. prompt + negativePrompt are encrypted
// at rest; the scorer needs plaintext to do substring matching.
const rawImages = await db.table('images').toArray();
const visibleImages = rawImages.filter((i) => !i.deletedAt);
const images = await decryptRecords('images', visibleImages);
for (const image of images) {
if (image.deletedAt) continue;
const { score, matchedField } = scoreRecord(
[
{ name: 'prompt', value: image.prompt, weight: 1.0 },

View file

@ -1,4 +1,5 @@
import { db } from '$lib/data/database';
import { decryptRecords } from '$lib/data/crypto';
import { getManaApp } from '@mana/shared-branding';
import { scoreRecord } from '../scoring';
import type { SearchProvider, SearchResult, SearchOptions } from '../types';
@ -16,10 +17,12 @@ export const storageSearchProvider: SearchProvider = {
const limit = options?.limit ?? 5;
const results: SearchResult[] = [];
// Search files
const files = await db.table('files').toArray();
// Search files. name + originalName are encrypted at rest; the
// scorer needs plaintext to do substring matching.
const rawFiles = await db.table('files').toArray();
const visibleFiles = rawFiles.filter((f) => !f.deletedAt && !f.isDeleted);
const files = await decryptRecords('files', visibleFiles);
for (const file of files) {
if (file.deletedAt || file.isDeleted) continue;
const { score, matchedField } = scoreRecord(
[
{ name: 'name', value: file.name, weight: 1.0 },

View file

@ -5,11 +5,15 @@
import type { LocalFile, LocalFolder } from '$lib/modules/storage/types';
import { liveQuery } from 'dexie';
import { db } from '$lib/data/database';
import { decryptRecords } from '$lib/data/crypto';
// Live query for deleted items (not permanently deleted)
// Live query for deleted items (not permanently deleted). file.name
// is encrypted on disk, so decrypt the visible set before the trash
// list renders.
const deletedFiles = liveQuery(async () => {
const all = await db.table<LocalFile>('files').toArray();
return all.filter((f) => f.isDeleted && !f.deletedAt);
const visible = all.filter((f) => f.isDeleted && !f.deletedAt);
return decryptRecords('files', visible);
});
const deletedFolders = liveQuery(async () => {