fix: enforce note ownership across all API endpoints

- Add userId check to /api/notes/reorder (was completely unprotected)
- Scope syncNoteTags by userId to prevent cross-user tag pollution
- Verify attachmentId belongs to noteId in attachment operations (IDOR fix)
- Remove DEFAULT 0 from userId columns so missing userId fails loudly
- Update test helpers and test data to provide required userId

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Frederic Cabassut
2026-03-09 01:50:12 +01:00
co-authored by Claude Opus 4.6
parent d45bc6fa21
commit b3d40999c9
14 changed files with 799 additions and 46 deletions
+37
View File
@@ -0,0 +1,37 @@
CREATE TABLE `login_attempts` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`ip` text NOT NULL,
`email` text NOT NULL,
`success` integer NOT NULL,
`timestamp` integer NOT NULL
);
--> statement-breakpoint
CREATE INDEX `login_attempts_ip_timestamp_idx` ON `login_attempts` (`ip`,`timestamp`);--> statement-breakpoint
DROP INDEX `tags_name_unique`;--> statement-breakpoint
ALTER TABLE `tags` ADD `user_id` integer NOT NULL REFERENCES users(id);--> statement-breakpoint
CREATE UNIQUE INDEX `tags_name_user_unique` ON `tags` (`name`,`user_id`);--> statement-breakpoint
PRAGMA foreign_keys=OFF;--> statement-breakpoint
CREATE TABLE `__new_users` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`email` text DEFAULT '' NOT NULL,
`display_name` text DEFAULT '' NOT NULL,
`role` text DEFAULT 'user' NOT NULL,
`password_hash` text,
`auth_provider` text DEFAULT 'password' NOT NULL,
`provider_id` text,
`created_at` integer NOT NULL
);
--> statement-breakpoint
INSERT INTO `__new_users`("id", "email", "display_name", "role", "password_hash", "auth_provider", "provider_id", "created_at") SELECT "id", "email", "display_name", "role", "password_hash", "auth_provider", "provider_id", "created_at" FROM `users`;--> statement-breakpoint
DROP TABLE `users`;--> statement-breakpoint
ALTER TABLE `__new_users` RENAME TO `users`;--> statement-breakpoint
PRAGMA foreign_keys=ON;--> statement-breakpoint
ALTER TABLE `attachments` ADD `user_id` integer NOT NULL REFERENCES users(id);--> statement-breakpoint
ALTER TABLE `attachments` ADD `featured` integer DEFAULT false NOT NULL;--> statement-breakpoint
ALTER TABLE `notes` ADD `user_id` integer NOT NULL REFERENCES users(id);--> statement-breakpoint
CREATE INDEX `notes_user_id_idx` ON `notes` (`user_id`);--> statement-breakpoint
ALTER TABLE `sessions` ADD `created_at` integer;--> statement-breakpoint
ALTER TABLE `sessions` ADD `user_agent` text;--> statement-breakpoint
ALTER TABLE `sessions` ADD `ip` text;--> statement-breakpoint
ALTER TABLE `sessions` ADD `last_used_at` integer;--> statement-breakpoint
ALTER TABLE `sync_log` ADD `user_id` integer NOT NULL REFERENCES users(id);
+684
View File
@@ -0,0 +1,684 @@
{
"version": "6",
"dialect": "sqlite",
"id": "9eb400be-28ff-4b92-be35-1cd0b344f2e3",
"prevId": "682b02aa-a28d-4904-8996-cbc4d740f626",
"tables": {
"attachments": {
"name": "attachments",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"user_id": {
"name": "user_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"note_id": {
"name": "note_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"filename": {
"name": "filename",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"mime_type": {
"name": "mime_type",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"size": {
"name": "size",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"path": {
"name": "path",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"thumbnail_path": {
"name": "thumbnail_path",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"featured": {
"name": "featured",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"attachments_user_id_users_id_fk": {
"name": "attachments_user_id_users_id_fk",
"tableFrom": "attachments",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
},
"attachments_note_id_notes_id_fk": {
"name": "attachments_note_id_notes_id_fk",
"tableFrom": "attachments",
"tableTo": "notes",
"columnsFrom": [
"note_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"login_attempts": {
"name": "login_attempts",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"ip": {
"name": "ip",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"success": {
"name": "success",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"timestamp": {
"name": "timestamp",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"login_attempts_ip_timestamp_idx": {
"name": "login_attempts_ip_timestamp_idx",
"columns": [
"ip",
"timestamp"
],
"isUnique": false
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"note_tags": {
"name": "note_tags",
"columns": {
"note_id": {
"name": "note_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"tag_id": {
"name": "tag_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"note_tags_note_id_idx": {
"name": "note_tags_note_id_idx",
"columns": [
"note_id"
],
"isUnique": false
},
"note_tags_tag_id_idx": {
"name": "note_tags_tag_id_idx",
"columns": [
"tag_id"
],
"isUnique": false
}
},
"foreignKeys": {
"note_tags_note_id_notes_id_fk": {
"name": "note_tags_note_id_notes_id_fk",
"tableFrom": "note_tags",
"tableTo": "notes",
"columnsFrom": [
"note_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"note_tags_tag_id_tags_id_fk": {
"name": "note_tags_tag_id_tags_id_fk",
"tableFrom": "note_tags",
"tableTo": "tags",
"columnsFrom": [
"tag_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"notes": {
"name": "notes",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"user_id": {
"name": "user_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "''"
},
"content": {
"name": "content",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "''"
},
"color": {
"name": "color",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'default'"
},
"pinned": {
"name": "pinned",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"archived": {
"name": "archived",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"trashed": {
"name": "trashed",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"trashed_at": {
"name": "trashed_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"checklist_mode": {
"name": "checklist_mode",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"sort_order": {
"name": "sort_order",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"version": {
"name": "version",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 1
}
},
"indexes": {
"notes_user_id_idx": {
"name": "notes_user_id_idx",
"columns": [
"user_id"
],
"isUnique": false
},
"notes_trashed_archived_idx": {
"name": "notes_trashed_archived_idx",
"columns": [
"trashed",
"archived"
],
"isUnique": false
},
"notes_updated_at_idx": {
"name": "notes_updated_at_idx",
"columns": [
"updated_at"
],
"isUnique": false
}
},
"foreignKeys": {
"notes_user_id_users_id_fk": {
"name": "notes_user_id_users_id_fk",
"tableFrom": "notes",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"sessions": {
"name": "sessions",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"user_id": {
"name": "user_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"expires_at": {
"name": "expires_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"user_agent": {
"name": "user_agent",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"ip": {
"name": "ip",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"last_used_at": {
"name": "last_used_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"sessions_user_id_users_id_fk": {
"name": "sessions_user_id_users_id_fk",
"tableFrom": "sessions",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"sync_log": {
"name": "sync_log",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"user_id": {
"name": "user_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"note_id": {
"name": "note_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"operation": {
"name": "operation",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"timestamp": {
"name": "timestamp",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"client_id": {
"name": "client_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"sync_log_timestamp_idx": {
"name": "sync_log_timestamp_idx",
"columns": [
"timestamp"
],
"isUnique": false
}
},
"foreignKeys": {
"sync_log_user_id_users_id_fk": {
"name": "sync_log_user_id_users_id_fk",
"tableFrom": "sync_log",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
},
"sync_log_note_id_notes_id_fk": {
"name": "sync_log_note_id_notes_id_fk",
"tableFrom": "sync_log",
"tableTo": "notes",
"columnsFrom": [
"note_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"tags": {
"name": "tags",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"user_id": {
"name": "user_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"tags_name_user_unique": {
"name": "tags_name_user_unique",
"columns": [
"name",
"user_id"
],
"isUnique": true
}
},
"foreignKeys": {
"tags_user_id_users_id_fk": {
"name": "tags_user_id_users_id_fk",
"tableFrom": "tags",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"users": {
"name": "users",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "''"
},
"display_name": {
"name": "display_name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "''"
},
"role": {
"name": "role",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'user'"
},
"password_hash": {
"name": "password_hash",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"auth_provider": {
"name": "auth_provider",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'password'"
},
"provider_id": {
"name": "provider_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}
+7
View File
@@ -8,6 +8,13 @@
"when": 1773000424830, "when": 1773000424830,
"tag": "0000_wooden_vargas", "tag": "0000_wooden_vargas",
"breakpoints": true "breakpoints": true
},
{
"idx": 1,
"version": "6",
"when": 1773016788899,
"tag": "0001_strange_lord_hawal",
"breakpoints": true
} }
] ]
} }
+12 -8
View File
@@ -4,7 +4,7 @@ import { join } from 'path';
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
import { db } from './db/index.js'; import { db } from './db/index.js';
import { attachments } from './db/schema.js'; import { attachments } from './db/schema.js';
import { eq, inArray } from 'drizzle-orm'; import { eq, and, inArray } from 'drizzle-orm';
import type { Attachment } from '$lib/types/index.js'; import type { Attachment } from '$lib/types/index.js';
const DATA_DIR = process.env.DATA_DIR || './data'; const DATA_DIR = process.env.DATA_DIR || './data';
@@ -80,25 +80,29 @@ export function fetchAttachmentsForNotes(noteIds: string[]): Map<string, Attachm
return map; return map;
} }
export async function getAttachment(id: string) { export async function getAttachment(id: string, noteId: string) {
return db.select().from(attachments).where(eq(attachments.id, id)).get(); return db
.select()
.from(attachments)
.where(and(eq(attachments.id, id), eq(attachments.noteId, noteId)))
.get();
} }
export async function getAttachmentsByNote(noteId: string) { export async function getAttachmentsByNote(noteId: string) {
return db.select().from(attachments).where(eq(attachments.noteId, noteId)); return db.select().from(attachments).where(eq(attachments.noteId, noteId));
} }
export async function updateAttachment(id: string, data: { featured: boolean }) { export async function updateAttachment(id: string, noteId: string, data: { featured: boolean }) {
const [updated] = await db const [updated] = await db
.update(attachments) .update(attachments)
.set({ featured: data.featured }) .set({ featured: data.featured })
.where(eq(attachments.id, id)) .where(and(eq(attachments.id, id), eq(attachments.noteId, noteId)))
.returning(); .returning();
return updated ?? null; return updated ?? null;
} }
export async function deleteAttachment(id: string) { export async function deleteAttachment(id: string, noteId: string) {
const attachment = await getAttachment(id); const attachment = await getAttachment(id, noteId);
if (attachment) { if (attachment) {
try { try {
await unlink(attachment.path); await unlink(attachment.path);
@@ -112,6 +116,6 @@ export async function deleteAttachment(id: string) {
// Thumbnail may already be deleted // Thumbnail may already be deleted
} }
} }
await db.delete(attachments).where(eq(attachments.id, id)); await db.delete(attachments).where(and(eq(attachments.id, id), eq(attachments.noteId, noteId)));
} }
} }
+18 -6
View File
@@ -9,7 +9,7 @@ import type * as schema from './schema.js';
let db: BetterSQLite3Database<typeof schema>; let db: BetterSQLite3Database<typeof schema>;
beforeEach(() => { beforeEach(() => {
const testDb = createTestDb(); const testDb = createTestDb({ seedUser: true });
db = testDb.db; db = testDb.db;
}); });
@@ -20,6 +20,7 @@ describe('Notes CRUD', () => {
await db.insert(notes).values({ await db.insert(notes).values({
id, id,
userId: 1,
title: 'Test Note', title: 'Test Note',
content: 'Hello world', content: 'Hello world',
createdAt: now, createdAt: now,
@@ -45,6 +46,7 @@ describe('Notes CRUD', () => {
await db.insert(notes).values({ await db.insert(notes).values({
id, id,
userId: 1,
title: 'Colored Note', title: 'Colored Note',
content: '', content: '',
color: 'coral', color: 'coral',
@@ -62,16 +64,17 @@ describe('Notes CRUD', () => {
it('should read all active notes (not archived, not trashed)', async () => { it('should read all active notes (not archived, not trashed)', async () => {
const now = new Date(); const now = new Date();
await db.insert(notes).values([ await db.insert(notes).values([
{ id: 'n1', title: 'Active', content: '', createdAt: now, updatedAt: now }, { id: 'n1', userId: 1, title: 'Active', content: '', createdAt: now, updatedAt: now },
{ {
id: 'n2', id: 'n2',
userId: 1,
title: 'Archived', title: 'Archived',
content: '', content: '',
archived: true, archived: true,
createdAt: now, createdAt: now,
updatedAt: now updatedAt: now
}, },
{ id: 'n3', title: 'Trashed', content: '', trashed: true, createdAt: now, updatedAt: now } { id: 'n3', userId: 1, title: 'Trashed', content: '', trashed: true, createdAt: now, updatedAt: now }
]); ]);
const active = await db const active = await db
@@ -89,6 +92,7 @@ describe('Notes CRUD', () => {
await db.insert(notes).values({ await db.insert(notes).values({
id, id,
userId: 1,
title: 'Original', title: 'Original',
content: '', content: '',
createdAt: now, createdAt: now,
@@ -113,6 +117,7 @@ describe('Notes CRUD', () => {
await db.insert(notes).values({ await db.insert(notes).values({
id, id,
userId: 1,
title: 'To Trash', title: 'To Trash',
content: '', content: '',
createdAt: now, createdAt: now,
@@ -132,6 +137,7 @@ describe('Notes CRUD', () => {
await db.insert(notes).values({ await db.insert(notes).values({
id, id,
userId: 1,
title: 'To Delete', title: 'To Delete',
content: '', content: '',
createdAt: now, createdAt: now,
@@ -149,6 +155,7 @@ describe('Notes CRUD', () => {
await db.insert(notes).values({ await db.insert(notes).values({
id, id,
userId: 1,
title: 'To Archive', title: 'To Archive',
content: '', content: '',
createdAt: now, createdAt: now,
@@ -170,6 +177,7 @@ describe('Notes CRUD', () => {
await db.insert(notes).values({ await db.insert(notes).values({
id, id,
userId: 1,
title: 'Pin Me', title: 'Pin Me',
content: '', content: '',
createdAt: now, createdAt: now,
@@ -193,6 +201,7 @@ describe('Notes with Tags', () => {
await db.insert(notes).values({ await db.insert(notes).values({
id: noteId, id: noteId,
userId: 1,
title: 'Tagged Note', title: 'Tagged Note',
content: '', content: '',
createdAt: now, createdAt: now,
@@ -200,8 +209,8 @@ describe('Notes with Tags', () => {
}); });
// Create tags // Create tags
const [tag1] = await db.insert(tags).values({ name: 'work' }).returning(); const [tag1] = await db.insert(tags).values({ userId: 1, name: 'work' }).returning();
const [tag2] = await db.insert(tags).values({ name: 'important' }).returning(); const [tag2] = await db.insert(tags).values({ userId: 1, name: 'important' }).returning();
// Associate // Associate
await db.insert(noteTags).values([ await db.insert(noteTags).values([
@@ -226,13 +235,14 @@ describe('Notes with Tags', () => {
await db.insert(notes).values({ await db.insert(notes).values({
id: noteId, id: noteId,
userId: 1,
title: '', title: '',
content: '', content: '',
createdAt: now, createdAt: now,
updatedAt: now updatedAt: now
}); });
const [tag] = await db.insert(tags).values({ name: 'temp' }).returning(); const [tag] = await db.insert(tags).values({ userId: 1, name: 'temp' }).returning();
await db.insert(noteTags).values({ noteId, tagId: tag.id }); await db.insert(noteTags).values({ noteId, tagId: tag.id });
// Delete note_tags first (FK cascade is disabled in test DB), then note // Delete note_tags first (FK cascade is disabled in test DB), then note
@@ -258,6 +268,7 @@ describe('Note ordering', () => {
await db.insert(notes).values([ await db.insert(notes).values([
{ {
id: 'unpinned', id: 'unpinned',
userId: 1,
title: 'Unpinned', title: 'Unpinned',
content: '', content: '',
pinned: false, pinned: false,
@@ -266,6 +277,7 @@ describe('Note ordering', () => {
}, },
{ {
id: 'pinned', id: 'pinned',
userId: 1,
title: 'Pinned', title: 'Pinned',
content: '', content: '',
pinned: true, pinned: true,
+4 -8
View File
@@ -29,8 +29,7 @@ export const notes = sqliteTable(
id: text('id').primaryKey(), id: text('id').primaryKey(),
userId: integer('user_id') userId: integer('user_id')
.references(() => users.id) .references(() => users.id)
.notNull() .notNull(),
.default(0),
title: text('title').default('').notNull(), title: text('title').default('').notNull(),
content: text('content').default('').notNull(), content: text('content').default('').notNull(),
color: text('color').default('default').notNull(), color: text('color').default('default').notNull(),
@@ -57,8 +56,7 @@ export const tags = sqliteTable(
id: integer('id').primaryKey({ autoIncrement: true }), id: integer('id').primaryKey({ autoIncrement: true }),
userId: integer('user_id') userId: integer('user_id')
.references(() => users.id) .references(() => users.id)
.notNull() .notNull(),
.default(0),
name: text('name').notNull() name: text('name').notNull()
}, },
(table) => [uniqueIndex('tags_name_user_unique').on(table.name, table.userId)] (table) => [uniqueIndex('tags_name_user_unique').on(table.name, table.userId)]
@@ -84,8 +82,7 @@ export const attachments = sqliteTable('attachments', {
id: text('id').primaryKey(), id: text('id').primaryKey(),
userId: integer('user_id') userId: integer('user_id')
.references(() => users.id) .references(() => users.id)
.notNull() .notNull(),
.default(0),
noteId: text('note_id') noteId: text('note_id')
.references(() => notes.id, { onDelete: 'cascade' }) .references(() => notes.id, { onDelete: 'cascade' })
.notNull(), .notNull(),
@@ -104,8 +101,7 @@ export const syncLog = sqliteTable(
id: integer('id').primaryKey({ autoIncrement: true }), id: integer('id').primaryKey({ autoIncrement: true }),
userId: integer('user_id') userId: integer('user_id')
.references(() => users.id) .references(() => users.id)
.notNull() .notNull(),
.default(0),
noteId: text('note_id') noteId: text('note_id')
.references(() => notes.id) .references(() => notes.id)
.notNull(), .notNull(),
+6 -6
View File
@@ -8,18 +8,18 @@ import type * as schema from './schema.js';
let db: BetterSQLite3Database<typeof schema>; let db: BetterSQLite3Database<typeof schema>;
beforeEach(async () => { beforeEach(async () => {
const testDb = createTestDb(); const testDb = createTestDb({ seedUser: true });
db = testDb.db; db = testDb.db;
const now = new Date(); const now = new Date();
await db.insert(notes).values([ await db.insert(notes).values([
{ id: 'n1', title: 'Shopping List', content: 'Buy milk and eggs', createdAt: now, updatedAt: now }, { id: 'n1', userId: 1, title: 'Shopping List', content: 'Buy milk and eggs', createdAt: now, updatedAt: now },
{ id: 'n2', title: 'Work Notes', content: 'Meeting at 3pm #work', createdAt: now, updatedAt: now }, { id: 'n2', userId: 1, title: 'Work Notes', content: 'Meeting at 3pm #work', createdAt: now, updatedAt: now },
{ id: 'n3', title: 'Recipe', content: 'Pasta with tomato sauce', createdAt: now, updatedAt: now }, { id: 'n3', userId: 1, title: 'Recipe', content: 'Pasta with tomato sauce', createdAt: now, updatedAt: now },
{ id: 'n4', title: 'Trashed', content: 'Should not appear', trashed: true, createdAt: now, updatedAt: now } { id: 'n4', userId: 1, title: 'Trashed', content: 'Should not appear', trashed: true, createdAt: now, updatedAt: now }
]); ]);
const [workTag] = await db.insert(tags).values({ name: 'work' }).returning(); const [workTag] = await db.insert(tags).values({ userId: 1, name: 'work' }).returning();
await db.insert(noteTags).values({ noteId: 'n2', tagId: workTag.id }); await db.insert(noteTags).values({ noteId: 'n2', tagId: workTag.id });
}); });
+9 -5
View File
@@ -6,7 +6,7 @@ import * as schema from './schema.js';
* Create an in-memory SQLite database for testing. * Create an in-memory SQLite database for testing.
* Each test gets a fresh database. * Each test gets a fresh database.
*/ */
export function createTestDb() { export function createTestDb({ seedUser = false }: { seedUser?: boolean } = {}) {
const sqlite = new Database(':memory:'); const sqlite = new Database(':memory:');
sqlite.pragma('journal_mode = WAL'); sqlite.pragma('journal_mode = WAL');
sqlite.pragma('foreign_keys = OFF'); sqlite.pragma('foreign_keys = OFF');
@@ -36,7 +36,7 @@ export function createTestDb() {
CREATE TABLE notes ( CREATE TABLE notes (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
user_id INTEGER NOT NULL DEFAULT 0 REFERENCES users(id), user_id INTEGER NOT NULL REFERENCES users(id),
title TEXT NOT NULL DEFAULT '', title TEXT NOT NULL DEFAULT '',
content TEXT NOT NULL DEFAULT '', content TEXT NOT NULL DEFAULT '',
color TEXT NOT NULL DEFAULT 'default', color TEXT NOT NULL DEFAULT 'default',
@@ -56,7 +56,7 @@ export function createTestDb() {
CREATE TABLE tags ( CREATE TABLE tags (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL DEFAULT 0 REFERENCES users(id), user_id INTEGER NOT NULL REFERENCES users(id),
name TEXT NOT NULL name TEXT NOT NULL
); );
CREATE UNIQUE INDEX tags_name_user_unique ON tags(name, user_id); CREATE UNIQUE INDEX tags_name_user_unique ON tags(name, user_id);
@@ -70,7 +70,7 @@ export function createTestDb() {
CREATE TABLE attachments ( CREATE TABLE attachments (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
user_id INTEGER NOT NULL DEFAULT 0 REFERENCES users(id), user_id INTEGER NOT NULL REFERENCES users(id),
note_id TEXT NOT NULL REFERENCES notes(id) ON DELETE CASCADE, note_id TEXT NOT NULL REFERENCES notes(id) ON DELETE CASCADE,
filename TEXT NOT NULL, filename TEXT NOT NULL,
mime_type TEXT NOT NULL, mime_type TEXT NOT NULL,
@@ -81,7 +81,7 @@ export function createTestDb() {
CREATE TABLE sync_log ( CREATE TABLE sync_log (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL DEFAULT 0 REFERENCES users(id), user_id INTEGER NOT NULL REFERENCES users(id),
note_id TEXT NOT NULL REFERENCES notes(id), note_id TEXT NOT NULL REFERENCES notes(id),
operation TEXT NOT NULL, operation TEXT NOT NULL,
timestamp INTEGER NOT NULL, timestamp INTEGER NOT NULL,
@@ -99,6 +99,10 @@ export function createTestDb() {
CREATE INDEX login_attempts_ip_timestamp_idx ON login_attempts(ip, timestamp); CREATE INDEX login_attempts_ip_timestamp_idx ON login_attempts(ip, timestamp);
`); `);
if (seedUser) {
sqlite.prepare(`INSERT INTO users (id, email, created_at) VALUES (?, ?, ?)`).run(1, 'test@test.com', 0);
}
const db = drizzle(sqlite, { schema }); const db = drizzle(sqlite, { schema });
return { db, sqlite }; return { db, sqlite };
} }
+8 -4
View File
@@ -1,6 +1,6 @@
import { db } from './db/index.js'; import { db } from './db/index.js';
import { noteTags, tags } from './db/schema.js'; import { noteTags, tags } from './db/schema.js';
import { eq, inArray } from 'drizzle-orm'; import { eq, and, inArray } from 'drizzle-orm';
/** /**
* Batch fetch tags for multiple notes in a single query. * Batch fetch tags for multiple notes in a single query.
@@ -32,13 +32,17 @@ export function fetchTagsForNotes(noteIds: string[]): Map<string, string[]> {
* Sync tags for a note: removes old associations, upserts tags, creates new associations. * Sync tags for a note: removes old associations, upserts tags, creates new associations.
* Should be called within a transaction for atomicity. * Should be called within a transaction for atomicity.
*/ */
export function syncNoteTags(noteId: string, tagNames: string[]) { export function syncNoteTags(noteId: string, tagNames: string[], userId: number) {
db.delete(noteTags).where(eq(noteTags.noteId, noteId)).run(); db.delete(noteTags).where(eq(noteTags.noteId, noteId)).run();
for (const name of tagNames) { for (const name of tagNames) {
let tagRow = db.select().from(tags).where(eq(tags.name, name)).get(); let tagRow = db
.select()
.from(tags)
.where(and(eq(tags.name, name), eq(tags.userId, userId)))
.get();
if (!tagRow) { if (!tagRow) {
const result = db.insert(tags).values({ name }).returning().all(); const result = db.insert(tags).values({ name, userId }).returning().all();
tagRow = result[0]; tagRow = result[0];
} }
db.insert(noteTags).values({ noteId, tagId: tagRow.id }).run(); db.insert(noteTags).values({ noteId, tagId: tagRow.id }).run();
+1 -1
View File
@@ -81,7 +81,7 @@ export async function processSyncPush(changes: SyncQueueItem[], userId: number):
const note = db.select().from(notes).where(and(eq(notes.id, noteId), eq(notes.userId, userId))).get(); const note = db.select().from(notes).where(and(eq(notes.id, noteId), eq(notes.userId, userId))).get();
if (note) { if (note) {
const content = `${note.title} ${note.content}`; const content = `${note.title} ${note.content}`;
syncNoteTags(noteId, extractTags(content)); syncNoteTags(noteId, extractTags(content), userId);
} }
} }
} }
+1 -1
View File
@@ -73,7 +73,7 @@ export const POST: RequestHandler = async ({ request, ...event }) => {
db.transaction((tx) => { db.transaction((tx) => {
tx.insert(notes).values(newNote).run(); tx.insert(notes).values(newNote).run();
}); });
syncNoteTags(id, extractedTags); syncNoteTags(id, extractedTags, userId);
return json({ ...newNote, tags: extractedTags }, { status: 201 }); return json({ ...newNote, tags: extractedTags }, { status: 201 });
}; };
+1 -1
View File
@@ -68,7 +68,7 @@ export const PATCH: RequestHandler = async ({ params, request, ...event }) => {
if (body.title !== undefined || body.content !== undefined) { if (body.title !== undefined || body.content !== undefined) {
const content = `${updated!.title} ${updated!.content}`; const content = `${updated!.title} ${updated!.content}`;
const extractedTags = extractTags(content); const extractedTags = extractTags(content);
syncNoteTags(params.id, extractedTags); syncNoteTags(params.id, extractedTags, userId);
} }
const tagMap = fetchTagsForNotes([params.id]); const tagMap = fetchTagsForNotes([params.id]);
@@ -25,7 +25,7 @@ export const GET: RequestHandler = async ({ params, url, ...event }) => {
const attachmentId = url.searchParams.get('attachmentId'); const attachmentId = url.searchParams.get('attachmentId');
if (attachmentId) { if (attachmentId) {
const attachment = await getAttachment(attachmentId); const attachment = await getAttachment(attachmentId, params.id);
if (!attachment) throw error(404, 'Attachment not found'); if (!attachment) throw error(404, 'Attachment not found');
const wantThumb = url.searchParams.get('thumb') === '1'; const wantThumb = url.searchParams.get('thumb') === '1';
@@ -81,7 +81,7 @@ export const PATCH: RequestHandler = async ({ params, url, request, ...event })
const body = await request.json(); const body = await request.json();
if (typeof body.featured !== 'boolean') throw error(400, 'featured (boolean) required'); if (typeof body.featured !== 'boolean') throw error(400, 'featured (boolean) required');
const updated = await updateAttachment(attachmentId, { featured: body.featured }); const updated = await updateAttachment(attachmentId, params.id, { featured: body.featured });
if (!updated) throw error(404, 'Attachment not found'); if (!updated) throw error(404, 'Attachment not found');
return json(updated); return json(updated);
@@ -94,6 +94,6 @@ export const DELETE: RequestHandler = async ({ params, url, ...event }) => {
const attachmentId = url.searchParams.get('attachmentId'); const attachmentId = url.searchParams.get('attachmentId');
if (!attachmentId) throw error(400, 'attachmentId required'); if (!attachmentId) throw error(400, 'attachmentId required');
await deleteAttachment(attachmentId); await deleteAttachment(attachmentId, params.id);
return json({ success: true }); return json({ success: true });
}; };
+8 -3
View File
@@ -2,9 +2,11 @@ import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types.js'; import type { RequestHandler } from './$types.js';
import { db } from '$lib/server/db/index.js'; import { db } from '$lib/server/db/index.js';
import { notes } from '$lib/server/db/schema.js'; import { notes } from '$lib/server/db/schema.js';
import { eq } from 'drizzle-orm'; import { eq, and } from 'drizzle-orm';
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 body = await request.json(); const body = await request.json();
const orders: { id: string; sortOrder: number }[] = body.orders; const orders: { id: string; sortOrder: number }[] = body.orders;
@@ -14,7 +16,10 @@ export const POST: RequestHandler = async ({ request }) => {
db.transaction((tx) => { db.transaction((tx) => {
for (const { id, sortOrder } of orders) { for (const { id, sortOrder } of orders) {
tx.update(notes).set({ sortOrder }).where(eq(notes.id, id)).run(); tx.update(notes)
.set({ sortOrder })
.where(and(eq(notes.id, id), eq(notes.userId, userId)))
.run();
} }
}); });