feat(db/auth): migrate auth storage to Drizzle and add claimable data flow

- replace custom DB adapter with Drizzle setup (SQLite/Postgres schemas) and add `drizzle.config.ts` plus migrations in `drizzle/` and `drizzle_pg/`
- switch better-auth to drizzleAdapter, add auth helpers (`getAuthContext`, `requireAuthContext`, `requireAudiobookOwned`), and make `useAuth`/`useAuthSession` safe no-ops when auth is disabled
- persist documents/audiobooks in DB with ownership checks, unclaimed fallback, and ref-counted deletes; add FS scan helper for no-auth mode
- gate docx-to-pdf, library, voices, whisper, and migration endpoints behind auth when enabled
- add unclaimed data scan/claim flow with `/api/user/claim`, `ClaimDataModal`, and server-side scan/claim helpers
- refactor rate limiting and account deletion to use Drizzle tables (`user_tts_chars`, `user`)
- run migrations via `scripts/migrate-if-auth.mjs` (auto on `pnpm start` + `pnpm migrate`), remove Docker entrypoint and old better-auth migration file
- update README and lockfile for the new migration workflow and dependencies
This commit is contained in:
Richard R 2026-01-26 17:01:36 -07:00
parent 9561d5a816
commit c57b913cb5
41 changed files with 3556 additions and 1491 deletions

View file

@ -65,9 +65,4 @@ ENV LD_LIBRARY_PATH=/opt/whisper.cpp/build
EXPOSE 3003
# Start the application
# Copy entrypoint script
COPY docker-entrypoint.sh /usr/local/bin/
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
ENTRYPOINT ["docker-entrypoint.sh"]
CMD ["pnpm", "start"]

View file

@ -69,7 +69,7 @@ OpenReader WebUI is an open source text to speech document reader web app built
> - `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`.
> - If you set `POSTGRES_URL`, the container will not auto-run migrations for you; run `npx @better-auth/cli migrate -y` against your Postgres database.
> - If you set `POSTGRES_URL`, the container will attempt to run migrations against it. Ensure the database is accessible.
<details>
<summary><strong>Docker environment variables</strong> (Click to expand)</summary>
@ -238,10 +238,13 @@ 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 (required when auth is enabled):
```bash
npx @better-auth/cli migrate -y
```
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.
5. Start the development server:

View file

@ -1,13 +0,0 @@
create table "user" ("id" text not null primary key, "name" text not null, "email" text not null unique, "emailVerified" boolean not null, "image" text, "createdAt" timestamptz default CURRENT_TIMESTAMP not null, "updatedAt" timestamptz default CURRENT_TIMESTAMP not null, "isAnonymous" boolean);
create table "session" ("id" text not null primary key, "expiresAt" timestamptz not null, "token" text not null unique, "createdAt" timestamptz default CURRENT_TIMESTAMP not null, "updatedAt" timestamptz not null, "ipAddress" text, "userAgent" text, "userId" text not null references "user" ("id") on delete cascade);
create table "account" ("id" text not null primary key, "accountId" text not null, "providerId" text not null, "userId" text not null references "user" ("id") on delete cascade, "accessToken" text, "refreshToken" text, "idToken" text, "accessTokenExpiresAt" timestamptz, "refreshTokenExpiresAt" timestamptz, "scope" text, "password" text, "createdAt" timestamptz default CURRENT_TIMESTAMP not null, "updatedAt" timestamptz not null);
create table "verification" ("id" text not null primary key, "identifier" text not null, "value" text not null, "expiresAt" timestamptz not null, "createdAt" timestamptz default CURRENT_TIMESTAMP not null, "updatedAt" timestamptz default CURRENT_TIMESTAMP not null);
create index "session_userId_idx" on "session" ("userId");
create index "account_userId_idx" on "account" ("userId");
create index "verification_identifier_idx" on "verification" ("identifier");

View file

@ -1,11 +0,0 @@
#!/bin/sh
set -e
# Run migrations if we have the SQLite file path set or default
if [ -z "$POSTGRES_URL" ]; then
echo "Running SQLite migrations..."
npx @better-auth/cli migrate -y
fi
# Start the application
exec "$@"

14
drizzle.config.ts Normal file
View file

@ -0,0 +1,14 @@
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

@ -0,0 +1,93 @@
CREATE TABLE `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 no action
);
--> statement-breakpoint
CREATE TABLE `audiobook_chapters` (
`id` text PRIMARY KEY NOT NULL,
`book_id` text NOT NULL,
`user_id` text,
`chapter_index` integer NOT NULL,
`title` text NOT NULL,
`duration` real DEFAULT 0,
`file_path` text NOT NULL,
`format` text NOT NULL,
FOREIGN KEY (`book_id`) REFERENCES `audiobooks`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE TABLE `audiobooks` (
`id` text PRIMARY KEY NOT NULL,
`user_id` text,
`title` text NOT NULL,
`author` text,
`description` text,
`cover_path` text,
`duration` real DEFAULT 0,
`created_at` integer DEFAULT (cast(strftime('%s','now') as int) * 1000)
);
--> statement-breakpoint
CREATE TABLE `documents` (
`id` text,
`user_id` text NOT NULL,
`name` text NOT NULL,
`type` text NOT NULL,
`size` integer NOT NULL,
`last_modified` integer NOT NULL,
`file_path` text NOT NULL,
`created_at` integer DEFAULT (cast(strftime('%s','now') as int) * 1000),
PRIMARY KEY(`id`, `user_id`)
);
--> statement-breakpoint
CREATE TABLE `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 no action
);
--> statement-breakpoint
CREATE UNIQUE INDEX `session_token_unique` ON `session` (`token`);--> statement-breakpoint
CREATE TABLE `user` (
`id` text PRIMARY KEY NOT NULL,
`name` text NOT NULL,
`email` text NOT NULL,
`email_verified` integer NOT NULL,
`image` text,
`created_at` integer NOT NULL,
`updated_at` integer NOT NULL,
`is_anonymous` integer
);
--> statement-breakpoint
CREATE UNIQUE INDEX `user_email_unique` ON `user` (`email`);--> statement-breakpoint
CREATE TABLE `user_tts_chars` (
`user_id` text NOT NULL,
`date` text NOT NULL,
`char_count` integer DEFAULT 0,
`created_at` integer DEFAULT (cast(strftime('%s','now') as int) * 1000),
`updated_at` integer DEFAULT (cast(strftime('%s','now') as int) * 1000),
PRIMARY KEY(`user_id`, `date`)
);
--> statement-breakpoint
CREATE INDEX `idx_user_tts_chars_date` ON `user_tts_chars` (`date`);--> statement-breakpoint
CREATE TABLE `verification` (
`id` text PRIMARY KEY NOT NULL,
`identifier` text NOT NULL,
`value` text NOT NULL,
`updated_at` integer
);

View file

@ -0,0 +1,621 @@
{
"version": "6",
"dialect": "sqlite",
"id": "f5c06478-a702-4809-ab9b-3a63f11b8b0c",
"prevId": "00000000-0000-0000-0000-000000000000",
"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": "no action",
"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": "no action",
"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

@ -0,0 +1,13 @@
{
"version": "7",
"dialect": "sqlite",
"entries": [
{
"idx": 0,
"version": "6",
"when": 1769395269830,
"tag": "0000_hard_mandarin",
"breakpoints": true
}
]
}

View file

@ -0,0 +1,94 @@
CREATE TABLE "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" timestamp,
"refresh_token_expires_at" timestamp,
"scope" text,
"password" text,
"created_at" timestamp NOT NULL,
"updated_at" timestamp NOT NULL
);
--> statement-breakpoint
CREATE TABLE "audiobook_chapters" (
"id" text PRIMARY KEY NOT NULL,
"book_id" text NOT NULL,
"user_id" text,
"chapter_index" integer NOT NULL,
"title" text NOT NULL,
"duration" real DEFAULT 0,
"file_path" text NOT NULL,
"format" text NOT NULL
);
--> statement-breakpoint
CREATE TABLE "audiobooks" (
"id" text PRIMARY KEY NOT NULL,
"user_id" text,
"title" text NOT NULL,
"author" text,
"description" text,
"cover_path" text,
"duration" real DEFAULT 0,
"created_at" timestamp DEFAULT now()
);
--> statement-breakpoint
CREATE TABLE "documents" (
"id" text,
"user_id" text NOT NULL,
"name" text NOT NULL,
"type" text NOT NULL,
"size" bigint NOT NULL,
"last_modified" bigint NOT NULL,
"file_path" text NOT NULL,
"created_at" timestamp DEFAULT now(),
CONSTRAINT "documents_id_user_id_pk" PRIMARY KEY("id","user_id")
);
--> statement-breakpoint
CREATE TABLE "session" (
"id" text PRIMARY KEY NOT NULL,
"expires_at" timestamp NOT NULL,
"token" text NOT NULL,
"created_at" timestamp NOT NULL,
"updated_at" timestamp NOT NULL,
"ip_address" text,
"user_agent" text,
"user_id" text NOT NULL,
CONSTRAINT "session_token_unique" UNIQUE("token")
);
--> statement-breakpoint
CREATE TABLE "user" (
"id" text PRIMARY KEY NOT NULL,
"name" text NOT NULL,
"email" text NOT NULL,
"email_verified" boolean NOT NULL,
"image" text,
"created_at" timestamp NOT NULL,
"updated_at" timestamp NOT NULL,
"is_anonymous" boolean,
CONSTRAINT "user_email_unique" UNIQUE("email")
);
--> statement-breakpoint
CREATE TABLE "user_tts_chars" (
"user_id" text NOT NULL,
"date" date NOT NULL,
"char_count" bigint DEFAULT 0,
"created_at" timestamp DEFAULT now(),
"updated_at" timestamp DEFAULT now(),
CONSTRAINT "user_tts_chars_user_id_date_pk" PRIMARY KEY("user_id","date")
);
--> statement-breakpoint
CREATE TABLE "verification" (
"id" text PRIMARY KEY NOT NULL,
"identifier" text NOT NULL,
"value" text NOT NULL,
"updated_at" timestamp
);
--> 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 "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");

View file

@ -0,0 +1,592 @@
{
"id": "5ac6552c-824b-4b79-8cb6-f0ec3d121031",
"prevId": "00000000-0000-0000-0000-000000000000",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.account": {
"name": "account",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"account_id": {
"name": "account_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"provider_id": {
"name": "provider_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"access_token": {
"name": "access_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"refresh_token": {
"name": "refresh_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"id_token": {
"name": "id_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"access_token_expires_at": {
"name": "access_token_expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"refresh_token_expires_at": {
"name": "refresh_token_expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"scope": {
"name": "scope",
"type": "text",
"primaryKey": false,
"notNull": false
},
"password": {
"name": "password",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
}
},
"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": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.audiobook_chapters": {
"name": "audiobook_chapters",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"book_id": {
"name": "book_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"chapter_index": {
"name": "chapter_index",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": true
},
"duration": {
"name": "duration",
"type": "real",
"primaryKey": false,
"notNull": false,
"default": 0
},
"file_path": {
"name": "file_path",
"type": "text",
"primaryKey": false,
"notNull": true
},
"format": {
"name": "format",
"type": "text",
"primaryKey": false,
"notNull": true
}
},
"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": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.audiobooks": {
"name": "audiobooks",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": true
},
"author": {
"name": "author",
"type": "text",
"primaryKey": false,
"notNull": false
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": false
},
"cover_path": {
"name": "cover_path",
"type": "text",
"primaryKey": false,
"notNull": false
},
"duration": {
"name": "duration",
"type": "real",
"primaryKey": false,
"notNull": false,
"default": 0
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.documents": {
"name": "documents",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"type": {
"name": "type",
"type": "text",
"primaryKey": false,
"notNull": true
},
"size": {
"name": "size",
"type": "bigint",
"primaryKey": false,
"notNull": true
},
"last_modified": {
"name": "last_modified",
"type": "bigint",
"primaryKey": false,
"notNull": true
},
"file_path": {
"name": "file_path",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {
"documents_id_user_id_pk": {
"name": "documents_id_user_id_pk",
"columns": [
"id",
"user_id"
]
}
},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.session": {
"name": "session",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"expires_at": {
"name": "expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"token": {
"name": "token",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"ip_address": {
"name": "ip_address",
"type": "text",
"primaryKey": false,
"notNull": false
},
"user_agent": {
"name": "user_agent",
"type": "text",
"primaryKey": false,
"notNull": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {
"session_user_id_user_id_fk": {
"name": "session_user_id_user_id_fk",
"tableFrom": "session",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"session_token_unique": {
"name": "session_token_unique",
"nullsNotDistinct": false,
"columns": [
"token"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.user": {
"name": "user",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": true
},
"email_verified": {
"name": "email_verified",
"type": "boolean",
"primaryKey": false,
"notNull": true
},
"image": {
"name": "image",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"is_anonymous": {
"name": "is_anonymous",
"type": "boolean",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"user_email_unique": {
"name": "user_email_unique",
"nullsNotDistinct": false,
"columns": [
"email"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.user_tts_chars": {
"name": "user_tts_chars",
"schema": "",
"columns": {
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"date": {
"name": "date",
"type": "date",
"primaryKey": false,
"notNull": true
},
"char_count": {
"name": "char_count",
"type": "bigint",
"primaryKey": false,
"notNull": false,
"default": 0
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false,
"default": "now()"
}
},
"indexes": {
"idx_user_tts_chars_date": {
"name": "idx_user_tts_chars_date",
"columns": [
{
"expression": "date",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {},
"compositePrimaryKeys": {
"user_tts_chars_user_id_date_pk": {
"name": "user_tts_chars_user_id_date_pk",
"columns": [
"user_id",
"date"
]
}
},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.verification": {
"name": "verification",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"identifier": {
"name": "identifier",
"type": "text",
"primaryKey": false,
"notNull": true
},
"value": {
"name": "value",
"type": "text",
"primaryKey": false,
"notNull": true
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}

View file

@ -0,0 +1,13 @@
{
"version": "7",
"dialect": "postgresql",
"entries": [
{
"idx": 0,
"version": "7",
"when": 1769393893675,
"tag": "0000_military_nemesis",
"breakpoints": true
}
]
}

View file

@ -5,12 +5,13 @@
"scripts": {
"dev": "next dev --turbopack -p 3003",
"build": "next build",
"start": "next start -p 3003",
"start": "node scripts/migrate-if-auth.mjs && next start -p 3003",
"lint": "next lint",
"test": "playwright test"
"test": "playwright test",
"migrate": "node scripts/migrate-if-auth.mjs",
"migrate:force": "drizzle-kit migrate"
},
"dependencies": {
"@better-auth/cli": "1.4.17",
"@headlessui/react": "^2.2.9",
"@types/howler": "^2.2.12",
"@types/uuid": "^10.0.0",
@ -22,6 +23,9 @@
"core-js": "^3.48.0",
"dexie": "^4.2.1",
"dexie-react-hooks": "^4.2.0",
"dotenv": "^17.2.3",
"drizzle-kit": "^0.31.8",
"drizzle-orm": "^0.45.1",
"epubjs": "^0.3.93",
"howler": "^2.2.4",
"lru-cache": "^11.2.4",

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,38 @@
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 authEnabled = Boolean(process.env.BETTER_AUTH_SECRET && process.env.BETTER_AUTH_URL);
if (!authEnabled) {
// When auth is disabled, the app must not touch sqlite/postgres at all.
// That includes running migrations which can create/open DB files.
console.log('[migrate] Skipping (auth disabled). Missing BETTER_AUTH_SECRET and/or BETTER_AUTH_URL.');
process.exit(0);
}
const extraArgs = process.argv.slice(2);
const result = spawnSync('drizzle-kit', ['migrate', ...extraArgs], {
stdio: 'inherit',
env: process.env,
});
process.exit(result.status ?? 1);

View file

@ -1,7 +1,9 @@
import { headers } from 'next/headers';
import { NextResponse } from 'next/server';
import { auth } from '@/lib/server/auth';
import { db } from '@/lib/server/db';
import { db } from '@/db';
import { user } from '@/db/schema';
import { eq } from 'drizzle-orm';
import { isAuthEnabled } from '@/lib/server/auth-config';
export async function DELETE() {
@ -19,11 +21,8 @@ export async function DELETE() {
try {
// Directly delete user from database
await db.transaction(async (client) => {
// Deleting user usually cascades to sessions, accounts, etc if FKs are set up correctly
// But better-auth schemas do cascade.
await client.query('DELETE FROM "user" WHERE id = $1', [session.user.id]);
});
// Drizzle will handle cascade if configured in foreign keys, but typical cascading happens in DB engine
await db.delete(user).where(eq(user.id, session.user.id));
return NextResponse.json({ success: true });
} catch (error) {

View file

@ -4,6 +4,10 @@ import { readdir, unlink } from 'fs/promises';
import { join } from 'path';
import { AUDIOBOOKS_V1_DIR, isAudiobooksV1Ready } from '@/lib/server/docstore';
import { findStoredChapterByIndex } from '@/lib/server/audiobook';
import { db } from '@/db';
import { audiobookChapters } from '@/db/schema';
import { and, eq } from 'drizzle-orm';
import { requireAuthContext, requireAudiobookOwned } from '@/lib/server/auth';
export const dynamic = 'force-dynamic';
@ -18,10 +22,10 @@ export async function GET(request: NextRequest) {
try {
const bookId = request.nextUrl.searchParams.get('bookId');
const chapterIndexStr = request.nextUrl.searchParams.get('chapterIndex');
if (!bookId || !chapterIndexStr) {
return NextResponse.json(
{ error: 'Missing bookId or chapterIndex parameter' },
{ error: 'Missing bookId or chapterIndex parameter' },
{ status: 400 }
);
}
@ -29,7 +33,7 @@ export async function GET(request: NextRequest) {
const chapterIndex = parseInt(chapterIndexStr);
if (isNaN(chapterIndex)) {
return NextResponse.json(
{ error: 'Invalid chapterIndex parameter' },
{ error: 'Invalid chapterIndex parameter' },
{ status: 400 }
);
}
@ -40,8 +44,16 @@ export async function GET(request: NextRequest) {
{ status: 409 },
);
}
const ctxOrRes = await requireAuthContext(request);
if (ctxOrRes instanceof Response) return ctxOrRes;
if (ctxOrRes.authEnabled) {
const denied = await requireAudiobookOwned(bookId, ctxOrRes.userId!, { onDenied: 'notFound' });
if (denied) return denied;
}
const intermediateDir = join(getAudiobooksRootDir(request), `${bookId}-audiobook`);
const chapter = await findStoredChapterByIndex(intermediateDir, chapterIndex, request.signal);
if (!chapter || !existsSync(chapter.filePath)) {
return NextResponse.json({ error: 'Chapter not found' }, { status: 404 });
@ -113,22 +125,38 @@ export async function DELETE(request: NextRequest) {
{ status: 409 },
);
}
const ctxOrRes = await requireAuthContext(request);
if (ctxOrRes instanceof Response) return ctxOrRes;
if (ctxOrRes.authEnabled) {
const denied = await requireAudiobookOwned(bookId, ctxOrRes.userId!, { onDenied: 'forbidden' });
if (denied) return denied;
// Delete from DB first
if (db) {
await db.delete(audiobookChapters).where(
and(eq(audiobookChapters.bookId, bookId), eq(audiobookChapters.chapterIndex, chapterIndex)),
);
}
}
const intermediateDir = join(getAudiobooksRootDir(request), `${bookId}-audiobook`);
const chapterPrefix = `${String(chapterIndex + 1).padStart(4, '0')}__`;
const files = await readdir(intermediateDir).catch(() => []);
for (const file of files) {
if (!file.startsWith(chapterPrefix)) continue;
if (!file.endsWith('.mp3') && !file.endsWith('.m4b')) continue;
await unlink(join(intermediateDir, file)).catch(() => {});
await unlink(join(intermediateDir, file)).catch(() => { });
}
// Invalidate any combined "complete" files
const completeM4b = join(intermediateDir, `complete.m4b`);
const completeMp3 = join(intermediateDir, `complete.mp3`);
if (existsSync(completeM4b)) await unlink(completeM4b).catch(() => {});
if (existsSync(completeMp3)) await unlink(completeMp3).catch(() => {});
await unlink(join(intermediateDir, 'complete.mp3.manifest.json')).catch(() => {});
await unlink(join(intermediateDir, 'complete.m4b.manifest.json')).catch(() => {});
if (existsSync(completeM4b)) await unlink(completeM4b).catch(() => { });
if (existsSync(completeMp3)) await unlink(completeMp3).catch(() => { });
await unlink(join(intermediateDir, 'complete.mp3.manifest.json')).catch(() => { });
await unlink(join(intermediateDir, 'complete.m4b.manifest.json')).catch(() => { });
return NextResponse.json({ success: true });
} catch (error) {
@ -139,3 +167,4 @@ export async function DELETE(request: NextRequest) {
);
}
}

View file

@ -8,6 +8,11 @@ import { AUDIOBOOKS_V1_DIR, isAudiobooksV1Ready } from '@/lib/server/docstore';
import { encodeChapterFileName, encodeChapterTitleTag, listStoredChapters, ffprobeAudio, escapeFFMetadata } from '@/lib/server/audiobook';
import type { TTSAudioBytes, TTSAudiobookFormat } from '@/types/tts';
import type { AudiobookGenerationSettings } from '@/types/client';
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';
export const dynamic = 'force-dynamic';
@ -51,7 +56,7 @@ async function getAudioDuration(filePath: string, signal?: AbortSignal): Promise
finished = true;
try {
ffprobe.kill('SIGKILL');
} catch {}
} catch { }
reject(new Error('ABORTED'));
};
@ -103,7 +108,7 @@ async function runFFmpeg(args: string[], signal?: AbortSignal): Promise<void> {
finished = true;
try {
ffmpeg.kill('SIGKILL');
} catch {}
} catch { }
reject(new Error('ABORTED'));
};
@ -158,7 +163,7 @@ export async function POST(request: NextRequest) {
// Parse the request body
const data: ConversionRequest = await request.json();
const requestedFormat = data.format || 'm4b';
if (!(await isAudiobooksV1Ready())) {
return NextResponse.json(
{ error: 'Audiobooks storage is not migrated; run /api/migrations/v1 first.' },
@ -166,15 +171,36 @@ export async function POST(request: NextRequest) {
);
}
const ctxOrRes = await requireAuthContext(request);
if (ctxOrRes instanceof Response) return ctxOrRes;
const userId = ctxOrRes.userId;
// Generate or use existing book ID
const bookId = data.bookId || randomUUID();
if (!isSafeId(bookId)) {
return NextResponse.json({ error: 'Invalid bookId parameter' }, { status: 400 });
}
// DB Check / Insert Audiobook
if (isAuthEnabled() && db) {
const [existingBook] = await db.select().from(audiobooks).where(eq(audiobooks.id, bookId));
if (!existingBook) {
await db.insert(audiobooks).values({
id: bookId,
userId,
title: data.chapterTitle || 'Untitled Audiobook',
});
} else {
if (existingBook.userId && existingBook.userId !== userId) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
}
}
const intermediateDir = join(getAudiobooksRootDir(request), `${bookId}-audiobook`);
// Create intermediate directory
await mkdir(intermediateDir, { recursive: true });
@ -254,7 +280,7 @@ export async function POST(request: NextRequest) {
const inputPath = join(intermediateDir, `${chapterIndex}-input.mp3`);
const chapterOutputTempPath = join(intermediateDir, `${chapterIndex}-chapter.tmp.${format}`);
const titleTag = encodeChapterTitleTag(chapterIndex, data.chapterTitle);
// Write the chapter audio to a temp file
await writeFile(inputPath, Buffer.from(new Uint8Array(data.buffer)));
@ -292,7 +318,7 @@ export async function POST(request: NextRequest) {
const duration = probe.durationSec ?? (await getAudioDuration(chapterOutputTempPath, request.signal));
const finalChapterPath = join(intermediateDir, encodeChapterFileName(chapterIndex, data.chapterTitle, format));
await unlink(finalChapterPath).catch(() => {});
await unlink(finalChapterPath).catch(() => { });
await rename(chapterOutputTempPath, finalChapterPath);
// Remove any existing chapter files for this index (e.g., if the title changed and the
@ -304,22 +330,39 @@ export async function POST(request: NextRequest) {
if (!file.startsWith(chapterPrefix)) continue;
if (!file.endsWith('.mp3') && !file.endsWith('.m4b')) continue;
if (file === finalChapterName) continue;
await unlink(join(intermediateDir, file)).catch(() => {});
await unlink(join(intermediateDir, file)).catch(() => { });
}
await unlink(join(intermediateDir, 'complete.mp3')).catch(() => {});
await unlink(join(intermediateDir, 'complete.m4b')).catch(() => {});
await unlink(join(intermediateDir, 'complete.mp3.manifest.json')).catch(() => {});
await unlink(join(intermediateDir, 'complete.m4b.manifest.json')).catch(() => {});
await unlink(join(intermediateDir, 'complete.mp3')).catch(() => { });
await unlink(join(intermediateDir, 'complete.m4b')).catch(() => { });
await unlink(join(intermediateDir, 'complete.mp3.manifest.json')).catch(() => { });
await unlink(join(intermediateDir, 'complete.m4b.manifest.json')).catch(() => { });
// Ensure meta exists after first successful chapter.
if (!existingSettings && incomingSettings) {
await writeFile(metaPath, JSON.stringify(incomingSettings, null, 2)).catch(() => {});
await writeFile(metaPath, JSON.stringify(incomingSettings, null, 2)).catch(() => { });
}
// Clean up input file
await unlink(inputPath).catch(console.error);
return NextResponse.json({
// Insert Chapter Record (Denormalized)
if (isAuthEnabled() && db) {
await db.insert(audiobookChapters).values({
id: `${bookId}-${chapterIndex}`,
bookId,
userId,
chapterIndex,
title: data.chapterTitle,
duration,
format,
filePath: finalChapterName
}).onConflictDoUpdate({
target: [audiobookChapters.id],
set: { title: data.chapterTitle, duration, format, filePath: finalChapterName }
});
}
return NextResponse.json({
index: chapterIndex,
title: data.chapterTitle,
duration,
@ -360,6 +403,19 @@ export async function GET(request: NextRequest) {
{ status: 409 },
);
}
// Auth Check
const session = await auth?.api.getSession({ headers: request.headers });
const userId = session?.user?.id || null;
// Verify ownership
if (isAuthEnabled() && db) {
const [existingBook] = await db.select().from(audiobooks).where(eq(audiobooks.id, bookId));
if (existingBook && existingBook.userId && existingBook.userId !== userId) {
return NextResponse.json({ error: 'Book not found' }, { status: 404 }); // Hide existence
}
}
const intermediateDir = join(getAudiobooksRootDir(request), `${bookId}-audiobook`);
if (!existsSync(intermediateDir)) {
@ -412,8 +468,8 @@ export async function GET(request: NextRequest) {
return streamFile(outputPath, format);
}
await unlink(outputPath).catch(() => {});
await unlink(manifestPath).catch(() => {});
await unlink(outputPath).catch(() => { });
await unlink(manifestPath).catch(() => { });
}
// Ensure we have chapter durations for chapter markers / ordering.
@ -425,7 +481,7 @@ export async function GET(request: NextRequest) {
chapter.duration = probe.durationSec;
continue;
}
} catch {}
} catch { }
try {
chapter.duration = await getAudioDuration(chapter.filePath, request.signal);
@ -445,7 +501,7 @@ export async function GET(request: NextRequest) {
metadata.push('[CHAPTER]', 'TIMEBASE=1/1000', `START=${startMs}`, `END=${endMs}`, `title=${escapeFFMetadata(chapter.title)}`);
}
await writeFile(metadataPath, ';FFMETADATA1\n' + metadata.join('\n'));
// Create list file for concat
@ -486,7 +542,7 @@ export async function GET(request: NextRequest) {
unlink(listPath).catch(console.error)
]);
await writeFile(manifestPath, JSON.stringify(signature, null, 2)).catch(() => {});
await writeFile(manifestPath, JSON.stringify(signature, null, 2)).catch(() => { });
// Stream the file back to the client
return streamFile(outputPath, format);
@ -553,6 +609,30 @@ export async function DELETE(request: NextRequest) {
{ status: 409 },
);
}
// 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 });
}
// Verify ownership
if (isAuthEnabled() && db) {
const [existingBook] = await db.select().from(audiobooks).where(eq(audiobooks.id, bookId));
if (existingBook) {
if (existingBook.userId && existingBook.userId !== userId) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
await db.delete(audiobooks).where(eq(audiobooks.id, bookId));
} else {
return NextResponse.json({ error: 'Book not found' }, { status: 404 });
}
}
const intermediateDir = join(getAudiobooksRootDir(request), `${bookId}-audiobook`);
// If directory doesn't exist, consider it already reset

View file

@ -6,6 +6,7 @@ import { listStoredChapters } from '@/lib/server/audiobook';
import type { AudiobookGenerationSettings } from '@/types/client';
import type { TTSAudiobookFormat, TTSAudiobookChapter } from '@/types/tts';
import { readFile } from 'fs/promises';
import { requireAuthContext, requireAudiobookOwned } from '@/lib/server/auth';
export const dynamic = 'force-dynamic';
@ -35,6 +36,23 @@ export async function GET(request: NextRequest) {
{ status: 409 },
);
}
const ctxOrRes = await requireAuthContext(request);
if (ctxOrRes instanceof Response) return ctxOrRes;
if (ctxOrRes.authEnabled) {
const denied = await requireAudiobookOwned(bookId, ctxOrRes.userId!, { onDenied: 'notFound' });
if (denied) {
return NextResponse.json({
chapters: [],
exists: false,
hasComplete: false,
bookId: null,
settings: null,
});
}
}
const intermediateDir = join(getAudiobooksRootDir(request), `${bookId}-audiobook`);
if (!existsSync(intermediateDir)) {
@ -66,8 +84,8 @@ export async function GET(request: NextRequest) {
const hasComplete = existsSync(join(intermediateDir, 'complete.mp3')) || existsSync(join(intermediateDir, 'complete.m4b'));
return NextResponse.json({
chapters,
return NextResponse.json({
chapters,
exists: true,
hasComplete,
bookId,

View file

@ -5,6 +5,7 @@ import path from 'path';
import { existsSync } from 'fs';
import { randomUUID } from 'crypto';
import { pathToFileURL } from 'url';
import { auth } from '@/lib/server/auth';
const DOCSTORE_DIR = path.join(process.cwd(), 'docstore');
const TEMP_DIR = path.join(DOCSTORE_DIR, 'tmp');
@ -75,11 +76,17 @@ async function waitForPdfReady(dir: string, timeoutMs = 20000, intervalMs = 100)
export async function POST(req: NextRequest) {
try {
// Auth check - require session
const session = await auth?.api.getSession({ headers: req.headers });
if (auth && !session?.user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
await ensureTempDir();
const formData = await req.formData();
const file = formData.get('file') as File;
if (!file) {
return NextResponse.json(
{ error: 'No file provided' },

View file

@ -2,6 +2,7 @@ import { readFile, stat } from 'fs/promises';
import path from 'path';
import { NextRequest, NextResponse } from 'next/server';
import { contentTypeForName, decodeLibraryId, isPathWithinRoot, parseLibraryRoots } from '@/lib/server/library';
import { auth } from '@/lib/server/auth';
export const dynamic = 'force-dynamic';
@ -57,6 +58,12 @@ function contentDispositionAttachment(filename: string): string {
}
export async function GET(req: NextRequest) {
// Auth check - require session
const session = await auth?.api.getSession({ headers: req.headers });
if (auth && !session?.user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const url = new URL(req.url);
const id = url.searchParams.get('id');
if (!id) {

View file

@ -4,6 +4,7 @@ import path from 'path';
import { NextRequest, NextResponse } from 'next/server';
import { parseLibraryRoots } from '@/lib/server/library';
import type { DocumentType } from '@/types/documents';
import { auth } from '@/lib/server/auth';
export const dynamic = 'force-dynamic';
@ -41,10 +42,10 @@ function libraryDocumentTypeFromName(name: string): DocumentType {
let cache:
| {
cacheKey: string;
cachedAt: number;
documents: LibraryDocument[];
}
cacheKey: string;
cachedAt: number;
documents: LibraryDocument[];
}
| undefined;
async function scanLibraryRoot(root: string, rootIndex: number, limit: number): Promise<LibraryDocument[]> {
@ -103,6 +104,12 @@ async function scanLibraryRoot(root: string, rootIndex: number, limit: number):
}
export async function GET(req: NextRequest) {
// Auth check - require session
const session = await auth?.api.getSession({ headers: req.headers });
if (auth && !session?.user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const url = new URL(req.url);
const refresh = url.searchParams.get('refresh') === '1';
const limit = Math.max(1, Math.min(Number(url.searchParams.get('limit') ?? '5000'), 10000));

View file

@ -1,9 +1,14 @@
import { createHash } from 'crypto';
import { readdir, readFile, stat, unlink, utimes, writeFile } from 'fs/promises';
import { readFile, stat, unlink, utimes, writeFile } from 'fs/promises';
import { NextRequest, NextResponse } from 'next/server';
import path from 'path';
import { DOCUMENTS_V1_DIR, isDocumentsV1Ready } from '@/lib/server/docstore';
import { DOCUMENTS_V1_DIR, isDocumentsV1Ready, scanDocumentsFS } from '@/lib/server/docstore';
import type { BaseDocument, DocumentType, SyncedDocument } from '@/types/documents';
import { db } from '@/db';
import { documents } from '@/db/schema';
import { eq, or, and, inArray, count } from 'drizzle-orm';
import { isAuthEnabled } from '@/lib/server/auth-config';
import { requireAuthContext } from '@/lib/server/auth';
export const dynamic = 'force-dynamic';
@ -29,67 +34,6 @@ function toDocumentTypeFromName(name: string): DocumentType {
return 'html';
}
function parseSyncedFileName(fileName: string): { id: string; name: string } | null {
const match = /^([a-f0-9]{64})__(.+)$/i.exec(fileName);
if (!match) return null;
try {
return { id: match[1].toLowerCase(), name: decodeURIComponent(match[2]) };
} catch {
return null;
}
}
async function loadSyncedDocuments(includeData: boolean, targetIds?: Set<string>): Promise<(BaseDocument | SyncedDocument)[]> {
const results: (BaseDocument | SyncedDocument)[] = [];
let files: string[] = [];
try {
files = await readdir(SYNC_DIR);
} catch {
return results;
}
for (const file of files) {
const parsed = parseSyncedFileName(file);
if (!parsed) continue;
// Filter by ID if specific IDs are requested
if (targetIds && !targetIds.has(parsed.id)) continue;
const filePath = path.join(SYNC_DIR, file);
let fileStat: Awaited<ReturnType<typeof stat>>;
try {
fileStat = await stat(filePath);
} catch {
continue;
}
if (!fileStat.isFile()) continue;
const type = toDocumentTypeFromName(parsed.name);
const metadata: BaseDocument = {
id: parsed.id,
name: parsed.name,
size: fileStat.size,
lastModified: fileStat.mtimeMs,
type,
};
if (!includeData) {
results.push(metadata);
continue;
}
const content = await readFile(filePath);
results.push({
...metadata,
data: Array.from(new Uint8Array(content)),
});
}
return results;
}
export async function POST(req: NextRequest) {
try {
if (!(await isDocumentsV1Ready())) {
@ -98,45 +42,56 @@ export async function POST(req: NextRequest) {
{ status: 409 },
);
}
const ctxOrRes = await requireAuthContext(req);
if (ctxOrRes instanceof Response) return ctxOrRes;
const userId = ctxOrRes.userId;
const data = await req.json();
const documents = data.documents as SyncedDocument[];
let existingFiles: string[] = [];
try {
existingFiles = await readdir(SYNC_DIR);
} catch {
existingFiles = [];
}
const existingById = new Map<string, string>();
for (const file of existingFiles) {
const parsed = parseSyncedFileName(file);
if (!parsed) continue;
if (!existingById.has(parsed.id)) {
existingById.set(parsed.id, file);
}
}
const documentsData = data.documents as SyncedDocument[];
const stored: Array<{ oldId: string; id: string; name: string }> = [];
for (const doc of documents) {
// Ensure directory exists (redundant with isDocumentsV1Ready but safe)
// const SYNC_DIR = DOCUMENTS_V1_DIR;
for (const doc of documentsData) {
const content = Buffer.from(new Uint8Array(doc.data));
const id = createHash('sha256').update(content).digest('hex');
const baseName = path.basename(doc.name || `${id}.${doc.type}`);
const safeName = baseName.replaceAll('\u0000', '').slice(0, 240) || `${id}.${doc.type}`;
const existingFile = existingById.get(id);
const targetFileName = existingFile ?? `${id}__${encodeURIComponent(safeName)}`;
const targetFileName = `${id}__${encodeURIComponent(safeName)}`;
const targetPath = path.join(SYNC_DIR, targetFileName);
if (!existingFile) {
// Write file if not exists
try {
await stat(targetPath);
} catch {
await writeFile(targetPath, content);
existingById.set(id, targetFileName);
}
await trySetFileMtime(targetPath, doc.lastModified);
// DB Upsert
// With composite PK (id, userId), we check if THIS user already has this document
if (isAuthEnabled() && db) {
const [existing] = await db.select().from(documents).where(
and(eq(documents.id, id), eq(documents.userId, userId))
);
if (!existing) {
await db.insert(documents).values({
id,
userId,
name: safeName,
type: doc.type,
size: content.length,
lastModified: doc.lastModified,
filePath: targetFileName
});
}
}
stored.push({ oldId: doc.id, id, name: safeName });
}
@ -156,6 +111,10 @@ 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';
const format = url.searchParams.get('format');
@ -165,22 +124,73 @@ export async function GET(req: NextRequest) {
// If format=metadata, force metadata only.
// Otherwise include data.
const includeData = !list && format !== 'metadata';
let targetIds: Set<string> | undefined;
if (idsParam) {
targetIds = new Set(idsParam.split(',').filter(Boolean));
const targetIds = idsParam ? idsParam.split(',').filter(Boolean) : null;
// Query database (or filesystem) for documents the user is allowed to access
let allowedDocs: { id: string; name: string; type: string; size: number; lastModified: number; filePath: string }[] = [];
if (!isAuthEnabled()) {
const fsDocs = await scanDocumentsFS();
allowedDocs = fsDocs.map(d => ({ ...d }));
if (targetIds) {
allowedDocs = allowedDocs.filter(d => targetIds!.includes(d.id));
}
} else {
if (!db) throw new Error("DB not initialized");
const rows = await db.select().from(documents).where(
and(
or(eq(documents.userId, userId), eq(documents.userId, 'unclaimed')),
targetIds ? inArray(documents.id, targetIds) : undefined
)
);
allowedDocs = rows as unknown as { id: string; name: string; type: string; size: number; lastModified: number; filePath: string }[];
}
const documents = await loadSyncedDocuments(includeData, targetIds);
const results: (BaseDocument | SyncedDocument)[] = [];
return NextResponse.json({ documents });
for (const doc of allowedDocs) {
const type: DocumentType =
doc.type === 'pdf' || doc.type === 'epub' || doc.type === 'docx' || doc.type === 'html'
? (doc.type as DocumentType)
: toDocumentTypeFromName(doc.name);
const metadata: BaseDocument = {
id: doc.id!,
name: doc.name,
size: doc.size,
lastModified: doc.lastModified,
type,
};
if (!includeData) {
results.push(metadata);
continue;
}
try {
const filePath = path.join(SYNC_DIR, doc.filePath);
const content = await readFile(filePath);
results.push({
...metadata,
data: Array.from(new Uint8Array(content)),
});
} catch (err) {
console.warn(`Failed to read content for document ${doc.id} at ${doc.filePath}`, err);
// Skip adding data if file is missing, or just skip item?
// Best to skip item to avoid client errors on partial data
}
}
return NextResponse.json({ documents: results });
} catch (error) {
console.error('Error loading documents:', error);
return NextResponse.json({ error: 'Failed to load documents' }, { status: 500 });
}
}
export async function DELETE() {
export async function DELETE(req: NextRequest) {
try {
if (!(await isDocumentsV1Ready())) {
return NextResponse.json(
@ -189,27 +199,86 @@ export async function DELETE() {
);
}
// Delete synced docs (new format) safely without touching unrelated docstore data.
let syncFiles: string[] = [];
try {
syncFiles = await readdir(SYNC_DIR);
} catch {
syncFiles = [];
}
// Auth check - require session
const ctxOrRes = await requireAuthContext(req);
if (ctxOrRes instanceof Response) return ctxOrRes;
const userId = ctxOrRes.userId;
for (const file of syncFiles) {
const filePath = path.join(SYNC_DIR, file);
try {
const st = await stat(filePath);
if (st.isFile()) {
await unlink(filePath);
}
} catch {
continue;
const url = new URL(req.url);
const idsParam = url.searchParams.get('ids');
// Determine which IDs to try to delete
let targetIds: string[] = [];
if (idsParam) {
targetIds = idsParam.split(',').filter(Boolean);
} else {
if (!isAuthEnabled()) {
const fsDocs = await scanDocumentsFS();
targetIds = fsDocs.map((d) => d.id);
} else {
// 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));
targetIds = userDocs.map((d: { id: string | null }) => d.id!).filter(Boolean) as string[];
}
}
return NextResponse.json({ success: true });
if (targetIds.length === 0) {
return NextResponse.json({ success: true, deleted: 0 });
}
const deletedRows: { id: string; filePath: string }[] = [];
if (!isAuthEnabled()) {
// FS cleanup only
// Since we don't track ownership, we just delete the files requested.
// This implies in no-auth mode, any user can delete any file if they know the ID.
const fsDocs = await scanDocumentsFS();
for (const doc of fsDocs) {
if (targetIds.includes(doc.id)) {
deletedRows.push({ id: doc.id, filePath: doc.filePath });
}
}
} else {
if (!db) throw new Error("DB not initialized");
const rows = await db.delete(documents)
.where(and(
eq(documents.userId, userId as string),
inArray(documents.id, targetIds)
))
.returning({ id: documents.id, filePath: documents.filePath });
// eslint-disable-next-line @typescript-eslint/no-explicit-any
rows.forEach((r: any) => deletedRows.push({ id: r.id!, filePath: r.filePath }));
}
// If driver doesn't support returning (e.g. older SQLite without properly configured returning), we might fallback.
// But Drizzle usually handles this.
let deletedCount = 0;
for (const row of deletedRows) {
deletedCount++;
// Chech reference count for this ID
// If 0 remaining, delete file
let refCount = 0;
if (isAuthEnabled() && db) {
const [ref] = await db.select({ count: count() }).from(documents).where(eq(documents.id, row.id!));
refCount = ref?.count ?? 0;
}
if (refCount === 0) {
const filePath = path.join(SYNC_DIR, row.filePath);
try {
await unlink(filePath);
} catch {
// Ignore if missing
}
}
}
return NextResponse.json({ success: true, deleted: deletedCount });
} catch (error) {
console.error('Error deleting documents:', error);
return NextResponse.json({ error: 'Failed to delete documents' }, { status: 500 });

View file

@ -9,6 +9,7 @@ import {
isAudiobooksV1Ready,
isDocumentsV1Ready,
} from '@/lib/server/docstore';
import { auth } from '@/lib/server/auth';
type Mapping = { oldId: string; id: string };
@ -42,7 +43,7 @@ async function mergeDirectoryContents(sourceDir: string, targetDir: string): Pro
if (remaining.length === 0) {
await rm(sourcePath);
}
} catch {}
} catch { }
continue;
}
@ -95,7 +96,7 @@ async function rekeyAudiobooksV1(mappings: Mapping[]): Promise<{ renamed: number
if (remaining.length === 0) {
await rm(sourceDir);
}
} catch {}
} catch { }
}
return { renamed, merged, skipped };
@ -103,6 +104,12 @@ async function rekeyAudiobooksV1(mappings: Mapping[]): Promise<{ renamed: number
export async function POST(request: NextRequest) {
try {
// Auth check - require session
const session = await auth?.api.getSession({ headers: request.headers });
if (auth && !session?.user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const raw = (await request.json().catch(() => null)) as { mappings?: Mapping[] } | null;
const mappings = (raw?.mappings ?? []).filter(
(m): m is Mapping => Boolean(m && typeof m.oldId === 'string' && typeof m.id === 'string'),

View file

@ -1,5 +1,6 @@
import { NextRequest, NextResponse } from 'next/server';
import { isKokoroModel } from '@/utils/voice';
import { auth } from '@/lib/server/auth';
const OPENAI_VOICES = ['alloy', 'echo', 'fable', 'onyx', 'nova', 'shimmer'];
const GPT4O_MINI_VOICES = ['alloy', 'ash', 'coral', 'echo', 'fable', 'onyx', 'nova', 'sage', 'shimmer'];
@ -27,7 +28,7 @@ function getDefaultVoices(provider: string, model: string): string[] {
}
return OPENAI_VOICES;
}
// For Custom OpenAI-Like provider
if (provider === 'custom-openai') {
// If using Kokoro-FastAPI (model string contains 'kokoro'), expose full Kokoro voices
@ -36,7 +37,7 @@ function getDefaultVoices(provider: string, model: string): string[] {
}
return CUSTOM_OPENAI_VOICES;
}
// For Deepinfra provider - model-specific voices
if (provider === 'deepinfra') {
if (model === 'hexgrad/Kokoro-82M') {
@ -58,7 +59,7 @@ function getDefaultVoices(provider: string, model: string): string[] {
// Default Deepinfra voices
return CUSTOM_OPENAI_VOICES;
}
// Default fallback
return OPENAI_VOICES;
}
@ -77,7 +78,7 @@ async function fetchDeepinfraVoices(apiKey: string): Promise<string[]> {
}
const data = await response.json();
// Extract voice names from the response, excluding preset voices
if (data.voices && Array.isArray(data.voices)) {
return data.voices
@ -93,6 +94,12 @@ async function fetchDeepinfraVoices(apiKey: string): Promise<string[]> {
export async function GET(req: NextRequest) {
try {
// Auth check - require session
const session = await auth?.api.getSession({ headers: req.headers });
if (auth && !session?.user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const openApiKey = req.headers.get('x-openai-key') || process.env.API_KEY || 'none';
const openApiBaseUrl = req.headers.get('x-openai-base-url') || process.env.API_BASE;
const provider = req.headers.get('x-tts-provider') || 'openai';
@ -106,9 +113,9 @@ export async function GET(req: NextRequest) {
// For Deepinfra provider with specific models that need API fetching
if (provider === 'deepinfra') {
const needsApiFetch = model === 'ResembleAI/chatterbox' ||
model === 'Zyphra/Zonos-v0.1-hybrid' ||
model === 'Zyphra/Zonos-v0.1-transformer';
model === 'Zyphra/Zonos-v0.1-hybrid' ||
model === 'Zyphra/Zonos-v0.1-transformer';
if (needsApiFetch) {
const apiVoices = await fetchDeepinfraVoices(openApiKey);
// Combine default voice with fetched voices
@ -117,7 +124,7 @@ export async function GET(req: NextRequest) {
return NextResponse.json({ voices: [...defaultVoice, ...apiVoices] });
}
}
// For other Deepinfra models, return static defaults
return NextResponse.json({ voices: getDefaultVoices(provider, model) });
}
@ -141,7 +148,7 @@ export async function GET(req: NextRequest) {
} catch {
console.log('Custom endpoint does not support voices, using defaults');
}
// Fallback to default voices if API call fails
return NextResponse.json({ voices: getDefaultVoices(provider, model) });
}

View file

@ -0,0 +1,32 @@
import { NextRequest, NextResponse } from 'next/server';
import { claimAnonymousData, scanAndPopulateDB } from '@/lib/server/claim-data';
import { auth } from '@/lib/server/auth';
export async function POST(req: NextRequest) {
try {
const session = await auth?.api.getSession({ headers: req.headers });
if (!session || !session.user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const userId = session.user.id;
const { action } = await req.json(); // "claim" or "scan" or "start_fresh" (optional logic)
if (action === 'scan') {
const counts = await scanAndPopulateDB();
return NextResponse.json({ success: true, message: 'Scanned file system', ...counts });
}
// Default action: Claim
const result = await claimAnonymousData(userId);
return NextResponse.json({
success: true,
claimed: result
});
} catch (error) {
console.error('Error claiming data:', error);
return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 });
}
}

View file

@ -6,6 +6,7 @@ import { join } from 'path';
import { spawn } from 'child_process';
import type { TTSSentenceAlignment, TTSAudioBytes, TTSAudioBuffer } from '@/types/tts';
import { preprocessSentenceForAudio } from '@/lib/nlp';
import { auth } from '@/lib/server/auth';
export const runtime = 'nodejs';
@ -416,6 +417,12 @@ function makeCacheKey(input: WhisperRequestBody) {
export async function POST(req: NextRequest) {
try {
// Auth check - require session
const session = await auth?.api.getSession({ headers: req.headers });
if (auth && !session?.user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const body = (await req.json()) as WhisperRequestBody;
const { text, audio, lang } = body;

View file

@ -6,6 +6,7 @@ import { Footer } from "@/components/Footer";
import { Toaster } from 'react-hot-toast';
import { Analytics } from "@vercel/analytics/next";
import { isAuthEnabled, getAuthBaseUrl } from '@/lib/server/auth-config';
import ClaimDataPopup from '@/components/ClaimDataModal';
export const metadata: Metadata = {
title: "OpenReader WebUI",
@ -64,6 +65,7 @@ export default function RootLayout({ children }: { children: ReactNode }) {
<body className="antialiased">
<Providers authEnabled={authEnabled} authBaseUrl={authBaseUrl}>
<div className="app-shell min-h-screen flex flex-col bg-background">
{authEnabled && <ClaimDataPopup />}
<main className="flex-1 flex flex-col">
{children}
<Analytics />

View file

@ -0,0 +1,154 @@
'use client';
import { Fragment, useState, useEffect, useCallback } from 'react';
import {
Dialog,
DialogPanel,
DialogTitle,
Transition,
TransitionChild,
Button,
} from '@headlessui/react';
import { useAuthSession } from '@/hooks/useAuth';
import { useRouter } from 'next/navigation';
export default function ClaimDataModal() {
const { data: sessionData } = useAuthSession();
const router = useRouter();
const [isOpen, setIsOpen] = useState(false);
const [hasChecked, setHasChecked] = useState(false);
const [isClaiming, setIsClaiming] = useState(false);
const user = sessionData?.user;
const checkClaimableData = useCallback(async () => {
setHasChecked(true);
try {
const res = await fetch('/api/user/claim', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'scan' })
});
if (res.ok) {
const data = await res.json();
if (data.documents > 0 || data.audiobooks > 0) {
setIsOpen(true);
}
}
} catch (e) {
console.error(e);
}
}, []);
useEffect(() => {
// Only check once per session if user is logged in (non-anonymous)
if (user && !user.isAnonymous && !hasChecked) {
checkClaimableData();
}
}, [user, hasChecked, checkClaimableData]);
const handleClaim = async () => {
setIsClaiming(true);
try {
const res = await fetch('/api/user/claim', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'claim' })
});
if (res.ok) {
const data = await res.json();
alert(`Successfully claimed ${data.claimed.documents} documents and ${data.claimed.audiobooks} audiobooks!`);
setIsOpen(false);
router.refresh();
}
} catch {
alert('Failed to claim data.');
} finally {
setIsClaiming(false);
}
};
const handleDismiss = () => {
// Close the modal for this session - will reappear on next page load/refresh
setIsOpen(false);
// Keep hasChecked = true so useEffect doesn't re-trigger in this session
};
return (
<Transition appear show={isOpen} as={Fragment}>
<Dialog as="div" className="relative z-50" onClose={handleDismiss}>
<TransitionChild
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="fixed inset-0 overlay-dim backdrop-blur-sm" />
</TransitionChild>
<div className="fixed inset-0 overflow-y-auto">
<div className="flex min-h-full items-start justify-center p-4 pt-6 text-center sm:items-center sm:pt-4">
<TransitionChild
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0 scale-95"
enterTo="opacity-100 scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95"
>
<DialogPanel className="w-full max-w-md transform rounded-2xl bg-base p-6 text-left align-middle shadow-xl transition-all">
<DialogTitle
as="h3"
className="text-lg font-semibold leading-6 text-foreground mb-4"
>
Existing Data Found
</DialogTitle>
<p className="text-sm text-muted mb-2">
We found documents and audiobooks that were created before auth was enabled.
Would you like to claim them and add them to your account?
</p>
<p className="text-xs text-muted/70 mb-6 italic">
First user to claim these files will own them and revoke access for anyone else.
</p>
<div className="flex justify-end gap-3">
<Button
type="button"
onClick={handleDismiss}
disabled={isClaiming}
className="inline-flex justify-center rounded-lg bg-background px-3 py-1.5 text-sm
font-medium text-foreground hover:bg-offbase focus:outline-none
focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2
transform transition-transform duration-200 ease-in-out hover:scale-[1.04] hover:text-accent
disabled:opacity-50"
>
Dismiss
</Button>
<Button
type="button"
onClick={handleClaim}
disabled={isClaiming}
className="inline-flex justify-center rounded-lg bg-accent px-3 py-1.5 text-sm
font-medium text-background hover:bg-secondary-accent focus:outline-none
focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2
transform transition-transform duration-200 ease-in-out hover:scale-[1.04] hover:text-background
disabled:opacity-50"
>
{isClaiming ? 'Claiming...' : 'Claim All'}
</Button>
</div>
</DialogPanel>
</TransitionChild>
</div>
</div>
</Dialog>
</Transition>
);
}

View file

@ -43,19 +43,14 @@ export function UserMenu({ className = '' }: { className?: string }) {
}
return (
<div className={`flex items-center gap-2 ${className}`}>
<div className="hidden sm:flex flex-col items-end mr-1">
<span className="text-xs font-medium text-foreground leading-none mb-0.5">
{session.user.name || session.user.email || 'Account'}
</span>
<span className="text-[10px] text-muted truncate max-w-[120px] leading-none">
{session.user.email}
</span>
</div>
<div className={`flex items-center gap-2 px-2 py-1 rounded-md border border-offbase bg-base ${className}`}>
<span className="hidden sm:block text-xs font-medium text-foreground truncate max-w-[160px]">
{session.user.email || 'Account'}
</span>
<Button
onClick={handleSignOut}
className="inline-flex items-center py-1 px-2 rounded-md border border-offbase bg-base text-foreground text-xs hover:bg-offbase transition-all duration-200 ease-in-out hover:scale-[1.09] hover:text-red-500"
className="inline-flex items-center text-foreground text-xs hover:text-accent transform transition-all duration-200 ease-in-out hover:scale-[1.09]"
title="Sign Out"
>
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">

42
src/db/index.ts Normal file
View file

@ -0,0 +1,42 @@
import { drizzle as drizzlePg } from 'drizzle-orm/node-postgres';
import { drizzle as drizzleSqlite } from 'drizzle-orm/better-sqlite3';
import { Pool } from 'pg';
import Database from 'better-sqlite3';
import path from 'path';
import fs from 'fs';
import * as schema from './schema';
import { isAuthEnabled } from '@/lib/server/auth-config';
// Singleton logic not strictly needed if Next.js handles module caching, but good for safety
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let dbInstance: any = null;
export function getDrizzleDB() {
if (dbInstance) return dbInstance;
// If auth is NOT enabled, we do not want to connect to any database
if (!isAuthEnabled()) {
return null;
}
if (process.env.POSTGRES_URL) {
const pool = new Pool({
connectionString: process.env.POSTGRES_URL,
ssl: process.env.NODE_ENV === "production" ? { rejectUnauthorized: false } : false,
});
dbInstance = drizzlePg(pool, { schema });
} else {
// Fallback to SQLite
const dbPath = path.join(process.cwd(), 'docstore', 'sqlite3.db');
const dir = path.dirname(dbPath);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
const sqlite = new Database(dbPath);
dbInstance = drizzleSqlite(sqlite, { schema });
}
return dbInstance;
}
export const db = getDrizzleDB();

14
src/db/schema.ts Normal file
View file

@ -0,0 +1,14 @@
import * as sqliteSchema from './schema_sqlite';
import * as postgresSchema from './schema_postgres';
const usePostgres = !!process.env.POSTGRES_URL;
// Export the correct table variants dynamically
export const documents = usePostgres ? postgresSchema.documents : sqliteSchema.documents;
export const audiobooks = usePostgres ? postgresSchema.audiobooks : sqliteSchema.audiobooks;
export const audiobookChapters = usePostgres ? postgresSchema.audiobookChapters : sqliteSchema.audiobookChapters;
export const user = usePostgres ? postgresSchema.user : sqliteSchema.user;
export const session = usePostgres ? postgresSchema.session : sqliteSchema.session;
export const account = usePostgres ? postgresSchema.account : sqliteSchema.account;
export const verification = usePostgres ? postgresSchema.verification : sqliteSchema.verification;
export const userTtsChars = usePostgres ? postgresSchema.userTtsChars : sqliteSchema.userTtsChars;

92
src/db/schema_postgres.ts Normal file
View file

@ -0,0 +1,92 @@
import { pgTable, text, integer, real, boolean, timestamp, date, bigint, primaryKey, index } from 'drizzle-orm/pg-core';
export const documents = pgTable('documents', {
id: text('id'),
userId: text('user_id').notNull(),
name: text('name').notNull(),
type: text('type').notNull(), // pdf, epub, docx, html
size: bigint('size', { mode: 'number' }).notNull(),
lastModified: bigint('last_modified', { mode: 'number' }).notNull(),
filePath: text('file_path').notNull(),
createdAt: timestamp('created_at').defaultNow(),
}, (table) => ({
pk: primaryKey({ columns: [table.id, table.userId] }),
}));
export const audiobooks = pgTable('audiobooks', {
id: text('id').primaryKey(),
userId: text('user_id'),
title: text('title').notNull(),
author: text('author'),
description: text('description'),
coverPath: text('cover_path'),
duration: real('duration').default(0),
createdAt: timestamp('created_at').defaultNow(),
});
export const audiobookChapters = pgTable('audiobook_chapters', {
id: text('id').primaryKey(),
bookId: text('book_id').notNull().references(() => audiobooks.id, { onDelete: 'cascade' }),
userId: text('user_id'),
chapterIndex: integer('chapter_index').notNull(),
title: text('title').notNull(),
duration: real('duration').default(0),
filePath: text('file_path').notNull(),
format: text('format').notNull(), // mp3, m4b
});
export const user = pgTable("user", {
id: text("id").primaryKey(),
name: text('name').notNull(),
email: text('email').notNull().unique(),
emailVerified: boolean('email_verified').notNull(),
image: text('image'),
createdAt: timestamp('created_at').notNull(),
updatedAt: timestamp('updated_at').notNull(),
isAnonymous: boolean('is_anonymous'),
});
export const session = pgTable("session", {
id: text("id").primaryKey(),
expiresAt: timestamp('expires_at').notNull(),
token: text('token').notNull().unique(),
createdAt: timestamp('created_at').notNull(),
updatedAt: timestamp('updated_at').notNull(),
ipAddress: text('ip_address'),
userAgent: text('user_agent'),
userId: text('user_id').notNull().references(() => user.id)
});
export const account = pgTable("account", {
id: text("id").primaryKey(),
accountId: text('account_id').notNull(),
providerId: text('provider_id').notNull(),
userId: text('user_id').notNull().references(() => user.id),
accessToken: text('access_token'),
refreshToken: text('refresh_token'),
idToken: text('id_token'),
accessTokenExpiresAt: timestamp('access_token_expires_at'),
refreshTokenExpiresAt: timestamp('refresh_token_expires_at'),
scope: text('scope'),
password: text('password'),
createdAt: timestamp('created_at').notNull(),
updatedAt: timestamp('updated_at').notNull(),
});
export const verification = pgTable("verification", {
id: text("id").primaryKey(),
identifier: text('identifier').notNull(),
value: text('value').notNull(),
updatedAt: timestamp('updated_at'),
});
export const userTtsChars = pgTable("user_tts_chars", {
userId: text('user_id').notNull(),
date: date('date').notNull(),
charCount: bigint('char_count', { mode: 'number' }).default(0),
createdAt: timestamp('created_at').defaultNow(),
updatedAt: timestamp('updated_at').defaultNow(),
}, (table) => ({
pk: primaryKey({ columns: [table.userId, table.date] }),
dateIdx: index('idx_user_tts_chars_date').on(table.date),
}));

93
src/db/schema_sqlite.ts Normal file
View file

@ -0,0 +1,93 @@
import { sqliteTable, text, integer, real, primaryKey, index } from 'drizzle-orm/sqlite-core';
import { sql } from 'drizzle-orm';
export const documents = sqliteTable('documents', {
id: text('id'),
userId: text('user_id').notNull(),
name: text('name').notNull(),
type: text('type').notNull(), // pdf, epub, docx, html
size: integer('size').notNull(),
lastModified: integer('last_modified').notNull(),
filePath: text('file_path').notNull(),
createdAt: integer('created_at').default(sql`(cast(strftime('%s','now') as int) * 1000)`),
}, (table) => ({
pk: primaryKey({ columns: [table.id, table.userId] }),
}));
export const audiobooks = sqliteTable('audiobooks', {
id: text('id').primaryKey(),
userId: text('user_id'),
title: text('title').notNull(),
author: text('author'),
description: text('description'),
coverPath: text('cover_path'),
duration: real('duration').default(0),
createdAt: integer('created_at').default(sql`(cast(strftime('%s','now') as int) * 1000)`),
});
export const audiobookChapters = sqliteTable('audiobook_chapters', {
id: text('id').primaryKey(),
bookId: text('book_id').notNull().references(() => audiobooks.id, { onDelete: 'cascade' }),
userId: text('user_id'),
chapterIndex: integer('chapter_index').notNull(),
title: text('title').notNull(),
duration: real('duration').default(0),
filePath: text('file_path').notNull(),
format: text('format').notNull(), // mp3, m4b
});
export const user = sqliteTable("user", {
id: text("id").primaryKey(),
name: text('name').notNull(),
email: text('email').notNull().unique(),
emailVerified: integer('email_verified', { mode: 'boolean' }).notNull(),
image: text('image'),
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(),
isAnonymous: integer('is_anonymous', { mode: 'boolean' }),
});
export const session = sqliteTable("session", {
id: text("id").primaryKey(),
expiresAt: integer('expires_at', { mode: 'timestamp' }).notNull(),
token: text('token').notNull().unique(),
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(),
ipAddress: text('ip_address'),
userAgent: text('user_agent'),
userId: text('user_id').notNull().references(() => user.id)
});
export const account = sqliteTable("account", {
id: text("id").primaryKey(),
accountId: text('account_id').notNull(),
providerId: text('provider_id').notNull(),
userId: text('user_id').notNull().references(() => user.id),
accessToken: text('access_token'),
refreshToken: text('refresh_token'),
idToken: text('id_token'),
accessTokenExpiresAt: integer('access_token_expires_at', { mode: 'timestamp' }),
refreshTokenExpiresAt: integer('refresh_token_expires_at', { mode: 'timestamp' }),
scope: text('scope'),
password: text('password'),
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(),
});
export const verification = sqliteTable("verification", {
id: text("id").primaryKey(),
identifier: text('identifier').notNull(),
value: text('value').notNull(),
updatedAt: integer('updated_at', { mode: 'timestamp' }),
});
export const userTtsChars = sqliteTable("user_tts_chars", {
userId: text('user_id').notNull(),
date: text('date').notNull(), // SQLite doesn't have native DATE type, text YYYY-MM-DD is standard
charCount: integer('char_count').default(0),
createdAt: integer('created_at').default(sql`(cast(strftime('%s','now') as int) * 1000)`),
updatedAt: integer('updated_at').default(sql`(cast(strftime('%s','now') as int) * 1000)`),
}, (table) => ({
pk: primaryKey({ columns: [table.userId, table.date] }),
dateIdx: index('idx_user_tts_chars_date').on(table.date),
}));

View file

@ -12,8 +12,22 @@ export function useAuth() {
const { baseUrl, authEnabled } = useAuthConfig();
const client = useMemo(() => {
if (!authEnabled || !baseUrl) return null;
return getAuthClient(baseUrl);
}, [baseUrl]);
}, [baseUrl, authEnabled]);
if (!client) {
// Safe no-op implementation when auth is disabled
return {
authEnabled: false,
baseUrl: null,
signIn: async () => ({ data: null, error: null }),
signUp: async () => ({ data: null, error: null }),
signOut: async () => ({ data: null, error: null }),
useSession: () => ({ data: null, isPending: false, error: null }),
getSession: async () => ({ data: null, error: null }),
};
}
return {
authEnabled,
@ -30,7 +44,16 @@ export function useAuth() {
* Hook for session that uses the correct baseUrl from context
*/
export function useAuthSession() {
const { baseUrl } = useAuthConfig();
const client = useMemo(() => getAuthClient(baseUrl), [baseUrl]);
const { baseUrl, authEnabled } = useAuthConfig();
const client = useMemo(() => {
if (!authEnabled || !baseUrl) return null;
return getAuthClient(baseUrl);
}, [baseUrl, authEnabled]);
if (!client) {
return { data: null, isPending: false, error: null };
}
return client.useSession();
}

View file

@ -1,39 +1,32 @@
import { betterAuth } from "better-auth";
import { nextCookies } from "better-auth/next-js";
import { anonymous } from "better-auth/plugins";
import { Pool } from "pg";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
import { db } from "@/db";
import { rateLimiter } from "@/lib/server/rate-limiter";
import { isAuthEnabled } from "@/lib/server/auth-config";
import { eq } from 'drizzle-orm';
const getAuthDatabase = () => {
if (process.env.POSTGRES_URL) {
return new Pool({
connectionString: process.env.POSTGRES_URL,
ssl: process.env.NODE_ENV === "production" ? { rejectUnauthorized: false } : false,
});
}
// Fallback to SQLite
// We need to dynamically import or require better-sqlite3 to avoid build issues if it's not used?
// Actually better-auth supports it directly, we just pass the instance.
/* eslint-disable @typescript-eslint/no-require-imports */
const Database = require("better-sqlite3");
const path = require("path");
const fs = require("fs");
/* eslint-enable @typescript-eslint/no-require-imports */
// Ensure directory exists
const dbPath = path.join(process.cwd(), 'docstore', 'sqlite3.db');
const dir = path.dirname(dbPath);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
import * as schema from "@/db/schema"; // Import the dynamic schema
return new Database(dbPath);
};
// ...
const createAuth = () => betterAuth({
database: getAuthDatabase(),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
database: drizzleAdapter(db as any, {
provider: process.env.POSTGRES_URL ? "pg" : "sqlite", // Dynamic provider
schema: {
...schema,
user: schema.user,
session: schema.session,
account: schema.account,
verification: schema.verification,
}
}),
secret: process.env.BETTER_AUTH_SECRET!,
baseURL: process.env.BETTER_AUTH_URL || "http://localhost:3003",
emailAndPassword: {
@ -59,16 +52,6 @@ const createAuth = () => betterAuth({
maxAge: 60 * 60 * 24 * 7, // 7 days for cookie cache
},
},
advanced: {
database: {
generateId: () => {
// Generate user-friendly IDs similar to current system
const timestamp = Date.now();
const random = Math.random().toString(36).substring(2, 8);
return `user-${timestamp}-${random}`;
},
},
},
plugins: [
nextCookies(), // Enable Next.js cookie handling
anonymous({
@ -103,4 +86,74 @@ export const auth = isAuthEnabled() ? createAuth() : null;
type AuthInstance = ReturnType<typeof createAuth>;
export type Session = AuthInstance["$Infer"]["Session"];
export type User = AuthInstance["$Infer"]["Session"]["user"];
export type User = AuthInstance["$Infer"]["Session"]["user"];
export type AuthContext = {
authEnabled: boolean;
session: Session | null;
user: User | null;
userId: string | null;
};
export async function getAuthContext(request: Pick<NextRequest, 'headers'>): Promise<AuthContext> {
const authEnabled = isAuthEnabled();
if (!authEnabled || !auth) {
return { authEnabled, session: null, user: null, userId: null };
}
const session = await auth.api.getSession({ headers: request.headers });
const user = session?.user ?? null;
const userId = user?.id ?? null;
return { authEnabled, session, user, userId };
}
export async function requireAuthContext(
request: Pick<NextRequest, 'headers'>,
options?: { requireNonAnonymous?: boolean },
): Promise<AuthContext | Response> {
const ctx = await getAuthContext(request);
if (!ctx.authEnabled) {
return ctx;
}
if (!ctx.userId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
if (options?.requireNonAnonymous && ctx.user?.isAnonymous) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
return ctx;
}
export type AudiobookAccessMode = 'forbidden' | 'notFound';
export async function requireAudiobookOwned(
bookId: string,
userId: string,
options?: { onDenied?: AudiobookAccessMode },
): Promise<Response | null> {
if (!isAuthEnabled() || !db) return null;
const [existingBook] = await db
.select({ userId: schema.audiobooks.userId })
.from(schema.audiobooks)
.where(eq(schema.audiobooks.id, bookId));
if (!existingBook) {
return NextResponse.json({ error: 'Book not found' }, { status: 404 });
}
if (existingBook.userId && existingBook.userId !== userId) {
if (options?.onDenied === 'notFound') {
return NextResponse.json({ error: 'Book not found' }, { status: 404 });
}
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
return null;
}

View file

@ -0,0 +1,178 @@
import { db } from '@/db';
import { documents, audiobooks, audiobookChapters } from '@/db/schema';
import { eq, isNull, count } from 'drizzle-orm';
import fs from 'fs/promises';
import { existsSync } from 'fs';
import path from 'path';
import { DOCUMENTS_V1_DIR, AUDIOBOOKS_V1_DIR } from './docstore';
import { listStoredChapters } from './audiobook';
import { isAuthEnabled } from '@/lib/server/auth-config';
// Helper to check if a file is already indexed
async function isDocumentIndexed(id: string) {
if (!isAuthEnabled() || !db) return false; // If no DB, assume not indexed or handle differently?
// Actually if no DB, we don't index into DB. So returning false is fine,
// but scanAndPopulateDB should probably return early if no DB.
const result = await db.select({ id: documents.id }).from(documents).where(eq(documents.id, id));
return result.length > 0;
}
async function isAudiobookIndexed(id: string) {
if (!isAuthEnabled() || !db) return false;
const result = await db.select({ id: audiobooks.id }).from(audiobooks).where(eq(audiobooks.id, id));
return result.length > 0;
}
const UNCLAIMED_ID = 'unclaimed';
/**
* Returns count of unclaimed documents and audiobooks in the DB (userId IS 'unclaimed')
*/
export async function getUnclaimedCounts() {
if (!isAuthEnabled() || !db) return { documents: 0, audiobooks: 0 };
const [docCount] = await db.select({ count: count() }).from(documents).where(eq(documents.userId, UNCLAIMED_ID));
const [bookCount] = await db.select({ count: count() }).from(audiobooks).where(eq(audiobooks.userId, UNCLAIMED_ID));
return {
documents: docCount?.count ?? 0,
audiobooks: bookCount?.count ?? 0
};
}
export async function scanAndPopulateDB() {
if (!isAuthEnabled() || !db) {
console.log('Skipping DB population (Auth/DB disabled)');
return { documents: 0, audiobooks: 0 };
}
console.log('Scanning file system for un-indexed content...');
// 0. Fix legacy NULL userIds
try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
await (db as any).update(documents).set({ userId: UNCLAIMED_ID }).where(isNull(documents.userId));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
await (db as any).update(audiobooks).set({ userId: UNCLAIMED_ID }).where(isNull(audiobooks.userId));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
await (db as any).update(audiobookChapters).set({ userId: UNCLAIMED_ID }).where(isNull(audiobookChapters.userId));
} catch (err) {
console.error('Error migrating legacy NULL userIds:', err);
}
// 1. Scan Documents
if (existsSync(DOCUMENTS_V1_DIR)) {
const files = await fs.readdir(DOCUMENTS_V1_DIR);
for (const file of files) {
const match = /^([a-f0-9]{64})__(.+)$/i.exec(file);
if (!match) continue;
const id = match[1];
const encodedName = match[2];
if (await isDocumentIndexed(id)) continue;
let name: string;
try {
name = decodeURIComponent(encodedName);
} catch {
continue;
}
const filePath = path.join(DOCUMENTS_V1_DIR, file);
const stats = await fs.stat(filePath);
const ext = path.extname(name).toLowerCase().replace('.', '');
await db.insert(documents).values({
id,
userId: UNCLAIMED_ID,
name,
type: ext,
size: stats.size,
lastModified: Math.floor(stats.mtimeMs),
filePath: file,
});
console.log(`Indexed document: ${name} (${id})`);
}
}
// 2. Scan Audiobooks
if (existsSync(AUDIOBOOKS_V1_DIR)) {
const entries = await fs.readdir(AUDIOBOOKS_V1_DIR, { withFileTypes: true });
for (const entry of entries) {
if (!entry.isDirectory()) continue;
if (!entry.name.endsWith('-audiobook')) continue;
const bookId = entry.name.replace('-audiobook', '');
if (await isAudiobookIndexed(bookId)) continue;
const dirPath = path.join(AUDIOBOOKS_V1_DIR, entry.name);
let title = `Unknown Title`;
try {
const metaPath = path.join(dirPath, 'audiobook.meta.json');
const metaContent = await fs.readFile(metaPath, 'utf8');
JSON.parse(metaContent); // validating json
} catch { }
const chapters = await listStoredChapters(dirPath);
const totalDuration = chapters.reduce((acc, c) => acc + (c.durationSec || 0), 0);
if (chapters.length > 0) {
title = chapters[0].title || title;
}
await db.insert(audiobooks).values({
id: bookId,
userId: UNCLAIMED_ID,
title: title,
duration: totalDuration,
});
console.log(`Indexed audiobook: ${bookId}`);
for (const chapter of chapters) {
await db.insert(audiobookChapters).values({
id: `${bookId}-${chapter.index}`,
bookId: bookId,
userId: UNCLAIMED_ID,
chapterIndex: chapter.index,
title: chapter.title,
duration: chapter.durationSec || 0,
filePath: chapter.filePath,
format: chapter.format
})
}
}
}
// Return current unclaimed counts (includes newly indexed + previously unclaimed)
return getUnclaimedCounts();
}
export async function claimAnonymousData(userId: string) {
if (!isAuthEnabled() || !db || !userId) return { documents: 0, audiobooks: 0 };
// Update Documents
const docResult = await db.update(documents)
.set({ userId })
.where(eq(documents.userId, UNCLAIMED_ID))
.returning({ id: documents.id }); // If supported by driver, otherwise use run and check changes
// Update Audiobooks
const bookResult = await db.update(audiobooks)
.set({ userId })
.where(eq(audiobooks.userId, UNCLAIMED_ID))
.returning({ id: audiobooks.id });
// Update Chapters (denormalized userId)
await db.update(audiobookChapters)
.set({ userId })
.where(eq(audiobookChapters.userId, UNCLAIMED_ID)); // Or match by bookId join
return {
documents: docResult.length,
audiobooks: bookResult.length
};
}

View file

@ -1,143 +0,0 @@
import { Pool, PoolClient } from "pg";
import Database from "better-sqlite3";
import fs from "fs";
import path from "path";
/**
* Common interface for database operations to support both Postgres and SQLite
*/
export interface DBAdapter {
query(text: string, params?: unknown[]): Promise<{ rows: unknown[], rowCount?: number }>;
transaction<T>(callback: (client: DBAdapter) => Promise<T>): Promise<T>;
}
export class PostgresAdapter implements DBAdapter {
constructor(private pool: Pool) { }
async query(text: string, params?: unknown[]) {
try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const result = await this.pool.query(text, params as any[]);
return {
rows: result.rows,
rowCount: result.rowCount ?? undefined
};
} catch (error) {
console.error("Postgres Query Error:", error);
throw error;
}
}
async transaction<T>(callback: (client: DBAdapter) => Promise<T>): Promise<T> {
const client = await this.pool.connect();
try {
await client.query("BEGIN");
const connectedAdapter = new PostgresConnectedAdapter(client);
const result = await callback(connectedAdapter);
await client.query("COMMIT");
return result;
} catch (e) {
await client.query("ROLLBACK");
throw e;
} finally {
client.release();
}
}
}
class PostgresConnectedAdapter implements DBAdapter {
constructor(private client: PoolClient) { }
async query(text: string, params?: unknown[]) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const result = await this.client.query(text, params as any[]);
return {
rows: result.rows,
rowCount: result.rowCount ?? undefined
};
}
async transaction<T>(callback: (client: DBAdapter) => Promise<T>): Promise<T> {
// Nested transactions not explicitly supported, just pass through
return callback(this);
}
}
export class SqliteAdapter implements DBAdapter {
private db: Database.Database;
constructor(filePath: string) {
// Ensure directory exists
const dir = path.dirname(filePath);
if (!fs.existsSync(dir)) {
try {
fs.mkdirSync(dir, { recursive: true });
console.log(`Created directory for SQLite: ${dir}`);
} catch (e) {
console.error(`Failed to create directory ${dir}:`, e);
}
}
this.db = new Database(filePath);
// Enable WAL mode for better concurrency
this.db.pragma('journal_mode = WAL');
}
async query(text: string, params?: unknown[]) {
// Convert Postgres $n params to SQLite placeholders.
// We separately reorder params to match placeholder order.
const convertedSql = text.replace(/\$(\d+)/g, "?");
// Reorder params array to match the order they appear in the SQL
// SQLite expects params in the order they appear, not by their $n number
const orderedParams: unknown[] = [];
if (params && params.length > 0) {
// Extract parameter numbers in order of appearance
const paramNumbers: number[] = [];
const regex = /\$(\d+)/g;
let match;
while ((match = regex.exec(text)) !== null) {
paramNumbers.push(parseInt(match[1], 10));
}
// Map to actual values
for (const num of paramNumbers) {
orderedParams.push(params[num - 1]); // $1 maps to params[0]
}
}
try {
const stmt = this.db.prepare(convertedSql);
const safeParams = orderedParams.length > 0 ? orderedParams : [];
const lowerSql = convertedSql.trim().toLowerCase();
// If it's a SELECT or RETURNING, we use .all() or .get()
if (lowerSql.startsWith("select") || /returning\s+/.test(lowerSql)) {
const rows = stmt.all(...safeParams);
return { rows, rowCount: rows.length };
} else {
// INSERT/UPDATE/DELETE with no RETURNING
const info = stmt.run(...safeParams);
return { rows: [], rowCount: info.changes };
}
} catch (error) {
console.error("SQLite Query Error:", error);
throw error;
}
}
async transaction<T>(callback: (client: DBAdapter) => Promise<T>): Promise<T> {
// Manual transaction management
// better-sqlite3 is synchronous, but we simulate async interface
this.db.exec("BEGIN");
try {
const result = await callback(this);
this.db.exec("COMMIT");
return result;
} catch (e) {
this.db.exec("ROLLBACK");
throw e;
}
}
}

View file

@ -1,45 +0,0 @@
import { Pool } from 'pg';
import path from 'path';
import { DBAdapter, PostgresAdapter, SqliteAdapter } from '@/lib/server/db-adapter';
import { isAuthEnabled } from '@/lib/server/auth-config';
// Singleton instance
let dbInstance: DBAdapter | null = null;
class NoOpAdapter implements DBAdapter {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
async query(text: string, params?: unknown[]) {
return { rows: [], rowCount: 0 };
}
async transaction<T>(callback: (client: DBAdapter) => Promise<T>): Promise<T> {
return callback(this);
}
}
export function getDB(): DBAdapter {
if (dbInstance) return dbInstance;
// Avoid creating database connection/file if auth is disabled
if (!isAuthEnabled()) {
dbInstance = new NoOpAdapter();
return dbInstance;
}
if (process.env.POSTGRES_URL) {
const pool = new Pool({
connectionString: process.env.POSTGRES_URL,
ssl: process.env.NODE_ENV === "production" ? { rejectUnauthorized: false } : false,
});
dbInstance = new PostgresAdapter(pool);
} else {
// Fallback to SQLite
const dbPath = path.join(process.cwd(), 'docstore', 'sqlite3.db');
console.log(`Using SQLite database at ${dbPath}`);
dbInstance = new SqliteAdapter(dbPath);
}
return dbInstance!;
}
export const db = getDB();

View file

@ -115,6 +115,59 @@ export function getMigratedDocumentFileName(id: string, name: string): string {
return targetFileName;
}
export type FSDocument = {
id: string;
name: string;
type: string;
size: number;
lastModified: number;
filePath: string;
};
export async function scanDocumentsFS(): Promise<FSDocument[]> {
if (!existsSync(DOCUMENTS_V1_DIR)) return [];
const results: FSDocument[] = [];
let files: string[] = [];
try {
files = await readdir(DOCUMENTS_V1_DIR);
} catch {
return [];
}
for (const file of files) {
// Expected format: id__filename or id.ext (legacy fallback?)
// Actually current format is id__encodedName
const match = /^([a-f0-9]{64})__(.+)$/i.exec(file);
if (!match) continue;
const id = match[1];
const encodedName = match[2];
const name = decodeURIComponent(encodedName);
const ext = path.extname(name).toLowerCase().replace('.', '');
// Validate file exists and get stats
try {
const filePath = path.join(DOCUMENTS_V1_DIR, file);
const stats = await stat(filePath);
if (!stats.isFile()) continue;
results.push({
id,
name,
type: ext,
size: stats.size,
lastModified: Math.floor(stats.mtimeMs),
filePath: file,
});
} catch {
continue;
}
}
return results;
}
export async function ensureDocumentsV1Ready(): Promise<boolean> {
await mkdir(DOCSTORE_DIR, { recursive: true });
await mkdir(DOCUMENTS_V1_DIR, { recursive: true });
@ -163,7 +216,7 @@ export async function ensureDocumentsV1Ready(): Promise<boolean> {
const id = createHash('sha256').update(content).digest('hex');
const fallbackName = `${id}.${metadata.type}`;
const name = safeDocumentName(metadata.name, fallbackName);
const targetFileName = getMigratedDocumentFileName(id, name);
const targetPath = path.join(DOCUMENTS_V1_DIR, targetFileName);
@ -171,12 +224,12 @@ export async function ensureDocumentsV1Ready(): Promise<boolean> {
await writeFile(targetPath, content);
if (Number.isFinite(metadata.lastModified) && metadata.lastModified > 0) {
const stamp = new Date(metadata.lastModified);
await utimes(targetPath, stamp, stamp).catch(() => {});
await utimes(targetPath, stamp, stamp).catch(() => { });
}
}
await unlink(metadataPath).catch(() => {});
await unlink(contentPath).catch(() => {});
await unlink(metadataPath).catch(() => { });
await unlink(contentPath).catch(() => { });
}
await saveMigrationState({ documentsV1Migrated: !(await hasLegacyDocumentFiles()) });
@ -263,7 +316,7 @@ async function mergeDirectoryContents(sourceDir: string, targetDir: string): Pro
if (remaining.length === 0) {
await rm(sourcePath);
}
} catch {}
} catch { }
continue;
}
@ -334,7 +387,7 @@ export async function ensureAudiobooksV1Ready(): Promise<boolean> {
} else {
console.warn(`Legacy audiobook dir not fully migrated (kept): ${sourceDir}`);
}
} catch {}
} catch { }
} catch (error) {
console.error('Error migrating legacy audiobook directory:', error);
throw error;
@ -425,10 +478,10 @@ async function normalizeAudiobookDirectoryChapterLayout(intermediateDir: string)
}
// Remove any combined output files from older layouts.
await unlink(path.join(intermediateDir, 'complete.mp3')).catch(() => {});
await unlink(path.join(intermediateDir, 'complete.m4b')).catch(() => {});
await unlink(path.join(intermediateDir, 'metadata.txt')).catch(() => {});
await unlink(path.join(intermediateDir, 'list.txt')).catch(() => {});
await unlink(path.join(intermediateDir, 'complete.mp3')).catch(() => { });
await unlink(path.join(intermediateDir, 'complete.m4b')).catch(() => { });
await unlink(path.join(intermediateDir, 'metadata.txt')).catch(() => { });
await unlink(path.join(intermediateDir, 'list.txt')).catch(() => { });
const metaFiles = files.filter((file) => file.endsWith('.meta.json'));
const migratedIndices = new Set<number>();
@ -448,7 +501,7 @@ async function normalizeAudiobookDirectoryChapterLayout(intermediateDir: string)
const format = meta.format === 'mp3' ? 'mp3' : 'm4b';
const sourceAudio = path.join(intermediateDir, `${index}-chapter.${format}`);
if (!existsSync(sourceAudio)) {
await unlink(metaPath).catch(() => {});
await unlink(metaPath).catch(() => { });
continue;
}
@ -463,11 +516,11 @@ async function normalizeAudiobookDirectoryChapterLayout(intermediateDir: string)
const finalName = encodeChapterFileName(index, meta.title ?? `Chapter ${index + 1}`, format);
const finalPath = path.join(intermediateDir, finalName);
await unlink(finalPath).catch(() => {});
await unlink(finalPath).catch(() => { });
await rename(taggedTemp, finalPath);
await unlink(sourceAudio).catch(() => {});
await unlink(metaPath).catch(() => {});
await unlink(sourceAudio).catch(() => { });
await unlink(metaPath).catch(() => { });
migratedIndices.add(index);
}
@ -493,10 +546,10 @@ async function normalizeAudiobookDirectoryChapterLayout(intermediateDir: string)
const finalName = encodeChapterFileName(index, `Chapter ${index + 1}`, format);
const finalPath = path.join(intermediateDir, finalName);
await unlink(finalPath).catch(() => {});
await unlink(finalPath).catch(() => { });
await rename(taggedTemp, finalPath);
await unlink(sourceAudio).catch(() => {});
await unlink(sourceAudio).catch(() => { });
}
// Rename any sha-named chapter files from previous runs into the index__title scheme.
@ -517,18 +570,18 @@ async function normalizeAudiobookDirectoryChapterLayout(intermediateDir: string)
const finalName = encodeChapterFileName(decoded.index, decoded.title, format);
const finalPath = path.join(intermediateDir, finalName);
await unlink(finalPath).catch(() => {});
await rename(sourceAudio, finalPath).catch(() => {});
await unlink(finalPath).catch(() => { });
await rename(sourceAudio, finalPath).catch(() => { });
}
// Remove any leftover input temp files.
files = await readdir(intermediateDir).catch(() => []);
for (const file of files) {
if (/^\d+-input\.mp3$/i.test(file)) {
await unlink(path.join(intermediateDir, file)).catch(() => {});
await unlink(path.join(intermediateDir, file)).catch(() => { });
}
if (file.endsWith('.meta.json') && file !== 'audiobook.meta.json') {
await unlink(path.join(intermediateDir, file)).catch(() => {});
await unlink(path.join(intermediateDir, file)).catch(() => { });
}
}
}
@ -544,12 +597,12 @@ async function normalizeAudiobookChapterLayout(): Promise<void> {
for (const entry of entries) {
if (!entry.isDirectory()) continue;
if (!entry.name.endsWith('-audiobook')) continue;
const dir = path.join(AUDIOBOOKS_V1_DIR, entry.name);
try {
const dir = path.join(AUDIOBOOKS_V1_DIR, entry.name);
try {
await normalizeAudiobookDirectoryChapterLayout(dir);
} catch (error) {
console.error('Error migrating audiobook chapter layout:', error);
throw error;
}
} catch (error) {
console.error('Error migrating audiobook chapter layout:', error);
throw error;
}
}
}

View file

@ -0,0 +1,178 @@
import { db } from '@/db';
import { documents, audiobooks, audiobookChapters } from '@/db/schema';
import { eq, isNull, count } from 'drizzle-orm';
import fs from 'fs/promises';
import { existsSync } from 'fs';
import path from 'path';
import { DOCUMENTS_V1_DIR, AUDIOBOOKS_V1_DIR } from './docstore';
import { listStoredChapters } from './audiobook';
import { isAuthEnabled } from '@/lib/server/auth-config';
// Helper to check if a file is already indexed
async function isDocumentIndexed(id: string) {
if (!isAuthEnabled() || !db) return false; // If no DB, assume not indexed or handle differently?
// Actually if no DB, we don't index into DB. So returning false is fine,
// but scanAndPopulateDB should probably return early if no DB.
const result = await db.select({ id: documents.id }).from(documents).where(eq(documents.id, id));
return result.length > 0;
}
async function isAudiobookIndexed(id: string) {
if (!isAuthEnabled() || !db) return false;
const result = await db.select({ id: audiobooks.id }).from(audiobooks).where(eq(audiobooks.id, id));
return result.length > 0;
}
const UNCLAIMED_ID = 'unclaimed';
/**
* Returns count of unclaimed documents and audiobooks in the DB (userId IS 'unclaimed')
*/
export async function getUnclaimedCounts() {
if (!isAuthEnabled() || !db) return { documents: 0, audiobooks: 0 };
const [docCount] = await db.select({ count: count() }).from(documents).where(eq(documents.userId, UNCLAIMED_ID));
const [bookCount] = await db.select({ count: count() }).from(audiobooks).where(eq(audiobooks.userId, UNCLAIMED_ID));
return {
documents: docCount?.count ?? 0,
audiobooks: bookCount?.count ?? 0
};
}
export async function scanAndPopulateDB() {
if (!isAuthEnabled() || !db) {
console.log('Skipping DB population (Auth/DB disabled)');
return { documents: 0, audiobooks: 0 };
}
console.log('Scanning file system for un-indexed content...');
// 0. Fix legacy NULL userIds
try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
await (db as any).update(documents).set({ userId: UNCLAIMED_ID }).where(isNull(documents.userId));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
await (db as any).update(audiobooks).set({ userId: UNCLAIMED_ID }).where(isNull(audiobooks.userId));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
await (db as any).update(audiobookChapters).set({ userId: UNCLAIMED_ID }).where(isNull(audiobookChapters.userId));
} catch (err) {
console.error('Error migrating legacy NULL userIds:', err);
}
// 1. Scan Documents
if (existsSync(DOCUMENTS_V1_DIR)) {
const files = await fs.readdir(DOCUMENTS_V1_DIR);
for (const file of files) {
const match = /^([a-f0-9]{64})__(.+)$/i.exec(file);
if (!match) continue;
const id = match[1];
const encodedName = match[2];
if (await isDocumentIndexed(id)) continue;
let name: string;
try {
name = decodeURIComponent(encodedName);
} catch {
continue;
}
const filePath = path.join(DOCUMENTS_V1_DIR, file);
const stats = await fs.stat(filePath);
const ext = path.extname(name).toLowerCase().replace('.', '');
await db.insert(documents).values({
id,
userId: UNCLAIMED_ID,
name,
type: ext,
size: stats.size,
lastModified: Math.floor(stats.mtimeMs),
filePath: file,
});
console.log(`Indexed document: ${name} (${id})`);
}
}
// 2. Scan Audiobooks
if (existsSync(AUDIOBOOKS_V1_DIR)) {
const entries = await fs.readdir(AUDIOBOOKS_V1_DIR, { withFileTypes: true });
for (const entry of entries) {
if (!entry.isDirectory()) continue;
if (!entry.name.endsWith('-audiobook')) continue;
const bookId = entry.name.replace('-audiobook', '');
if (await isAudiobookIndexed(bookId)) continue;
const dirPath = path.join(AUDIOBOOKS_V1_DIR, entry.name);
let title = `Unknown Title`;
try {
const metaPath = path.join(dirPath, 'audiobook.meta.json');
const metaContent = await fs.readFile(metaPath, 'utf8');
JSON.parse(metaContent); // validating json
} catch { }
const chapters = await listStoredChapters(dirPath);
const totalDuration = chapters.reduce((acc, c) => acc + (c.durationSec || 0), 0);
if (chapters.length > 0) {
title = chapters[0].title || title;
}
await db.insert(audiobooks).values({
id: bookId,
userId: UNCLAIMED_ID,
title: title,
duration: totalDuration,
});
console.log(`Indexed audiobook: ${bookId}`);
for (const chapter of chapters) {
await db.insert(audiobookChapters).values({
id: `${bookId}-${chapter.index}`,
bookId: bookId,
userId: UNCLAIMED_ID,
chapterIndex: chapter.index,
title: chapter.title,
duration: chapter.durationSec || 0,
filePath: chapter.filePath,
format: chapter.format
})
}
}
}
// Return current unclaimed counts (includes newly indexed + previously unclaimed)
return getUnclaimedCounts();
}
export async function claimAnonymousData(userId: string) {
if (!isAuthEnabled() || !db || !userId) return { documents: 0, audiobooks: 0 };
// Update Documents
const docResult = await db.update(documents)
.set({ userId })
.where(eq(documents.userId, UNCLAIMED_ID))
.returning({ id: documents.id }); // If supported by driver, otherwise use run and check changes
// Update Audiobooks
const bookResult = await db.update(audiobooks)
.set({ userId })
.where(eq(audiobooks.userId, UNCLAIMED_ID))
.returning({ id: audiobooks.id });
// Update Chapters (denormalized userId)
await db.update(audiobookChapters)
.set({ userId })
.where(eq(audiobookChapters.userId, UNCLAIMED_ID)); // Or match by bookId join
return {
documents: docResult.length,
audiobooks: bookResult.length
};
}

View file

@ -1,52 +1,22 @@
import { db } from '@/lib/server/db';
import { db } from '@/db';
import { userTtsChars } from '@/db/schema';
import { isAuthEnabled } from '@/lib/server/auth-config';
import { eq, and, lt, sql } from 'drizzle-orm';
// Rate limits configuration - character counts per day
export const RATE_LIMITS = {
ANONYMOUS: 50_000, // 50K characters per day for anonymous users
AUTHENTICATED: 500_000, // 500K characters per day for authenticated users
// IP-based backstop limits to make it harder to reset limits by creating new accounts
// or clearing storage/cookies. These are intentionally conservative defaults and can
// be tuned via env vars.
// or clearing storage/cookies
IP_ANONYMOUS: Number(process.env.TTS_IP_DAILY_LIMIT_ANONYMOUS || 100_000),
IP_AUTHENTICATED: Number(process.env.TTS_IP_DAILY_LIMIT_AUTHENTICATED || 1_000_000),
} as const;
// Singleton flag to ensure we only initialize the table once per process
let tableInitialized: Promise<void> | null = null;
// Helper to ensure DB is strictly typed when we know it exists
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const safeDb = () => db as any;
// Initialize rate limiting table (cached, runs only once per process)
export async function initializeRateLimitTable() {
if (tableInitialized) {
return tableInitialized;
}
tableInitialized = (async () => {
// Use transaction to ensure safe initialization
await db.transaction(async (client) => {
// Check if table exists first to avoid errors on some DBs
// Simple create table if not exists
await client.query(`
CREATE TABLE IF NOT EXISTS user_tts_chars (
user_id VARCHAR(255) NOT NULL,
date DATE NOT NULL,
char_count BIGINT DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (user_id, date)
)
`);
// Create index for faster queries
await client.query(`
CREATE INDEX IF NOT EXISTS idx_user_tts_chars_date ON user_tts_chars(date)
`);
});
console.log('Rate limit table initialized');
})();
return tableInitialized;
}
export interface RateLimitResult {
allowed: boolean;
@ -56,10 +26,6 @@ export interface RateLimitResult {
remainingChars: number;
}
interface DBCharCountRow {
char_count: string | number;
}
export interface UserInfo {
id: string;
isAnonymous?: boolean;
@ -79,7 +45,6 @@ type Bucket = {
};
function normalizeBackstopKey(prefix: string, value: string): string {
// Keep the key reasonably bounded; prevent extremely long identifiers from bloating DB.
const trimmed = value.trim();
const safe = trimmed.length > 128 ? trimmed.slice(0, 128) : trimmed;
return `${prefix}:${safe}`;
@ -128,10 +93,8 @@ export class RateLimiter {
/**
* Check if a user can use TTS and increment their char count if allowed
* @param charCount - Number of characters to add
*/
async checkAndIncrementLimit(user: UserInfo, charCount: number, backstops?: RateLimitBackstops): Promise<RateLimitResult> {
// If auth is not enabled, always allow
if (!isAuthEnabled()) {
return {
allowed: true,
@ -142,9 +105,7 @@ export class RateLimiter {
};
}
await initializeRateLimitTable();
const today = new Date().toISOString().split('T')[0]; // YYYY-MM-DD format
const today = new Date().toISOString().split('T')[0];
const userLimit = user.isAnonymous ? RATE_LIMITS.ANONYMOUS : RATE_LIMITS.AUTHENTICATED;
const buckets: Bucket[] = [{ key: user.id, limit: userLimit }];
@ -164,46 +125,65 @@ export class RateLimiter {
}
try {
return await db.transaction(async (client) => {
if (!db) throw new Error("DB not initialized");
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return await safeDb().transaction(async (tx: any) => {
// Ensure records exist for each bucket
for (const bucket of buckets) {
await client.query(`
INSERT INTO user_tts_chars (user_id, date, char_count)
VALUES ($1, $2, 0)
ON CONFLICT (user_id, date)
DO UPDATE SET updated_at = CURRENT_TIMESTAMP
`, [bucket.key, today]);
await tx.insert(userTtsChars)
.values({
userId: bucket.key,
date: today as string,
charCount: 0,
})
.onConflictDoUpdate({
target: [userTtsChars.userId, userTtsChars.date],
set: { updatedAt: new Date() },
});
}
// Attempt to increment each bucket. If any bucket is already at/over the limit,
// throw to force a transaction rollback (no partial increments).
// Attempt to increment each bucket
for (const bucket of buckets) {
const updateResult = await client.query(`
UPDATE user_tts_chars
SET char_count = char_count + $3, updated_at = CURRENT_TIMESTAMP
WHERE user_id = $1 AND date = $2 AND char_count < $4
`, [bucket.key, today, charCount, bucket.limit]);
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const updateResult = await tx.update(userTtsChars)
.set({
charCount: sql`${userTtsChars.charCount} + ${charCount}`,
updatedAt: new Date()
})
.where(and(
eq(userTtsChars.userId, bucket.key),
eq(userTtsChars.date, today as string),
lt(userTtsChars.charCount, bucket.limit)
));
const updated = (updateResult.rowCount ?? 0) > 0;
if (!updated) {
// Check if update actually happened
const row = await tx.select({ count: userTtsChars.charCount }).from(userTtsChars)
.where(and(eq(userTtsChars.userId, bucket.key), eq(userTtsChars.date, today)))
// eslint-disable-next-line @typescript-eslint/no-explicit-any
.then((res: any) => res[0]);
if (!row || Number(row.count) > bucket.limit) {
throw new RateLimitExceeded();
}
}
// Fetch current counts for all buckets after increment
// Fetch current counts
const bucketResults: Array<{ currentCount: number; limit: number }> = [];
for (const bucket of buckets) {
const result = await client.query(`
SELECT char_count FROM user_tts_chars
WHERE user_id = $1 AND date = $2
`, [bucket.key, today]);
const result = await tx.select({ currentCount: userTtsChars.charCount })
.from(userTtsChars)
.where(and(eq(userTtsChars.userId, bucket.key), eq(userTtsChars.date, today)));
const currentCount = parseInt(((result.rows[0] as unknown) as DBCharCountRow)?.char_count?.toString() || '0', 10);
const currentCount = result[0]?.currentCount ? Number(result[0].currentCount) : 0;
bucketResults.push({ currentCount, limit: bucket.limit });
}
const effective = pickEffectiveResult(bucketResults);
if (effective.currentCount > effective.limit) {
throw new RateLimitExceeded();
}
return {
allowed: true,
currentCount: effective.currentCount,
@ -225,7 +205,6 @@ export class RateLimiter {
* Get current usage for a user without incrementing
*/
async getCurrentUsage(user: UserInfo, backstops?: RateLimitBackstops): Promise<RateLimitResult> {
// If auth is not enabled, return unlimited
if (!isAuthEnabled()) {
return {
allowed: true,
@ -236,8 +215,6 @@ export class RateLimiter {
};
}
await initializeRateLimitTable();
const today = new Date().toISOString().split('T')[0];
const userLimit = user.isAnonymous ? RATE_LIMITS.ANONYMOUS : RATE_LIMITS.AUTHENTICATED;
@ -258,14 +235,21 @@ export class RateLimiter {
}
const bucketResults: Array<{ currentCount: number; limit: number }> = [];
if (!db) {
const effective = pickEffectiveResult([]);
return {
...effective,
resetTime: this.getResetTime(),
};
}
for (const bucket of buckets) {
const result = await db.query(
'SELECT char_count FROM user_tts_chars WHERE user_id = $1 AND date = $2',
[bucket.key, today]
);
const currentCount = result.rows.length > 0
? parseInt(((result.rows[0] as unknown) as DBCharCountRow).char_count.toString(), 10)
: 0;
const result = await safeDb().select({ charCount: userTtsChars.charCount })
.from(userTtsChars)
.where(and(eq(userTtsChars.userId, bucket.key), eq(userTtsChars.date, today)));
const currentCount = result[0]?.charCount ? Number(result[0].charCount) : 0;
bucketResults.push({ currentCount, limit: bucket.limit });
}
@ -286,39 +270,38 @@ export class RateLimiter {
async transferAnonymousUsage(anonymousUserId: string, authenticatedUserId: string): Promise<void> {
if (!isAuthEnabled()) return;
await initializeRateLimitTable();
if (!db) return;
await db.transaction(async (client) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
await safeDb().transaction(async (tx: any) => {
const today = new Date().toISOString().split('T')[0];
// Get anonymous user's current count
const anonymousResult = await client.query(
'SELECT char_count FROM user_tts_chars WHERE user_id = $1 AND date = $2',
[anonymousUserId, today]
);
const anonymousResult = await tx.select({ charCount: userTtsChars.charCount })
.from(userTtsChars)
.where(and(eq(userTtsChars.userId, anonymousUserId), eq(userTtsChars.date, today)));
if (anonymousResult.rows.length > 0) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const anonymousCount = parseInt((anonymousResult.rows[0] as any).char_count, 10);
if (anonymousResult.length > 0) {
const anonymousCount = Number(anonymousResult[0].charCount);
// Update or create record for authenticated user
await client.query(`
INSERT INTO user_tts_chars (user_id, date, char_count)
VALUES ($1, $2, $3)
ON CONFLICT (user_id, date)
DO UPDATE SET
char_count = CASE
WHEN user_tts_chars.char_count > $3 THEN user_tts_chars.char_count
ELSE $3
END,
updated_at = CURRENT_TIMESTAMP
`, [authenticatedUserId, today, anonymousCount]);
const existingAuth = await tx.select({ charCount: userTtsChars.charCount })
.from(userTtsChars)
.where(and(eq(userTtsChars.userId, authenticatedUserId), eq(userTtsChars.date, today)));
if (existingAuth.length === 0) {
await tx.insert(userTtsChars).values({ userId: authenticatedUserId, date: today, charCount: anonymousCount });
} else {
const existingCount = Number(existingAuth[0].charCount);
if (anonymousCount > existingCount) {
await tx.update(userTtsChars)
.set({ charCount: anonymousCount, updatedAt: new Date() })
.where(and(eq(userTtsChars.userId, authenticatedUserId), eq(userTtsChars.date, today)));
}
}
// Remove anonymous user's record
await client.query(
'DELETE FROM user_tts_chars WHERE user_id = $1 AND date = $2',
[anonymousUserId, today]
);
await tx.delete(userTtsChars)
.where(and(eq(userTtsChars.userId, anonymousUserId), eq(userTtsChars.date, today)));
}
});
}
@ -331,10 +314,10 @@ export class RateLimiter {
cutoffDate.setDate(cutoffDate.getDate() - daysToKeep);
const cutoffDateStr = cutoffDate.toISOString().split('T')[0];
await db.query(
'DELETE FROM user_tts_chars WHERE date < $1',
[cutoffDateStr]
);
if (!db) return;
// Assuming string comparison works for YYYY-MM-DD
// eslint-disable-next-line @typescript-eslint/no-explicit-any
await safeDb().delete(userTtsChars).where(lt(userTtsChars.date, cutoffDateStr as any));
}
private getResetTime(): Date {