From c9e041bffe2ece4d6b7ff227a8646013c7848303 Mon Sep 17 00:00:00 2001 From: Frederic Cabassut Date: Sun, 8 Mar 2026 21:29:49 +0100 Subject: [PATCH] refactor(api): scope all queries by authenticated user Add getUserId/requireAdmin helpers in api-utils.ts. All notes, tags, search, sync, and attachment routes now filter by userId to ensure complete data isolation between users. Sync server accepts userId parameter for push/pull operations. Co-Authored-By: Claude Opus 4.6 --- src/lib/server/api-utils.ts | 12 ++++++ src/lib/server/attachments.ts | 2 + src/lib/sync/server.ts | 20 +++++----- src/routes/api/notes/+server.ts | 14 ++++--- src/routes/api/notes/[id]/+server.ts | 36 +++++++++++++----- .../api/notes/[id]/attachments/+server.ts | 37 ++++++++++++++++--- src/routes/api/search/+server.ts | 9 +++-- src/routes/api/sync/+server.ts | 11 ++++-- src/routes/api/tags/+server.ts | 7 +++- 9 files changed, 110 insertions(+), 38 deletions(-) create mode 100644 src/lib/server/api-utils.ts diff --git a/src/lib/server/api-utils.ts b/src/lib/server/api-utils.ts new file mode 100644 index 0000000..6950d5f --- /dev/null +++ b/src/lib/server/api-utils.ts @@ -0,0 +1,12 @@ +import { error, type RequestEvent } from '@sveltejs/kit'; + +export function getUserId(event: RequestEvent): number { + if (!event.locals.user) throw error(401, 'Unauthorized'); + return event.locals.user.id; +} + +export function requireAdmin(event: RequestEvent) { + const user = event.locals.user; + if (!user || user.role !== 'admin') throw error(403, 'Forbidden'); + return user; +} diff --git a/src/lib/server/attachments.ts b/src/lib/server/attachments.ts index 1b1a288..e63549b 100644 --- a/src/lib/server/attachments.ts +++ b/src/lib/server/attachments.ts @@ -18,6 +18,7 @@ if (!existsSync(ATTACHMENTS_DIR)) { export async function saveAttachment( noteId: string, file: File, + userId: number, thumbnail?: File | Blob | null ): Promise { const id = uuidv4(); @@ -40,6 +41,7 @@ export async function saveAttachment( .insert(attachments) .values({ id, + userId, noteId, filename: file.name, mimeType: file.type, diff --git a/src/lib/sync/server.ts b/src/lib/sync/server.ts index 1518b3e..479bb91 100644 --- a/src/lib/sync/server.ts +++ b/src/lib/sync/server.ts @@ -1,6 +1,6 @@ import { db } from '$lib/server/db/index.js'; import { notes, syncLog } from '$lib/server/db/schema.js'; -import { eq, gt } from 'drizzle-orm'; +import { eq, gt, and } from 'drizzle-orm'; import { extractTags } from '$lib/utils/tags.js'; import { syncNoteTags } from '$lib/server/tags.js'; import type { SyncQueueItem } from './idb.js'; @@ -8,7 +8,7 @@ import type { SyncQueueItem } from './idb.js'; /** * Process incoming sync changes from client. */ -export async function processSyncPush(changes: SyncQueueItem[]): Promise { +export async function processSyncPush(changes: SyncQueueItem[], userId: number): Promise { const noteIdsToSyncTags: string[] = []; db.transaction((tx) => { @@ -20,7 +20,7 @@ export async function processSyncPush(changes: SyncQueueItem[]): Promise { const existing = tx .select() .from(notes) - .where(eq(notes.id, change.noteId)) + .where(and(eq(notes.id, change.noteId), eq(notes.userId, userId))) .get(); if (existing) { @@ -31,13 +31,14 @@ export async function processSyncPush(changes: SyncQueueItem[]): Promise { updatedAt: new Date(change.timestamp), version: existing.version + 1 }) - .where(eq(notes.id, change.noteId)) + .where(and(eq(notes.id, change.noteId), eq(notes.userId, userId))) .run(); } } else if (change.operation === 'create' && change.data) { tx.insert(notes) .values({ id: change.noteId, + userId, title: change.data.title || '', content: change.data.content || '', color: change.data.color || 'default', @@ -59,13 +60,14 @@ export async function processSyncPush(changes: SyncQueueItem[]): Promise { break; } case 'delete': { - tx.delete(notes).where(eq(notes.id, change.noteId)).run(); + tx.delete(notes).where(and(eq(notes.id, change.noteId), eq(notes.userId, userId))).run(); break; } } tx.insert(syncLog) .values({ + userId, noteId: change.noteId, operation: change.operation, timestamp: new Date(change.timestamp), @@ -76,7 +78,7 @@ export async function processSyncPush(changes: SyncQueueItem[]): Promise { }); for (const noteId of noteIdsToSyncTags) { - const note = db.select().from(notes).where(eq(notes.id, noteId)).get(); + const note = db.select().from(notes).where(and(eq(notes.id, noteId), eq(notes.userId, userId))).get(); if (note) { const content = `${note.title} ${note.content}`; syncNoteTags(noteId, extractTags(content)); @@ -85,12 +87,12 @@ export async function processSyncPush(changes: SyncQueueItem[]): Promise { } /** - * Get all notes updated since a given timestamp. + * Get all notes updated since a given timestamp for a specific user. */ -export async function getChangesSince(sinceTimestamp: number) { +export async function getChangesSince(sinceTimestamp: number, userId: number) { return db .select() .from(notes) - .where(gt(notes.updatedAt, new Date(sinceTimestamp))) + .where(and(eq(notes.userId, userId), gt(notes.updatedAt, new Date(sinceTimestamp)))) .all(); } diff --git a/src/routes/api/notes/+server.ts b/src/routes/api/notes/+server.ts index d8d33c0..c02ebad 100644 --- a/src/routes/api/notes/+server.ts +++ b/src/routes/api/notes/+server.ts @@ -7,20 +7,22 @@ import { v4 as uuidv4 } from 'uuid'; import { extractTags } from '$lib/utils/tags.js'; import { fetchTagsForNotes, syncNoteTags } from '$lib/server/tags.js'; import { fetchAttachmentsForNotes } from '$lib/server/attachments.js'; +import { getUserId } from '$lib/server/api-utils.js'; -export const GET: RequestHandler = async ({ url }) => { +export const GET: RequestHandler = async ({ url, ...event }) => { + const userId = getUserId(event); const filter = url.searchParams.get('filter') || 'all'; let conditions; switch (filter) { case 'archived': - conditions = and(eq(notes.archived, true), eq(notes.trashed, false)); + conditions = and(eq(notes.userId, userId), eq(notes.archived, true), eq(notes.trashed, false)); break; case 'trashed': - conditions = eq(notes.trashed, true); + conditions = and(eq(notes.userId, userId), eq(notes.trashed, true)); break; default: - conditions = and(eq(notes.archived, false), eq(notes.trashed, false)); + conditions = and(eq(notes.userId, userId), eq(notes.archived, false), eq(notes.trashed, false)); } const result = db @@ -42,13 +44,15 @@ export const GET: RequestHandler = async ({ url }) => { return json(notesWithTags); }; -export const POST: RequestHandler = async ({ request }) => { +export const POST: RequestHandler = async ({ request, ...event }) => { + const userId = getUserId(event); const body = await request.json(); const now = new Date(); const id = body.id || uuidv4(); const newNote = { id, + userId, title: body.title || '', content: body.content || '', color: body.color || 'default', diff --git a/src/routes/api/notes/[id]/+server.ts b/src/routes/api/notes/[id]/+server.ts index 9e02326..facd0a5 100644 --- a/src/routes/api/notes/[id]/+server.ts +++ b/src/routes/api/notes/[id]/+server.ts @@ -2,13 +2,19 @@ import { json, error } from '@sveltejs/kit'; import type { RequestHandler } from './$types.js'; import { db } from '$lib/server/db/index.js'; import { notes } from '$lib/server/db/schema.js'; -import { eq } from 'drizzle-orm'; +import { eq, and } from 'drizzle-orm'; import { extractTags } from '$lib/utils/tags.js'; import { fetchTagsForNotes, syncNoteTags } from '$lib/server/tags.js'; import { fetchAttachmentsForNotes } from '$lib/server/attachments.js'; +import { getUserId } from '$lib/server/api-utils.js'; -export const GET: RequestHandler = async ({ params }) => { - const note = db.select().from(notes).where(eq(notes.id, params.id)).get(); +export const GET: RequestHandler = async ({ params, ...event }) => { + const userId = getUserId(event); + const note = db + .select() + .from(notes) + .where(and(eq(notes.id, params.id), eq(notes.userId, userId))) + .get(); if (!note) { throw error(404, 'Note not found'); @@ -24,9 +30,14 @@ export const GET: RequestHandler = async ({ params }) => { }); }; -export const PATCH: RequestHandler = async ({ params, request }) => { +export const PATCH: RequestHandler = async ({ params, request, ...event }) => { + const userId = getUserId(event); const body = await request.json(); - const existing = db.select().from(notes).where(eq(notes.id, params.id)).get(); + const existing = db + .select() + .from(notes) + .where(and(eq(notes.id, params.id), eq(notes.userId, userId))) + .get(); if (!existing) { throw error(404, 'Note not found'); @@ -50,9 +61,9 @@ export const PATCH: RequestHandler = async ({ params, request }) => { if (body.checklistMode !== undefined) updates.checklistMode = body.checklistMode; if (body.sortOrder !== undefined) updates.sortOrder = body.sortOrder; - db.update(notes).set(updates).where(eq(notes.id, params.id)).run(); + db.update(notes).set(updates).where(and(eq(notes.id, params.id), eq(notes.userId, userId))).run(); - const updated = db.select().from(notes).where(eq(notes.id, params.id)).get(); + const updated = db.select().from(notes).where(and(eq(notes.id, params.id), eq(notes.userId, userId))).get(); if (body.title !== undefined || body.content !== undefined) { const content = `${updated!.title} ${updated!.content}`; @@ -70,13 +81,18 @@ export const PATCH: RequestHandler = async ({ params, request }) => { }); }; -export const DELETE: RequestHandler = async ({ params }) => { - const existing = db.select().from(notes).where(eq(notes.id, params.id)).get(); +export const DELETE: RequestHandler = async ({ params, ...event }) => { + const userId = getUserId(event); + const existing = db + .select() + .from(notes) + .where(and(eq(notes.id, params.id), eq(notes.userId, userId))) + .get(); if (!existing) { throw error(404, 'Note not found'); } - db.delete(notes).where(eq(notes.id, params.id)).run(); + db.delete(notes).where(and(eq(notes.id, params.id), eq(notes.userId, userId))).run(); return json({ success: true }); }; diff --git a/src/routes/api/notes/[id]/attachments/+server.ts b/src/routes/api/notes/[id]/attachments/+server.ts index 3aa01bb..0a3c695 100644 --- a/src/routes/api/notes/[id]/attachments/+server.ts +++ b/src/routes/api/notes/[id]/attachments/+server.ts @@ -2,8 +2,26 @@ import { json, error } from '@sveltejs/kit'; import type { RequestHandler } from './$types.js'; import { saveAttachment, getAttachmentsByNote, getAttachment, deleteAttachment, updateAttachment } from '$lib/server/attachments.js'; import { readFile } from 'fs/promises'; +import { db } from '$lib/server/db/index.js'; +import { notes } from '$lib/server/db/schema.js'; +import { eq, and } from 'drizzle-orm'; +import { getUserId } from '$lib/server/api-utils.js'; + +/** Verify the note belongs to the authenticated user */ +async function verifyNoteOwnership(noteId: string, userId: number) { + const note = await db + .select() + .from(notes) + .where(and(eq(notes.id, noteId), eq(notes.userId, userId))) + .get(); + if (!note) throw error(404, 'Note not found'); + return note; +} + +export const GET: RequestHandler = async ({ params, url, ...event }) => { + const userId = getUserId(event); + await verifyNoteOwnership(params.id, userId); -export const GET: RequestHandler = async ({ params, url }) => { const attachmentId = url.searchParams.get('attachmentId'); if (attachmentId) { @@ -28,7 +46,10 @@ export const GET: RequestHandler = async ({ params, url }) => { return json(list); }; -export const POST: RequestHandler = async ({ params, request }) => { +export const POST: RequestHandler = async ({ params, request, ...event }) => { + const userId = getUserId(event); + await verifyNoteOwnership(params.id, userId); + const formData = await request.formData(); const file = formData.get('file') as File; @@ -46,11 +67,14 @@ export const POST: RequestHandler = async ({ params, request }) => { } const thumbnail = formData.get('thumbnail') as File | null; - const attachment = await saveAttachment(params.id, file, thumbnail); + const attachment = await saveAttachment(params.id, file, userId, thumbnail); return json(attachment, { status: 201 }); }; -export const PATCH: RequestHandler = async ({ url, request }) => { +export const PATCH: RequestHandler = async ({ params, url, request, ...event }) => { + const userId = getUserId(event); + await verifyNoteOwnership(params.id, userId); + const attachmentId = url.searchParams.get('attachmentId'); if (!attachmentId) throw error(400, 'attachmentId required'); @@ -63,7 +87,10 @@ export const PATCH: RequestHandler = async ({ url, request }) => { return json(updated); }; -export const DELETE: RequestHandler = async ({ url }) => { +export const DELETE: RequestHandler = async ({ params, url, ...event }) => { + const userId = getUserId(event); + await verifyNoteOwnership(params.id, userId); + const attachmentId = url.searchParams.get('attachmentId'); if (!attachmentId) throw error(400, 'attachmentId required'); diff --git a/src/routes/api/search/+server.ts b/src/routes/api/search/+server.ts index 7d7083e..419888d 100644 --- a/src/routes/api/search/+server.ts +++ b/src/routes/api/search/+server.ts @@ -4,8 +4,10 @@ import { db } from '$lib/server/db/index.js'; import { notes, noteTags, tags } from '$lib/server/db/schema.js'; import { and, eq, like, or, inArray } from 'drizzle-orm'; import { fetchTagsForNotes } from '$lib/server/tags.js'; +import { getUserId } from '$lib/server/api-utils.js'; -export const GET: RequestHandler = async ({ url }) => { +export const GET: RequestHandler = async ({ url, ...event }) => { + const userId = getUserId(event); const query = url.searchParams.get('q')?.trim(); if (!query) { @@ -19,6 +21,7 @@ export const GET: RequestHandler = async ({ url }) => { .from(notes) .where( and( + eq(notes.userId, userId), eq(notes.trashed, false), or(like(notes.title, pattern), like(notes.content, pattern)) ) @@ -30,7 +33,7 @@ export const GET: RequestHandler = async ({ url }) => { .select({ noteId: noteTags.noteId }) .from(noteTags) .innerJoin(tags, eq(noteTags.tagId, tags.id)) - .where(like(tags.name, pattern)) + .where(and(like(tags.name, pattern), eq(tags.userId, userId))) .all(); const resultIds = new Set(results.map((n) => n.id)); @@ -43,7 +46,7 @@ export const GET: RequestHandler = async ({ url }) => { extraNotes = db .select() .from(notes) - .where(and(inArray(notes.id, extraNoteIds), eq(notes.trashed, false))) + .where(and(eq(notes.userId, userId), inArray(notes.id, extraNoteIds), eq(notes.trashed, false))) .all(); } diff --git a/src/routes/api/sync/+server.ts b/src/routes/api/sync/+server.ts index 5090c8a..208893f 100644 --- a/src/routes/api/sync/+server.ts +++ b/src/routes/api/sync/+server.ts @@ -3,21 +3,24 @@ import type { RequestHandler } from './$types.js'; import { processSyncPush, getChangesSince } from '$lib/sync/server.js'; import { fetchTagsForNotes } from '$lib/server/tags.js'; import { fetchAttachmentsForNotes } from '$lib/server/attachments.js'; +import { getUserId } from '$lib/server/api-utils.js'; -export const POST: RequestHandler = async ({ request }) => { +export const POST: RequestHandler = async ({ request, ...event }) => { + const userId = getUserId(event); const { changes } = await request.json(); if (!Array.isArray(changes)) { return json({ error: 'Invalid changes' }, { status: 400 }); } - await processSyncPush(changes); + await processSyncPush(changes, userId); return json({ success: true }); }; -export const GET: RequestHandler = async ({ url }) => { +export const GET: RequestHandler = async ({ url, ...event }) => { + const userId = getUserId(event); const since = parseInt(url.searchParams.get('since') || '0', 10); - const changes = await getChangesSince(since); + const changes = await getChangesSince(since, userId); const noteIds = changes.map((n) => n.id); const tagMap = fetchTagsForNotes(noteIds); diff --git a/src/routes/api/tags/+server.ts b/src/routes/api/tags/+server.ts index 9859b5e..e1f145c 100644 --- a/src/routes/api/tags/+server.ts +++ b/src/routes/api/tags/+server.ts @@ -2,8 +2,11 @@ import { json } from '@sveltejs/kit'; import type { RequestHandler } from './$types.js'; import { db } from '$lib/server/db/index.js'; import { tags } from '$lib/server/db/schema.js'; +import { eq } from 'drizzle-orm'; +import { getUserId } from '$lib/server/api-utils.js'; -export const GET: RequestHandler = async () => { - const allTags = await db.select().from(tags); +export const GET: RequestHandler = async (event) => { + const userId = getUserId(event); + const allTags = await db.select().from(tags).where(eq(tags.userId, userId)); return json(allTags); };