mirror of
https://github.com/bretzel-app/crumbs.git
synced 2026-10-12 20:06:57 +02:00
feat(db): add multi-user columns and login_attempts table
Add email, displayName, role, authProvider, providerId to users table. Add userId foreign key to notes, tags, attachments, syncLog tables. Add loginAttempts table for rate limiting. Include migration logic to detect old single-user schema and backfill existing data. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
committed by
Fred Cabassut
co-authored by
Claude Opus 4.6
parent
f21c98df19
commit
9405acdb4d
@@ -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 };
|
||||
|
||||
@@ -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)]
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user