diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..d67f374 --- /dev/null +++ b/.npmrc @@ -0,0 +1 @@ +node-linker=hoisted diff --git a/docs-site/docs/configure/database.md b/docs-site/docs/configure/database.md index 88c79f5..eb8e89f 100644 --- a/docs-site/docs/configure/database.md +++ b/docs-site/docs/configure/database.md @@ -12,12 +12,14 @@ This page covers database mode selection for OpenReader WebUI. ## What the database stores - Document and audiobook metadata/state used by server routes. -- Auth/session tables when auth is enabled. +- Auth/session tables (`user`, `session`, `account`, `verification`) when auth is enabled — schema is auto-generated by Better Auth. - TTS character usage counters (`user_tts_chars`) for daily rate limiting (when enabled). - User settings preferences (`user_preferences`) when auth is enabled. - User reading progress (`user_document_progress`) when auth is enabled. - Document preview job/asset metadata (`document_previews`) for server-side PDF/EPUB thumbnails. +App-specific tables are manually maintained in Drizzle schema files, while auth tables are generated by the Better Auth CLI. Both are migrated together via Drizzle. See [Migrations](./migrations) for details. + ## Related variables - `POSTGRES_URL` diff --git a/docs-site/docs/configure/migrations.md b/docs-site/docs/configure/migrations.md index 934472f..9cb3234 100644 --- a/docs-site/docs/configure/migrations.md +++ b/docs-site/docs/configure/migrations.md @@ -76,20 +76,34 @@ pnpm exec drizzle-kit migrate --config drizzle.config.pg.ts ## Generate migrations -`pnpm generate` creates migration files for both configs in one run: +`pnpm generate` is a two-phase script for contributors and schema changes: -- `drizzle.config.sqlite.ts` -- `drizzle.config.pg.ts` +1. **Better Auth schema generation** — runs the Better Auth CLI twice (once for SQLite, once for Postgres) to produce auto-generated Drizzle schema files for auth tables (`user`, `session`, `account`, `verification`). +2. **Drizzle migration generation** — runs `drizzle-kit generate` for both `drizzle.config.sqlite.ts` and `drizzle.config.pg.ts`, producing SQL migration files from all schema files (app + auth). :::note Most users do not need to run `pnpm generate`. Use it when contributing or when you have changed Drizzle schema files and need new migration files. ::: +### Schema ownership + +Auth tables are owned by Better Auth. Their Drizzle schema definitions are auto-generated and should **not** be hand-edited: + +- `src/db/schema_auth_sqlite.ts` +- `src/db/schema_auth_postgres.ts` + +App-specific tables are manually maintained in the standard Drizzle schema files: + +- `src/db/schema_sqlite.ts` +- `src/db/schema_postgres.ts` + +Both sets of schema files are included in the Drizzle configs, so `drizzle-kit generate` and `drizzle-kit migrate` handle all tables together. + ```bash -# Generate migration files for both SQLite and Postgres outputs +# Full pipeline: Better Auth CLI + Drizzle generate (both dialects) pnpm generate ``` @@ -97,13 +111,17 @@ pnpm generate ```bash -# Generate SQLite migrations +# Generate SQLite migrations only (skips Better Auth CLI) pnpm exec drizzle-kit generate --config drizzle.config.sqlite.ts -# Generate Postgres migrations +# Generate Postgres migrations only (skips Better Auth CLI) pnpm exec drizzle-kit generate --config drizzle.config.pg.ts ``` +:::warning +Running `drizzle-kit generate` directly skips the Better Auth CLI step. If auth schema has changed upstream (e.g. after a Better Auth version bump), run `pnpm generate` instead to regenerate the auth schema files first. +::: + diff --git a/docs-site/docs/deploy/local-development.md b/docs-site/docs/deploy/local-development.md index e123369..36d5fc4 100644 --- a/docs-site/docs/deploy/local-development.md +++ b/docs-site/docs/deploy/local-development.md @@ -15,7 +15,7 @@ npm install -g pnpm ``` - A reachable TTS API server -- [SeaweedFS](https://github.com/seaweedfs/seaweedfs) `weed` binary (required) +- [SeaweedFS](https://github.com/seaweedfs/seaweedfs) `weed` binary (required unless using external S3 storage) @@ -93,10 +93,10 @@ Optional: For all environment variables, see [Environment Variables](../reference/environment-variables). ::: -For app/auth behavior, see [Auth](../configure/auth). -For storage configuration, see [Object / Blob Storage](../configure/object-blob-storage). -For database mode, see [Database](../configure/database). -For migration behavior and commands, see [Migrations](../configure/migrations). +See [Auth](../configure/auth) for app/auth behavior. +Storage configuration details are in [Object / Blob Storage](../configure/object-blob-storage). +Refer to [Database](../configure/database) for database modes. +Learn about migration behavior and commands in [Migrations](../configure/migrations). 4. Run DB migrations. diff --git a/docs-site/docs/docker-quick-start.md b/docs-site/docs/docker-quick-start.md index fdf3239..dd21287 100644 --- a/docs-site/docs/docker-quick-start.md +++ b/docs-site/docs/docker-quick-start.md @@ -44,7 +44,7 @@ docker run --name openreader-webui \ -e API_BASE=http://host.docker.internal:8880/v1 \ -e API_KEY=none \ -e BASE_URL=http://localhost:3003 \ - -e AUTH_SECRET= \ + -e AUTH_SECRET=$(openssl rand -base64 32) \ ghcr.io/richardr1126/openreader-webui:latest ``` diff --git a/docs-site/docs/reference/environment-variables.md b/docs-site/docs/reference/environment-variables.md index b97350a..5504bc1 100644 --- a/docs-site/docs/reference/environment-variables.md +++ b/docs-site/docs/reference/environment-variables.md @@ -51,8 +51,6 @@ This is the single reference page for OpenReader WebUI environment variables. | `FFMPEG_BIN` | Audio runtime | auto-detected (`ffmpeg-static`) | Override ffmpeg binary path | | `FFPROBE_BIN` | Audio runtime | auto-detected (`ffprobe-static`) | Override ffprobe binary path | -## Detailed Reference - ## Client Runtime and Feature Flags ### NEXT_PUBLIC_NODE_ENV diff --git a/docs-site/docs/reference/stack.md b/docs-site/docs/reference/stack.md index 136b170..cc45fa2 100644 --- a/docs-site/docs/reference/stack.md +++ b/docs-site/docs/reference/stack.md @@ -31,6 +31,8 @@ title: Stack - State sync: request-based today (not realtime push updates) - Authentication: [Better Auth](https://www.better-auth.com/) server handlers/adapters - Metadata DB: [Drizzle ORM](https://orm.drizzle.team/) with SQLite (`better-sqlite3`) by default and optional Postgres (`pg`) + - App tables are manually maintained in Drizzle schema files + - Auth tables are auto-generated by the [Better Auth](https://www.better-auth.com/) CLI and migrated alongside app tables via Drizzle - Blob storage: embedded [SeaweedFS](https://github.com/seaweedfs/seaweedfs) (`weed mini`) by default, or external S3-compatible storage via AWS SDK v3 - Audio/processing pipeline: OpenAI-compatible TTS providers, [ffmpeg](https://ffmpeg.org/) for audiobook assembly, optional [whisper.cpp](https://github.com/ggerganov/whisper.cpp) for word timestamps diff --git a/next.config.ts b/next.config.ts index bcf83cd..de6f7dd 100644 --- a/next.config.ts +++ b/next.config.ts @@ -6,7 +6,7 @@ const nextConfig: NextConfig = { canvas: '@napi-rs/canvas', }, }, - serverExternalPackages: ["@napi-rs/canvas", "ffmpeg-static"], + serverExternalPackages: ["@napi-rs/canvas", "ffmpeg-static", "better-sqlite3"], outputFileTracingIncludes: { '/api/audiobook': [ './node_modules/ffmpeg-static/ffmpeg', diff --git a/scripts/openreader-entrypoint.mjs b/scripts/openreader-entrypoint.mjs index 23f49c2..fa9b235 100644 --- a/scripts/openreader-entrypoint.mjs +++ b/scripts/openreader-entrypoint.mjs @@ -352,9 +352,7 @@ async function main() { runtimeEnv.AWS_ACCESS_KEY_ID = runtimeEnv.S3_ACCESS_KEY_ID; runtimeEnv.AWS_SECRET_ACCESS_KEY = runtimeEnv.S3_SECRET_ACCESS_KEY; - if (!runtimeEnv.S3_ACCESS_KEY_ID || !runtimeEnv.S3_SECRET_ACCESS_KEY) { - throw new Error('Failed to initialize embedded S3 credentials.'); - } + fs.mkdirSync(runtimeEnv.WEED_MINI_DIR, { recursive: true }); diff --git a/src/app/(app)/signup/page.tsx b/src/app/(app)/signup/page.tsx index 6bc4efe..a6f3f6c 100644 --- a/src/app/(app)/signup/page.tsx +++ b/src/app/(app)/signup/page.tsx @@ -51,6 +51,10 @@ export default function SignUpPage() { setError('Please enter a valid email address'); return; } + if (password.length < 8) { + setError('Password must be at least 8 characters'); + return; + } const { strength } = validatePassword(password); if (strength < 3) { setError('Password is too weak'); diff --git a/src/app/api/account/delete/route.ts b/src/app/api/account/delete/route.ts index fd7e0b0..cf5d0a8 100644 --- a/src/app/api/account/delete/route.ts +++ b/src/app/api/account/delete/route.ts @@ -8,8 +8,10 @@ export async function DELETE() { return NextResponse.json({ error: 'Authentication disabled' }, { status: 403 }); } + const reqHeaders = await headers(); + const session = await auth.api.getSession({ - headers: await headers() + headers: reqHeaders }); if (!session?.user?.id) { @@ -19,7 +21,7 @@ export async function DELETE() { try { // Use Better Auth's built-in deleteUser to handle cascading cleanup await auth.api.deleteUser({ - headers: await headers(), + headers: reqHeaders, body: {}, }); diff --git a/src/app/api/audiobook/status/route.ts b/src/app/api/audiobook/status/route.ts index b88c458..3155fd4 100644 --- a/src/app/api/audiobook/status/route.ts +++ b/src/app/api/audiobook/status/route.ts @@ -136,7 +136,7 @@ export async function GET(request: NextRequest) { const exists = chapters.length > 0 || hasComplete || settings !== null; if (!exists) { - await db.delete(audiobookChapters).where(and(eq(audiobookChapters.bookId, bookId), eq(audiobookChapters.userId, existingBookUserId))); + // Deleting the audiobook row cascades to audiobookChapters via bookFk await db.delete(audiobooks).where(and(eq(audiobooks.id, bookId), eq(audiobooks.userId, existingBookUserId))); return NextResponse.json({ chapters: [], diff --git a/src/app/api/documents/blob/preview/ensure/route.ts b/src/app/api/documents/blob/preview/ensure/route.ts index 8d417ad..0bdae9b 100644 --- a/src/app/api/documents/blob/preview/ensure/route.ts +++ b/src/app/api/documents/blob/preview/ensure/route.ts @@ -8,57 +8,15 @@ import { presignDocumentPreviewGet } from '@/lib/server/document-previews-blobst import { ensureDocumentPreview, isPreviewableDocumentType } from '@/lib/server/document-previews'; import { getOpenReaderTestNamespace, getUnclaimedUserIdForNamespace } from '@/lib/server/test-namespace'; import { isS3Configured } from '@/lib/server/s3'; +import { validatePreviewRequest } from '../utils'; export const dynamic = 'force-dynamic'; -function s3NotConfiguredResponse(): NextResponse { - return NextResponse.json( - { error: 'Documents storage is not configured. Set S3_* environment variables.' }, - { status: 503 }, - ); -} - export async function GET(req: NextRequest) { try { - if (!isS3Configured()) return s3NotConfiguredResponse(); - - const ctxOrRes = await requireAuthContext(req); - if (ctxOrRes instanceof Response) return ctxOrRes; - - const testNamespace = getOpenReaderTestNamespace(req.headers); - const unclaimedUserId = getUnclaimedUserIdForNamespace(testNamespace); - const storageUserId = ctxOrRes.userId ?? unclaimedUserId; - const allowedUserIds = ctxOrRes.authEnabled ? [storageUserId, unclaimedUserId] : [unclaimedUserId]; - - const url = new URL(req.url); - const id = (url.searchParams.get('id') || '').trim().toLowerCase(); - if (!isValidDocumentId(id)) { - return NextResponse.json({ error: 'Invalid id' }, { status: 400 }); - } - - const rows = (await db - .select({ - id: documents.id, - userId: documents.userId, - type: documents.type, - lastModified: documents.lastModified, - }) - .from(documents) - .where(and(eq(documents.id, id), inArray(documents.userId, allowedUserIds)))) as Array<{ - id: string; - userId: string; - type: string; - lastModified: number; - }>; - - const doc = rows.find((row) => row.userId === storageUserId) ?? rows[0]; - if (!doc) { - return NextResponse.json({ error: 'Not found' }, { status: 404 }); - } - - if (!isPreviewableDocumentType(doc.type)) { - return NextResponse.json({ error: `Preview not supported for type ${doc.type}` }, { status: 415 }); - } + const validation = await validatePreviewRequest(req); + if (validation.errorResponse) return validation.errorResponse; + const { doc, testNamespace, id } = validation; const presignUrl = `/api/documents/blob/preview/presign?id=${encodeURIComponent(id)}`; const fallbackUrl = `/api/documents/blob/preview/fallback?id=${encodeURIComponent(id)}`; diff --git a/src/app/api/documents/blob/preview/presign/route.ts b/src/app/api/documents/blob/preview/presign/route.ts index d52e0f5..ef280d5 100644 --- a/src/app/api/documents/blob/preview/presign/route.ts +++ b/src/app/api/documents/blob/preview/presign/route.ts @@ -8,57 +8,15 @@ import { presignDocumentPreviewGet } from '@/lib/server/document-previews-blobst import { ensureDocumentPreview, isPreviewableDocumentType } from '@/lib/server/document-previews'; import { getOpenReaderTestNamespace, getUnclaimedUserIdForNamespace } from '@/lib/server/test-namespace'; import { isS3Configured } from '@/lib/server/s3'; +import { validatePreviewRequest } from '../utils'; export const dynamic = 'force-dynamic'; -function s3NotConfiguredResponse(): NextResponse { - return NextResponse.json( - { error: 'Documents storage is not configured. Set S3_* environment variables.' }, - { status: 503 }, - ); -} - export async function GET(req: NextRequest) { try { - if (!isS3Configured()) return s3NotConfiguredResponse(); - - const ctxOrRes = await requireAuthContext(req); - if (ctxOrRes instanceof Response) return ctxOrRes; - - const testNamespace = getOpenReaderTestNamespace(req.headers); - const unclaimedUserId = getUnclaimedUserIdForNamespace(testNamespace); - const storageUserId = ctxOrRes.userId ?? unclaimedUserId; - const allowedUserIds = ctxOrRes.authEnabled ? [storageUserId, unclaimedUserId] : [unclaimedUserId]; - - const url = new URL(req.url); - const id = (url.searchParams.get('id') || '').trim().toLowerCase(); - if (!isValidDocumentId(id)) { - return NextResponse.json({ error: 'Invalid id' }, { status: 400 }); - } - - const rows = (await db - .select({ - id: documents.id, - userId: documents.userId, - type: documents.type, - lastModified: documents.lastModified, - }) - .from(documents) - .where(and(eq(documents.id, id), inArray(documents.userId, allowedUserIds)))) as Array<{ - id: string; - userId: string; - type: string; - lastModified: number; - }>; - - const doc = rows.find((row) => row.userId === storageUserId) ?? rows[0]; - if (!doc) { - return NextResponse.json({ error: 'Not found' }, { status: 404 }); - } - - if (!isPreviewableDocumentType(doc.type)) { - return NextResponse.json({ error: `Preview not supported for type ${doc.type}` }, { status: 415 }); - } + const validation = await validatePreviewRequest(req); + if (validation.errorResponse) return validation.errorResponse; + const { doc, testNamespace, id } = validation; const fallbackUrl = `/api/documents/blob/preview/fallback?id=${encodeURIComponent(id)}`; const preview = await ensureDocumentPreview( diff --git a/src/app/api/documents/blob/preview/utils.ts b/src/app/api/documents/blob/preview/utils.ts new file mode 100644 index 0000000..d8d6ed2 --- /dev/null +++ b/src/app/api/documents/blob/preview/utils.ts @@ -0,0 +1,87 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { and, eq, inArray } from 'drizzle-orm'; +import { db } from '@/db'; +import { documents } from '@/db/schema'; +import { requireAuthContext } from '@/lib/server/auth'; +import { isValidDocumentId } from '@/lib/server/documents-blobstore'; +import { isPreviewableDocumentType } from '@/lib/server/document-previews'; +import { getOpenReaderTestNamespace, getUnclaimedUserIdForNamespace } from '@/lib/server/test-namespace'; +import { isS3Configured } from '@/lib/server/s3'; + +export function s3NotConfiguredResponse(): NextResponse { + return NextResponse.json( + { error: 'Documents storage is not configured. Set S3_* environment variables.' }, + { status: 503 }, + ); +} + +export type ValidatedPreviewRequest = { + doc: { + id: string; + userId: string; + type: string; + lastModified: number; + }; + testNamespace: string | null; + id: string; + errorResponse?: undefined; +} | { + doc?: undefined; + testNamespace?: undefined; + id?: undefined; + errorResponse: NextResponse | Response; +}; + +export async function validatePreviewRequest(req: NextRequest): Promise { + if (!isS3Configured()) return { errorResponse: s3NotConfiguredResponse() }; + + const ctxOrRes = await requireAuthContext(req); + if (ctxOrRes instanceof Response) return { errorResponse: ctxOrRes }; + + const testNamespace = getOpenReaderTestNamespace(req.headers); + const unclaimedUserId = getUnclaimedUserIdForNamespace(testNamespace); + const storageUserId = ctxOrRes.userId ?? unclaimedUserId; + + // Deduplicate allowedUserIds + const allowedUserIds = Array.from(new Set( + ctxOrRes.authEnabled ? [storageUserId, unclaimedUserId] : [unclaimedUserId] + )); + + const url = new URL(req.url); + const id = (url.searchParams.get('id') || '').trim().toLowerCase(); + + if (!isValidDocumentId(id)) { + return { errorResponse: NextResponse.json({ error: 'Invalid id' }, { status: 400 }) }; + } + + const rows = (await db + .select({ + id: documents.id, + userId: documents.userId, + type: documents.type, + lastModified: documents.lastModified, + }) + .from(documents) + .where(and(eq(documents.id, id), inArray(documents.userId, allowedUserIds)))) as Array<{ + id: string; + userId: string; + type: string; + lastModified: number; + }>; + + const doc = rows.find((row) => row.userId === storageUserId) ?? rows[0]; + + if (!doc) { + return { errorResponse: NextResponse.json({ error: 'Not found' }, { status: 404 }) }; + } + + if (!isPreviewableDocumentType(doc.type)) { + return { errorResponse: NextResponse.json({ error: `Preview not supported for type ${doc.type}` }, { status: 415 }) }; + } + + return { + doc, + testNamespace, + id + }; +} diff --git a/src/app/api/documents/route.ts b/src/app/api/documents/route.ts index 5492b18..0e64479 100644 --- a/src/app/api/documents/route.ts +++ b/src/app/api/documents/route.ts @@ -165,9 +165,9 @@ export async function GET(req: NextRequest) { const idsParam = url.searchParams.get('ids'); const targetIds = idsParam ? idsParam - .split(',') - .map((id) => id.trim().toLowerCase()) - .filter((id) => isValidDocumentId(id)) + .split(',') + .map((id) => id.trim().toLowerCase()) + .filter((id) => isValidDocumentId(id)) : null; if (idsParam && (!targetIds || targetIds.length === 0)) { @@ -282,7 +282,7 @@ export async function DELETE(req: NextRequest) { await deleteDocumentBlob(id, testNamespace); } catch (error) { if (!isMissingBlobError(error)) { - throw error; + console.error(`[best-effort] Failed to delete blob for document ${id}, orphaned blob may need manual cleanup:`, error); } } diff --git a/src/app/api/rate-limit/status/route.ts b/src/app/api/rate-limit/status/route.ts index 4274a88..021775b 100644 --- a/src/app/api/rate-limit/status/route.ts +++ b/src/app/api/rate-limit/status/route.ts @@ -58,7 +58,7 @@ export async function GET(req: NextRequest) { const isAnonymous = Boolean(session.user.isAnonymous); const ip = getClientIp(req); - const device = isAnonymous && ttsRateLimitEnabled ? getOrCreateDeviceId(req) : null; + const device = isTtsRateLimitEnabled() ? (isAnonymous ? getOrCreateDeviceId(req) : null) : null; const result = await rateLimiter.getCurrentUsage( { diff --git a/src/app/api/tts/route.ts b/src/app/api/tts/route.ts index 8fc1453..de3c167 100644 --- a/src/app/api/tts/route.ts +++ b/src/app/api/tts/route.ts @@ -13,6 +13,12 @@ import { isAuthEnabled } from '@/lib/server/auth-config'; import { getClientIp } from '@/lib/server/request-ip'; import { getOrCreateDeviceId, setDeviceIdCookie } from '@/lib/server/device-id'; +function attachDeviceIdCookie(response: NextResponse, deviceId: string | null, didCreate: boolean) { + if (didCreate && deviceId) { + setDeviceIdCookie(response, deviceId); + } +} + type CustomVoice = string; type ExtendedSpeechParams = Omit & { voice: SpeechCreateParams['voice'] | CustomVoice; @@ -210,9 +216,7 @@ export async function POST(req: NextRequest) { }, }); - if (didCreateDeviceIdCookie && deviceIdToSet) { - setDeviceIdCookie(response, deviceIdToSet); - } + attachDeviceIdCookie(response, deviceIdToSet, didCreateDeviceIdCookie); return response; } @@ -286,9 +290,7 @@ export async function POST(req: NextRequest) { } }); - if (didCreateDeviceIdCookie && deviceIdToSet) { - setDeviceIdCookie(response, deviceIdToSet); - } + attachDeviceIdCookie(response, deviceIdToSet, didCreateDeviceIdCookie); return response; } @@ -304,9 +306,7 @@ export async function POST(req: NextRequest) { } }); - if (didCreateDeviceIdCookie && deviceIdToSet) { - setDeviceIdCookie(response, deviceIdToSet); - } + attachDeviceIdCookie(response, deviceIdToSet, didCreateDeviceIdCookie); return response; } @@ -338,9 +338,7 @@ export async function POST(req: NextRequest) { } }); - if (didCreateDeviceIdCookie && deviceIdToSet) { - setDeviceIdCookie(response, deviceIdToSet); - } + attachDeviceIdCookie(response, deviceIdToSet, didCreateDeviceIdCookie); return response; } finally { @@ -392,9 +390,7 @@ export async function POST(req: NextRequest) { } }); - if (didCreateDeviceIdCookie && deviceIdToSet) { - setDeviceIdCookie(response, deviceIdToSet); - } + attachDeviceIdCookie(response, deviceIdToSet, didCreateDeviceIdCookie); return response; } catch (error) { diff --git a/src/app/api/user/claim/route.ts b/src/app/api/user/claim/route.ts index 9acb8db..344c88d 100644 --- a/src/app/api/user/claim/route.ts +++ b/src/app/api/user/claim/route.ts @@ -25,22 +25,13 @@ async function checkClaimMigrationReadiness(): Promise { async function getClaimableCounts( unclaimedUserId: string, ): Promise<{ documents: number; audiobooks: number; preferences: number; progress: number }> { - const [docCount] = await db - .select({ count: count() }) - .from(documents) - .where(eq(documents.userId, unclaimedUserId)); - const [bookCount] = await db - .select({ count: count() }) - .from(audiobooks) - .where(eq(audiobooks.userId, unclaimedUserId)); - const [preferencesCount] = await db - .select({ count: count() }) - .from(userPreferences) - .where(eq(userPreferences.userId, unclaimedUserId)); - const [progressCount] = await db - .select({ count: count() }) - .from(userDocumentProgress) - .where(eq(userDocumentProgress.userId, unclaimedUserId)); + const [[docCount], [bookCount], [preferencesCount], [progressCount]] = + await Promise.all([ + db.select({ count: count() }).from(documents).where(eq(documents.userId, unclaimedUserId)), + db.select({ count: count() }).from(audiobooks).where(eq(audiobooks.userId, unclaimedUserId)), + db.select({ count: count() }).from(userPreferences).where(eq(userPreferences.userId, unclaimedUserId)), + db.select({ count: count() }).from(userDocumentProgress).where(eq(userDocumentProgress.userId, unclaimedUserId)), + ]); return { documents: Number(docCount?.count ?? 0), diff --git a/src/app/api/user/state/progress/route.ts b/src/app/api/user/state/progress/route.ts index 99666d3..4eb6f9a 100644 --- a/src/app/api/user/state/progress/route.ts +++ b/src/app/api/user/state/progress/route.ts @@ -1,5 +1,5 @@ import { NextRequest, NextResponse } from 'next/server'; -import { and, eq } from 'drizzle-orm'; +import { and, eq, sql } from 'drizzle-orm'; import { db } from '@/db'; import { userDocumentProgress } from '@/db/schema'; import type { ReaderType } from '@/types/user-state'; @@ -84,12 +84,12 @@ export async function PUT(req: NextRequest) { const body = (await req.json().catch(() => null)) as | { - documentId?: unknown; - readerType?: unknown; - location?: unknown; - progress?: unknown; - clientUpdatedAtMs?: unknown; - } + documentId?: unknown; + readerType?: unknown; + location?: unknown; + progress?: unknown; + clientUpdatedAtMs?: unknown; + } | null; const documentId = typeof body?.documentId === 'string' ? body.documentId.trim().toLowerCase() : ''; @@ -111,8 +111,8 @@ export async function PUT(req: NextRequest) { body?.progress == null ? null : Number.isFinite(body.progress) - ? Math.max(0, Math.min(1, Number(body.progress))) - : null; + ? Math.max(0, Math.min(1, Number(body.progress))) + : null; const clientUpdatedAtMs = normalizeClientUpdatedAtMs(body?.clientUpdatedAtMs); const existingRows = await db @@ -167,6 +167,7 @@ export async function PUT(req: NextRequest) { clientUpdatedAtMs, updatedAt, }, + setWhere: sql`${userDocumentProgress.clientUpdatedAtMs} <= ${clientUpdatedAtMs}`, }); return NextResponse.json({ diff --git a/src/app/globals.css b/src/app/globals.css index 2e72e40..881ff04 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -131,6 +131,7 @@ h1, h2, h3, h4, h5, h6 { .pdf-page-stage { /* Ensure we always paint something (matches app theme) while react-pdf swaps canvases */ background: var(--background); + position: relative; } .pdf-viewer .react-pdf__Page { @@ -139,16 +140,13 @@ h1, h2, h3, h4, h5, h6 { isolation: isolate; } +/* Fade-in the freshly rendered canvas (react-pdf replaces the canvas element). */ .pdf-viewer .react-pdf__Page canvas { display: block; background: var(--background); /* GPU hint; helps reduce flicker on some browsers */ transform: translateZ(0); backface-visibility: hidden; -} - -/* Fade-in the freshly rendered canvas (react-pdf replaces the canvas element). */ -.pdf-viewer .react-pdf__Page canvas { opacity: 0; animation: pdf-canvas-fade-in 140ms ease-out forwards; } @@ -163,9 +161,7 @@ h1, h2, h3, h4, h5, h6 { display: none !important; } -.pdf-page-stage { - position: relative; -} + /* App shell utility (fullscreen, vertical layout) */ .app-shell { diff --git a/src/app/providers.tsx b/src/app/providers.tsx index efbaf11..d75257d 100644 --- a/src/app/providers.tsx +++ b/src/app/providers.tsx @@ -24,7 +24,7 @@ interface ProvidersProps { export function Providers({ children, authEnabled, authBaseUrl, allowAnonymousAuthSessions }: ProvidersProps) { const pathname = usePathname(); - const isAuthPage = pathname === '/signin' || pathname === '/signup'; + const isAuthPage = pathname?.startsWith('/signin') || pathname?.startsWith('/signup'); if (isAuthPage) { return ( diff --git a/src/components/SettingsModal.tsx b/src/components/SettingsModal.tsx index 265523b..e69d499 100644 --- a/src/components/SettingsModal.tsx +++ b/src/components/SettingsModal.tsx @@ -296,10 +296,10 @@ export function SettingsModal({ className = '' }: { className?: string }) { try { if (deleteDocsMode === 'user') { await deleteDocuments(); - await refreshDocuments().catch(() => {}); + await refreshDocuments().catch(() => { }); } else if (deleteDocsMode === 'unclaimed') { await deleteDocuments({ scope: 'unclaimed' }); - await refreshDocuments().catch(() => {}); + await refreshDocuments().catch(() => { }); } } catch (error) { console.error('Delete failed:', error); @@ -366,8 +366,8 @@ export function SettingsModal({ className = '' }: { className?: string }) { const userDeleteTitle = authEnabled ? (isAnonymous ? 'Delete Anonymous Docs' : 'Delete All User Docs') : 'Delete Server Docs'; const userDeleteMessage = authEnabled ? (isAnonymous - ? 'Are you sure you want to delete all anonymous-session documents? This action cannot be undone.' - : 'Are you sure you want to delete all of your documents from the server? This action cannot be undone.') + ? 'Are you sure you want to delete all anonymous-session documents? This action cannot be undone.' + : 'Are you sure you want to delete all of your documents from the server? This action cannot be undone.') : 'Are you sure you want to delete all documents from the server? This action cannot be undone.'; const [unclaimedCounts, setUnclaimedCounts] = useState<{ documents: number; audiobooks: number } | null>(null); @@ -386,7 +386,7 @@ export function SettingsModal({ className = '' }: { className?: string }) { setUnclaimedCounts({ documents: data.documents, audiobooks: data.audiobooks }); } }) - .catch(() => {}); + .catch(() => { }); }, [authEnabled, session?.user]); return ( @@ -661,7 +661,6 @@ export function SettingsModal({ className = '' }: { className?: string }) { setModelValue('kokoro'); setCustomModelInput(''); setLocalTTSInstructions(''); - setLocalTTSInstructions(''); }} > Reset @@ -876,8 +875,8 @@ export function SettingsModal({ className = '' }: { className?: string }) {

{session?.user?.isAnonymous ? (allowAnonymousAuthSessions - ? 'You are using an anonymous session. Sign up to save your progress permanently.' - : 'Anonymous sessions are disabled. Please sign in or create an account.') + ? 'You are using an anonymous session. Sign up to save your progress permanently.' + : 'Anonymous sessions are disabled. Please sign in or create an account.') : 'No active session. Please sign in or create an account.'}

diff --git a/src/components/doclist/DocumentPreview.tsx b/src/components/doclist/DocumentPreview.tsx index 699adb9..992dd33 100644 --- a/src/components/doclist/DocumentPreview.tsx +++ b/src/components/doclist/DocumentPreview.tsx @@ -19,8 +19,34 @@ interface DocumentPreviewProps { doc: DocumentListDocument; } +const MAX_TEXT_PREVIEW_CACHE = 100; const textPreviewCache = new Map(); +/** Read from cache and promote entry to most-recently-used. */ +function textPreviewCacheGet(key: string): string | undefined { + const value = textPreviewCache.get(key); + if (value !== undefined) { + // Re-insert to move to end (most-recently-used) + textPreviewCache.delete(key); + textPreviewCache.set(key, value); + } + return value; +} + +/** Write to cache, evicting the least-recently-used entry when over the cap. */ +function textPreviewCacheSet(key: string, value: string): void { + // If the key already exists, delete first so re-insertion moves it to the end + if (textPreviewCache.has(key)) { + textPreviewCache.delete(key); + } + textPreviewCache.set(key, value); + if (textPreviewCache.size > MAX_TEXT_PREVIEW_CACHE) { + // Map keys iterate in insertion order; first key is the LRU entry + const oldest = textPreviewCache.keys().next().value; + if (oldest !== undefined) textPreviewCache.delete(oldest); + } +} + export function DocumentPreview({ doc }: DocumentPreviewProps) { const isPDF = doc.type === 'pdf'; const isEPUB = doc.type === 'epub'; @@ -71,7 +97,7 @@ export function DocumentPreview({ doc }: DocumentPreviewProps) { return; } - const cachedText = textPreviewCache.get(previewKey); + const cachedText = textPreviewCacheGet(previewKey); if (cachedText) { setTextPreview(cachedText); setImagePreview(null); @@ -154,7 +180,7 @@ export function DocumentPreview({ doc }: DocumentPreviewProps) { signal: controller.signal, }); if (cancelled) return; - textPreviewCache.set(previewKey, snippet); + textPreviewCacheSet(previewKey, snippet); setTextPreview(snippet); setImagePreview(null); return; @@ -238,7 +264,7 @@ export function DocumentPreview({ doc }: DocumentPreviewProps) { doc.id, Number(doc.lastModified), previewKey, - ).catch(() => {}); + ).catch(() => { }); }} /> {isImageReady ?
: null} diff --git a/src/components/documents/DexieMigrationModal.tsx b/src/components/documents/DexieMigrationModal.tsx index ff9a493..7e3226f 100644 --- a/src/components/documents/DexieMigrationModal.tsx +++ b/src/components/documents/DexieMigrationModal.tsx @@ -86,7 +86,7 @@ export function DexieMigrationModal() { return () => window.removeEventListener('openreader:privacyAccepted', handler as EventListener); }, [checkAndMaybePrompt]); - const title = useMemo(() => `Upload your local documents?`, []); + const title = 'Upload your local documents?'; const handleSkip = useCallback(async () => { await updateAppConfig({ documentsMigrationPrompted: true }); @@ -124,7 +124,7 @@ export function DexieMigrationModal() { const uploaded = await uploadDocuments([file]); const stored = uploaded[0] ?? null; if (stored) { - await cacheStoredDocumentFromBytes(stored, bytes).catch(() => {}); + await cacheStoredDocumentFromBytes(stored, bytes).catch(() => { }); } } else if (doc.type === 'epub') { const full = epubById.get(doc.id) ?? null; @@ -137,7 +137,7 @@ export function DexieMigrationModal() { const uploaded = await uploadDocuments([file]); const stored = uploaded[0] ?? null; if (stored) { - await cacheStoredDocumentFromBytes(stored, bytes).catch(() => {}); + await cacheStoredDocumentFromBytes(stored, bytes).catch(() => { }); } } else { const full = htmlById.get(doc.id) ?? null; @@ -150,7 +150,7 @@ export function DexieMigrationModal() { const uploaded = await uploadDocuments([file]); const stored = uploaded[0] ?? null; if (stored) { - await cacheStoredDocumentFromBytes(stored, bytes).catch(() => {}); + await cacheStoredDocumentFromBytes(stored, bytes).catch(() => { }); } } } diff --git a/src/contexts/ConfigContext.tsx b/src/contexts/ConfigContext.tsx index bb68f2b..c5d2c5e 100644 --- a/src/contexts/ConfigContext.tsx +++ b/src/contexts/ConfigContext.tsx @@ -4,7 +4,7 @@ import { createContext, useCallback, useContext, useEffect, useMemo, useRef, use import { useLiveQuery } from 'dexie-react-hooks'; import { db, initDB, migrateLegacyDexieDocumentIdsToSha, updateAppConfig } from '@/lib/dexie'; import { APP_CONFIG_DEFAULTS, type ViewType, type SavedVoices, type AppConfigValues, type AppConfigRow } from '@/types/config'; -import { scheduleUserPreferencesSync, getUserPreferences, putUserPreferences } from '@/lib/client-user-state'; +import { scheduleUserPreferencesSync, cancelPendingPreferenceSync, getUserPreferences, putUserPreferences } from '@/lib/client-user-state'; import { SYNCED_PREFERENCE_KEYS, type SyncedPreferenceKey, type SyncedPreferencesPatch } from '@/types/user-state'; import { useAuthSession } from '@/hooks/useAuthSession'; import { useAuthConfig } from '@/contexts/AuthRateLimitContext'; @@ -64,7 +64,7 @@ export function ConfigProvider({ children }: { children: ReactNode }) { const getVoiceKey = (provider: string, model: string) => `${provider}:${model}`; const queueSyncedPreferencePatch = useCallback((patch: Partial) => { - if (!authEnabled) return; + if (!authEnabled || sessionKey === 'no-session') return; const syncedPatch: SyncedPreferencesPatch = {}; for (const key of SYNCED_PREFERENCE_KEYS) { @@ -74,8 +74,15 @@ export function ConfigProvider({ children }: { children: ReactNode }) { (syncedPatch as Record)[key] = value; } if (Object.keys(syncedPatch).length === 0) return; - scheduleUserPreferencesSync(syncedPatch); - }, [authEnabled]); + scheduleUserPreferencesSync(syncedPatch, sessionKey); + }, [authEnabled, sessionKey]); + + // Cancel pending/in-flight preference syncs whenever the session changes or on unmount. + useEffect(() => { + return () => { + cancelPendingPreferenceSync(); + }; + }, [sessionKey]); const buildSyncedPreferencePatch = useCallback(( source: Partial, @@ -152,35 +159,44 @@ export function ConfigProvider({ children }: { children: ReactNode }) { void run(); }, [isDBReady]); - const refreshSyncedPreferencesFromServer = useCallback(async () => { + const refreshSyncedPreferencesFromServer = useCallback(async (signal?: AbortSignal) => { if (!isDBReady || !authEnabled) return; try { - const remote = await getUserPreferences(); + const remote = await getUserPreferences({ signal }); if (!remote?.hasStoredPreferences) return; if (!remote.preferences || Object.keys(remote.preferences).length === 0) return; await updateAppConfig(remote.preferences as Partial); } catch (error) { + if ((error as Error)?.name === 'AbortError') return; console.warn('Failed to load synced preferences:', error); } }, [isDBReady, authEnabled]); useEffect(() => { if (!isDBReady || !authEnabled || isSessionPending) return; - refreshSyncedPreferencesFromServer().catch((error) => { + const controller = new AbortController(); + refreshSyncedPreferencesFromServer(controller.signal).catch((error) => { + if ((error as Error)?.name === 'AbortError') return; console.warn('Synced preferences refresh failed:', error); }); + return () => controller.abort(); }, [isDBReady, authEnabled, isSessionPending, sessionKey, refreshSyncedPreferencesFromServer]); useEffect(() => { if (!isDBReady || !authEnabled) return; + let activeController: AbortController | null = null; const onFocus = () => { - refreshSyncedPreferencesFromServer().catch((error) => { + if (activeController) activeController.abort(); + activeController = new AbortController(); + refreshSyncedPreferencesFromServer(activeController.signal).catch((error) => { + if ((error as Error)?.name === 'AbortError') return; console.warn('Focus synced preferences refresh failed:', error); }); }; window.addEventListener('focus', onFocus); return () => { window.removeEventListener('focus', onFocus); + if (activeController) activeController.abort(); }; }, [isDBReady, authEnabled, refreshSyncedPreferencesFromServer]); @@ -206,9 +222,11 @@ export function ConfigProvider({ children }: { children: ReactNode }) { if (didAttemptInitialPreferenceSeedForSession.current === sessionKey) return; didAttemptInitialPreferenceSeedForSession.current = sessionKey; + const controller = new AbortController(); + const run = async () => { try { - const remote = await getUserPreferences(); + const remote = await getUserPreferences({ signal: controller.signal }); if (remote?.hasStoredPreferences) return; // Seed only user-customized (non-default) values. This prevents fresh/default @@ -216,15 +234,19 @@ export function ConfigProvider({ children }: { children: ReactNode }) { const patch = buildSyncedPreferencePatch(appConfig, { nonDefaultOnly: true }); if (Object.keys(patch).length === 0) return; - await putUserPreferences(patch, { clientUpdatedAtMs: Date.now() }); + await putUserPreferences(patch, { clientUpdatedAtMs: Date.now(), signal: controller.signal }); } catch (error) { + if ((error as Error)?.name === 'AbortError') return; console.warn('Failed to seed initial synced preferences from local Dexie:', error); } }; run().catch((error) => { + if ((error as Error)?.name === 'AbortError') return; console.warn('Initial synced preferences seed failed:', error); }); + + return () => controller.abort(); }, [isDBReady, authEnabled, appConfig, buildSyncedPreferencePatch, isSessionPending, sessionKey]); // Destructure for convenience and to match context shape diff --git a/src/contexts/DocumentContext.tsx b/src/contexts/DocumentContext.tsx index e4e178e..6290578 100644 --- a/src/contexts/DocumentContext.tsx +++ b/src/contexts/DocumentContext.tsx @@ -1,7 +1,7 @@ 'use client'; import { createContext, useCallback, useContext, useEffect, useMemo, useState, ReactNode } from 'react'; -import type { BaseDocument, DocumentType } from '@/types/documents'; +import type { BaseDocument } from '@/types/documents'; import { listDocuments, uploadDocuments, deleteDocuments } from '@/lib/client-documents'; import { putCachedEpub, putCachedHtml, putCachedPdf, evictCachedEpub, evictCachedHtml, evictCachedPdf } from '@/lib/document-cache'; import { useAuthSession } from '@/hooks/useAuthSession'; @@ -27,9 +27,7 @@ interface DocumentContextType { refreshDocuments: () => Promise; - clearPDFs: () => Promise; - clearEPUBs: () => Promise; - clearHTML: () => Promise; + } const DocumentContext = createContext(undefined); @@ -118,19 +116,7 @@ export function DocumentProvider({ children }: { children: ReactNode }) { const removeEPUBDocument = useCallback(async (id: string) => removeById(id), [removeById]); const removeHTMLDocument = useCallback(async (id: string) => removeById(id), [removeById]); - const clearByType = useCallback(async (type: DocumentType) => { - const toDelete = (docs ?? []).filter((d) => d.type === type).map((d) => d.id); - if (toDelete.length) { - await deleteDocuments({ ids: toDelete }); - } - // Evict cache and update local list - await Promise.allSettled(toDelete.map((id) => Promise.all([evictCachedPdf(id), evictCachedEpub(id), evictCachedHtml(id)]))); - setDocs((prev) => (prev ?? []).filter((d) => d.type !== type)); - }, [docs]); - - const clearPDFs = useCallback(async () => clearByType('pdf'), [clearByType]); - const clearEPUBs = useCallback(async () => clearByType('epub'), [clearByType]); - const clearHTML = useCallback(async () => clearByType('html'), [clearByType]); + // Removed unused clear functions return ( {children} diff --git a/src/contexts/PDFContext.tsx b/src/contexts/PDFContext.tsx index 8221216..bdcabf9 100644 --- a/src/contexts/PDFContext.tsx +++ b/src/contexts/PDFContext.tsx @@ -156,6 +156,10 @@ export function PDFProvider({ children }: { children: ReactNode }) { const loadSeqRef = useRef(0); const emptyRetryRef = useRef<{ page: number; attempt: number; timer: ReturnType | null } | null>(null); + // Guards for setCurrentDocument to prevent stale loads from overwriting newer selections. + const docLoadSeqRef = useRef(0); + const docLoadAbortRef = useRef(null); + useEffect(() => { pdfDocumentRef.current = pdfDocument; }, [pdfDocument]); @@ -317,6 +321,12 @@ export function PDFProvider({ children }: { children: ReactNode }) { * @returns {Promise} */ const setCurrentDocument = useCallback(async (id: string): Promise => { + // --- race-condition guard --- + const seq = ++docLoadSeqRef.current; + docLoadAbortRef.current?.abort(); + const controller = new AbortController(); + docLoadAbortRef.current = controller; + try { // Reset any state tied to the previously loaded PDF. This prevents calling // `getPage()` on a stale/destroyed PDFDocumentProxy after login redirects @@ -335,13 +345,15 @@ export function PDFProvider({ children }: { children: ReactNode }) { setCurrDocName(undefined); setCurrDocData(undefined); - const meta = await getDocumentMetadata(id); + const meta = await getDocumentMetadata(id, { signal: controller.signal }); + if (seq !== docLoadSeqRef.current) return; // stale if (!meta) { console.error('Document not found on server'); return; } - const doc = await ensureCachedDocument(meta); + const doc = await ensureCachedDocument(meta, { signal: controller.signal }); + if (seq !== docLoadSeqRef.current) return; // stale if (doc.type !== 'pdf') { console.error('Document is not a PDF'); return; @@ -352,7 +364,13 @@ export function PDFProvider({ children }: { children: ReactNode }) { // buffer passed into the worker; we always pass clones to react-pdf. setCurrDocData(doc.data.slice(0)); } catch (error) { + if (error instanceof DOMException && error.name === 'AbortError') return; console.error('Failed to get document:', error); + } finally { + // Clean up the controller only if it's still ours (a newer call hasn't replaced it). + if (docLoadAbortRef.current === controller) { + docLoadAbortRef.current = null; + } } }, [setCurrDocId, setCurrDocName, setCurrDocData, setCurrDocPages, setCurrDocText, setPdfDocument]); @@ -364,6 +382,10 @@ export function PDFProvider({ children }: { children: ReactNode }) { pdfDocGenerationRef.current += 1; pdfDocumentRef.current = undefined; loadSeqRef.current += 1; + // Invalidate any in-flight setCurrentDocument load. + docLoadSeqRef.current += 1; + docLoadAbortRef.current?.abort(); + docLoadAbortRef.current = null; if (emptyRetryRef.current?.timer) { clearTimeout(emptyRetryRef.current.timer); } diff --git a/src/db/index.ts b/src/db/index.ts index 6028f95..599b9f2 100644 --- a/src/db/index.ts +++ b/src/db/index.ts @@ -33,18 +33,24 @@ export function ensureSystemUserExists(userId: string) { const drizzleDb = getDrizzleDB(); try { if (dbIsPostgres) { + // Fire-and-forget: Postgres drizzle returns a Promise. We intentionally + // don't await (to keep this helper synchronous for SQLite compat), but we + // only mark the user as seeded once the insert actually resolves. drizzleDb.execute(sql` INSERT INTO "user" (id, name, email, email_verified, created_at, updated_at, is_anonymous) VALUES (${userId}, 'System User', ${userId + '@local'}, false, now(), now(), false) ON CONFLICT (id) DO NOTHING - `).catch(() => { /* table may not exist yet */ }); + `).then(() => { + seededUserIds.add(userId); + }).catch(() => { /* table may not exist yet */ }); } else { + // better-sqlite3 driver is fully synchronous — no Promise returned. drizzleDb.run(sql` INSERT OR IGNORE INTO user (id, name, email, email_verified, created_at, updated_at, is_anonymous) VALUES (${userId}, 'System User', ${userId + '@local'}, 0, ${Date.now()}, ${Date.now()}, 0) `); + seededUserIds.add(userId); } - seededUserIds.add(userId); } catch { // Silently ignore – the user table may not exist yet on first boot before migrations run. } diff --git a/src/lib/client-user-state.ts b/src/lib/client-user-state.ts index 5bc8823..bf654a4 100644 --- a/src/lib/client-user-state.ts +++ b/src/lib/client-user-state.ts @@ -56,30 +56,69 @@ export async function putUserPreferences( type PendingPreferenceSync = { patch: SyncedPreferencesPatch; timer: ReturnType | null; + sessionId: string | null; }; const pendingPreferenceSync: PendingPreferenceSync = { patch: {}, timer: null, + sessionId: null, }; -export function scheduleUserPreferencesSync(patch: SyncedPreferencesPatch, debounceMs: number = 600): void { +let activeSyncController: AbortController | null = null; + +/** + * Cancel any pending debounced preference sync and abort in-flight requests. + * Call this on session change / sign-out to prevent cross-account writes. + */ +export function cancelPendingPreferenceSync(): void { + if (pendingPreferenceSync.timer) { + clearTimeout(pendingPreferenceSync.timer); + pendingPreferenceSync.timer = null; + } + pendingPreferenceSync.patch = {}; + pendingPreferenceSync.sessionId = null; + + if (activeSyncController) { + activeSyncController.abort(); + activeSyncController = null; + } +} + +export function scheduleUserPreferencesSync( + patch: SyncedPreferencesPatch, + sessionId: string, + debounceMs: number = 600, +): void { Object.assign(pendingPreferenceSync.patch, sanitizePreferencesPatch(patch)); + pendingPreferenceSync.sessionId = sessionId; if (pendingPreferenceSync.timer) { clearTimeout(pendingPreferenceSync.timer); } + const capturedSessionId = sessionId; + pendingPreferenceSync.timer = setTimeout(async () => { + // If the session changed between scheduling and firing, discard. + if (pendingPreferenceSync.sessionId !== capturedSessionId) return; + const payload = { ...pendingPreferenceSync.patch }; pendingPreferenceSync.patch = {}; pendingPreferenceSync.timer = null; if (Object.keys(payload).length === 0) return; + // Abort any previous in-flight sync and create a fresh controller. + if (activeSyncController) activeSyncController.abort(); + activeSyncController = new AbortController(); + try { - await putUserPreferences(payload); + await putUserPreferences(payload, { signal: activeSyncController.signal }); } catch (error) { + if ((error as Error)?.name === 'AbortError') return; console.warn('Failed to sync user preferences:', error); + } finally { + activeSyncController = null; } }, debounceMs); }