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 });