From d96ff7b205a481dda99860097f0235481d81f5be Mon Sep 17 00:00:00 2001 From: Irfan Fadilah Date: Thu, 15 Oct 2026 09:49:27 +0700 Subject: [PATCH 01/13] Save Notes Atomically --- server/api/notes/[id].put.ts | 149 ++++++++++++++------------------- server/api/notes/index.post.ts | 83 +++++++----------- server/utils/db.ts | 3 + server/utils/labelLinksSvc.ts | 36 +++++--- server/utils/noteInput.ts | 85 +++++++++++++++++++ 5 files changed, 204 insertions(+), 152 deletions(-) create mode 100644 server/utils/noteInput.ts diff --git a/server/api/notes/[id].put.ts b/server/api/notes/[id].put.ts index b61355b..112c603 100644 --- a/server/api/notes/[id].put.ts +++ b/server/api/notes/[id].put.ts @@ -1,50 +1,24 @@ import { getDb, schema } from '../../utils/db' -import { eq, inArray } from 'drizzle-orm' +import { and, eq, inArray, ne } from 'drizzle-orm' import { nanoid } from 'nanoid' -import { replaceNoteLabels } from '../../utils/labelLinksSvc' +import { assertLabelsExist, replaceNoteLabels } from '../../utils/labelLinksSvc' import { getNoteWithMeta, syncNoteToIndex } from '../../utils/notesSvc' +import { parseNoteInput } from '../../utils/noteInput' import { realtimeBus } from '../../utils/realtime' import { requireNote } from '../../utils/guards' -import { CONSTRAINTS } from '#shared/utils/constants' export default defineEventHandler(async (event) => { const db = getDb() const { id } = await requireNote(event) - const body = await readBody(event) - - const { - title, - content, - isArchived, - isTrashed, - type, - checklistItems, - labelIds, - } = body as { - title?: string - content?: string - isArchived?: boolean - isTrashed?: boolean - type?: 'text' | 'checklist' - checklistItems?: Array<{ id?: string; text: string; isChecked?: boolean; position?: number }> - labelIds?: string[] - } + const { title, content, isArchived, isTrashed, type, checklistItems, labelIds: rawLabelIds } = + parseNoteInput(await readBody(event)) + const labelIds = rawLabelIds && assertLabelsExist(db, rawLabelIds) const now = new Date().toISOString() - let labelsChanged = false - const updateData: Partial = { updatedAt: now } - if (title !== undefined) { - if (!title.trim()) throw createError({ statusCode: 400, statusMessage: 'Title is required' }) - if (title.length > CONSTRAINTS.TITLE_MAX_LENGTH) throw createError({ statusCode: 400, statusMessage: `Title must be ${CONSTRAINTS.TITLE_MAX_LENGTH} characters or less` }) - updateData.title = title - } - if (content !== undefined) { - if (content.length > CONSTRAINTS.CONTENT_MAX_LENGTH) throw createError({ statusCode: 400, statusMessage: 'Content must be 10,000 characters or less' }) - updateData.content = content - } + if (title !== undefined) updateData.title = title + if (content !== undefined) updateData.content = content if (type !== undefined) updateData.type = type - if (isArchived !== undefined) { updateData.isArchived = isArchived if (isArchived) updateData.isTrashed = false @@ -55,64 +29,67 @@ export default defineEventHandler(async (event) => { if (isTrashed) updateData.isArchived = false } - await db.update(schema.notes).set(updateData).where(eq(schema.notes.id, id)) + let labelsChanged = false - // Update checklist items if provided - if (checklistItems !== undefined) { - if (!Array.isArray(checklistItems) || checklistItems.length > CONSTRAINTS.CHECKLIST_MAX_ITEMS) { - throw createError({ statusCode: 400, statusMessage: `Checklist may have at most ${CONSTRAINTS.CHECKLIST_MAX_ITEMS} items` }) - } - if (checklistItems.some((i) => typeof i.text !== 'string' || i.text.length > CONSTRAINTS.CHECKLIST_ITEM_MAX_LENGTH)) { - throw createError({ statusCode: 400, statusMessage: `Each checklist item text must be ${CONSTRAINTS.CHECKLIST_ITEM_MAX_LENGTH} characters or less` }) - } - const existingItems = await db - .select() - .from(schema.checklistItems) - .where(eq(schema.checklistItems.noteId, id)) - .all() - const existingItemIds = new Set(existingItems.map((i) => i.id)) + db.transaction((tx) => { + tx.update(schema.notes).set(updateData).where(eq(schema.notes.id, id)).run() - // Delete items that are no longer in the incoming list - const incomingIds = checklistItems.filter((i) => i.id && existingItemIds.has(i.id)).map((i) => i.id!) - const toDelete = existingItems.filter((i) => !incomingIds.includes(i.id)).map((i) => i.id) - if (toDelete.length > 0) { - await db.delete(schema.checklistItems).where(inArray(schema.checklistItems.id, toDelete)) - } + if (checklistItems !== undefined) { + const ownIds = new Set( + tx.select({ id: schema.checklistItems.id }) + .from(schema.checklistItems) + .where(eq(schema.checklistItems.noteId, id)) + .all() + .map((i) => i.id) + ) + // New items keep their client-generated id so later saves can update them, + // unless that id already belongs to another note. + const foreignIds = new Set( + tx.select({ id: schema.checklistItems.id }) + .from(schema.checklistItems) + .where(and( + inArray(schema.checklistItems.id, checklistItems.flatMap((i) => (i.id && !ownIds.has(i.id) ? [i.id] : []))), + ne(schema.checklistItems.noteId, id), + )) + .all() + .map((i) => i.id) + ) - for (let idx = 0; idx < checklistItems.length; idx++) { - const item = checklistItems[idx]! - if (item.id && existingItemIds.has(item.id)) { - // Update existing DB item - await db - .update(schema.checklistItems) - .set({ text: item.text, isChecked: item.isChecked ?? false, position: idx }) - .where(eq(schema.checklistItems.id, item.id)) - } else { - // Insert new item — use provided client ID so subsequent saves can update it - await db.insert(schema.checklistItems).values({ - id: item.id ?? nanoid(), - noteId: id, - text: item.text, - isChecked: item.isChecked ?? false, - position: idx, - }) + const keptIds = checklistItems.flatMap((i) => (i.id && ownIds.has(i.id) ? [i.id] : [])) + const toDelete = [...ownIds].filter((existingId) => !keptIds.includes(existingId)) + if (toDelete.length > 0) { + tx.delete(schema.checklistItems).where(inArray(schema.checklistItems.id, toDelete)).run() } + + const usedIds = new Set() + checklistItems.forEach((item, position) => { + if (item.id && ownIds.has(item.id) && !usedIds.has(item.id)) { + usedIds.add(item.id) + tx.update(schema.checklistItems) + .set({ text: item.text, isChecked: item.isChecked, position }) + .where(eq(schema.checklistItems.id, item.id)) + .run() + return + } + const newId = item.id && !foreignIds.has(item.id) && !usedIds.has(item.id) ? item.id : nanoid() + usedIds.add(newId) + tx.insert(schema.checklistItems) + .values({ id: newId, noteId: id, text: item.text, isChecked: item.isChecked, position }) + .run() + }) } - } - // Update labels if provided - if (labelIds !== undefined) { - const existingLabelLinks = await db - .select({ labelId: schema.noteLabels.labelId }) - .from(schema.noteLabels) - .where(eq(schema.noteLabels.noteId, id)) - .all() - const currentLabelIds = existingLabelLinks.map((link) => link.labelId).sort() - const nextLabelIds = [...labelIds].sort() - labelsChanged = currentLabelIds.length !== nextLabelIds.length || - currentLabelIds.some((labelId, index) => labelId !== nextLabelIds[index]) - await replaceNoteLabels(db, id, labelIds) - } + if (labelIds !== undefined) { + const current = tx + .select({ labelId: schema.noteLabels.labelId }) + .from(schema.noteLabels) + .where(eq(schema.noteLabels.noteId, id)) + .all() + .map((link) => link.labelId) + labelsChanged = current.length !== labelIds.length || current.some((labelId) => !labelIds.includes(labelId)) + replaceNoteLabels(tx, id, labelIds) + } + }) const result = (await getNoteWithMeta(id))! void syncNoteToIndex(id) diff --git a/server/api/notes/index.post.ts b/server/api/notes/index.post.ts index 734ecc1..c68e772 100644 --- a/server/api/notes/index.post.ts +++ b/server/api/notes/index.post.ts @@ -1,71 +1,48 @@ import { getDb, schema } from '../../utils/db' import { nanoid } from 'nanoid' -import { linkLabelsToNote } from '../../utils/labelLinksSvc' +import { assertLabelsExist, linkLabelsToNote } from '../../utils/labelLinksSvc' import { getNoteWithMeta, syncNoteToIndex } from '../../utils/notesSvc' +import { parseNoteInput } from '../../utils/noteInput' import { realtimeBus } from '../../utils/realtime' -import { CONSTRAINTS } from '#shared/utils/constants' export default defineEventHandler(async (event) => { const db = getDb() - const body = await readBody(event) - - const { - type = 'text', - title = '', - content = '', - checklistItems = [], - labelIds = [], - } = body as { - type?: 'text' | 'checklist' - title?: string - content?: string - checklistItems?: Array<{ text: string; isChecked?: boolean }> - labelIds?: string[] - } - - if (!title.trim()) { + const input = parseNoteInput(await readBody(event)) + if (input.title === undefined) { throw createError({ statusCode: 400, statusMessage: 'Title is required' }) } - if (title.length > CONSTRAINTS.TITLE_MAX_LENGTH) { - throw createError({ statusCode: 400, statusMessage: `Title must be ${CONSTRAINTS.TITLE_MAX_LENGTH} characters or less` }) - } - if (content.length > CONSTRAINTS.CONTENT_MAX_LENGTH) { - throw createError({ statusCode: 400, statusMessage: 'Content must be 10,000 characters or less' }) - } - if (!Array.isArray(checklistItems) || checklistItems.length > CONSTRAINTS.CHECKLIST_MAX_ITEMS) { - throw createError({ statusCode: 400, statusMessage: `Checklist may have at most ${CONSTRAINTS.CHECKLIST_MAX_ITEMS} items` }) - } - if (checklistItems.some((i) => typeof i.text !== 'string' || i.text.length > CONSTRAINTS.CHECKLIST_ITEM_MAX_LENGTH)) { - throw createError({ statusCode: 400, statusMessage: `Each checklist item text must be ${CONSTRAINTS.CHECKLIST_ITEM_MAX_LENGTH} characters or less` }) - } + const type = input.type ?? 'text' + const checklistItems = input.checklistItems ?? [] + const labelIds = assertLabelsExist(db, input.labelIds ?? []) const id = nanoid() const now = new Date().toISOString() - await db.insert(schema.notes).values({ - id, - type, - title, - content, - createdAt: now, - updatedAt: now, + db.transaction((tx) => { + tx.insert(schema.notes).values({ + id, + type, + title: input.title!, + content: input.content ?? '', + createdAt: now, + updatedAt: now, + }).run() + + if (type === 'checklist' && checklistItems.length > 0) { + tx.insert(schema.checklistItems).values( + checklistItems.map((item, position) => ({ + id: nanoid(), + noteId: id, + text: item.text, + isChecked: item.isChecked, + position, + })) + ).run() + } + + linkLabelsToNote(tx, id, labelIds) }) - // Insert checklist items - if (type === 'checklist' && checklistItems.length > 0) { - await db.insert(schema.checklistItems).values( - checklistItems.map((item, idx) => ({ - id: nanoid(), - noteId: id, - text: item.text, - isChecked: item.isChecked ?? false, - position: idx, - })) - ) - } - - await linkLabelsToNote(db, id, labelIds) - const note = (await getNoteWithMeta(id))! void syncNoteToIndex(id) diff --git a/server/utils/db.ts b/server/utils/db.ts index 9ff2b4d..6f0f749 100644 --- a/server/utils/db.ts +++ b/server/utils/db.ts @@ -30,6 +30,9 @@ export function getDb() { } export type DbClient = ReturnType +/** Transaction handle passed to `db.transaction()` callbacks (better-sqlite3 transactions are synchronous). */ +export type DbTransaction = Parameters[0]>[0] +export type DbExecutor = DbClient | DbTransaction export function runMigrations() { migrate(getDb(), { migrationsFolder: MIGRATIONS_FOLDER }) diff --git a/server/utils/labelLinksSvc.ts b/server/utils/labelLinksSvc.ts index 747254a..f66e04d 100644 --- a/server/utils/labelLinksSvc.ts +++ b/server/utils/labelLinksSvc.ts @@ -1,17 +1,27 @@ -import { eq } from 'drizzle-orm' -import { schema, type DbClient } from './db' +import { eq, inArray } from 'drizzle-orm' +import { schema, type DbExecutor } from './db' -export async function linkLabelsToNote(db: DbClient, noteId: string, labelIds?: string[]) { - if (!Array.isArray(labelIds) || labelIds.length === 0) return - await db.insert(schema.noteLabels).values( - labelIds.map((labelId) => ({ - noteId, - labelId, - })) - ) +/** Throw 400 unless every id refers to an existing label. Returns the de-duplicated ids. */ +export function assertLabelsExist(db: DbExecutor, labelIds: string[]): string[] { + const unique = [...new Set(labelIds)] + if (unique.length === 0) return unique + const found = db + .select({ id: schema.labels.id }) + .from(schema.labels) + .where(inArray(schema.labels.id, unique)) + .all() + if (found.length !== unique.length) { + throw createError({ statusCode: 400, statusMessage: 'Unknown label' }) + } + return unique } -export async function replaceNoteLabels(db: DbClient, noteId: string, labelIds?: string[]) { - await db.delete(schema.noteLabels).where(eq(schema.noteLabels.noteId, noteId)) - await linkLabelsToNote(db, noteId, labelIds) +export function linkLabelsToNote(db: DbExecutor, noteId: string, labelIds: string[]) { + if (labelIds.length === 0) return + db.insert(schema.noteLabels).values(labelIds.map((labelId) => ({ noteId, labelId }))).run() +} + +export function replaceNoteLabels(db: DbExecutor, noteId: string, labelIds: string[]) { + db.delete(schema.noteLabels).where(eq(schema.noteLabels.noteId, noteId)).run() + linkLabelsToNote(db, noteId, labelIds) } diff --git a/server/utils/noteInput.ts b/server/utils/noteInput.ts new file mode 100644 index 0000000..ca32b2c --- /dev/null +++ b/server/utils/noteInput.ts @@ -0,0 +1,85 @@ +import type { NoteType } from '#shared/types/notes' +import { CONSTRAINTS } from '#shared/utils/constants' + +// Hand-rolled request validation for note bodies; turns malformed input into 400s instead of 500s. + +export interface ChecklistItemInput { + id?: string + text: string + isChecked: boolean +} + +export interface NoteInput { + type?: NoteType + title?: string + content?: string + isArchived?: boolean + isTrashed?: boolean + checklistItems?: ChecklistItemInput[] + labelIds?: string[] +} + +const NOTE_TYPES: readonly NoteType[] = ['text', 'checklist'] + +function badRequest(message: string): never { + throw createError({ statusCode: 400, statusMessage: message }) +} + +function optionalBoolean(value: unknown, field: string): boolean | undefined { + if (value === undefined) return undefined + if (typeof value !== 'boolean') badRequest(`${field} must be a boolean`) + return value +} + +function optionalString(value: unknown, field: string, maxLength: number): string | undefined { + if (value === undefined) return undefined + if (typeof value !== 'string') badRequest(`${field} must be a string`) + if (value.length > maxLength) badRequest(`${field} must be ${maxLength.toLocaleString('en-US')} characters or less`) + return value +} + +function parseChecklistItems(value: unknown): ChecklistItemInput[] | undefined { + if (value === undefined) return undefined + if (!Array.isArray(value)) badRequest('Checklist items must be an array') + if (value.length > CONSTRAINTS.CHECKLIST_MAX_ITEMS) { + badRequest(`Checklist may have at most ${CONSTRAINTS.CHECKLIST_MAX_ITEMS} items`) + } + return value.map((raw: unknown) => { + if (typeof raw !== 'object' || raw === null) badRequest('Invalid checklist item') + const item = raw as Record + if (typeof item.text !== 'string' || item.text.length > CONSTRAINTS.CHECKLIST_ITEM_MAX_LENGTH) { + badRequest(`Each checklist item text must be ${CONSTRAINTS.CHECKLIST_ITEM_MAX_LENGTH} characters or less`) + } + if (item.id !== undefined && (typeof item.id !== 'string' || !item.id)) badRequest('Invalid checklist item id') + return { + id: item.id as string | undefined, + text: item.text, + isChecked: optionalBoolean(item.isChecked, 'isChecked') ?? false, + } + }) +} + +function parseLabelIds(value: unknown): string[] | undefined { + if (value === undefined) return undefined + if (!Array.isArray(value) || value.some((id) => typeof id !== 'string')) badRequest('labelIds must be an array of strings') + return value as string[] +} + +export function parseNoteInput(body: unknown): NoteInput { + if (typeof body !== 'object' || body === null) badRequest('Request body must be an object') + const raw = body as Record + + if (raw.type !== undefined && !NOTE_TYPES.includes(raw.type as NoteType)) badRequest('Invalid note type') + const title = optionalString(raw.title, 'Title', CONSTRAINTS.TITLE_MAX_LENGTH) + if (title !== undefined && !title.trim()) badRequest('Title is required') + + return { + type: raw.type as NoteType | undefined, + title, + content: optionalString(raw.content, 'Content', CONSTRAINTS.CONTENT_MAX_LENGTH), + isArchived: optionalBoolean(raw.isArchived, 'isArchived'), + isTrashed: optionalBoolean(raw.isTrashed, 'isTrashed'), + checklistItems: parseChecklistItems(raw.checklistItems), + labelIds: parseLabelIds(raw.labelIds), + } +} From f73360ed1cc389210a6dcbabd0ce04dfa99bbddf Mon Sep 17 00:00:00 2001 From: Irfan Fadilah Date: Thu, 15 Oct 2026 09:49:27 +0700 Subject: [PATCH 02/13] Use Transactions for Reorder --- server/api/labels/reorder.put.ts | 10 +++++----- server/api/notes/[id]/attachments/reorder.put.ts | 16 +++++++++------- 2 files changed, 14 insertions(+), 12 deletions(-) diff --git a/server/api/labels/reorder.put.ts b/server/api/labels/reorder.put.ts index eea9d8f..43dac7c 100644 --- a/server/api/labels/reorder.put.ts +++ b/server/api/labels/reorder.put.ts @@ -19,11 +19,11 @@ export default defineEventHandler(async (event) => { } } - await Promise.all( - body.order.map(({ id, position }) => - db.update(schema.labels).set({ position }).where(eq(schema.labels.id, id)) - ) - ) + db.transaction((tx) => { + for (const { id, position } of body.order) { + tx.update(schema.labels).set({ position }).where(eq(schema.labels.id, id)).run() + } + }) realtimeBus.publish({ type: 'label.reordered', data: { resource: 'labels', order: body.order } }) return { ok: true } diff --git a/server/api/notes/[id]/attachments/reorder.put.ts b/server/api/notes/[id]/attachments/reorder.put.ts index b05fa7f..88f6185 100644 --- a/server/api/notes/[id]/attachments/reorder.put.ts +++ b/server/api/notes/[id]/attachments/reorder.put.ts @@ -7,7 +7,7 @@ export default defineEventHandler(async (event) => { const db = getDb() const noteId = requireRouterParam(event, 'id') const body = await readBody(event) - const ids: string[] = Array.isArray(body?.ids) ? body.ids : [] + const ids: string[] = Array.isArray(body?.ids) && body.ids.every((id: unknown) => typeof id === 'string') ? body.ids : [] if (ids.length === 0) { throw createError({ statusCode: 400, statusMessage: 'Attachment order is required' }) @@ -24,12 +24,14 @@ export default defineEventHandler(async (event) => { throw createError({ statusCode: 400, statusMessage: 'Invalid attachment order' }) } - for (let i = 0; i < ids.length; i++) { - await db - .update(schema.attachments) - .set({ position: i }) - .where(and(eq(schema.attachments.id, ids[i]!), eq(schema.attachments.noteId, noteId))) - } + db.transaction((tx) => { + ids.forEach((id, position) => { + tx.update(schema.attachments) + .set({ position }) + .where(and(eq(schema.attachments.id, id), eq(schema.attachments.noteId, noteId))) + .run() + }) + }) realtimeBus.publish({ type: 'attachment.reordered', data: { resource: 'notes', noteId, attachmentIds: ids } }) return { success: true } From a2648ad1e260f7e94ac86d76d90eea7bc6d7f59e Mon Sep 17 00:00:00 2001 From: Irfan Fadilah Date: Thu, 15 Oct 2026 09:49:27 +0700 Subject: [PATCH 03/13] Fix Label Validation --- server/api/labels/[id].put.ts | 19 ++++++++++++++----- server/api/labels/index.post.ts | 10 +++++----- 2 files changed, 19 insertions(+), 10 deletions(-) diff --git a/server/api/labels/[id].put.ts b/server/api/labels/[id].put.ts index 82ecadb..a28bfae 100644 --- a/server/api/labels/[id].put.ts +++ b/server/api/labels/[id].put.ts @@ -1,13 +1,13 @@ import { getDb, schema } from '../../utils/db' -import { eq } from 'drizzle-orm' +import { and, eq, ne } from 'drizzle-orm' import { realtimeBus } from '../../utils/realtime' import { CONSTRAINTS } from '#shared/utils/constants' import { requireLabel } from '../../utils/guards' export default defineEventHandler(async (event) => { - const body = await readBody(event) as { name: string } + const body = await readBody(event) as { name?: unknown } | null - if (!body.name?.trim()) { + if (typeof body?.name !== 'string' || !body.name.trim()) { throw createError({ statusCode: 400, statusMessage: 'Label name is required' }) } if (body.name.trim().length > CONSTRAINTS.LABEL_MAX_LENGTH) { @@ -15,10 +15,19 @@ export default defineEventHandler(async (event) => { } const label = await requireLabel(event) + const name = body.name.trim() + const db = getDb() - const updated = await getDb() + const duplicate = await db + .select({ id: schema.labels.id }) + .from(schema.labels) + .where(and(eq(schema.labels.name, name), ne(schema.labels.id, label.id))) + .get() + if (duplicate) throw createError({ statusCode: 409, statusMessage: 'Label already exists' }) + + const updated = await db .update(schema.labels) - .set({ name: body.name.trim() }) + .set({ name }) .where(eq(schema.labels.id, label.id)) .returning() .get() diff --git a/server/api/labels/index.post.ts b/server/api/labels/index.post.ts index 894bbfa..02b7520 100644 --- a/server/api/labels/index.post.ts +++ b/server/api/labels/index.post.ts @@ -1,14 +1,14 @@ import { getDb, schema } from '../../utils/db' -import { eq, count } from 'drizzle-orm' +import { eq, max } from 'drizzle-orm' import { nanoid } from 'nanoid' import { realtimeBus } from '../../utils/realtime' import { CONSTRAINTS } from '#shared/utils/constants' export default defineEventHandler(async (event) => { const db = getDb() - const body = await readBody(event) as { name: string } + const body = await readBody(event) as { name?: unknown } | null - if (!body.name?.trim()) { + if (typeof body?.name !== 'string' || !body.name.trim()) { throw createError({ statusCode: 400, statusMessage: 'Label name is required' }) } if (body.name.trim().length > CONSTRAINTS.LABEL_MAX_LENGTH) { @@ -19,10 +19,10 @@ export default defineEventHandler(async (event) => { const existing = await db.select().from(schema.labels).where(eq(schema.labels.name, name)).get() if (existing) throw createError({ statusCode: 409, statusMessage: 'Label already exists' }) - const { total } = await db.select({ total: count() }).from(schema.labels).get() ?? { total: 0 } + const { maxPosition } = await db.select({ maxPosition: max(schema.labels.position) }).from(schema.labels).get() ?? {} const label = await db .insert(schema.labels) - .values({ id: nanoid(), name, position: total, createdAt: new Date().toISOString() }) + .values({ id: nanoid(), name, position: (maxPosition ?? -1) + 1, createdAt: new Date().toISOString() }) .returning() .get() From 7e88210acf845442f612a98bad55cab76af295eb Mon Sep 17 00:00:00 2001 From: Irfan Fadilah Date: Thu, 15 Oct 2026 09:49:27 +0700 Subject: [PATCH 04/13] Harden File Uploads --- server/api/files/[id].get.ts | 70 +++------ server/api/notes/[id]/attachments.post.ts | 177 ++++++++++------------ server/utils/fileTypes.ts | 51 +++++++ shared/utils/files.ts | 9 +- 4 files changed, 154 insertions(+), 153 deletions(-) create mode 100644 server/utils/fileTypes.ts diff --git a/server/api/files/[id].get.ts b/server/api/files/[id].get.ts index 09fe1a1..0228bb9 100644 --- a/server/api/files/[id].get.ts +++ b/server/api/files/[id].get.ts @@ -1,37 +1,9 @@ import { getDb, schema } from '../../utils/db' import { eq } from 'drizzle-orm' -import { createReadStream, statSync, readdirSync } from 'node:fs' -import { getAbsolutePath, getFilesBasePath } from '../../utils/filesSvc' -import { join, basename, dirname } from 'node:path' +import { createReadStream, existsSync } from 'node:fs' +import { getAbsolutePath } from '../../utils/filesSvc' import { requireRouterParam } from '../../utils/guards' - -/** Resolve the actual file path, falling back to same-stem different-extension - * to handle DB records written before the .bin fix was deployed. */ -function resolveFilePath(storagePath: string, filename: string): string | null { - const abs = getAbsolutePath(storagePath) - try { statSync(abs); return abs } catch {} - - // Fallback: find any file with the same name stem (different extension) - const absDir = join(getFilesBasePath(), dirname(storagePath)) - const stem = basename(storagePath).replace(/\.[^.]+$/, '') - try { - const match = readdirSync(absDir).find((f) => f.replace(/\.[^.]+$/, '') === stem) - if (match) return join(absDir, match) - } catch {} - - // Legacy fallback: files stored with original filename - if (filename) { - const byName = join(absDir, basename(filename)) - try { statSync(byName); return byName } catch {} - - const nameStem = basename(filename).replace(/\.[^.]+$/, '') - try { - const match = readdirSync(absDir).find((f) => f.replace(/\.[^.]+$/, '') === nameStem) - if (match) return join(absDir, match) - } catch {} - } - return null -} +import { INLINE_MIME_TYPES } from '#shared/utils/files' export default defineEventHandler(async (event) => { const db = getDb() @@ -43,33 +15,25 @@ export default defineEventHandler(async (event) => { const att = await db.select().from(schema.attachments).where(eq(schema.attachments.id, id)).get() if (!att) throw createError({ statusCode: 404, statusMessage: 'File not found' }) - const preferThumb = thumb && att.thumbnailPath - const storagePath = preferThumb ? att.thumbnailPath! : att.storagePath - let filePath = resolveFilePath(storagePath, att.filename) - - if (!filePath && preferThumb) { - filePath = resolveFilePath(att.storagePath, att.filename) - } - - if (!filePath) { - console.warn('[files] Missing file on disk', { - id, - storagePath, - filename: att.filename, - basePath: getFilesBasePath(), - preferThumb, - }) + const useThumb = thumb && !!att.thumbnailPath && existsSync(getAbsolutePath(att.thumbnailPath)) + const filePath = getAbsolutePath(useThumb ? att.thumbnailPath! : att.storagePath) + if (!existsSync(filePath)) { + console.warn('[files] Missing file on disk', { id, storagePath: att.storagePath }) throw createError({ statusCode: 404, statusMessage: 'File not found on disk' }) } - const mimeType = preferThumb ? 'image/webp' : att.mimeType + const mimeType = useThumb ? 'image/webp' : att.mimeType + const inline = !forceDownload && INLINE_MIME_TYPES.has(mimeType) + setHeader(event, 'Content-Type', mimeType) setHeader(event, 'Cache-Control', 'private, max-age=31536000, immutable') - - const encodedFilename = encodeURIComponent(att.filename) - const inline = mimeType.startsWith('image/') || mimeType === 'application/pdf' - const disposition = forceDownload ? 'attachment' : inline ? 'inline' : 'attachment' - setHeader(event, 'Content-Disposition', `${disposition}; filename*=UTF-8''${encodedFilename}`) + setHeader(event, 'X-Content-Type-Options', 'nosniff') + // Uploaded content must never run script in the app's origin. Chrome refuses to render + // sandboxed PDFs, so PDFs rely on their viewer's own isolation instead. + if (mimeType !== 'application/pdf') { + setHeader(event, 'Content-Security-Policy', "default-src 'none'; img-src 'self'; media-src 'self'; style-src 'unsafe-inline'; sandbox") + } + setHeader(event, 'Content-Disposition', `${inline ? 'inline' : 'attachment'}; filename*=UTF-8''${encodeURIComponent(att.filename)}`) return sendStream(event, createReadStream(filePath)) }) diff --git a/server/api/notes/[id]/attachments.post.ts b/server/api/notes/[id]/attachments.post.ts index 8e1951e..c55827e 100644 --- a/server/api/notes/[id]/attachments.post.ts +++ b/server/api/notes/[id]/attachments.post.ts @@ -1,138 +1,119 @@ import { getDb, schema } from '../../../utils/db' import type { AttachmentRow } from '../../../utils/db' -import { eq } from 'drizzle-orm' +import { count, eq } from 'drizzle-orm' import { nanoid } from 'nanoid' import sharp from 'sharp' -import { writeFile } from 'node:fs' -import { join, extname } from 'node:path' -import { promisify } from 'node:util' +import { unlink, writeFile } from 'node:fs/promises' +import { join } from 'node:path' import { ensureFilesDir, getFilesBasePath } from '../../../utils/filesSvc' +import { resolveUploadType } from '../../../utils/fileTypes' import { syncNoteToIndex } from '../../../utils/notesSvc' import { sanitizeFilename } from '../../../utils/sanitize' import { realtimeBus } from '../../../utils/realtime' import { requireNote } from '../../../utils/guards' import { CONSTRAINTS } from '#shared/utils/constants' -import { EXT_TO_MIME, MIME_TO_EXT, isImageMime } from '#shared/utils/files' - -const writeFileAsync = promisify(writeFile) +import { isImageMime } from '#shared/utils/files' const MAX_FILE_SIZE = CONSTRAINTS.FILE_MAX_SIZE const MAX_FILENAME_LENGTH = CONSTRAINTS.FILENAME_MAX_LENGTH +const MAX_FILES_PER_REQUEST = 20 +/** Allowance for multipart boundaries and headers on top of the file itself. */ +const MULTIPART_OVERHEAD = 1024 * 1024 const THUMBNAIL_SIZE = 400 const THUMBNAIL_QUALITY = 75 -/** Detect MIME type from magic bytes — immune to Cloudflare header stripping. */ -function sniffMimeType(buf: Buffer): string | null { - if (buf.length < 4) return null - const b = buf - if (b[0] === 0xFF && b[1] === 0xD8 && b[2] === 0xFF) return 'image/jpeg' - if (b[0] === 0x89 && b[1] === 0x50 && b[2] === 0x4E && b[3] === 0x47) return 'image/png' - if (b[0] === 0x47 && b[1] === 0x49 && b[2] === 0x46) return 'image/gif' - if (b[0] === 0x52 && b[1] === 0x49 && b[2] === 0x46 && b[3] === 0x46 && - buf.length >= 12 && b[8] === 0x57 && b[9] === 0x45 && b[10] === 0x42 && b[11] === 0x50) - return 'image/webp' - if (b[0] === 0x25 && b[1] === 0x50 && b[2] === 0x44 && b[3] === 0x46) return 'application/pdf' - if ((b[0] === 0x49 && b[1] === 0x49 && b[2] === 0x2A && b[3] === 0x00) || - (b[0] === 0x4D && b[1] === 0x4D && b[2] === 0x00 && b[3] === 0x2A)) return 'image/tiff' - if (b[0] === 0x42 && b[1] === 0x4D) return 'image/bmp' - if (buf.length >= 12 && - b[4] === 0x66 && b[5] === 0x74 && b[6] === 0x79 && b[7] === 0x70) { - const brand = buf.toString('latin1', 8, 12) - if (brand === 'heic' || brand === 'heif') return 'image/heic' - if (brand === 'mif1' || brand === 'msf1' || brand === 'avif') return 'image/avif' - if (brand === 'isom' || brand === 'iso2' || brand === 'mp41' || brand === 'mp42' || brand === 'avc1') - return 'video/mp4' +/** Reject oversized requests before the body is buffered into memory. */ +function assertRequestSize(contentLength: string | undefined) { + if (!contentLength) { + throw createError({ statusCode: 411, statusMessage: 'Content-Length header is required' }) + } + if (Number(contentLength) > MAX_FILE_SIZE + MULTIPART_OVERHEAD) { + throw createError({ statusCode: 413, statusMessage: 'Upload exceeds 100MB limit' }) } - return null -} - -function resolveExt(filename: string, mimeType: string, sniffed: string | null): string { - // Magic bytes take highest priority — immune to any header/filename manipulation - if (sniffed && MIME_TO_EXT[sniffed]) return MIME_TO_EXT[sniffed] - // Good filename extension (not .bin) - const ext = extname(filename).toLowerCase() - if (ext && ext !== '.bin') return ext - // Declared/derived MIME type - return MIME_TO_EXT[mimeType] ?? '' } export default defineEventHandler(async (event) => { const db = getDb() const { id: noteId } = await requireNote(event) + assertRequestSize(getRequestHeader(event, 'content-length')) - const formData = await readMultipartFormData(event) - if (!formData || formData.length === 0) { + const parts = (await readMultipartFormData(event))?.filter((part) => part.filename && part.data) + if (!parts || parts.length === 0) { throw createError({ statusCode: 400, statusMessage: 'No files uploaded' }) } + if (parts.length > MAX_FILES_PER_REQUEST) { + throw createError({ statusCode: 400, statusMessage: `At most ${MAX_FILES_PER_REQUEST} files per upload` }) + } - const uploaded: AttachmentRow[] = [] - const existingCount = ( - await db.select().from(schema.attachments).where(eq(schema.attachments.noteId, noteId)).all() - ).length - - for (const part of formData) { - if (!part.filename || !part.data) continue - - if (part.data.length > MAX_FILE_SIZE) { - throw createError({ statusCode: 413, statusMessage: `File ${part.filename} exceeds 100MB limit` }) - } + const { existingCount } = db + .select({ existingCount: count() }) + .from(schema.attachments) + .where(eq(schema.attachments.noteId, noteId)) + .get() ?? { existingCount: 0 } - const sniffed = sniffMimeType(part.data) - const mimeType = sniffed ?? (part.type || 'application/octet-stream') - const attId = nanoid() - let safeFilename = sanitizeFilename(part.filename) - if (safeFilename.length > MAX_FILENAME_LENGTH) { - throw createError({ statusCode: 400, statusMessage: `Maximum file name length is ${MAX_FILENAME_LENGTH} characters.` }) - } - const ext = resolveExt(safeFilename, mimeType, sniffed) - if (ext) { - const base = safeFilename.replace(/\.[^.]+$/, '') - const next = `${base}${ext}` - if (next.length <= MAX_FILENAME_LENGTH) safeFilename = next - } - const storedMime = (mimeType && mimeType !== 'application/octet-stream') - ? mimeType - : (EXT_TO_MIME[ext] ?? mimeType) - const storageName = `${attId}${ext}` + const writtenPaths: string[] = [] + const rows: AttachmentRow[] = [] + try { ensureFilesDir(noteId) - const storagePath = join(noteId, storageName) - const absPath = join(getFilesBasePath(), storagePath) + for (const part of parts) { + if (part.data.length > MAX_FILE_SIZE) { + throw createError({ statusCode: 413, statusMessage: `File ${part.filename} exceeds 100MB limit` }) + } - await writeFileAsync(absPath, part.data) + let filename = sanitizeFilename(part.filename!) + if (filename.length > MAX_FILENAME_LENGTH) { + throw createError({ statusCode: 400, statusMessage: `Maximum file name length is ${MAX_FILENAME_LENGTH} characters.` }) + } + const { mimeType, ext } = resolveUploadType(part.data, part.type, filename) + if (ext) { + const next = `${filename.replace(/\.[^.]+$/, '')}${ext}` + if (next.length <= MAX_FILENAME_LENGTH) filename = next + } - let thumbnailPath: string | null = null + const attId = nanoid() + const storagePath = join(noteId, `${attId}${ext}`) + const absPath = join(getFilesBasePath(), storagePath) + await writeFile(absPath, part.data) + writtenPaths.push(absPath) - if (isImageMime(storedMime)) { - try { - const thumbName = `${attId}_thumb.webp` - const thumbRelPath = join(noteId, thumbName) + let thumbnailPath: string | null = null + if (isImageMime(mimeType)) { + const thumbRelPath = join(noteId, `${attId}_thumb.webp`) const thumbAbsPath = join(getFilesBasePath(), thumbRelPath) - await sharp(part.data) - .resize(THUMBNAIL_SIZE, THUMBNAIL_SIZE, { fit: 'inside', withoutEnlargement: true }) - .webp({ quality: THUMBNAIL_QUALITY }) - .toFile(thumbAbsPath) - thumbnailPath = thumbRelPath - } catch { - // thumbnail generation failed, continue without it + try { + await sharp(part.data) + .resize(THUMBNAIL_SIZE, THUMBNAIL_SIZE, { fit: 'inside', withoutEnlargement: true }) + .webp({ quality: THUMBNAIL_QUALITY }) + .toFile(thumbAbsPath) + writtenPaths.push(thumbAbsPath) + thumbnailPath = thumbRelPath + } catch { + // thumbnail generation failed, continue without it + } } + + rows.push({ + id: attId, + noteId, + filename, + storagePath, + mimeType, + size: part.data.length, + thumbnailPath, + position: existingCount + rows.length, + createdAt: new Date().toISOString(), + }) } - const att = await db.insert(schema.attachments).values({ - id: attId, - noteId, - filename: safeFilename, - storagePath, - mimeType: storedMime, - size: part.data.length, - thumbnailPath, - position: existingCount + uploaded.length, - createdAt: new Date().toISOString(), - }).returning().get() - uploaded.push(att) + db.insert(schema.attachments).values(rows).run() + } catch (error) { + // Don't leave orphaned files behind when any part of the upload fails + await Promise.all(writtenPaths.map((path) => unlink(path).catch(() => {}))) + throw error } void syncNoteToIndex(noteId) - realtimeBus.publish({ type: 'attachment.uploaded', data: { resource: 'notes', noteId, attachments: uploaded } }) - return { attachments: uploaded } + realtimeBus.publish({ type: 'attachment.uploaded', data: { resource: 'notes', noteId, attachments: rows } }) + return { attachments: rows } }) diff --git a/server/utils/fileTypes.ts b/server/utils/fileTypes.ts new file mode 100644 index 0000000..38a0e93 --- /dev/null +++ b/server/utils/fileTypes.ts @@ -0,0 +1,51 @@ +import { extname } from 'node:path' +import { MIME_TO_EXT } from '#shared/utils/files' + +const startsWith = (buf: Buffer, bytes: number[], offset = 0) => + buf.length >= offset + bytes.length && bytes.every((byte, i) => buf[offset + i] === byte) + +/** Detect MIME type from magic bytes — immune to Cloudflare header stripping and spoofed headers. */ +export function sniffMimeType(buf: Buffer): string | null { + if (startsWith(buf, [0xFF, 0xD8, 0xFF])) return 'image/jpeg' + if (startsWith(buf, [0x89, 0x50, 0x4E, 0x47])) return 'image/png' + if (startsWith(buf, [0x47, 0x49, 0x46])) return 'image/gif' + if (startsWith(buf, [0x25, 0x50, 0x44, 0x46])) return 'application/pdf' + if (startsWith(buf, [0x49, 0x49, 0x2A, 0x00]) || startsWith(buf, [0x4D, 0x4D, 0x00, 0x2A])) return 'image/tiff' + if (startsWith(buf, [0x42, 0x4D])) return 'image/bmp' + if (startsWith(buf, [0x1A, 0x45, 0xDF, 0xA3])) return 'video/webm' + if (startsWith(buf, [0x4F, 0x67, 0x67, 0x53])) return 'audio/ogg' + if (startsWith(buf, [0x49, 0x44, 0x33]) || startsWith(buf, [0xFF, 0xFB]) || startsWith(buf, [0xFF, 0xF3]) || startsWith(buf, [0xFF, 0xF2])) { + return 'audio/mpeg' + } + if (startsWith(buf, [0x52, 0x49, 0x46, 0x46])) { + const format = buf.toString('latin1', 8, 12) + if (format === 'WEBP') return 'image/webp' + if (format === 'WAVE') return 'audio/wav' + } + if (startsWith(buf, [0x66, 0x74, 0x79, 0x70], 4)) { + const brand = buf.toString('latin1', 8, 12) + if (brand === 'heic' || brand === 'heif') return 'image/heic' + if (brand === 'mif1' || brand === 'msf1' || brand === 'avif') return 'image/avif' + if (['isom', 'iso2', 'mp41', 'mp42', 'avc1'].includes(brand)) return 'video/mp4' + } + return null +} + +/** Types that cannot be recognised from their bytes, so the client's declaration is accepted. */ +const DECLARABLE_MIME_TYPES = new Set(['text/plain']) + +export const GENERIC_MIME_TYPE = 'application/octet-stream' + +/** + * Decide the stored MIME type and file extension for an upload. Only types verified by their + * bytes (or harmless declared ones) keep their type; anything else becomes a generic download. + */ +export function resolveUploadType(data: Buffer, declaredType: string | undefined, filename: string) { + const sniffed = sniffMimeType(data) + const declared = (declaredType || '').toLowerCase() + const mimeType = sniffed ?? (DECLARABLE_MIME_TYPES.has(declared) ? declared : GENERIC_MIME_TYPE) + + const nameExt = extname(filename).toLowerCase() + const ext = MIME_TO_EXT[mimeType] ?? (nameExt && nameExt !== '.bin' && nameExt !== '.svg' ? nameExt : '') + return { mimeType, ext } +} diff --git a/shared/utils/files.ts b/shared/utils/files.ts index 630135a..4551659 100644 --- a/shared/utils/files.ts +++ b/shared/utils/files.ts @@ -1,10 +1,10 @@ +// SVG is intentionally absent: it can carry script, so it is never accepted as an image. export const MIME_TO_EXT: Record = { 'image/jpeg': '.jpg', 'image/jpg': '.jpg', 'image/png': '.png', 'image/gif': '.gif', 'image/webp': '.webp', - 'image/svg+xml': '.svg', 'image/heic': '.heic', 'image/heif': '.heif', 'image/avif': '.avif', @@ -25,7 +25,6 @@ export const EXT_TO_MIME: Record = { '.png': 'image/png', '.gif': 'image/gif', '.webp': 'image/webp', - '.svg': 'image/svg+xml', '.heic': 'image/heic', '.heif': 'image/heif', '.avif': 'image/avif', @@ -33,6 +32,12 @@ export const EXT_TO_MIME: Record = { '.tiff': 'image/tiff', } +/** Types the file endpoint may render in the browser; everything else is served as a download. */ +export const INLINE_MIME_TYPES = new Set([ + 'image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/avif', 'image/heic', 'image/heif', 'image/bmp', 'image/tiff', + 'application/pdf', +]) + /** Any image MIME type — used server-side to decide whether to generate a thumbnail. */ export function isImageMime(mimeType: string): boolean { return mimeType.startsWith('image/') From 299be4b49eceeeeda39e7a0c45a363cf92bd1dac Mon Sep 17 00:00:00 2001 From: Irfan Fadilah Date: Thu, 15 Oct 2026 09:49:27 +0700 Subject: [PATCH 05/13] Keep Trashed Notes Searchable --- server/plugins/migrations.ts | 33 ++++++++++++++++++----- server/utils/notesSvc.ts | 51 ++++++++++++++++++++++-------------- server/utils/searchSvc.ts | 15 ++++++++++- 3 files changed, 72 insertions(+), 27 deletions(-) diff --git a/server/plugins/migrations.ts b/server/plugins/migrations.ts index b154e3a..9bfe5cc 100644 --- a/server/plugins/migrations.ts +++ b/server/plugins/migrations.ts @@ -1,22 +1,41 @@ -import { runMigrations } from '../utils/db' +import { eq } from 'drizzle-orm' +import { getDb, runMigrations, schema } from '../utils/db' import { ensureNotesIndex } from '../utils/searchSvc' +import { reindexAllNotes } from '../utils/notesSvc' import { getJwtSecret } from '../utils/auth' +/** Bump when the shape or scope of indexed documents changes to trigger a full reindex on startup. */ +const SEARCH_INDEX_VERSION = '2' +const SEARCH_INDEX_VERSION_KEY = 'search_index_version' + export default defineNitroPlugin(async () => { // Fail fast on a missing or weak secret instead of on the first request getJwtSecret() - try { - runMigrations() - console.log('[db] Migrations applied successfully') - } catch (e) { - console.error('[db] Migration failed:', e) - } + // A failed migration leaves the schema in an unknown state; refuse to start rather than serve from it + runMigrations() + console.log('[db] Migrations applied successfully') try { await ensureNotesIndex() console.log('[search] Meilisearch index ready') } catch (e) { console.warn('[search] Meilisearch not available, search will be disabled:', e) + return + } + + const db = getDb() + const stored = db.select().from(schema.settings).where(eq(schema.settings.key, SEARCH_INDEX_VERSION_KEY)).get() + if (stored?.value === SEARCH_INDEX_VERSION) return + + try { + const count = await reindexAllNotes() + db.insert(schema.settings) + .values({ key: SEARCH_INDEX_VERSION_KEY, value: SEARCH_INDEX_VERSION }) + .onConflictDoUpdate({ target: schema.settings.key, set: { value: SEARCH_INDEX_VERSION } }) + .run() + console.log(`[search] Reindexed ${count} notes`) + } catch (e) { + console.warn('[search] Reindex failed, will retry on next startup:', e) } }) diff --git a/server/utils/notesSvc.ts b/server/utils/notesSvc.ts index 11e97b1..476e70d 100644 --- a/server/utils/notesSvc.ts +++ b/server/utils/notesSvc.ts @@ -1,7 +1,7 @@ import { asc, eq, inArray } from 'drizzle-orm' import type { Note } from '#shared/types/notes' import { getDb, schema } from './db' -import { indexNote, removeNoteFromIndex } from './searchSvc' +import { indexNote, indexNotes, removeNoteFromIndex, type NoteSearchDocument } from './searchSvc' /** Load notes with their labels, checklist items and attachments, preserving the order of `noteIds`. */ export async function getNotesWithMeta(noteIds: string[]): Promise { @@ -46,30 +46,43 @@ export async function getNoteWithMeta(noteId: string): Promise return note } +function toSearchDocument(note: Note): NoteSearchDocument { + return { + id: note.id, + title: note.title, + content: note.content, + labels: note.labels.map((l) => l.name), + checklistItems: note.checklistItems.map((i) => i.text), + attachmentNames: note.attachments.filter((a) => !a.thumbnailPath).map((a) => a.filename), + isArchived: note.isArchived, + isTrashed: note.isTrashed, + updatedAt: note.updatedAt, + createdAt: note.createdAt, + } +} + /** - * Push the current state of a note to the search index. Trashed notes are removed. - * Never throws: search is best-effort and must not fail the request. + * Push the current state of a note to the search index (trashed notes stay indexed so + * the trash can be searched). Never throws: search is best-effort and must not fail the request. */ export async function syncNoteToIndex(noteId: string): Promise { try { const note = await getNoteWithMeta(noteId) - if (!note || note.isTrashed) { - await removeNoteFromIndex(noteId) - return - } - await indexNote({ - id: note.id, - title: note.title, - content: note.content, - labels: note.labels.map((l) => l.name), - checklistItems: note.checklistItems.map((i) => i.text), - attachmentNames: note.attachments.filter((a) => !a.thumbnailPath).map((a) => a.filename), - isArchived: note.isArchived, - isTrashed: note.isTrashed, - updatedAt: note.updatedAt, - createdAt: note.createdAt, - }) + if (note) await indexNote(toSearchDocument(note)) + else await removeNoteFromIndex(noteId) } catch (e) { console.warn(`[search] Failed to sync note ${noteId}:`, e instanceof Error ? e.message : e) } } + +const REINDEX_BATCH_SIZE = 200 + +/** Re-send every note to the search index. */ +export async function reindexAllNotes(): Promise { + const ids = getDb().select({ id: schema.notes.id }).from(schema.notes).all().map((n) => n.id) + for (let i = 0; i < ids.length; i += REINDEX_BATCH_SIZE) { + const notes = await getNotesWithMeta(ids.slice(i, i + REINDEX_BATCH_SIZE)) + await indexNotes(notes.map(toSearchDocument)) + } + return ids.length +} diff --git a/server/utils/searchSvc.ts b/server/utils/searchSvc.ts index 84faef5..a228622 100644 --- a/server/utils/searchSvc.ts +++ b/server/utils/searchSvc.ts @@ -12,7 +12,11 @@ export function getMeiliClient(): MeiliSearch { } return host })(), - apiKey: process.env.MEILI_MASTER_KEY || '', + apiKey: (() => { + const key = process.env.MEILI_MASTER_KEY + if (!key) console.warn('[search] MEILI_MASTER_KEY is not set; requests to Meilisearch will be unauthenticated.') + return key || '' + })(), }) return _client } @@ -56,12 +60,21 @@ export async function indexNote(doc: NoteSearchDocument) { await index.addDocuments([doc]) } +export async function indexNotes(docs: NoteSearchDocument[]) { + if (docs.length === 0) return + await getMeiliClient().index(NOTES_INDEX).addDocuments(docs) +} + export async function removeNoteFromIndex(id: string) { const client = getMeiliClient() const index = client.index(NOTES_INDEX) await index.deleteDocument(id) } +export async function removeNotesFromIndex(ids: string[]) { + await getMeiliClient().index(NOTES_INDEX).deleteDocuments(ids) +} + export async function searchNotes(query: string, options: { filter?: string sort?: string[] From d0760587afe2dc74eb6dce135d190255a0a1f5b3 Mon Sep 17 00:00:00 2001 From: Irfan Fadilah Date: Thu, 15 Oct 2026 09:49:27 +0700 Subject: [PATCH 06/13] Delete Rows Before Files --- server/api/notes/[id].delete.ts | 13 +++---------- server/api/notes/[id]/attachments/[attId].delete.ts | 3 +-- 2 files changed, 4 insertions(+), 12 deletions(-) diff --git a/server/api/notes/[id].delete.ts b/server/api/notes/[id].delete.ts index 95070aa..61b605d 100644 --- a/server/api/notes/[id].delete.ts +++ b/server/api/notes/[id].delete.ts @@ -1,23 +1,16 @@ import { getDb, schema } from '../../utils/db' import { eq } from 'drizzle-orm' import { removeNoteFromIndex } from '../../utils/searchSvc' -import { deleteFile, deleteNoteDir } from '../../utils/filesSvc' +import { deleteNoteDir } from '../../utils/filesSvc' import { realtimeBus } from '../../utils/realtime' import { requireNote } from '../../utils/guards' export default defineEventHandler(async (event) => { - const db = getDb() const { id } = await requireNote(event) - // Delete associated files and directory - const attachments = await db.select().from(schema.attachments).where(eq(schema.attachments.noteId, id)).all() - for (const att of attachments) { - deleteFile(att.storagePath) - if (att.thumbnailPath) deleteFile(att.thumbnailPath) - } + // Remove the rows first so a failure never leaves a note pointing at deleted files + getDb().delete(schema.notes).where(eq(schema.notes.id, id)).run() deleteNoteDir(id) - - await db.delete(schema.notes).where(eq(schema.notes.id, id)) removeNoteFromIndex(id).catch((e) => console.warn('[search] Failed to remove note from index:', e?.message)) realtimeBus.publish({ type: 'note.deleted', data: { resource: 'notes', noteId: id } }) diff --git a/server/api/notes/[id]/attachments/[attId].delete.ts b/server/api/notes/[id]/attachments/[attId].delete.ts index cbf1679..f36a21c 100644 --- a/server/api/notes/[id]/attachments/[attId].delete.ts +++ b/server/api/notes/[id]/attachments/[attId].delete.ts @@ -14,11 +14,10 @@ export default defineEventHandler(async (event) => { throw createError({ statusCode: 404, statusMessage: 'Attachment not found' }) } + await db.delete(schema.attachments).where(eq(schema.attachments.id, attId)) deleteFile(att.storagePath) if (att.thumbnailPath) deleteFile(att.thumbnailPath) - await db.delete(schema.attachments).where(eq(schema.attachments.id, attId)) - realtimeBus.publish({ type: 'attachment.deleted', data: { resource: 'notes', noteId, attachmentId: attId } }) return { success: true } }) From 43c508bd4fe55e1bcfe881fef0feebc704248caf Mon Sep 17 00:00:00 2001 From: Irfan Fadilah Date: Thu, 15 Oct 2026 09:49:27 +0700 Subject: [PATCH 07/13] Add Empty Trash Endpoint --- app/composables/useNotes.ts | 32 ++++++++++++++++---------------- server/api/notes/trash.delete.ts | 28 ++++++++++++++++++++++++++++ shared/types/realtime.ts | 1 + 3 files changed, 45 insertions(+), 16 deletions(-) create mode 100644 server/api/notes/trash.delete.ts diff --git a/app/composables/useNotes.ts b/app/composables/useNotes.ts index 2ca196a..8791950 100644 --- a/app/composables/useNotes.ts +++ b/app/composables/useNotes.ts @@ -215,27 +215,28 @@ export const useNotes = (options: { realtime?: boolean } = {}) => { useSnackbar().show('Note Unarchived') } + // One request per file keeps each request under the server's upload size limit const uploadAttachment = async (noteId: string, files: File[]) => { - const form = new FormData() - files.forEach((f) => { + const uploaded: Note['attachments'] = [] + for (const f of files) { + const form = new FormData() const [file, name] = fileWithExt(f) form.append('files', file, name) - }) - let result: { attachments: Note['attachments'] } - try { - result = await apiFetch<{ attachments: Note['attachments'] }>(`/api/notes/${noteId}/attachments`, { - method: 'POST', - body: form, - }) - } catch (err) { - useSnackbar().show(getErrorMessage(err, 'Upload Failed'), 'error') - return [] + try { + const result = await apiFetch<{ attachments: Note['attachments'] }>(`/api/notes/${noteId}/attachments`, { + method: 'POST', + body: form, + }) + uploaded.push(...result.attachments) + } catch (err) { + useSnackbar().show(getErrorMessage(err, 'Upload Failed'), 'error') + } } const idx = notes.value.findIndex((n) => n.id === noteId) if (idx !== -1) { - notes.value[idx]!.attachments.push(...result.attachments) + notes.value[idx]!.attachments.push(...uploaded) } - return result.attachments + return uploaded } const deleteAttachment = async (noteId: string, attId: string) => { @@ -277,8 +278,7 @@ export const useNotes = (options: { realtime?: boolean } = {}) => { } const emptyTrash = async () => { - const trashed = notes.value.filter((n) => n.isTrashed) - await Promise.all(trashed.map((n) => apiFetch(noteUrl(n.id), { method: 'DELETE' }))) + await apiFetch('/api/notes/trash', { method: 'DELETE' }) notes.value = notes.value.filter((n) => !n.isTrashed) useSnackbar().show('All Trash Permanently Deleted') } diff --git a/server/api/notes/trash.delete.ts b/server/api/notes/trash.delete.ts new file mode 100644 index 0000000..7d0cf32 --- /dev/null +++ b/server/api/notes/trash.delete.ts @@ -0,0 +1,28 @@ +import { getDb, schema } from '../../utils/db' +import { eq } from 'drizzle-orm' +import { removeNotesFromIndex } from '../../utils/searchSvc' +import { deleteNoteDir } from '../../utils/filesSvc' +import { realtimeBus } from '../../utils/realtime' + +/** Permanently delete every trashed note, not just the ones loaded in the client. */ +export default defineEventHandler(async () => { + const db = getDb() + const noteIds = db.transaction((tx) => { + const ids = tx + .select({ id: schema.notes.id }) + .from(schema.notes) + .where(eq(schema.notes.isTrashed, true)) + .all() + .map((n) => n.id) + tx.delete(schema.notes).where(eq(schema.notes.isTrashed, true)).run() + return ids + }) + + for (const id of noteIds) deleteNoteDir(id) + if (noteIds.length > 0) { + removeNotesFromIndex(noteIds).catch((e) => console.warn('[search] Failed to remove notes from index:', e?.message)) + realtimeBus.publish({ type: 'note.trash-emptied', data: { resource: 'notes', noteIds } }) + } + + return { deleted: noteIds.length } +}) diff --git a/shared/types/realtime.ts b/shared/types/realtime.ts index e6bd8a4..7320337 100644 --- a/shared/types/realtime.ts +++ b/shared/types/realtime.ts @@ -6,6 +6,7 @@ export type RealtimeEvent = | { type: 'note.created'; data: { resource: 'notes'; note: Note } } | { type: 'note.updated'; data: { resource: 'notes'; note: Note; refresh: 'note' | 'list' } } | { type: 'note.deleted'; data: { resource: 'notes'; noteId: string } } + | { type: 'note.trash-emptied'; data: { resource: 'notes'; noteIds: string[] } } | { type: 'attachment.uploaded'; data: { resource: 'notes'; noteId: string; attachments: Attachment[] } } | { type: 'attachment.updated'; data: { resource: 'notes'; noteId: string; attachment: Attachment } } | { type: 'attachment.deleted'; data: { resource: 'notes'; noteId: string; attachmentId: string } } From e62a83bf3c8cacaaf726f1c6db70770e23c781e4 Mon Sep 17 00:00:00 2001 From: Irfan Fadilah Date: Thu, 15 Oct 2026 09:49:27 +0700 Subject: [PATCH 08/13] Harden Login Throttling --- server/api/auth/login.post.ts | 60 ++++++++++------------------------- server/utils/loginThrottle.ts | 54 +++++++++++++++++++++++++++++++ server/utils/otp.ts | 16 ++++++++-- 3 files changed, 84 insertions(+), 46 deletions(-) create mode 100644 server/utils/loginThrottle.ts diff --git a/server/api/auth/login.post.ts b/server/api/auth/login.post.ts index 9733e1f..2bde05e 100644 --- a/server/api/auth/login.post.ts +++ b/server/api/auth/login.post.ts @@ -2,64 +2,36 @@ import { getDb, schema } from '../../utils/db' import { eq } from 'drizzle-orm' import { setAuthCookie } from '../../utils/auth' import { verifyOtp } from '../../utils/otp' +import { getLoginWait, recordLoginFailure, recordLoginSuccess } from '../../utils/loginThrottle' -const COOLDOWN_SECONDS = 60 -const MAX_ATTEMPTS = 2 - -// In-memory brute-force protection (single-user app, no persistence needed) -let failedAttempts = 0 -let cooldownUntil = 0 +function tooManyAttempts(secondsLeft: number) { + return createError({ statusCode: 429, statusMessage: 'Too many attempts', data: { secondsLeft } }) +} export default defineEventHandler(async (event) => { - const now = Date.now() - - // Check cooldown - if (cooldownUntil > now) { - const secondsLeft = Math.ceil((cooldownUntil - now) / 1000) - throw createError({ - statusCode: 429, - statusMessage: 'Too many attempts', - data: { secondsLeft }, - }) - } + const clientKey = getRequestIP(event, { xForwardedFor: true }) ?? 'unknown' - const db = getDb() - const body = await readBody(event) - const { code } = body as { code: string } + const wait = getLoginWait(clientKey) + if (wait > 0) throw tooManyAttempts(wait) - if (!code || !/^\d{6}$/.test(code)) { + const body = await readBody(event) as { code?: unknown } | null + const code = body?.code + if (typeof code !== 'string' || !/^\d{6}$/.test(code)) { throw createError({ statusCode: 400, statusMessage: 'Invalid OTP Format' }) } - const row = await db.select().from(schema.settings).where(eq(schema.settings.key, 'totp_secret')).get() + const row = await getDb().select().from(schema.settings).where(eq(schema.settings.key, 'totp_secret')).get() if (!row) { throw createError({ statusCode: 404, statusMessage: 'OTP Not Configured' }) } - const valid = await verifyOtp(code, row.value) - if (!valid) { - failedAttempts++ - if (failedAttempts >= MAX_ATTEMPTS) { - cooldownUntil = now + COOLDOWN_SECONDS * 1000 - failedAttempts = 0 - throw createError({ - statusCode: 429, - statusMessage: 'Too many attempts', - data: { secondsLeft: COOLDOWN_SECONDS }, - }) - } - throw createError({ - statusCode: 401, - statusMessage: 'Invalid OTP', - data: { attemptsLeft: MAX_ATTEMPTS - failedAttempts }, - }) + if (!(await verifyOtp(code, row.value))) { + const { wait: nextWait, attemptsLeft } = recordLoginFailure(clientKey) + if (nextWait > 0) throw tooManyAttempts(nextWait) + throw createError({ statusCode: 401, statusMessage: 'Invalid OTP', data: { attemptsLeft } }) } - // Successful login — reset counters - failedAttempts = 0 - cooldownUntil = 0 - + recordLoginSuccess(clientKey) await setAuthCookie(event) - return { success: true } }) diff --git a/server/utils/loginThrottle.ts b/server/utils/loginThrottle.ts new file mode 100644 index 0000000..3e98764 --- /dev/null +++ b/server/utils/loginThrottle.ts @@ -0,0 +1,54 @@ +// In-memory brute-force protection for the single-user login (resets on restart). +// +// Each client gets an exponentially growing delay after failed attempts, so one attacker +// only slows themselves down. A global ceiling bounds guessing even if an attacker rotates +// addresses; it only applies while failures keep arriving and expires on its own. + +const BASE_DELAY_SECONDS = 1 +const MAX_DELAY_SECONDS = 5 * 60 +const FREE_ATTEMPTS = 3 +const GLOBAL_WINDOW_MS = 10 * 60 * 1000 +const GLOBAL_MAX_FAILURES = 30 +const MAX_TRACKED_CLIENTS = 1000 + +interface ClientState { + failures: number + blockedUntil: number +} + +const clients = new Map() +let globalFailures: number[] = [] + +function pruneGlobal(now: number) { + globalFailures = globalFailures.filter((t) => now - t < GLOBAL_WINDOW_MS) +} + +/** Seconds the client must wait before trying again, or 0 if it may try now. */ +export function getLoginWait(clientKey: string, now = Date.now()): number { + pruneGlobal(now) + const client = clients.get(clientKey) + const clientWait = client ? client.blockedUntil - now : 0 + const globalWait = globalFailures.length >= GLOBAL_MAX_FAILURES + ? globalFailures[0]! + GLOBAL_WINDOW_MS - now + : 0 + return Math.max(0, Math.ceil(Math.max(clientWait, globalWait) / 1000)) +} + +/** Record a failed attempt; returns the wait (seconds) before the next try and the free attempts left. */ +export function recordLoginFailure(clientKey: string, now = Date.now()): { wait: number; attemptsLeft: number } { + globalFailures.push(now) + if (!clients.has(clientKey) && clients.size >= MAX_TRACKED_CLIENTS) { + clients.delete(clients.keys().next().value!) + } + const client = clients.get(clientKey) ?? { failures: 0, blockedUntil: 0 } + client.failures++ + const over = client.failures - FREE_ATTEMPTS + const delay = over > 0 ? Math.min(BASE_DELAY_SECONDS * 2 ** (over - 1), MAX_DELAY_SECONDS) : 0 + client.blockedUntil = now + delay * 1000 + clients.set(clientKey, client) + return { wait: getLoginWait(clientKey, now), attemptsLeft: Math.max(0, -over) } +} + +export function recordLoginSuccess(clientKey: string) { + clients.delete(clientKey) +} diff --git a/server/utils/otp.ts b/server/utils/otp.ts index d86c056..92b9d45 100644 --- a/server/utils/otp.ts +++ b/server/utils/otp.ts @@ -10,7 +10,19 @@ const totp = new TOTP({ base32: new ScureBase32Plugin(), }) +/** Accept codes from the neighbouring 30s window to tolerate clock drift on the phone. */ +const EPOCH_TOLERANCE_SECONDS = 30 + +// Time step of the last accepted code; a code can only be used once. +let lastUsedTimeStep: number | undefined + export async function verifyOtp(code: string, secret: string): Promise { - const result = await totp.verify(code, { secret, epochTolerance: 0 }) - return result.valid + const result = await totp.verify(code, { + secret, + epochTolerance: EPOCH_TOLERANCE_SECONDS, + afterTimeStep: lastUsedTimeStep, + }) + if (!result.valid) return false + lastUsedTimeStep = result.timeStep + return true } From 93ea51d31c584dae468d6fac8bd68a3c020b0eb9 Mon Sep 17 00:00:00 2001 From: Irfan Fadilah Date: Thu, 15 Oct 2026 09:49:27 +0700 Subject: [PATCH 09/13] Harden Session Cookie --- .env.example | 2 +- README.md | 2 +- server/middleware/auth.ts | 9 ++++++--- server/utils/auth.ts | 7 +++++-- 4 files changed, 13 insertions(+), 7 deletions(-) diff --git a/.env.example b/.env.example index 70172a5..882e9cd 100644 --- a/.env.example +++ b/.env.example @@ -4,7 +4,7 @@ JWT_SECRET= # Generate with: openssl rand -hex 16 MEILI_MASTER_KEY= -# Secure cookies (true in production, false in development) +# Secure cookies (default true; set to false only when serving over plain HTTP) COOKIE_SECURE= # App port diff --git a/README.md b/README.md index 442e7f7..0dfec8c 100644 --- a/README.md +++ b/README.md @@ -75,7 +75,7 @@ The app reads secrets from `.env` (created by `setup.sh`). |---|---|---| | `JWT_SECRET` | Yes | Secret for signing auth tokens (min 32 chars). | | `MEILI_MASTER_KEY` | Yes | Meilisearch master key. | -| `COOKIE_SECURE` | Yes | Use secure cookies (HTTPS). | +| `COOKIE_SECURE` | No | Use secure cookies (HTTPS). Defaults to `true`; set to `false` when serving over plain HTTP. | | `PORT` | Yes | Host port for the app. Default `3000`. | | `DATABASE_PATH` | Yes | SQLite database file path. | | `FILES_PATH` | Yes | Attachments storage path. | diff --git a/server/middleware/auth.ts b/server/middleware/auth.ts index 13c9dc7..5de69b3 100644 --- a/server/middleware/auth.ts +++ b/server/middleware/auth.ts @@ -1,4 +1,4 @@ -import { AUTH_COOKIE, setAuthCookie, verifyToken } from '../utils/auth' +import { AUTH_COOKIE, SESSION_REFRESH_AFTER_SECONDS, setAuthCookie, verifyToken } from '../utils/auth' export default defineEventHandler(async (event) => { const path = event.path @@ -24,6 +24,9 @@ export default defineEventHandler(async (event) => { throw createError({ statusCode: 401, statusMessage: 'Unauthorized' }) } - // Sliding session: refresh token on each authenticated request - await setAuthCookie(event) + // Sliding session: re-issue the token once a day instead of on every request + const issuedAt = typeof payload.iat === 'number' ? payload.iat : 0 + if (Date.now() / 1000 - issuedAt > SESSION_REFRESH_AFTER_SECONDS) { + await setAuthCookie(event) + } }) diff --git a/server/utils/auth.ts b/server/utils/auth.ts index dcebca9..5191086 100644 --- a/server/utils/auth.ts +++ b/server/utils/auth.ts @@ -2,6 +2,8 @@ import type { H3Event } from 'h3' import { SignJWT, jwtVerify } from 'jose' export const SESSION_MAX_AGE_SECONDS = 60 * 60 * 24 * 30 // 30 days +/** Sliding session: re-issue the token once it is older than this. */ +export const SESSION_REFRESH_AFTER_SECONDS = 60 * 60 * 24 // 1 day export const AUTH_COOKIE = 'auth_token' export function getJwtSecret(): Uint8Array { @@ -25,7 +27,7 @@ export async function signToken(payload: Record): Promise | null> { try { - const { payload } = await jwtVerify(token, getJwtSecret()) + const { payload } = await jwtVerify(token, getJwtSecret(), { algorithms: ['HS256'] }) return payload as Record } catch { return null @@ -38,7 +40,8 @@ function authCookieOptions(maxAge: number) { sameSite: 'lax' as const, path: '/', maxAge, - secure: process.env.COOKIE_SECURE === 'true', + // Secure by default; plain-HTTP installs must opt out with COOKIE_SECURE=false + secure: process.env.COOKIE_SECURE !== 'false', } } From 35d772f3866377c87fb820c89b9d53750c23ec42 Mon Sep 17 00:00:00 2001 From: Irfan Fadilah Date: Thu, 15 Oct 2026 09:49:27 +0700 Subject: [PATCH 10/13] Add Security Headers --- server/middleware/security.ts | 26 +++++++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/server/middleware/security.ts b/server/middleware/security.ts index 118a941..db1e4ae 100644 --- a/server/middleware/security.ts +++ b/server/middleware/security.ts @@ -1,7 +1,31 @@ +// Nuxt's hydration payload and the color-mode script are inline, so scripts and styles allow +// 'unsafe-inline'; everything else is restricted to this origin. Dev mode needs eval and HMR sockets. +const CONTENT_SECURITY_POLICY = [ + "default-src 'self'", + "script-src 'self' 'unsafe-inline'", + "style-src 'self' 'unsafe-inline'", + "img-src 'self' data: blob:", + "media-src 'self' blob:", + "font-src 'self' data:", + "connect-src 'self'", + "worker-src 'self'", + "manifest-src 'self'", + "frame-src 'self'", + "object-src 'none'", + "base-uri 'self'", + "form-action 'self'", + "frame-ancestors 'self'", +].join('; ') + export default defineEventHandler((event) => { setHeader(event, 'X-Frame-Options', 'SAMEORIGIN') setHeader(event, 'X-Content-Type-Options', 'nosniff') - setHeader(event, 'X-XSS-Protection', '1; mode=block') setHeader(event, 'Referrer-Policy', 'strict-origin-when-cross-origin') setHeader(event, 'Permissions-Policy', 'geolocation=(), microphone=(), camera=()') + if (!import.meta.dev) { + setHeader(event, 'Content-Security-Policy', CONTENT_SECURITY_POLICY) + } + if (getRequestProtocol(event, { xForwardedProto: true }) === 'https') { + setHeader(event, 'Strict-Transport-Security', 'max-age=31536000') + } }) From 956f5e3110a1b974ab93d59ab9dd8c30aa211dac Mon Sep 17 00:00:00 2001 From: Irfan Fadilah Date: Thu, 15 Oct 2026 09:49:27 +0700 Subject: [PATCH 11/13] Handle Note Save Errors --- app/components/note/CreateBar.vue | 21 ++++++++++++++------- app/components/note/NoteModal.vue | 25 ++++++++++++++++++------- 2 files changed, 32 insertions(+), 14 deletions(-) diff --git a/app/components/note/CreateBar.vue b/app/components/note/CreateBar.vue index e68a49b..e36ecbf 100644 --- a/app/components/note/CreateBar.vue +++ b/app/components/note/CreateBar.vue @@ -35,6 +35,7 @@ const showModal = ref(false) const initType = ref<'text' | 'checklist'>('text') const { createNote, uploadAttachment } = useNotes() const { startUpload, endUpload } = useNoteUploadState() +const { show: showSnackbar } = useSnackbar() const openModal = (type: 'text' | 'checklist' = 'text') => { initType.value = type @@ -44,13 +45,19 @@ const openModal = (type: 'text' | 'checklist' = 'text') => { const onSave = async (payload: NewNotePayload) => { showModal.value = false const labelIds = [...new Set([...(props.defaultLabelIds ?? []), ...payload.labelIds])] - const note = await createNote({ - type: payload.type, - title: payload.title, - content: payload.content, - labelIds, - checklistItems: payload.checklistItems, - }) + let note: Note + try { + note = await createNote({ + type: payload.type, + title: payload.title, + content: payload.content, + labelIds, + checklistItems: payload.checklistItems, + }) + } catch (e) { + showSnackbar(getErrorMessage(e, 'Failed to Create Note'), 'error') + return + } emit('created', note) if (payload.pendingFiles.length) { startUpload(note.id) diff --git a/app/components/note/NoteModal.vue b/app/components/note/NoteModal.vue index 4971651..c982878 100644 --- a/app/components/note/NoteModal.vue +++ b/app/components/note/NoteModal.vue @@ -193,6 +193,9 @@ const onUpdate = (payload: NoteEditorState) => { if (title.value.trim()) titleError.value = false } +// After a failed save the next close discards the edits instead of trapping the user in the modal +const saveFailed = ref(false) + const handleClose = async () => { if (isCurrentlyUploading.value) return if (props.readonly) { @@ -221,13 +224,21 @@ const handleClose = async () => { showSnackbar('Title is Required', 'error') return } - await updateNote(props.note.id, { - type: type.value, - title: title.value, - content: content.value, - labelIds: selectedLabelIds.value, - checklistItems: checklistItems.value.filter((i) => i.text.trim() !== ''), - }).catch(() => {}) + if (!saveFailed.value) { + try { + await updateNote(props.note.id, { + type: type.value, + title: title.value, + content: content.value, + labelIds: selectedLabelIds.value, + checklistItems: checklistItems.value.filter((i) => i.text.trim() !== ''), + }) + } catch (e) { + saveFailed.value = true + showSnackbar(`${getErrorMessage(e, 'Failed to Save Note')}. Close Again to Discard Changes`, 'error') + return + } + } } emit('close') } From 08f966604b1893b2fffebedee7caa5989936a2ad Mon Sep 17 00:00:00 2001 From: Irfan Fadilah Date: Thu, 15 Oct 2026 09:49:27 +0700 Subject: [PATCH 12/13] Redirect Signed Out Users on Server --- app/middleware/auth.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/app/middleware/auth.ts b/app/middleware/auth.ts index 0eb2577..541e8fa 100644 --- a/app/middleware/auth.ts +++ b/app/middleware/auth.ts @@ -2,6 +2,11 @@ export default defineNuxtRouteMiddleware(async () => { // useRequestFetch forwards browser cookies during SSR (hard refresh), // falling back to plain $fetch on client-side navigation. const apiFetch = useRequestFetch() - const { execute } = useAuthRequest() - await execute(() => apiFetch('/api/auth/me', { credentials: 'include' })) + try { + await apiFetch('/api/auth/me', { credentials: 'include' }) + } catch (error) { + // Returning navigateTo makes SSR answer with a redirect instead of rendering a 401 page + if (getErrorStatus(error) === 401) return navigateTo('/login') + throw error + } }) From 87ce1cb6aeb47a18f8a1e665fa60da0de188d969 Mon Sep 17 00:00:00 2001 From: Irfan Fadilah Date: Thu, 15 Oct 2026 09:49:27 +0700 Subject: [PATCH 13/13] Fix Client Bugs --- app/assets/css/main.css | 8 ++++++++ app/components/attachment/ImageGrid.vue | 10 +++++++++- app/components/auth/PinInput.vue | 6 ------ app/components/note/Checklist.vue | 9 +++++++-- app/error.vue | 23 ++++++++++++++++++++--- 5 files changed, 44 insertions(+), 12 deletions(-) diff --git a/app/assets/css/main.css b/app/assets/css/main.css index d00ccac..faf738c 100644 --- a/app/assets/css/main.css +++ b/app/assets/css/main.css @@ -154,3 +154,11 @@ ::-webkit-scrollbar-track { background: transparent; } ::-webkit-scrollbar-thumb { background-color: var(--nord-ice); border-radius: 9999px; } .dark ::-webkit-scrollbar-thumb { background-color: var(--nord-graphite); } + +/* Global so it reaches both the PIN input component and the mobile PIN dots */ +@keyframes shake { + 0%, 100% { transform: translateX(0); } + 20%, 60% { transform: translateX(-6px); } + 40%, 80% { transform: translateX(6px); } +} +.animate-shake { animation: shake 0.5s ease-in-out; } diff --git a/app/components/attachment/ImageGrid.vue b/app/components/attachment/ImageGrid.vue index db2c813..b52b796 100644 --- a/app/components/attachment/ImageGrid.vue +++ b/app/components/attachment/ImageGrid.vue @@ -11,7 +11,7 @@ class="w-full object-cover cursor-zoom-in" :class="visibleAttachments.length === 1 ? 'max-h-56' : 'h-32'" loading="lazy" - @error="(e) => (e.target as HTMLImageElement).src = `/api/files/${att.id}`" + @error="(e) => fallbackToOriginal(e, att.id)" @click.stop="openLightbox(idx)" > @@ -65,6 +65,14 @@