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:
parent
9561d5a816
commit
c57b913cb5
41 changed files with 3556 additions and 1491 deletions
|
|
@ -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"]
|
||||
|
|
|
|||
13
README.md
13
README.md
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
@ -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
14
drizzle.config.ts
Normal 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;
|
||||
93
drizzle/0000_hard_mandarin.sql
Normal file
93
drizzle/0000_hard_mandarin.sql
Normal 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
|
||||
);
|
||||
621
drizzle/meta/0000_snapshot.json
Normal file
621
drizzle/meta/0000_snapshot.json
Normal 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": {}
|
||||
}
|
||||
}
|
||||
13
drizzle/meta/_journal.json
Normal file
13
drizzle/meta/_journal.json
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
{
|
||||
"version": "7",
|
||||
"dialect": "sqlite",
|
||||
"entries": [
|
||||
{
|
||||
"idx": 0,
|
||||
"version": "6",
|
||||
"when": 1769395269830,
|
||||
"tag": "0000_hard_mandarin",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
94
drizzle_pg/0000_military_nemesis.sql
Normal file
94
drizzle_pg/0000_military_nemesis.sql
Normal 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");
|
||||
592
drizzle_pg/meta/0000_snapshot.json
Normal file
592
drizzle_pg/meta/0000_snapshot.json
Normal 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": {}
|
||||
}
|
||||
}
|
||||
13
drizzle_pg/meta/_journal.json
Normal file
13
drizzle_pg/meta/_journal.json
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
{
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"entries": [
|
||||
{
|
||||
"idx": 0,
|
||||
"version": "7",
|
||||
"when": 1769393893675,
|
||||
"tag": "0000_military_nemesis",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
10
package.json
10
package.json
|
|
@ -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",
|
||||
|
|
|
|||
1506
pnpm-lock.yaml
1506
pnpm-lock.yaml
File diff suppressed because it is too large
Load diff
38
scripts/migrate-if-auth.mjs
Normal file
38
scripts/migrate-if-auth.mjs
Normal 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);
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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) {
|
|||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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' },
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
|
|
|
|||
|
|
@ -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) });
|
||||
}
|
||||
|
|
|
|||
32
src/app/api/user/claim/route.ts
Normal file
32
src/app/api/user/claim/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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 />
|
||||
|
|
|
|||
154
src/components/ClaimDataModal.tsx
Normal file
154
src/components/ClaimDataModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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
42
src/db/index.ts
Normal 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
14
src/db/schema.ts
Normal 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
92
src/db/schema_postgres.ts
Normal 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
93
src/db/schema_sqlite.ts
Normal 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),
|
||||
}));
|
||||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
178
src/lib/server/claim-data.ts
Normal file
178
src/lib/server/claim-data.ts
Normal 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
|
||||
};
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
178
src/lib/server/migration-manager.ts
Normal file
178
src/lib/server/migration-manager.ts
Normal 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
|
||||
};
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Reference in a new issue