diff --git a/src/lib/server/db/index.ts b/src/lib/server/db/index.ts index 1ec3ff5..3b6d0fd 100644 --- a/src/lib/server/db/index.ts +++ b/src/lib/server/db/index.ts @@ -22,7 +22,12 @@ sqlite.pragma('foreign_keys = ON'); sqlite.exec(` CREATE TABLE IF NOT EXISTS users ( id INTEGER PRIMARY KEY AUTOINCREMENT, - password_hash TEXT NOT NULL, + email TEXT NOT NULL DEFAULT '', + display_name TEXT NOT NULL DEFAULT '', + role TEXT NOT NULL DEFAULT 'user', + password_hash TEXT, + auth_provider TEXT NOT NULL DEFAULT 'password', + provider_id TEXT, created_at INTEGER NOT NULL ); @@ -34,6 +39,7 @@ sqlite.exec(` CREATE TABLE IF NOT EXISTS notes ( id TEXT PRIMARY KEY, + user_id INTEGER NOT NULL DEFAULT 0 REFERENCES users(id), title TEXT NOT NULL DEFAULT '', content TEXT NOT NULL DEFAULT '', color TEXT NOT NULL DEFAULT 'default', @@ -50,9 +56,9 @@ sqlite.exec(` CREATE TABLE IF NOT EXISTS tags ( id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL DEFAULT 0 REFERENCES users(id), name TEXT NOT NULL ); - CREATE UNIQUE INDEX IF NOT EXISTS tags_name_unique ON tags(name); CREATE TABLE IF NOT EXISTS note_tags ( note_id TEXT NOT NULL REFERENCES notes(id) ON DELETE CASCADE, @@ -61,30 +67,53 @@ sqlite.exec(` CREATE TABLE IF NOT EXISTS attachments ( id TEXT PRIMARY KEY, + user_id INTEGER NOT NULL DEFAULT 0 REFERENCES users(id), note_id TEXT NOT NULL REFERENCES notes(id) ON DELETE CASCADE, filename TEXT NOT NULL, mime_type TEXT NOT NULL, size INTEGER NOT NULL, path TEXT NOT NULL, thumbnail_path TEXT, + featured INTEGER NOT NULL DEFAULT 0, created_at INTEGER NOT NULL ); CREATE TABLE IF NOT EXISTS sync_log ( id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL DEFAULT 0 REFERENCES users(id), note_id TEXT NOT NULL REFERENCES notes(id), operation TEXT NOT NULL, timestamp INTEGER NOT NULL, client_id TEXT NOT NULL ); + CREATE TABLE IF NOT EXISTS login_attempts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + ip TEXT NOT NULL, + email TEXT NOT NULL, + success INTEGER NOT NULL, + timestamp INTEGER NOT NULL + ); + + CREATE INDEX IF NOT EXISTS notes_user_id_idx ON notes(user_id); CREATE INDEX IF NOT EXISTS notes_trashed_archived_idx ON notes(trashed, archived); CREATE INDEX IF NOT EXISTS notes_updated_at_idx ON notes(updated_at); CREATE INDEX IF NOT EXISTS note_tags_note_id_idx ON note_tags(note_id); CREATE INDEX IF NOT EXISTS note_tags_tag_id_idx ON note_tags(tag_id); CREATE INDEX IF NOT EXISTS sync_log_timestamp_idx ON sync_log(timestamp); + CREATE INDEX IF NOT EXISTS login_attempts_ip_timestamp_idx ON login_attempts(ip, timestamp); `); +// Drop the old unique index on tags(name) if it exists, replace with compound index +try { + sqlite.exec(`DROP INDEX IF EXISTS tags_name_unique`); +} catch { + // Index may not exist +} +sqlite.exec( + `CREATE UNIQUE INDEX IF NOT EXISTS tags_name_user_unique ON tags(name, user_id)` +); + // Idempotent migrations: add columns that may be missing from older DBs try { sqlite.exec(`ALTER TABLE attachments ADD COLUMN thumbnail_path TEXT;`); @@ -97,5 +126,51 @@ try { // Column already exists } +// Migration: detect old schema (no email column on users) and migrate +const userColumns = sqlite + .prepare("PRAGMA table_info('users')") + .all() as { name: string }[]; +const hasEmail = userColumns.some((col) => col.name === 'email'); + +if (!hasEmail) { + // Old single-user schema — add new columns + sqlite.exec(` + ALTER TABLE users ADD COLUMN email TEXT NOT NULL DEFAULT ''; + ALTER TABLE users ADD COLUMN display_name TEXT NOT NULL DEFAULT ''; + ALTER TABLE users ADD COLUMN role TEXT NOT NULL DEFAULT 'user'; + ALTER TABLE users ADD COLUMN auth_provider TEXT NOT NULL DEFAULT 'password'; + ALTER TABLE users ADD COLUMN provider_id TEXT; + `); + + // Check if notes table has user_id column + const notesColumns = sqlite + .prepare("PRAGMA table_info('notes')") + .all() as { name: string }[]; + const notesHasUserId = notesColumns.some((col) => col.name === 'user_id'); + + if (!notesHasUserId) { + sqlite.exec(` + ALTER TABLE notes ADD COLUMN user_id INTEGER NOT NULL DEFAULT 0 REFERENCES users(id); + ALTER TABLE tags ADD COLUMN user_id INTEGER NOT NULL DEFAULT 0 REFERENCES users(id); + ALTER TABLE attachments ADD COLUMN user_id INTEGER NOT NULL DEFAULT 0 REFERENCES users(id); + ALTER TABLE sync_log ADD COLUMN user_id INTEGER NOT NULL DEFAULT 0 REFERENCES users(id); + `); + } + + // Backfill: promote existing user to admin and assign all data to them + const existingUser = sqlite.prepare('SELECT id FROM users LIMIT 1').get() as + | { id: number } + | undefined; + if (existingUser) { + const uid = existingUser.id; + const backfill = sqlite.prepare('UPDATE users SET role = ? WHERE id = ?'); + backfill.run('admin', uid); + + for (const table of ['notes', 'tags', 'attachments', 'sync_log']) { + sqlite.prepare(`UPDATE ${table} SET user_id = ? WHERE user_id = 0`).run(uid); + } + } +} + export const db = drizzle(sqlite, { schema }); export { sqlite }; diff --git a/src/lib/server/db/schema.ts b/src/lib/server/db/schema.ts index 3ce57b3..401dd48 100644 --- a/src/lib/server/db/schema.ts +++ b/src/lib/server/db/schema.ts @@ -2,7 +2,12 @@ import { sqliteTable, text, integer, uniqueIndex, index } from 'drizzle-orm/sqli export const users = sqliteTable('users', { id: integer('id').primaryKey({ autoIncrement: true }), - passwordHash: text('password_hash').notNull(), + email: text('email').notNull().default(''), + displayName: text('display_name').notNull().default(''), + role: text('role', { enum: ['admin', 'user'] }).notNull().default('user'), + passwordHash: text('password_hash'), + authProvider: text('auth_provider').notNull().default('password'), + providerId: text('provider_id'), createdAt: integer('created_at', { mode: 'timestamp' }).notNull() }); @@ -18,6 +23,10 @@ export const notes = sqliteTable( 'notes', { id: text('id').primaryKey(), + userId: integer('user_id') + .references(() => users.id) + .notNull() + .default(0), title: text('title').default('').notNull(), content: text('content').default('').notNull(), color: text('color').default('default').notNull(), @@ -32,6 +41,7 @@ export const notes = sqliteTable( version: integer('version').default(1).notNull() }, (table) => [ + index('notes_user_id_idx').on(table.userId), index('notes_trashed_archived_idx').on(table.trashed, table.archived), index('notes_updated_at_idx').on(table.updatedAt) ] @@ -41,9 +51,13 @@ export const tags = sqliteTable( 'tags', { id: integer('id').primaryKey({ autoIncrement: true }), + userId: integer('user_id') + .references(() => users.id) + .notNull() + .default(0), name: text('name').notNull() }, - (table) => [uniqueIndex('tags_name_unique').on(table.name)] + (table) => [uniqueIndex('tags_name_user_unique').on(table.name, table.userId)] ); export const noteTags = sqliteTable( @@ -64,6 +78,10 @@ export const noteTags = sqliteTable( export const attachments = sqliteTable('attachments', { id: text('id').primaryKey(), + userId: integer('user_id') + .references(() => users.id) + .notNull() + .default(0), noteId: text('note_id') .references(() => notes.id, { onDelete: 'cascade' }) .notNull(), @@ -80,6 +98,10 @@ export const syncLog = sqliteTable( 'sync_log', { id: integer('id').primaryKey({ autoIncrement: true }), + userId: integer('user_id') + .references(() => users.id) + .notNull() + .default(0), noteId: text('note_id') .references(() => notes.id) .notNull(), @@ -89,3 +111,15 @@ export const syncLog = sqliteTable( }, (table) => [index('sync_log_timestamp_idx').on(table.timestamp)] ); + +export const loginAttempts = sqliteTable( + 'login_attempts', + { + id: integer('id').primaryKey({ autoIncrement: true }), + ip: text('ip').notNull(), + email: text('email').notNull(), + success: integer('success', { mode: 'boolean' }).notNull(), + timestamp: integer('timestamp', { mode: 'timestamp' }).notNull() + }, + (table) => [index('login_attempts_ip_timestamp_idx').on(table.ip, table.timestamp)] +);