mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 22:41:09 +02:00
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:
parent
40b7069eb0
commit
be611cd1ee
20 changed files with 194 additions and 74 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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[]);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 ?? [];
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 ?? [];
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 ?? [];
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue