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.
This commit is contained in:
Richard R 2026-01-27 16:53:58 -07:00
parent 8189087ad9
commit 297752e83e
19 changed files with 815 additions and 55 deletions

View file

@ -34,13 +34,16 @@ OpenReader WebUI is an open source text to speech document reader web app built
## 🐳 Docker Quick Start ## 🐳 Docker Quick Start
### Prerequisites ### Prerequisites
- Recent version of Docker installed on your machine - Recent version of Docker installed on your machine
- A TTS API server (Kokoro-FastAPI, Orpheus-FastAPI, Deepinfra, OpenAI, etc.) running and accessible - 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). > **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): Minimal (no persistence, auth disabled unless you set auth env vars):
```bash ```bash
docker run --name openreader-webui \ docker run --name openreader-webui \
--restart unless-stopped \ --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): Fully featured (persistent storage + server library import + KokoroFastAPI in Docker + optional auth):
```bash ```bash
docker run --name openreader-webui \ docker run --name openreader-webui \
--restart unless-stopped \ --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. You can remove both `BETTER_AUTH_*` env vars to keep auth disabled.
> **Notes:** > **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`). > - `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`). > - `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`. > - 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. Visit [http://localhost:3003](http://localhost:3003) to run the app and set your settings.
### 2. ⚙️ Configure the app settings in the UI: ### 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) - Set the TTS Provider and Model in the Settings modal
- Select your model's voice from the dropdown (voices try to be fetched from TTS Provider API) - 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 ### 3. ⬆️ Updating Docker Image
```bash ```bash
docker stop openreader-webui && \ docker stop openreader-webui && \
docker rm 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). 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).
<details> <details>
<summary> <summary>
@ -170,6 +176,7 @@ docker run -d \
</details> </details>
> **⚠️ Important Notes:** > **⚠️ Important Notes:**
>
> - For best results, set the `-e API_BASE=` for OpenReader's Docker to `http://kokoro-tts:8880/v1` > - 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). > - 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. > - 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 ## Local Development Installation
### Prerequisites ### Prerequisites
- Node.js (recommended: use [nvm](https://github.com/nvm-sh/nvm)) - Node.js (recommended: use [nvm](https://github.com/nvm-sh/nvm))
- pnpm (recommended) or npm - pnpm (recommended) or npm
```bash ```bash
npm install -g pnpm npm install -g pnpm
``` ```
- A TTS API server (Kokoro-FastAPI, Orpheus-FastAPI, Deepinfra, OpenAI, etc.) running and accessible - A TTS API server (Kokoro-FastAPI, Orpheus-FastAPI, Deepinfra, OpenAI, etc.) running and accessible
Optionally required for different features: Optionally required for different features:
- [FFmpeg](https://ffmpeg.org) (required for audiobook m4b creation only) - [FFmpeg](https://ffmpeg.org) (required for audiobook m4b creation only)
```bash ```bash
brew install ffmpeg brew install ffmpeg
``` ```
- [libreoffice](https://www.libreoffice.org) (required for DOCX files) - [libreoffice](https://www.libreoffice.org) (required for DOCX files)
```bash ```bash
brew install libreoffice brew install libreoffice
``` ```
- [whisper.cpp](https://github.com/ggml-org/whisper.cpp) (optional, required for word-by-word highlighting) - [whisper.cpp](https://github.com/ggml-org/whisper.cpp) (optional, required for word-by-word highlighting)
```bash ```bash
# clone and build whisper.cpp (no model download needed OpenReader handles that) # clone and build whisper.cpp (no model download needed OpenReader handles that)
git clone https://github.com/ggml-org/whisper.cpp.git 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. > **Note:** The `WHISPER_CPP_BIN` path should be set in your `.env` file for OpenReader to use word-by-word highlighting features.
### Steps ### Steps
1. Clone the repository: 1. Clone the repository:
```bash ```bash
git clone https://github.com/richardr1126/OpenReader-WebUI.git git clone https://github.com/richardr1126/OpenReader-WebUI.git
cd OpenReader-WebUI cd OpenReader-WebUI
``` ```
2. Install dependencies: 2. Install dependencies:
With pnpm (recommended): With pnpm (recommended):
```bash ```bash
pnpm i # or npm i pnpm i # or npm i
``` ```
3. Configure the environment: 3. Configure the environment:
```bash ```bash
cp template.env .env cp template.env .env
# Edit .env with your configuration settings # Edit .env with your configuration settings
``` ```
Auth is recommended for contributors and is enabled when **both** values are set: 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`) - Set `BETTER_AUTH_URL` to your local URL (default: `http://localhost:3003`)
- Generate a `BETTER_AUTH_SECRET` and paste it into `.env`: - Generate a `BETTER_AUTH_SECRET` and paste it into `.env`:
```bash ```bash
openssl rand -base64 32 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 > Note: The base URL for the TTS API should be accessible and relative to the Next.js server
4. Run auth DB migrations: 4. Run auth DB migrations:
- **Production / Docker**: Migrations run automatically on startup via `pnpm start`. - **Production / Docker**: Migrations run automatically on startup via `pnpm start`.
- **Development**: Run explicitly: - **Development**: Run explicitly:
```bash ```bash
pnpm migrate 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: 5. Start the development server:
With pnpm (recommended): With pnpm (recommended):
```bash ```bash
pnpm dev # or npm run dev pnpm dev # or npm run dev
``` ```
or build and run the production server: or build and run the production server:
With pnpm: With pnpm:
```bash ```bash
pnpm build # or npm run build pnpm build # or npm run build
pnpm start # or npm start 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. Visit [http://localhost:3003](http://localhost:3003) to run the app.
## 💡 Feature requests ## 💡 Feature requests
For feature requests or ideas you have for the project, please use the [Discussions](https://github.com/richardr1126/OpenReader-WebUI/discussions) tab. 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 - [react-reader](https://github.com/happyr/react-reader) npm package
## Docker Supported Architectures ## Docker Supported Architectures
- linux/amd64 (x86_64) - linux/amd64 (x86_64)
- linux/arm64 (Apple Silicon, Raspberry Pi, SBCs, etc.) - 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 - **Containerization:** Docker
- **Storage:** - **Storage:**
- [Dexie.js](https://dexie.org/) IndexedDB wrapper for client-side storage - [Dexie.js](https://dexie.org/) IndexedDB wrapper for client-side storage
- **PDF:** - **PDF:**
- [react-pdf](https://github.com/wojtekmaj/react-pdf) - [react-pdf](https://github.com/wojtekmaj/react-pdf)
- [pdf.js](https://mozilla.github.io/pdf.js/) - [pdf.js](https://mozilla.github.io/pdf.js/)
- **EPUB:** - **EPUB:**
@ -307,7 +335,7 @@ This project would not be possible without standing on the shoulders of these gi
- **Markdown/Text:** - **Markdown/Text:**
- [react-markdown](https://github.com/remarkjs/react-markdown) - [react-markdown](https://github.com/remarkjs/react-markdown)
- [remark-gfm](https://github.com/remarkjs/remark-gfm) - [remark-gfm](https://github.com/remarkjs/remark-gfm)
- **UI:** - **UI:**
- [Tailwind CSS](https://tailwindcss.com) - [Tailwind CSS](https://tailwindcss.com)
- [Headless UI](https://headlessui.com) - [Headless UI](https://headlessui.com)
- [@tailwindcss/typography](https://tailwindcss.com/docs/typography-plugin) - [@tailwindcss/typography](https://tailwindcss.com/docs/typography-plugin)

18
drizzle.config.pg.ts Normal file
View file

@ -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;

13
drizzle.config.sqlite.ts Normal file
View file

@ -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;

View file

@ -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;

View file

@ -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 "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 "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 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"); CREATE INDEX "idx_user_tts_chars_date" ON "user_tts_chars" USING btree ("date");

View file

@ -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"; ALTER TABLE "session" DROP CONSTRAINT "session_user_id_user_id_fk";
--> statement-breakpoint --> 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 "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; 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;

View file

@ -589,4 +589,4 @@
"schemas": {}, "schemas": {},
"tables": {} "tables": {}
} }
} }

View file

@ -589,4 +589,4 @@
"schemas": {}, "schemas": {},
"tables": {} "tables": {}
} }
} }

View file

@ -17,4 +17,4 @@
"breakpoints": true "breakpoints": true
} }
] ]
} }

View file

@ -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`);

View file

@ -618,4 +618,4 @@
"internal": { "internal": {
"indexes": {} "indexes": {}
} }
} }

View file

@ -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": {}
}
}

View file

@ -8,6 +8,13 @@
"when": 1769395269830, "when": 1769395269830,
"tag": "0000_hard_mandarin", "tag": "0000_hard_mandarin",
"breakpoints": true "breakpoints": true
},
{
"idx": 1,
"version": "6",
"when": 1769535439728,
"tag": "0001_violet_mantis",
"breakpoints": true
} }
] ]
} }

View file

@ -9,7 +9,7 @@
"lint": "next lint", "lint": "next lint",
"test": "playwright test", "test": "playwright test",
"migrate": "node scripts/migrate-if-auth.mjs", "migrate": "node scripts/migrate-if-auth.mjs",
"migrate:force": "drizzle-kit migrate" "migrate:force": "node scripts/migrate.mjs"
}, },
"dependencies": { "dependencies": {
"@headlessui/react": "^2.2.9", "@headlessui/react": "^2.2.9",
@ -70,4 +70,4 @@
"@types/localforage": "npm:empty-module@0.0.2" "@types/localforage": "npm:empty-module@0.0.2"
} }
} }
} }

View file

@ -30,7 +30,12 @@ if (!authEnabled) {
} }
const extraArgs = process.argv.slice(2); 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', stdio: 'inherit',
env: process.env, env: process.env,
}); });

33
scripts/migrate.mjs Normal file
View file

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

View file

@ -12,7 +12,7 @@ import { db } from '@/db';
import { audiobooks, audiobookChapters } from '@/db/schema'; import { audiobooks, audiobookChapters } from '@/db/schema';
import { eq } from 'drizzle-orm'; import { eq } from 'drizzle-orm';
import { isAuthEnabled } from '@/lib/server/auth-config'; 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'; export const dynamic = 'force-dynamic';
@ -404,9 +404,7 @@ export async function GET(request: NextRequest) {
); );
} }
// Auth Check const { userId } = await getAuthContext(request);
const session = await auth?.api.getSession({ headers: request.headers });
const userId = session?.user?.id || null;
// Verify ownership // Verify ownership
if (isAuthEnabled() && db) { if (isAuthEnabled() && db) {
@ -610,13 +608,9 @@ export async function DELETE(request: NextRequest) {
); );
} }
// Auth Check const ctxOrRes = await requireAuthContext(request);
const session = await auth?.api.getSession({ headers: request.headers }); if (ctxOrRes instanceof Response) return ctxOrRes;
const userId = session?.user?.id || null; const userId = ctxOrRes.userId;
if (!userId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
// Verify ownership // Verify ownership
if (isAuthEnabled() && db) { if (isAuthEnabled() && db) {

View file

@ -45,7 +45,6 @@ export async function POST(req: NextRequest) {
const ctxOrRes = await requireAuthContext(req); const ctxOrRes = await requireAuthContext(req);
if (ctxOrRes instanceof Response) return ctxOrRes; if (ctxOrRes instanceof Response) return ctxOrRes;
const userId = ctxOrRes.userId;
const data = await req.json(); const data = await req.json();
const documentsData = data.documents as SyncedDocument[]; const documentsData = data.documents as SyncedDocument[];
@ -75,6 +74,11 @@ export async function POST(req: NextRequest) {
// DB Upsert // DB Upsert
// With composite PK (id, userId), we check if THIS user already has this document // With composite PK (id, userId), we check if THIS user already has this document
if (isAuthEnabled() && db) { if (isAuthEnabled() && db) {
const userId = ctxOrRes.userId;
if (!userId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const [existing] = await db.select().from(documents).where( const [existing] = await db.select().from(documents).where(
and(eq(documents.id, id), eq(documents.userId, userId)) and(eq(documents.id, id), eq(documents.userId, userId))
); );
@ -113,7 +117,6 @@ export async function GET(req: NextRequest) {
const ctxOrRes = await requireAuthContext(req); const ctxOrRes = await requireAuthContext(req);
if (ctxOrRes instanceof Response) return ctxOrRes; if (ctxOrRes instanceof Response) return ctxOrRes;
const userId = ctxOrRes.userId;
const url = new URL(req.url); const url = new URL(req.url);
const list = url.searchParams.get('list') === 'true'; 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)); allowedDocs = allowedDocs.filter(d => targetIds!.includes(d.id));
} }
} else { } else {
const userId = ctxOrRes.userId;
if (!userId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
if (!db) throw new Error("DB not initialized"); if (!db) throw new Error("DB not initialized");
const rows = await db.select().from(documents).where( const rows = await db.select().from(documents).where(
and( and(
@ -202,7 +210,6 @@ export async function DELETE(req: NextRequest) {
// Auth check - require session // Auth check - require session
const ctxOrRes = await requireAuthContext(req); const ctxOrRes = await requireAuthContext(req);
if (ctxOrRes instanceof Response) return ctxOrRes; if (ctxOrRes instanceof Response) return ctxOrRes;
const userId = ctxOrRes.userId;
const url = new URL(req.url); const url = new URL(req.url);
const idsParam = url.searchParams.get('ids'); const idsParam = url.searchParams.get('ids');
@ -217,9 +224,14 @@ export async function DELETE(req: NextRequest) {
const fsDocs = await scanDocumentsFS(); const fsDocs = await scanDocumentsFS();
targetIds = fsDocs.map((d) => d.id); targetIds = fsDocs.map((d) => d.id);
} else { } 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. // Existing behavior was "nuke everything"; keep it scoped to "my" docs.
if (!db) throw new Error("DB not initialized"); 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[]; targetIds = userDocs.map((d: { id: string | null }) => d.id!).filter(Boolean) as string[];
} }
} }
@ -241,10 +253,15 @@ export async function DELETE(req: NextRequest) {
} }
} }
} else { } else {
const userId = ctxOrRes.userId;
if (!userId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
if (!db) throw new Error("DB not initialized"); if (!db) throw new Error("DB not initialized");
const rows = await db.delete(documents) const rows = await db.delete(documents)
.where(and( .where(and(
eq(documents.userId, userId as string), eq(documents.userId, userId),
inArray(documents.id, targetIds) inArray(documents.id, targetIds)
)) ))
.returning({ id: documents.id, filePath: documents.filePath }); .returning({ id: documents.id, filePath: documents.filePath });