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
### 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).
<details>
<summary>
@ -170,6 +176,7 @@ docker run -d \
</details>
> **⚠️ 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)

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 "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");
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";
--> 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": {},
"tables": {}
}
}
}

View file

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

View file

@ -17,4 +17,4 @@
"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": {
"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,
"tag": "0000_hard_mandarin",
"breakpoints": true
},
{
"idx": 1,
"version": "6",
"when": 1769535439728,
"tag": "0001_violet_mantis",
"breakpoints": true
}
]
}
}

View file

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

View file

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

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 { 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) {

View file

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