diff --git a/drizzle/postgres/0000_stormy_zaladane.sql b/drizzle/postgres/0000_famous_justin_hammer.sql similarity index 96% rename from drizzle/postgres/0000_stormy_zaladane.sql rename to drizzle/postgres/0000_famous_justin_hammer.sql index 032e794..a52c405 100644 --- a/drizzle/postgres/0000_stormy_zaladane.sql +++ b/drizzle/postgres/0000_famous_justin_hammer.sql @@ -134,6 +134,7 @@ CREATE TABLE "verification" ( ); --> statement-breakpoint ALTER TABLE "audiobook_chapters" ADD CONSTRAINT "audiobook_chapters_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "audiobook_chapters" ADD CONSTRAINT "audiobook_chapters_book_id_user_id_audiobooks_id_user_id_fk" FOREIGN KEY ("book_id","user_id") REFERENCES "public"."audiobooks"("id","user_id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint ALTER TABLE "audiobooks" ADD CONSTRAINT "audiobooks_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint ALTER TABLE "documents" ADD CONSTRAINT "documents_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint ALTER TABLE "user_document_progress" ADD CONSTRAINT "user_document_progress_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint diff --git a/drizzle/postgres/meta/0000_snapshot.json b/drizzle/postgres/meta/0000_snapshot.json index f8fc122..00399f8 100644 --- a/drizzle/postgres/meta/0000_snapshot.json +++ b/drizzle/postgres/meta/0000_snapshot.json @@ -1,5 +1,5 @@ { - "id": "369ec851-a528-4b18-a4c9-b8ea703283ca", + "id": "ad9a34b6-48d9-4e3a-95cd-38941cb2e055", "prevId": "00000000-0000-0000-0000-000000000000", "version": "7", "dialect": "postgresql", @@ -72,6 +72,21 @@ ], "onDelete": "cascade", "onUpdate": "no action" + }, + "audiobook_chapters_book_id_user_id_audiobooks_id_user_id_fk": { + "name": "audiobook_chapters_book_id_user_id_audiobooks_id_user_id_fk", + "tableFrom": "audiobook_chapters", + "tableTo": "audiobooks", + "columnsFrom": [ + "book_id", + "user_id" + ], + "columnsTo": [ + "id", + "user_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" } }, "compositePrimaryKeys": { diff --git a/drizzle/postgres/meta/_journal.json b/drizzle/postgres/meta/_journal.json index 37531a2..92851a2 100644 --- a/drizzle/postgres/meta/_journal.json +++ b/drizzle/postgres/meta/_journal.json @@ -5,8 +5,8 @@ { "idx": 0, "version": "7", - "when": 1771185356470, - "tag": "0000_stormy_zaladane", + "when": 1771188820113, + "tag": "0000_famous_justin_hammer", "breakpoints": true } ] diff --git a/drizzle/sqlite/0000_furry_dragon_man.sql b/drizzle/sqlite/0000_nervous_preak.sql similarity index 97% rename from drizzle/sqlite/0000_furry_dragon_man.sql rename to drizzle/sqlite/0000_nervous_preak.sql index 66e71af..c7dab2b 100644 --- a/drizzle/sqlite/0000_furry_dragon_man.sql +++ b/drizzle/sqlite/0000_nervous_preak.sql @@ -8,7 +8,8 @@ CREATE TABLE `audiobook_chapters` ( `file_path` text NOT NULL, `format` text NOT NULL, PRIMARY KEY(`id`, `user_id`), - FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade + FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`book_id`,`user_id`) REFERENCES `audiobooks`(`id`,`user_id`) ON UPDATE no action ON DELETE cascade ); --> statement-breakpoint CREATE TABLE `audiobooks` ( diff --git a/drizzle/sqlite/meta/0000_snapshot.json b/drizzle/sqlite/meta/0000_snapshot.json index 459b7d5..13599f0 100644 --- a/drizzle/sqlite/meta/0000_snapshot.json +++ b/drizzle/sqlite/meta/0000_snapshot.json @@ -1,7 +1,7 @@ { "version": "6", "dialect": "sqlite", - "id": "a55d11c6-847c-4e19-9a7c-fbc2401df061", + "id": "d7c41fe0-7d08-42af-8f53-d95fd35776c2", "prevId": "00000000-0000-0000-0000-000000000000", "tables": { "audiobook_chapters": { @@ -79,6 +79,21 @@ ], "onDelete": "cascade", "onUpdate": "no action" + }, + "audiobook_chapters_book_id_user_id_audiobooks_id_user_id_fk": { + "name": "audiobook_chapters_book_id_user_id_audiobooks_id_user_id_fk", + "tableFrom": "audiobook_chapters", + "tableTo": "audiobooks", + "columnsFrom": [ + "book_id", + "user_id" + ], + "columnsTo": [ + "id", + "user_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" } }, "compositePrimaryKeys": { diff --git a/drizzle/sqlite/meta/_journal.json b/drizzle/sqlite/meta/_journal.json index 82b0b5f..637471a 100644 --- a/drizzle/sqlite/meta/_journal.json +++ b/drizzle/sqlite/meta/_journal.json @@ -5,8 +5,8 @@ { "idx": 0, "version": "6", - "when": 1771185356176, - "tag": "0000_furry_dragon_man", + "when": 1771188819810, + "tag": "0000_nervous_preak", "breakpoints": true } ] diff --git a/src/db/index.ts b/src/db/index.ts index e54c9d1..6028f95 100644 --- a/src/db/index.ts +++ b/src/db/index.ts @@ -1,5 +1,6 @@ import { drizzle as drizzlePg } from 'drizzle-orm/node-postgres'; import { drizzle as drizzleSqlite } from 'drizzle-orm/better-sqlite3'; +import { sql } from 'drizzle-orm'; import { Pool } from 'pg'; import Database from 'better-sqlite3'; import path from 'path'; @@ -8,13 +9,53 @@ import * as schema from './schema'; import * as authSchemaSqlite from './schema_auth_sqlite'; import * as authSchemaPostgres from './schema_auth_postgres'; +const UNCLAIMED_USER_ID = 'unclaimed'; + // eslint-disable-next-line @typescript-eslint/no-explicit-any let dbInstance: any = null; +let dbIsPostgres = false; + +/** Track which system user IDs we have already ensured exist this process. */ +const seededUserIds = new Set(); + +/** + * Ensure a system/placeholder user row exists in the user table for `userId`. + * All user-facing tables now have userId foreign keys with ON DELETE CASCADE + * referencing the user table. When auth is disabled the app stores data under + * the 'unclaimed' userId (and namespace variants like 'unclaimed::ns'), so + * those rows must exist before any data can be inserted. + * + * This is safe to call repeatedly — it short-circuits via an in-memory Set + * and uses INSERT … ON CONFLICT/OR IGNORE at the SQL level. + */ +export function ensureSystemUserExists(userId: string) { + if (seededUserIds.has(userId)) return; + const drizzleDb = getDrizzleDB(); + try { + if (dbIsPostgres) { + drizzleDb.execute(sql` + INSERT INTO "user" (id, name, email, email_verified, created_at, updated_at, is_anonymous) + VALUES (${userId}, 'System User', ${userId + '@local'}, false, now(), now(), false) + ON CONFLICT (id) DO NOTHING + `).catch(() => { /* table may not exist yet */ }); + } else { + drizzleDb.run(sql` + INSERT OR IGNORE INTO user (id, name, email, email_verified, created_at, updated_at, is_anonymous) + VALUES (${userId}, 'System User', ${userId + '@local'}, 0, ${Date.now()}, ${Date.now()}, 0) + `); + } + seededUserIds.add(userId); + } catch { + // Silently ignore – the user table may not exist yet on first boot before migrations run. + } +} function getDrizzleDB() { if (dbInstance) return dbInstance; - if (process.env.POSTGRES_URL) { + dbIsPostgres = !!process.env.POSTGRES_URL; + + if (dbIsPostgres) { const pool = new Pool({ connectionString: process.env.POSTGRES_URL, }); @@ -30,6 +71,8 @@ function getDrizzleDB() { dbInstance = drizzleSqlite(sqlite, { schema: { ...schema, ...authSchemaSqlite } }); } + ensureSystemUserExists(UNCLAIMED_USER_ID); + return dbInstance; } @@ -44,3 +87,4 @@ export const db: any = new Proxy({} as any, { return typeof value === 'function' ? value.bind(instance) : value; }, }); + diff --git a/src/db/schema_postgres.ts b/src/db/schema_postgres.ts index 585e7f8..1a4bec2 100644 --- a/src/db/schema_postgres.ts +++ b/src/db/schema_postgres.ts @@ -1,4 +1,4 @@ -import { pgTable, text, integer, real, timestamp, date, bigint, primaryKey, index, jsonb } from 'drizzle-orm/pg-core'; +import { pgTable, text, integer, real, timestamp, date, bigint, primaryKey, index, jsonb, foreignKey } from 'drizzle-orm/pg-core'; import { user } from './schema_auth_postgres'; export const documents = pgTable('documents', { @@ -40,6 +40,10 @@ export const audiobookChapters = pgTable('audiobook_chapters', { format: text('format').notNull(), // mp3, m4b }, (table) => ({ pk: primaryKey({ columns: [table.id, table.userId] }), + bookFk: foreignKey({ + columns: [table.bookId, table.userId], + foreignColumns: [audiobooks.id, audiobooks.userId], + }).onDelete('cascade'), })); // Auth tables (user, session, account, verification) are managed by Better Auth. diff --git a/src/db/schema_sqlite.ts b/src/db/schema_sqlite.ts index e453a96..6ac98f7 100644 --- a/src/db/schema_sqlite.ts +++ b/src/db/schema_sqlite.ts @@ -1,4 +1,4 @@ -import { sqliteTable, text, integer, real, primaryKey, index } from 'drizzle-orm/sqlite-core'; +import { sqliteTable, text, integer, real, primaryKey, index, foreignKey } from 'drizzle-orm/sqlite-core'; import { sql } from 'drizzle-orm'; import { user } from './schema_auth_sqlite'; @@ -41,6 +41,10 @@ export const audiobookChapters = sqliteTable('audiobook_chapters', { format: text('format').notNull(), // mp3, m4b }, (table) => ({ pk: primaryKey({ columns: [table.id, table.userId] }), + bookFk: foreignKey({ + columns: [table.bookId, table.userId], + foreignColumns: [audiobooks.id, audiobooks.userId], + }).onDelete('cascade'), })); // Auth tables (user, session, account, verification) are managed by Better Auth. diff --git a/src/lib/server/auth.ts b/src/lib/server/auth.ts index 8851f70..36982bd 100644 --- a/src/lib/server/auth.ts +++ b/src/lib/server/auth.ts @@ -7,6 +7,7 @@ import type { NextRequest } from 'next/server'; import { db } from "@/db"; import { rateLimiter } from "@/lib/server/rate-limiter"; import { isAuthEnabled, isAnonymousAuthSessionsEnabled } from "@/lib/server/auth-config"; +import { deleteUserStorageData } from "@/lib/server/user-data-cleanup"; import * as authSchemaSqlite from "@/db/schema_auth_sqlite"; import * as authSchemaPostgres from "@/db/schema_auth_postgres"; import { @@ -77,6 +78,15 @@ const createAuth = () => betterAuth({ user: { deleteUser: { enabled: true, + beforeDelete: async (user) => { + try { + await deleteUserStorageData(user.id, null); + } catch (error) { + console.error('[auth] Failed to clean up user storage before deletion:', error); + // Don't throw – allow the user deletion to proceed even if S3 cleanup fails. + // Orphaned blobs are preferable to a blocked account deletion. + } + }, }, }, rateLimit: { @@ -103,76 +113,76 @@ const createAuth = () => betterAuth({ nextCookies(), // Enable Next.js cookie handling ...(isAnonymousAuthSessionsEnabled() ? [ - anonymous({ - onLinkAccount: async ({ anonymousUser, newUser }) => { + anonymous({ + onLinkAccount: async ({ anonymousUser, newUser }) => { + try { + // Log when anonymous user links to a real account + console.log("Anonymous user linked to account:", { + anonymousUserId: anonymousUser.user.id, + newUserId: newUser.user.id, + newUserEmail: newUser.user.email, + }); + + // Transfer rate limiting data (TTS char counts) from anonymous user to authenticated user try { - // Log when anonymous user links to a real account - console.log("Anonymous user linked to account:", { - anonymousUserId: anonymousUser.user.id, - newUserId: newUser.user.id, - newUserEmail: newUser.user.email, - }); - - // Transfer rate limiting data (TTS char counts) from anonymous user to authenticated user - try { - await rateLimiter.transferAnonymousUsage(anonymousUser.user.id, newUser.user.id); - console.log(`Successfully transferred rate limit data from anonymous user ${anonymousUser.user.id} to user ${newUser.user.id}`); - } catch (error) { - console.error("Error transferring rate limit data during account linking:", error); - // Don't throw here to prevent blocking the account linking process - } - - // Transfer audiobooks from anonymous user to new authenticated user - try { - const transferred = await transferUserAudiobooks(anonymousUser.user.id, newUser.user.id); - if (transferred > 0) { - console.log(`Successfully transferred ${transferred} audiobook(s) from anonymous user ${anonymousUser.user.id} to user ${newUser.user.id}`); - } - } catch (error) { - console.error("Error transferring audiobooks during account linking:", error); - // Don't throw here to prevent blocking the account linking process - } - - // Transfer documents from anonymous user to new authenticated user - try { - const transferred = await transferUserDocuments(anonymousUser.user.id, newUser.user.id); - if (transferred > 0) { - console.log(`Successfully transferred ${transferred} document(s) from anonymous user ${anonymousUser.user.id} to user ${newUser.user.id}`); - } - } catch (error) { - console.error("Error transferring documents during account linking:", error); - // Don't throw here to prevent blocking the account linking process - } - - // Transfer preferences from anonymous user to new authenticated user - try { - const transferred = await transferUserPreferences(anonymousUser.user.id, newUser.user.id); - if (transferred > 0) { - console.log(`Successfully transferred preferences from anonymous user ${anonymousUser.user.id} to user ${newUser.user.id}`); - } - } catch (error) { - console.error("Error transferring preferences during account linking:", error); - // Don't throw here to prevent blocking the account linking process - } - - // Transfer reading progress from anonymous user to new authenticated user - try { - const transferred = await transferUserProgress(anonymousUser.user.id, newUser.user.id); - if (transferred > 0) { - console.log(`Successfully transferred ${transferred} progress row(s) from anonymous user ${anonymousUser.user.id} to user ${newUser.user.id}`); - } - } catch (error) { - console.error("Error transferring reading progress during account linking:", error); - // Don't throw here to prevent blocking the account linking process - } + await rateLimiter.transferAnonymousUsage(anonymousUser.user.id, newUser.user.id); + console.log(`Successfully transferred rate limit data from anonymous user ${anonymousUser.user.id} to user ${newUser.user.id}`); } catch (error) { - console.error("Error in onLinkAccount callback:", error); + console.error("Error transferring rate limit data during account linking:", error); // Don't throw here to prevent blocking the account linking process } - // Note: Anonymous user will be automatically deleted after this callback completes - }, - }), - ] + + // Transfer audiobooks from anonymous user to new authenticated user + try { + const transferred = await transferUserAudiobooks(anonymousUser.user.id, newUser.user.id); + if (transferred > 0) { + console.log(`Successfully transferred ${transferred} audiobook(s) from anonymous user ${anonymousUser.user.id} to user ${newUser.user.id}`); + } + } catch (error) { + console.error("Error transferring audiobooks during account linking:", error); + // Don't throw here to prevent blocking the account linking process + } + + // Transfer documents from anonymous user to new authenticated user + try { + const transferred = await transferUserDocuments(anonymousUser.user.id, newUser.user.id); + if (transferred > 0) { + console.log(`Successfully transferred ${transferred} document(s) from anonymous user ${anonymousUser.user.id} to user ${newUser.user.id}`); + } + } catch (error) { + console.error("Error transferring documents during account linking:", error); + // Don't throw here to prevent blocking the account linking process + } + + // Transfer preferences from anonymous user to new authenticated user + try { + const transferred = await transferUserPreferences(anonymousUser.user.id, newUser.user.id); + if (transferred > 0) { + console.log(`Successfully transferred preferences from anonymous user ${anonymousUser.user.id} to user ${newUser.user.id}`); + } + } catch (error) { + console.error("Error transferring preferences during account linking:", error); + // Don't throw here to prevent blocking the account linking process + } + + // Transfer reading progress from anonymous user to new authenticated user + try { + const transferred = await transferUserProgress(anonymousUser.user.id, newUser.user.id); + if (transferred > 0) { + console.log(`Successfully transferred ${transferred} progress row(s) from anonymous user ${anonymousUser.user.id} to user ${newUser.user.id}`); + } + } catch (error) { + console.error("Error transferring reading progress during account linking:", error); + // Don't throw here to prevent blocking the account linking process + } + } catch (error) { + console.error("Error in onLinkAccount callback:", error); + // Don't throw here to prevent blocking the account linking process + } + // Note: Anonymous user will be automatically deleted after this callback completes + }, + }), + ] : []), ], }); diff --git a/src/lib/server/test-namespace.ts b/src/lib/server/test-namespace.ts index 5fe4671..e5aa038 100644 --- a/src/lib/server/test-namespace.ts +++ b/src/lib/server/test-namespace.ts @@ -1,5 +1,6 @@ import path from 'path'; import { UNCLAIMED_USER_ID } from '@/lib/server/docstore'; +import { ensureSystemUserExists } from '@/db'; const TEST_NAMESPACE_HEADER = 'x-openreader-test-namespace'; const SAFE_NAMESPACE_REGEX = /^[a-zA-Z0-9._-]{1,128}$/; @@ -24,7 +25,8 @@ export function applyOpenReaderTestNamespacePath(baseDir: string, namespace: str } export function getUnclaimedUserIdForNamespace(namespace: string | null): string { - if (!namespace) return UNCLAIMED_USER_ID; - return `${UNCLAIMED_USER_ID}::${namespace}`; + const userId = !namespace ? UNCLAIMED_USER_ID : `${UNCLAIMED_USER_ID}::${namespace}`; + ensureSystemUserExists(userId); + return userId; } diff --git a/src/lib/server/user-data-cleanup.ts b/src/lib/server/user-data-cleanup.ts new file mode 100644 index 0000000..81d78ae --- /dev/null +++ b/src/lib/server/user-data-cleanup.ts @@ -0,0 +1,83 @@ +/** + * Cleans up all S3 storage artifacts belonging to a user. + * Called from Better Auth's `beforeDelete` hook so that blobs are removed + * before the DB cascade wipes the metadata rows we query against. + */ + +import { db } from '@/db'; +import { documents, audiobooks } from '@/db/schema'; +import { eq } from 'drizzle-orm'; +import { isS3Configured } from '@/lib/server/s3'; +import { deleteDocumentBlob } from '@/lib/server/documents-blobstore'; +import { deleteDocumentPreviewArtifacts } from '@/lib/server/document-previews-blobstore'; +import { audiobookPrefix, deleteAudiobookPrefix } from '@/lib/server/audiobooks-blobstore'; + +type DocumentRow = { id: string }; +type AudiobookRow = { id: string }; + +/** + * Delete all S3 blobs owned by `userId`. + * + * This covers: + * - Document file blobs + * - Document preview images + * - Audiobook audio files (chapter mp3s, metadata json, etc.) + * + * Each item is cleaned up independently; a failure on one does not block the rest. + */ +export async function deleteUserStorageData( + userId: string, + namespace: string | null, +): Promise { + if (!isS3Configured()) return; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const database = db as any; + + // --- Documents & previews --- + const userDocs: DocumentRow[] = await database + .select({ id: documents.id }) + .from(documents) + .where(eq(documents.userId, userId)); + + let docsDeleted = 0; + for (const doc of userDocs) { + try { + await deleteDocumentBlob(doc.id, namespace); + docsDeleted++; + } catch (error) { + console.error(`[user-data-cleanup] Failed to delete document blob ${doc.id}:`, error); + } + + try { + await deleteDocumentPreviewArtifacts(doc.id, namespace); + } catch (error) { + console.error(`[user-data-cleanup] Failed to delete preview for ${doc.id}:`, error); + } + } + + // --- Audiobooks --- + const userBooks: AudiobookRow[] = await database + .select({ id: audiobooks.id }) + .from(audiobooks) + .where(eq(audiobooks.userId, userId)); + + let booksDeleted = 0; + for (const book of userBooks) { + try { + const prefix = audiobookPrefix(book.id, userId, namespace); + await deleteAudiobookPrefix(prefix); + booksDeleted++; + } catch (error) { + console.error(`[user-data-cleanup] Failed to delete audiobook blobs ${book.id}:`, error); + } + } + + if (docsDeleted > 0 || booksDeleted > 0) { + console.log( + `[user-data-cleanup] Cleaned up S3 data for user ${userId}: ` + + `${docsDeleted}/${userDocs.length} document(s), ` + + `${booksDeleted}/${userBooks.length} audiobook(s)`, + ); + } +}