From 297752e83ea6b341929660ce8e9285fd2a5e4c41 Mon Sep 17 00:00:00 2001 From: Richard R Date: Tue, 27 Jan 2026 16:53:58 -0700 Subject: [PATCH] fix(db): select drizzle config for migrations Add dedicated Drizzle config files for sqlite and postgres and generate new migration outputs for each dialect. Update migrate scripts to load env files and auto-pick the correct config unless explicitly provided, and tighten API auth handling by using shared auth context helpers and enforcing userId checks when auth is enabled. --- README.md | 58 +- drizzle.config.pg.ts | 18 + drizzle.config.sqlite.ts | 13 + drizzle.config.ts | 14 - .../postgres}/0000_military_nemesis.sql | 2 +- .../postgres}/0001_futuristic_harpoon.sql | 2 +- .../postgres}/meta/0000_snapshot.json | 2 +- .../postgres}/meta/0001_snapshot.json | 2 +- .../postgres}/meta/_journal.json | 2 +- drizzle/{ => sqlite}/0000_hard_mandarin.sql | 0 drizzle/sqlite/0001_violet_mantis.sql | 38 ++ drizzle/{ => sqlite}/meta/0000_snapshot.json | 2 +- drizzle/sqlite/meta/0001_snapshot.json | 621 ++++++++++++++++++ drizzle/{ => sqlite}/meta/_journal.json | 9 +- package.json | 4 +- scripts/migrate-if-auth.mjs | 7 +- scripts/migrate.mjs | 33 + src/app/api/audiobook/route.ts | 16 +- src/app/api/documents/route.ts | 27 +- 19 files changed, 815 insertions(+), 55 deletions(-) create mode 100644 drizzle.config.pg.ts create mode 100644 drizzle.config.sqlite.ts delete mode 100644 drizzle.config.ts rename {drizzle_pg => drizzle/postgres}/0000_military_nemesis.sql (99%) rename {drizzle_pg => drizzle/postgres}/0001_futuristic_harpoon.sql (93%) rename {drizzle_pg => drizzle/postgres}/meta/0000_snapshot.json (99%) rename {drizzle_pg => drizzle/postgres}/meta/0001_snapshot.json (99%) rename {drizzle_pg => drizzle/postgres}/meta/_journal.json (99%) rename drizzle/{ => sqlite}/0000_hard_mandarin.sql (100%) create mode 100644 drizzle/sqlite/0001_violet_mantis.sql rename drizzle/{ => sqlite}/meta/0000_snapshot.json (99%) create mode 100644 drizzle/sqlite/meta/0001_snapshot.json rename drizzle/{ => sqlite}/meta/_journal.json (58%) create mode 100644 scripts/migrate.mjs diff --git a/README.md b/README.md index 047b37d..3712282 100644 --- a/README.md +++ b/README.md @@ -34,13 +34,16 @@ OpenReader WebUI is an open source text to speech document reader web app built ## 🐳 Docker Quick Start ### Prerequisites + - Recent version of Docker installed on your machine - A TTS API server (Kokoro-FastAPI, Orpheus-FastAPI, Deepinfra, OpenAI, etc.) running and accessible > **Note:** If you have good hardware, you can run [Kokoro-FastAPI with Docker locally](#🗣️-local-kokoro-fastapi-quick-start-cpu-or-gpu) (see below). -### 1. 🐳 Start the Docker container: +### 1. 🐳 Start the Docker container + Minimal (no persistence, auth disabled unless you set auth env vars): + ```bash docker run --name openreader-webui \ --restart unless-stopped \ @@ -49,6 +52,7 @@ OpenReader WebUI is an open source text to speech document reader web app built ``` Fully featured (persistent storage + server library import + KokoroFastAPI in Docker + optional auth): + ```bash docker run --name openreader-webui \ --restart unless-stopped \ @@ -66,6 +70,7 @@ OpenReader WebUI is an open source text to speech document reader web app built You can remove both `BETTER_AUTH_*` env vars to keep auth disabled. > **Notes:** + > > - `API_BASE` should point to your TTS API server's base URL (if running Kokoro-FastAPI locally in Docker, use `http://host.docker.internal:8880/v1`). > - `BETTER_AUTH_URL` should be your externally-facing URL for this app (for example `https://reader.example.com` or `http://localhost:3003`). > - To enable auth, set **both** `BETTER_AUTH_URL` and `BETTER_AUTH_SECRET` generated with `openssl rand -base64 32`. @@ -102,12 +107,14 @@ OpenReader WebUI is an open source text to speech document reader web app built Visit [http://localhost:3003](http://localhost:3003) to run the app and set your settings. -### 2. ⚙️ Configure the app settings in the UI: - - Set the TTS Provider and Model in the Settings modal - - Set the TTS API Base URL and API Key if needed (more secure to set in env vars) - - Select your model's voice from the dropdown (voices try to be fetched from TTS Provider API) +### 2. ⚙️ Configure the app settings in the UI + +- Set the TTS Provider and Model in the Settings modal +- Set the TTS API Base URL and API Key if needed (more secure to set in env vars) +- Select your model's voice from the dropdown (voices try to be fetched from TTS Provider API) ### 3. ⬆️ Updating Docker Image + ```bash docker stop openreader-webui && \ docker rm openreader-webui && \ @@ -118,7 +125,6 @@ docker pull ghcr.io/richardr1126/openreader-webui:latest You can run the Kokoro TTS API server directly with Docker. **We are not responsible for issues with [Kokoro-FastAPI](https://github.com/remsky/Kokoro-FastAPI).** For best performance, use an NVIDIA GPU (for GPU version) or Apple Silicon (for CPU version). -
@@ -170,6 +176,7 @@ docker run -d \
> **⚠️ Important Notes:** +> > - For best results, set the `-e API_BASE=` for OpenReader's Docker to `http://kokoro-tts:8880/v1` > - For issues or support, see the [Kokoro-FastAPI repository](https://github.com/remsky/Kokoro-FastAPI). > - The GPU version requires NVIDIA Docker support and works best with NVIDIA GPUs. The CPU version works best on Apple Silicon or modern x86 CPUs. @@ -177,22 +184,30 @@ docker run -d \ ## Local Development Installation ### Prerequisites + - Node.js (recommended: use [nvm](https://github.com/nvm-sh/nvm)) - pnpm (recommended) or npm + ```bash npm install -g pnpm ``` + - A TTS API server (Kokoro-FastAPI, Orpheus-FastAPI, Deepinfra, OpenAI, etc.) running and accessible Optionally required for different features: - [FFmpeg](https://ffmpeg.org) (required for audiobook m4b creation only) + ```bash brew install ffmpeg ``` + - [libreoffice](https://www.libreoffice.org) (required for DOCX files) + ```bash brew install libreoffice ``` + - [whisper.cpp](https://github.com/ggml-org/whisper.cpp) (optional, required for word-by-word highlighting) + ```bash # clone and build whisper.cpp (no model download needed – OpenReader handles that) git clone https://github.com/ggml-org/whisper.cpp.git @@ -205,31 +220,36 @@ Optionally required for different features: ``` > **Note:** The `WHISPER_CPP_BIN` path should be set in your `.env` file for OpenReader to use word-by-word highlighting features. - + ### Steps 1. Clone the repository: + ```bash git clone https://github.com/richardr1126/OpenReader-WebUI.git cd OpenReader-WebUI ``` 2. Install dependencies: - + With pnpm (recommended): + ```bash pnpm i # or npm i ``` 3. Configure the environment: + ```bash cp template.env .env # Edit .env with your configuration settings ``` + Auth is recommended for contributors and is enabled when **both** values are set: - Set `BETTER_AUTH_URL` to your local URL (default: `http://localhost:3003`) - Generate a `BETTER_AUTH_SECRET` and paste it into `.env`: + ```bash openssl rand -base64 32 ``` @@ -239,24 +259,32 @@ Optionally required for different features: > Note: The base URL for the TTS API should be accessible and relative to the Next.js server 4. Run auth DB migrations: - + - **Production / Docker**: Migrations run automatically on startup via `pnpm start`. - **Development**: Run explicitly: + ```bash pnpm migrate ``` - > Note: If you set `POSTGRES_URL` in `.env`, migrations will target Postgres instead of local SQLite. + + > Note: If you set `POSTGRES_URL` in `.env`, migrations will target Postgres instead of local SQLite. + > + > Manual Drizzle Kit runs: + > - SQLite: `npx drizzle-kit migrate --config drizzle.config.sqlite.ts` + > - Postgres: `npx drizzle-kit migrate --config drizzle.config.pg.ts` 5. Start the development server: - + With pnpm (recommended): + ```bash pnpm dev # or npm run dev ``` or build and run the production server: - + With pnpm: + ```bash pnpm build # or npm run build pnpm start # or npm start @@ -264,7 +292,6 @@ Optionally required for different features: Visit [http://localhost:3003](http://localhost:3003) to run the app. - ## 💡 Feature requests For feature requests or ideas you have for the project, please use the [Discussions](https://github.com/richardr1126/OpenReader-WebUI/discussions) tab. @@ -289,6 +316,7 @@ This project would not be possible without standing on the shoulders of these gi - [react-reader](https://github.com/happyr/react-reader) npm package ## Docker Supported Architectures + - linux/amd64 (x86_64) - linux/arm64 (Apple Silicon, Raspberry Pi, SBCs, etc.) @@ -298,7 +326,7 @@ This project would not be possible without standing on the shoulders of these gi - **Containerization:** Docker - **Storage:** - [Dexie.js](https://dexie.org/) IndexedDB wrapper for client-side storage -- **PDF:** +- **PDF:** - [react-pdf](https://github.com/wojtekmaj/react-pdf) - [pdf.js](https://mozilla.github.io/pdf.js/) - **EPUB:** @@ -307,7 +335,7 @@ This project would not be possible without standing on the shoulders of these gi - **Markdown/Text:** - [react-markdown](https://github.com/remarkjs/react-markdown) - [remark-gfm](https://github.com/remarkjs/remark-gfm) -- **UI:** +- **UI:** - [Tailwind CSS](https://tailwindcss.com) - [Headless UI](https://headlessui.com) - [@tailwindcss/typography](https://tailwindcss.com/docs/typography-plugin) diff --git a/drizzle.config.pg.ts b/drizzle.config.pg.ts new file mode 100644 index 0000000..af2aded --- /dev/null +++ b/drizzle.config.pg.ts @@ -0,0 +1,18 @@ +import type { Config } from 'drizzle-kit'; +import * as dotenv from 'dotenv'; + +dotenv.config(); + +const url = process.env.POSTGRES_URL; +if (!url) { + throw new Error('POSTGRES_URL is required for drizzle.config.pg.ts'); +} + +export default { + schema: './src/db/schema_postgres.ts', + out: './drizzle/postgres', + dialect: 'postgresql', + dbCredentials: { + url, + }, +} satisfies Config; diff --git a/drizzle.config.sqlite.ts b/drizzle.config.sqlite.ts new file mode 100644 index 0000000..b2ea43e --- /dev/null +++ b/drizzle.config.sqlite.ts @@ -0,0 +1,13 @@ +import type { Config } from 'drizzle-kit'; +import * as dotenv from 'dotenv'; + +dotenv.config(); + +export default { + schema: './src/db/schema_sqlite.ts', + out: './drizzle/sqlite', + dialect: 'sqlite', + dbCredentials: { + url: 'docstore/sqlite3.db', + }, +} satisfies Config; diff --git a/drizzle.config.ts b/drizzle.config.ts deleted file mode 100644 index 47e21b7..0000000 --- a/drizzle.config.ts +++ /dev/null @@ -1,14 +0,0 @@ -import type { Config } from 'drizzle-kit'; -import * as dotenv from 'dotenv'; -dotenv.config(); - -const isPostgres = !!process.env.POSTGRES_URL; - -export default { - schema: './src/db/schema.ts', - out: isPostgres ? './drizzle_pg' : './drizzle', - dialect: isPostgres ? 'postgresql' : 'sqlite', - dbCredentials: { - url: process.env.POSTGRES_URL || 'docstore/sqlite3.db', - }, -} satisfies Config; diff --git a/drizzle_pg/0000_military_nemesis.sql b/drizzle/postgres/0000_military_nemesis.sql similarity index 99% rename from drizzle_pg/0000_military_nemesis.sql rename to drizzle/postgres/0000_military_nemesis.sql index 824aaff..f40f596 100644 --- a/drizzle_pg/0000_military_nemesis.sql +++ b/drizzle/postgres/0000_military_nemesis.sql @@ -91,4 +91,4 @@ CREATE TABLE "verification" ( ALTER TABLE "account" ADD CONSTRAINT "account_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint ALTER TABLE "audiobook_chapters" ADD CONSTRAINT "audiobook_chapters_book_id_audiobooks_id_fk" FOREIGN KEY ("book_id") REFERENCES "public"."audiobooks"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint ALTER TABLE "session" ADD CONSTRAINT "session_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint -CREATE INDEX "idx_user_tts_chars_date" ON "user_tts_chars" USING btree ("date"); \ No newline at end of file +CREATE INDEX "idx_user_tts_chars_date" ON "user_tts_chars" USING btree ("date"); diff --git a/drizzle_pg/0001_futuristic_harpoon.sql b/drizzle/postgres/0001_futuristic_harpoon.sql similarity index 93% rename from drizzle_pg/0001_futuristic_harpoon.sql rename to drizzle/postgres/0001_futuristic_harpoon.sql index 722b14e..d6354ee 100644 --- a/drizzle_pg/0001_futuristic_harpoon.sql +++ b/drizzle/postgres/0001_futuristic_harpoon.sql @@ -3,4 +3,4 @@ ALTER TABLE "account" DROP CONSTRAINT "account_user_id_user_id_fk"; ALTER TABLE "session" DROP CONSTRAINT "session_user_id_user_id_fk"; --> statement-breakpoint ALTER TABLE "account" ADD CONSTRAINT "account_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint -ALTER TABLE "session" ADD CONSTRAINT "session_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action; \ No newline at end of file +ALTER TABLE "session" ADD CONSTRAINT "session_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action; diff --git a/drizzle_pg/meta/0000_snapshot.json b/drizzle/postgres/meta/0000_snapshot.json similarity index 99% rename from drizzle_pg/meta/0000_snapshot.json rename to drizzle/postgres/meta/0000_snapshot.json index 5f7ff78..90b979e 100644 --- a/drizzle_pg/meta/0000_snapshot.json +++ b/drizzle/postgres/meta/0000_snapshot.json @@ -589,4 +589,4 @@ "schemas": {}, "tables": {} } -} \ No newline at end of file +} diff --git a/drizzle_pg/meta/0001_snapshot.json b/drizzle/postgres/meta/0001_snapshot.json similarity index 99% rename from drizzle_pg/meta/0001_snapshot.json rename to drizzle/postgres/meta/0001_snapshot.json index e19c0cf..adf5b27 100644 --- a/drizzle_pg/meta/0001_snapshot.json +++ b/drizzle/postgres/meta/0001_snapshot.json @@ -589,4 +589,4 @@ "schemas": {}, "tables": {} } -} \ No newline at end of file +} diff --git a/drizzle_pg/meta/_journal.json b/drizzle/postgres/meta/_journal.json similarity index 99% rename from drizzle_pg/meta/_journal.json rename to drizzle/postgres/meta/_journal.json index 5157a5b..7b605fa 100644 --- a/drizzle_pg/meta/_journal.json +++ b/drizzle/postgres/meta/_journal.json @@ -17,4 +17,4 @@ "breakpoints": true } ] -} \ No newline at end of file +} diff --git a/drizzle/0000_hard_mandarin.sql b/drizzle/sqlite/0000_hard_mandarin.sql similarity index 100% rename from drizzle/0000_hard_mandarin.sql rename to drizzle/sqlite/0000_hard_mandarin.sql diff --git a/drizzle/sqlite/0001_violet_mantis.sql b/drizzle/sqlite/0001_violet_mantis.sql new file mode 100644 index 0000000..5051a39 --- /dev/null +++ b/drizzle/sqlite/0001_violet_mantis.sql @@ -0,0 +1,38 @@ +PRAGMA foreign_keys=OFF;--> statement-breakpoint +CREATE TABLE `__new_account` ( + `id` text PRIMARY KEY NOT NULL, + `account_id` text NOT NULL, + `provider_id` text NOT NULL, + `user_id` text NOT NULL, + `access_token` text, + `refresh_token` text, + `id_token` text, + `access_token_expires_at` integer, + `refresh_token_expires_at` integer, + `scope` text, + `password` text, + `created_at` integer NOT NULL, + `updated_at` integer NOT NULL, + FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +INSERT INTO `__new_account`("id", "account_id", "provider_id", "user_id", "access_token", "refresh_token", "id_token", "access_token_expires_at", "refresh_token_expires_at", "scope", "password", "created_at", "updated_at") SELECT "id", "account_id", "provider_id", "user_id", "access_token", "refresh_token", "id_token", "access_token_expires_at", "refresh_token_expires_at", "scope", "password", "created_at", "updated_at" FROM `account`;--> statement-breakpoint +DROP TABLE `account`;--> statement-breakpoint +ALTER TABLE `__new_account` RENAME TO `account`;--> statement-breakpoint +PRAGMA foreign_keys=ON;--> statement-breakpoint +CREATE TABLE `__new_session` ( + `id` text PRIMARY KEY NOT NULL, + `expires_at` integer NOT NULL, + `token` text NOT NULL, + `created_at` integer NOT NULL, + `updated_at` integer NOT NULL, + `ip_address` text, + `user_agent` text, + `user_id` text NOT NULL, + FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +INSERT INTO `__new_session`("id", "expires_at", "token", "created_at", "updated_at", "ip_address", "user_agent", "user_id") SELECT "id", "expires_at", "token", "created_at", "updated_at", "ip_address", "user_agent", "user_id" FROM `session`;--> statement-breakpoint +DROP TABLE `session`;--> statement-breakpoint +ALTER TABLE `__new_session` RENAME TO `session`;--> statement-breakpoint +CREATE UNIQUE INDEX `session_token_unique` ON `session` (`token`); diff --git a/drizzle/meta/0000_snapshot.json b/drizzle/sqlite/meta/0000_snapshot.json similarity index 99% rename from drizzle/meta/0000_snapshot.json rename to drizzle/sqlite/meta/0000_snapshot.json index 04103cf..a159691 100644 --- a/drizzle/meta/0000_snapshot.json +++ b/drizzle/sqlite/meta/0000_snapshot.json @@ -618,4 +618,4 @@ "internal": { "indexes": {} } -} \ No newline at end of file +} diff --git a/drizzle/sqlite/meta/0001_snapshot.json b/drizzle/sqlite/meta/0001_snapshot.json new file mode 100644 index 0000000..bb004f5 --- /dev/null +++ b/drizzle/sqlite/meta/0001_snapshot.json @@ -0,0 +1,621 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "8d8679bc-1bba-44ce-9f87-271c1eafe068", + "prevId": "f5c06478-a702-4809-ab9b-3a63f11b8b0c", + "tables": { + "account": { + "name": "account", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "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 + } + }, + "indexes": {}, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "audiobook_chapters": { + "name": "audiobook_chapters", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "book_id": { + "name": "book_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "chapter_index": { + "name": "chapter_index", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "duration": { + "name": "duration", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "file_path": { + "name": "file_path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "format": { + "name": "format", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "audiobook_chapters_book_id_audiobooks_id_fk": { + "name": "audiobook_chapters_book_id_audiobooks_id_fk", + "tableFrom": "audiobook_chapters", + "tableTo": "audiobooks", + "columnsFrom": [ + "book_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "audiobooks": { + "name": "audiobooks", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "author": { + "name": "author", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cover_path": { + "name": "cover_path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "duration": { + "name": "duration", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(cast(strftime('%s','now') as int) * 1000)" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "documents": { + "name": "documents", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_modified": { + "name": "last_modified", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "file_path": { + "name": "file_path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(cast(strftime('%s','now') as int) * 1000)" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "documents_id_user_id_pk": { + "columns": [ + "id", + "user_id" + ], + "name": "documents_id_user_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "session": { + "name": "session", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "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 + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "session_token_unique": { + "name": "session_token_unique", + "columns": [ + "token" + ], + "isUnique": true + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "user": { + "name": "user", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email_verified": { + "name": "email_verified", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "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 + }, + "is_anonymous": { + "name": "is_anonymous", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "user_email_unique": { + "name": "user_email_unique", + "columns": [ + "email" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "user_tts_chars": { + "name": "user_tts_chars", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "date": { + "name": "date", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "char_count": { + "name": "char_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(cast(strftime('%s','now') as int) * 1000)" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(cast(strftime('%s','now') as int) * 1000)" + } + }, + "indexes": { + "idx_user_tts_chars_date": { + "name": "idx_user_tts_chars_date", + "columns": [ + "date" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "user_tts_chars_user_id_date_pk": { + "columns": [ + "user_id", + "date" + ], + "name": "user_tts_chars_user_id_date_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "verification": { + "name": "verification", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} diff --git a/drizzle/meta/_journal.json b/drizzle/sqlite/meta/_journal.json similarity index 58% rename from drizzle/meta/_journal.json rename to drizzle/sqlite/meta/_journal.json index 68e333c..5f8b89b 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/sqlite/meta/_journal.json @@ -8,6 +8,13 @@ "when": 1769395269830, "tag": "0000_hard_mandarin", "breakpoints": true + }, + { + "idx": 1, + "version": "6", + "when": 1769535439728, + "tag": "0001_violet_mantis", + "breakpoints": true } ] -} \ No newline at end of file +} diff --git a/package.json b/package.json index 5c6c0e1..10be184 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,7 @@ "lint": "next lint", "test": "playwright test", "migrate": "node scripts/migrate-if-auth.mjs", - "migrate:force": "drizzle-kit migrate" + "migrate:force": "node scripts/migrate.mjs" }, "dependencies": { "@headlessui/react": "^2.2.9", @@ -70,4 +70,4 @@ "@types/localforage": "npm:empty-module@0.0.2" } } -} \ No newline at end of file +} diff --git a/scripts/migrate-if-auth.mjs b/scripts/migrate-if-auth.mjs index 28b6d4e..c190068 100644 --- a/scripts/migrate-if-auth.mjs +++ b/scripts/migrate-if-auth.mjs @@ -30,7 +30,12 @@ if (!authEnabled) { } const extraArgs = process.argv.slice(2); -const result = spawnSync('drizzle-kit', ['migrate', ...extraArgs], { + +const hasConfigArg = extraArgs.includes('--config'); +const configFile = process.env.POSTGRES_URL ? 'drizzle.config.pg.ts' : 'drizzle.config.sqlite.ts'; +const configArgs = hasConfigArg ? [] : ['--config', configFile]; + +const result = spawnSync('drizzle-kit', ['migrate', ...configArgs, ...extraArgs], { stdio: 'inherit', env: process.env, }); diff --git a/scripts/migrate.mjs b/scripts/migrate.mjs new file mode 100644 index 0000000..1e0914a --- /dev/null +++ b/scripts/migrate.mjs @@ -0,0 +1,33 @@ +import { spawnSync } from 'node:child_process'; +import path from 'node:path'; +import fs from 'node:fs'; +import * as dotenv from 'dotenv'; + +function loadEnvFiles() { + // Approximate Next.js behavior enough for server-side scripts. + // Load .env first, then .env.local (local overrides). + const cwd = process.cwd(); + const envPath = path.join(cwd, '.env'); + const envLocalPath = path.join(cwd, '.env.local'); + + if (fs.existsSync(envPath)) { + dotenv.config({ path: envPath }); + } + if (fs.existsSync(envLocalPath)) { + dotenv.config({ path: envLocalPath, override: true }); + } +} + +loadEnvFiles(); + +const extraArgs = process.argv.slice(2); +const hasConfigArg = extraArgs.includes('--config'); +const configFile = process.env.POSTGRES_URL ? 'drizzle.config.pg.ts' : 'drizzle.config.sqlite.ts'; +const configArgs = hasConfigArg ? [] : ['--config', configFile]; + +const result = spawnSync('drizzle-kit', ['migrate', ...configArgs, ...extraArgs], { + stdio: 'inherit', + env: process.env, +}); + +process.exit(result.status ?? 1); diff --git a/src/app/api/audiobook/route.ts b/src/app/api/audiobook/route.ts index a086da4..c04834c 100644 --- a/src/app/api/audiobook/route.ts +++ b/src/app/api/audiobook/route.ts @@ -12,7 +12,7 @@ import { db } from '@/db'; import { audiobooks, audiobookChapters } from '@/db/schema'; import { eq } from 'drizzle-orm'; import { isAuthEnabled } from '@/lib/server/auth-config'; -import { requireAuthContext } from '@/lib/server/auth'; +import { getAuthContext, requireAuthContext } from '@/lib/server/auth'; export const dynamic = 'force-dynamic'; @@ -404,9 +404,7 @@ export async function GET(request: NextRequest) { ); } - // Auth Check - const session = await auth?.api.getSession({ headers: request.headers }); - const userId = session?.user?.id || null; + const { userId } = await getAuthContext(request); // Verify ownership if (isAuthEnabled() && db) { @@ -610,13 +608,9 @@ export async function DELETE(request: NextRequest) { ); } - // Auth Check - const session = await auth?.api.getSession({ headers: request.headers }); - const userId = session?.user?.id || null; - - if (!userId) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); - } + const ctxOrRes = await requireAuthContext(request); + if (ctxOrRes instanceof Response) return ctxOrRes; + const userId = ctxOrRes.userId; // Verify ownership if (isAuthEnabled() && db) { diff --git a/src/app/api/documents/route.ts b/src/app/api/documents/route.ts index ab02515..41e9af9 100644 --- a/src/app/api/documents/route.ts +++ b/src/app/api/documents/route.ts @@ -45,7 +45,6 @@ export async function POST(req: NextRequest) { const ctxOrRes = await requireAuthContext(req); if (ctxOrRes instanceof Response) return ctxOrRes; - const userId = ctxOrRes.userId; const data = await req.json(); const documentsData = data.documents as SyncedDocument[]; @@ -75,6 +74,11 @@ export async function POST(req: NextRequest) { // DB Upsert // With composite PK (id, userId), we check if THIS user already has this document if (isAuthEnabled() && db) { + const userId = ctxOrRes.userId; + if (!userId) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + const [existing] = await db.select().from(documents).where( and(eq(documents.id, id), eq(documents.userId, userId)) ); @@ -113,7 +117,6 @@ export async function GET(req: NextRequest) { const ctxOrRes = await requireAuthContext(req); if (ctxOrRes instanceof Response) return ctxOrRes; - const userId = ctxOrRes.userId; const url = new URL(req.url); const list = url.searchParams.get('list') === 'true'; @@ -137,6 +140,11 @@ export async function GET(req: NextRequest) { allowedDocs = allowedDocs.filter(d => targetIds!.includes(d.id)); } } else { + const userId = ctxOrRes.userId; + if (!userId) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + if (!db) throw new Error("DB not initialized"); const rows = await db.select().from(documents).where( and( @@ -202,7 +210,6 @@ export async function DELETE(req: NextRequest) { // Auth check - require session const ctxOrRes = await requireAuthContext(req); if (ctxOrRes instanceof Response) return ctxOrRes; - const userId = ctxOrRes.userId; const url = new URL(req.url); const idsParam = url.searchParams.get('ids'); @@ -217,9 +224,14 @@ export async function DELETE(req: NextRequest) { const fsDocs = await scanDocumentsFS(); targetIds = fsDocs.map((d) => d.id); } else { + const userId = ctxOrRes.userId; + if (!userId) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + // Existing behavior was "nuke everything"; keep it scoped to "my" docs. if (!db) throw new Error("DB not initialized"); - const userDocs = await db.select({ id: documents.id }).from(documents).where(eq(documents.userId, userId as string)); + const userDocs = await db.select({ id: documents.id }).from(documents).where(eq(documents.userId, userId)); targetIds = userDocs.map((d: { id: string | null }) => d.id!).filter(Boolean) as string[]; } } @@ -241,10 +253,15 @@ export async function DELETE(req: NextRequest) { } } } else { + const userId = ctxOrRes.userId; + if (!userId) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + if (!db) throw new Error("DB not initialized"); const rows = await db.delete(documents) .where(and( - eq(documents.userId, userId as string), + eq(documents.userId, userId), inArray(documents.id, targetIds) )) .returning({ id: documents.id, filePath: documents.filePath });