diff --git a/package.json b/package.json index 5b305e0..e94f1e0 100644 --- a/package.json +++ b/package.json @@ -55,6 +55,7 @@ "idb": "^8.0.3", "lucide-svelte": "^0.577.0", "markdown-it": "^14.1.1", + "nanoid": "^5.1.7", "node-diff3": "^3.2.0", "nodemailer": "^8.0.2", "svelte-dnd-action": "^0.9.69", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c97d4ee..a7f4735 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -80,6 +80,9 @@ importers: markdown-it: specifier: ^14.1.1 version: 14.1.1 + nanoid: + specifier: ^5.1.7 + version: 5.1.7 node-diff3: specifier: ^3.2.0 version: 3.2.0 @@ -2998,6 +3001,11 @@ packages: engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true + nanoid@5.1.7: + resolution: {integrity: sha512-ua3NDgISf6jdwezAheMOk4mbE1LXjm1DfMUDMuJf4AqxLFK3ccGpgWizwa5YV7Yz9EpXwEaWoRXSb/BnV0t5dQ==} + engines: {node: ^18 || >=20} + hasBin: true + napi-build-utils@2.0.0: resolution: {integrity: sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==} @@ -6783,6 +6791,8 @@ snapshots: nanoid@3.3.11: {} + nanoid@5.1.7: {} + napi-build-utils@2.0.0: {} negotiator@1.0.0: {} diff --git a/src/lib/server/notes-service.ts b/src/lib/server/notes-service.ts index 260c113..b610c57 100644 --- a/src/lib/server/notes-service.ts +++ b/src/lib/server/notes-service.ts @@ -6,6 +6,7 @@ import { extractTags } from '$lib/utils/tags.js'; import { fetchTagsForNotes, syncNoteTags } from './tags.js'; import { fetchAttachmentsForNotes } from './attachments.js'; import { fetchCollaboratorsForNotes } from './collaborators.js'; +import { fetchSharesForNotes } from './shares-service.js'; import { canAccessNote } from './api-utils.js'; import { createSnapshot } from './versions-service.js'; import { mergeContent } from '$lib/utils/content-merge.js'; @@ -33,6 +34,7 @@ function hydrateNotes(db: Db, noteRows: NoteRow[], userId: number) { const tagMap = fetchTagsForNotes(db, noteIds); const attachmentMap = fetchAttachmentsForNotes(db, noteIds); const collabMap = fetchCollaboratorsForNotes(db, noteIds); + const shareMap = fetchSharesForNotes(db, noteIds); return noteRows.map((note) => { const collaborators = collabMap.get(note.id) ?? []; @@ -44,7 +46,8 @@ function hydrateNotes(db: Db, noteRows: NoteRow[], userId: number) { attachments: attachmentMap.get(note.id) ?? [], collaborators, isOwner, - isShared + isShared, + shareToken: shareMap.get(note.id) ?? undefined }; }); } diff --git a/src/lib/server/shares-service.ts b/src/lib/server/shares-service.ts new file mode 100644 index 0000000..6b81182 --- /dev/null +++ b/src/lib/server/shares-service.ts @@ -0,0 +1,108 @@ +import type { Db } from './db/index.js'; +import { sharedNotes, notes, attachments } from './db/schema.js'; +import { eq, and, inArray } from 'drizzle-orm'; +import { nanoid } from 'nanoid'; +import { requireNoteOwnership } from './api-utils.js'; +import type { SharedNoteData, Attachment } from '$lib/types/index.js'; + +/** + * Batch fetch share tokens for multiple notes. + * Returns a Map of noteId → token. + */ +export function fetchSharesForNotes(db: Db, noteIds: string[]): Map { + if (noteIds.length === 0) return new Map(); + + const rows = db + .select({ noteId: sharedNotes.noteId, token: sharedNotes.token }) + .from(sharedNotes) + .where(inArray(sharedNotes.noteId, noteIds)) + .all(); + + const map = new Map(); + for (const row of rows) { + map.set(row.noteId, row.token); + } + return map; +} + +/** + * Create a public share link for a note. Idempotent — returns existing token if already shared. + */ +export function createShare(db: Db, noteId: string, userId: number): { token: string } { + requireNoteOwnership(db, noteId, userId); + + const existing = db + .select({ token: sharedNotes.token }) + .from(sharedNotes) + .where(eq(sharedNotes.noteId, noteId)) + .get(); + + if (existing) return { token: existing.token }; + + const token = nanoid(21); + db.insert(sharedNotes) + .values({ noteId, token, createdAt: new Date() }) + .run(); + + return { token }; +} + +/** + * Revoke a public share link for a note. + */ +export function revokeShare(db: Db, noteId: string, userId: number): void { + requireNoteOwnership(db, noteId, userId); + + db.delete(sharedNotes) + .where(eq(sharedNotes.noteId, noteId)) + .run(); +} + +/** + * Get share info for a specific note. + */ +export function getShareByNoteId(db: Db, noteId: string) { + return db + .select() + .from(sharedNotes) + .where(eq(sharedNotes.noteId, noteId)) + .get() ?? null; +} + +/** + * Get a shared note by its public token. Returns null if note is trashed or token is invalid. + */ +export function getSharedNote(db: Db, token: string): (SharedNoteData & { noteId: string }) | null { + const share = db + .select() + .from(sharedNotes) + .where(eq(sharedNotes.token, token)) + .get(); + + if (!share) return null; + + const note = db + .select() + .from(notes) + .where(and(eq(notes.id, share.noteId), eq(notes.trashed, false))) + .get(); + + if (!note) return null; + + const noteAttachments = db + .select() + .from(attachments) + .where(eq(attachments.noteId, share.noteId)) + .all() as Attachment[]; + + return { + noteId: note.id, + title: note.title, + content: note.content, + checklistMode: note.checklistMode, + color: note.color as SharedNoteData['color'], + attachments: noteAttachments, + createdAt: note.createdAt, + updatedAt: note.updatedAt + }; +}