forked from mirror/crumbs-original
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:
co-authored by
Claude Opus 4.6
parent
d45bc6fa21
commit
b3d40999c9
@@ -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);
|
||||||
@@ -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": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -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)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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 });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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 };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 });
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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 });
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user