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:
parent
8189087ad9
commit
297752e83e
19 changed files with 815 additions and 55 deletions
58
README.md
58
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).
|
||||
|
||||
|
||||
<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
18
drizzle.config.pg.ts
Normal 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
13
drizzle.config.sqlite.ts
Normal 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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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");
|
||||
|
|
@ -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;
|
||||
|
|
@ -589,4 +589,4 @@
|
|||
"schemas": {},
|
||||
"tables": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -589,4 +589,4 @@
|
|||
"schemas": {},
|
||||
"tables": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -17,4 +17,4 @@
|
|||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
38
drizzle/sqlite/0001_violet_mantis.sql
Normal file
38
drizzle/sqlite/0001_violet_mantis.sql
Normal 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`);
|
||||
|
|
@ -618,4 +618,4 @@
|
|||
"internal": {
|
||||
"indexes": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
621
drizzle/sqlite/meta/0001_snapshot.json
Normal file
621
drizzle/sqlite/meta/0001_snapshot.json
Normal 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": {}
|
||||
}
|
||||
}
|
||||
|
|
@ -8,6 +8,13 @@
|
|||
"when": 1769395269830,
|
||||
"tag": "0000_hard_mandarin",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 1,
|
||||
"version": "6",
|
||||
"when": 1769535439728,
|
||||
"tag": "0001_violet_mantis",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
33
scripts/migrate.mjs
Normal 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);
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
Loading…
Reference in a new issue