diff --git a/drizzle/postgres/0000_fearless_vargas.sql b/drizzle/postgres/0000_black_lucky_pierre.sql similarity index 91% rename from drizzle/postgres/0000_fearless_vargas.sql rename to drizzle/postgres/0000_black_lucky_pierre.sql index a4e7837..eaee5c5 100644 --- a/drizzle/postgres/0000_fearless_vargas.sql +++ b/drizzle/postgres/0000_black_lucky_pierre.sql @@ -18,7 +18,7 @@ CREATE TABLE "audiobooks" ( "description" text, "cover_path" text, "duration" real DEFAULT 0, - "created_at" timestamp DEFAULT now(), + "created_at" bigint DEFAULT (extract(epoch from now()) * 1000)::bigint, CONSTRAINT "audiobooks_id_user_id_pk" PRIMARY KEY("id","user_id") ); --> statement-breakpoint @@ -51,7 +51,7 @@ CREATE TABLE "documents" ( "size" bigint NOT NULL, "last_modified" bigint NOT NULL, "file_path" text NOT NULL, - "created_at" timestamp DEFAULT now(), + "created_at" bigint DEFAULT (extract(epoch from now()) * 1000)::bigint, CONSTRAINT "documents_id_user_id_pk" PRIMARY KEY("id","user_id") ); --> statement-breakpoint @@ -62,8 +62,8 @@ CREATE TABLE "user_document_progress" ( "location" text NOT NULL, "progress" real, "client_updated_at_ms" bigint DEFAULT 0 NOT NULL, - "created_at" timestamp DEFAULT now(), - "updated_at" timestamp DEFAULT now(), + "created_at" bigint DEFAULT (extract(epoch from now()) * 1000)::bigint, + "updated_at" bigint DEFAULT (extract(epoch from now()) * 1000)::bigint, CONSTRAINT "user_document_progress_user_id_document_id_pk" PRIMARY KEY("user_id","document_id") ); --> statement-breakpoint @@ -71,16 +71,16 @@ CREATE TABLE "user_preferences" ( "user_id" text PRIMARY KEY NOT NULL, "data_json" jsonb DEFAULT '{}'::jsonb NOT NULL, "client_updated_at_ms" bigint DEFAULT 0 NOT NULL, - "created_at" timestamp DEFAULT now(), - "updated_at" timestamp DEFAULT now() + "created_at" bigint DEFAULT (extract(epoch from now()) * 1000)::bigint, + "updated_at" bigint DEFAULT (extract(epoch from now()) * 1000)::bigint ); --> 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(), + "created_at" bigint DEFAULT (extract(epoch from now()) * 1000)::bigint, + "updated_at" bigint DEFAULT (extract(epoch from now()) * 1000)::bigint, CONSTRAINT "user_tts_chars_user_id_date_pk" PRIMARY KEY("user_id","date") ); --> statement-breakpoint diff --git a/drizzle/postgres/meta/0000_snapshot.json b/drizzle/postgres/meta/0000_snapshot.json index 5a0f4c1..efff69d 100644 --- a/drizzle/postgres/meta/0000_snapshot.json +++ b/drizzle/postgres/meta/0000_snapshot.json @@ -1,5 +1,5 @@ { - "id": "ed6cbfef-d016-4265-999b-239f10f9e4e1", + "id": "a6243c0b-4032-4549-aa08-1f7602362f15", "prevId": "00000000-0000-0000-0000-000000000000", "version": "7", "dialect": "postgresql", @@ -152,10 +152,10 @@ }, "created_at": { "name": "created_at", - "type": "timestamp", + "type": "bigint", "primaryKey": false, "notNull": false, - "default": "now()" + "default": "(extract(epoch from now()) * 1000)::bigint" } }, "indexes": {}, @@ -391,10 +391,10 @@ }, "created_at": { "name": "created_at", - "type": "timestamp", + "type": "bigint", "primaryKey": false, "notNull": false, - "default": "now()" + "default": "(extract(epoch from now()) * 1000)::bigint" } }, "indexes": { @@ -507,17 +507,17 @@ }, "created_at": { "name": "created_at", - "type": "timestamp", + "type": "bigint", "primaryKey": false, "notNull": false, - "default": "now()" + "default": "(extract(epoch from now()) * 1000)::bigint" }, "updated_at": { "name": "updated_at", - "type": "timestamp", + "type": "bigint", "primaryKey": false, "notNull": false, - "default": "now()" + "default": "(extract(epoch from now()) * 1000)::bigint" } }, "indexes": { @@ -598,17 +598,17 @@ }, "created_at": { "name": "created_at", - "type": "timestamp", + "type": "bigint", "primaryKey": false, "notNull": false, - "default": "now()" + "default": "(extract(epoch from now()) * 1000)::bigint" }, "updated_at": { "name": "updated_at", - "type": "timestamp", + "type": "bigint", "primaryKey": false, "notNull": false, - "default": "now()" + "default": "(extract(epoch from now()) * 1000)::bigint" } }, "indexes": {}, @@ -658,17 +658,17 @@ }, "created_at": { "name": "created_at", - "type": "timestamp", + "type": "bigint", "primaryKey": false, "notNull": false, - "default": "now()" + "default": "(extract(epoch from now()) * 1000)::bigint" }, "updated_at": { "name": "updated_at", - "type": "timestamp", + "type": "bigint", "primaryKey": false, "notNull": false, - "default": "now()" + "default": "(extract(epoch from now()) * 1000)::bigint" } }, "indexes": { diff --git a/drizzle/postgres/meta/_journal.json b/drizzle/postgres/meta/_journal.json index e32ad39..f20e818 100644 --- a/drizzle/postgres/meta/_journal.json +++ b/drizzle/postgres/meta/_journal.json @@ -5,8 +5,8 @@ { "idx": 0, "version": "7", - "when": 1771284020206, - "tag": "0000_fearless_vargas", + "when": 1771386480954, + "tag": "0000_black_lucky_pierre", "breakpoints": true } ] diff --git a/drizzle/sqlite/0000_lucky_inertia.sql b/drizzle/sqlite/0000_familiar_caretaker.sql similarity index 89% rename from drizzle/sqlite/0000_lucky_inertia.sql rename to drizzle/sqlite/0000_familiar_caretaker.sql index 5bf7e20..29594fe 100644 --- a/drizzle/sqlite/0000_lucky_inertia.sql +++ b/drizzle/sqlite/0000_familiar_caretaker.sql @@ -20,7 +20,7 @@ CREATE TABLE `audiobooks` ( `description` text, `cover_path` text, `duration` real DEFAULT 0, - `created_at` integer DEFAULT (cast(strftime('%s','now') as int) * 1000), + `created_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)), PRIMARY KEY(`id`, `user_id`), FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade ); @@ -55,7 +55,7 @@ CREATE TABLE `documents` ( `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), + `created_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)), PRIMARY KEY(`id`, `user_id`), FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade ); @@ -69,8 +69,8 @@ CREATE TABLE `user_document_progress` ( `location` text NOT NULL, `progress` real, `client_updated_at_ms` integer DEFAULT 0 NOT NULL, - `created_at` integer DEFAULT (cast(strftime('%s','now') as int) * 1000), - `updated_at` integer DEFAULT (cast(strftime('%s','now') as int) * 1000), + `created_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)), + `updated_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)), PRIMARY KEY(`user_id`, `document_id`), FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade ); @@ -80,8 +80,8 @@ CREATE TABLE `user_preferences` ( `user_id` text PRIMARY KEY NOT NULL, `data_json` text DEFAULT '{}' NOT NULL, `client_updated_at_ms` integer DEFAULT 0 NOT NULL, - `created_at` integer DEFAULT (cast(strftime('%s','now') as int) * 1000), - `updated_at` integer DEFAULT (cast(strftime('%s','now') as int) * 1000), + `created_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)), + `updated_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)), FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade ); --> statement-breakpoint @@ -89,8 +89,8 @@ 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), + `created_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)), + `updated_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)), PRIMARY KEY(`user_id`, `date`) ); --> statement-breakpoint diff --git a/drizzle/sqlite/meta/0000_snapshot.json b/drizzle/sqlite/meta/0000_snapshot.json index 6162db8..213ff4e 100644 --- a/drizzle/sqlite/meta/0000_snapshot.json +++ b/drizzle/sqlite/meta/0000_snapshot.json @@ -1,7 +1,7 @@ { "version": "6", "dialect": "sqlite", - "id": "2406985e-1b41-49e8-9add-304814080095", + "id": "23271fb3-935b-4a9b-a890-e73fc9943bef", "prevId": "00000000-0000-0000-0000-000000000000", "tables": { "audiobook_chapters": { @@ -167,7 +167,7 @@ "primaryKey": false, "notNull": false, "autoincrement": false, - "default": "(cast(strftime('%s','now') as int) * 1000)" + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" } }, "indexes": {}, @@ -412,7 +412,7 @@ "primaryKey": false, "notNull": false, "autoincrement": false, - "default": "(cast(strftime('%s','now') as int) * 1000)" + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" } }, "indexes": { @@ -511,7 +511,7 @@ "primaryKey": false, "notNull": false, "autoincrement": false, - "default": "(cast(strftime('%s','now') as int) * 1000)" + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" }, "updated_at": { "name": "updated_at", @@ -519,7 +519,7 @@ "primaryKey": false, "notNull": false, "autoincrement": false, - "default": "(cast(strftime('%s','now') as int) * 1000)" + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" } }, "indexes": { @@ -591,7 +591,7 @@ "primaryKey": false, "notNull": false, "autoincrement": false, - "default": "(cast(strftime('%s','now') as int) * 1000)" + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" }, "updated_at": { "name": "updated_at", @@ -599,7 +599,7 @@ "primaryKey": false, "notNull": false, "autoincrement": false, - "default": "(cast(strftime('%s','now') as int) * 1000)" + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" } }, "indexes": {}, @@ -653,7 +653,7 @@ "primaryKey": false, "notNull": false, "autoincrement": false, - "default": "(cast(strftime('%s','now') as int) * 1000)" + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" }, "updated_at": { "name": "updated_at", @@ -661,7 +661,7 @@ "primaryKey": false, "notNull": false, "autoincrement": false, - "default": "(cast(strftime('%s','now') as int) * 1000)" + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" } }, "indexes": { diff --git a/drizzle/sqlite/meta/_journal.json b/drizzle/sqlite/meta/_journal.json index 0aa3b15..3536d28 100644 --- a/drizzle/sqlite/meta/_journal.json +++ b/drizzle/sqlite/meta/_journal.json @@ -5,8 +5,8 @@ { "idx": 0, "version": "6", - "when": 1771284019941, - "tag": "0000_lucky_inertia", + "when": 1771386480690, + "tag": "0000_familiar_caretaker", "breakpoints": true } ] diff --git a/src/app/api/documents/library/route.ts b/src/app/api/documents/library/route.ts index d152a94..7d428bc 100644 --- a/src/app/api/documents/library/route.ts +++ b/src/app/api/documents/library/route.ts @@ -93,7 +93,7 @@ async function scanLibraryRoot(root: string, rootIndex: number, limit: number): id, name: relativePath, size: fileStat.size, - lastModified: fileStat.mtimeMs, + lastModified: Math.floor(fileStat.mtimeMs), type: libraryDocumentTypeFromName(relativePath), }); } diff --git a/src/app/api/rate-limit/status/route.ts b/src/app/api/rate-limit/status/route.ts index 6e78adb..aaaf548 100644 --- a/src/app/api/rate-limit/status/route.ts +++ b/src/app/api/rate-limit/status/route.ts @@ -5,15 +5,12 @@ import { headers } from 'next/headers'; import { isAuthEnabled } from '@/lib/server/auth/config'; import { getClientIp } from '@/lib/server/rate-limit/request-ip'; import { getOrCreateDeviceId, setDeviceIdCookie } from '@/lib/server/rate-limit/device-id'; +import { nextUtcMidnightTimestampMs } from '@/lib/shared/timestamps'; export const dynamic = 'force-dynamic'; -function getUtcResetTimeIso(): string { - const now = new Date(); - const tomorrow = new Date(now); - tomorrow.setUTCDate(now.getUTCDate() + 1); - tomorrow.setUTCHours(0, 0, 0, 0); - return tomorrow.toISOString(); +function getUtcResetTimeMs(): number { + return nextUtcMidnightTimestampMs(); } export async function GET(req: NextRequest) { @@ -22,7 +19,7 @@ export async function GET(req: NextRequest) { // If auth is not enabled, return unlimited status if (!isAuthEnabled() || !auth) { - const resetTime = getUtcResetTimeIso(); + const resetTimeMs = getUtcResetTimeMs(); return NextResponse.json({ allowed: true, currentCount: 0, @@ -30,7 +27,7 @@ export async function GET(req: NextRequest) { // because authEnabled=false, but we keep it finite to prevent surprises. limit: Number.MAX_SAFE_INTEGER, remainingChars: Number.MAX_SAFE_INTEGER, - resetTime, + resetTimeMs, userType: 'unauthenticated', authEnabled: false }); @@ -43,13 +40,13 @@ export async function GET(req: NextRequest) { // No session means unauthenticated if (!session?.user) { - const resetTime = getUtcResetTimeIso(); + const resetTimeMs = getUtcResetTimeMs(); return NextResponse.json({ allowed: true, currentCount: 0, limit: ttsRateLimitEnabled ? RATE_LIMITS.ANONYMOUS : Number.MAX_SAFE_INTEGER, remainingChars: ttsRateLimitEnabled ? RATE_LIMITS.ANONYMOUS : Number.MAX_SAFE_INTEGER, - resetTime, + resetTimeMs, userType: 'unauthenticated', authEnabled: true }); @@ -76,7 +73,7 @@ export async function GET(req: NextRequest) { currentCount: result.currentCount, limit: result.limit, remainingChars: result.remainingChars, - resetTime: result.resetTime.toISOString(), + resetTimeMs: result.resetTimeMs, userType: isAnonymous ? 'anonymous' : 'authenticated', authEnabled: true }); diff --git a/src/app/api/tts/route.ts b/src/app/api/tts/route.ts index 81e2659..156c788 100644 --- a/src/app/api/tts/route.ts +++ b/src/app/api/tts/route.ts @@ -185,10 +185,10 @@ export async function POST(req: NextRequest) { ); if (!rateLimitResult.allowed) { - const resetTime = rateLimitResult.resetTime.toISOString(); + const resetTimeMs = rateLimitResult.resetTimeMs; const retryAfterSeconds = Math.max( 0, - Math.ceil((rateLimitResult.resetTime.getTime() - Date.now()) / 1000) + Math.ceil((resetTimeMs - Date.now()) / 1000) ); const problem: ProblemDetails = { @@ -200,7 +200,7 @@ export async function POST(req: NextRequest) { currentCount: rateLimitResult.currentCount, limit: rateLimitResult.limit, remainingChars: rateLimitResult.remainingChars, - resetTime, + resetTimeMs, userType: isAnonymous ? 'anonymous' : 'authenticated', upgradeHint: isAnonymous ? `Sign up to increase your limit from ${formatLimitForHint(RATE_LIMITS.ANONYMOUS)} to ${formatLimitForHint(RATE_LIMITS.AUTHENTICATED)} characters per day` diff --git a/src/app/api/user/export/route.ts b/src/app/api/user/export/route.ts index ad633b1..d68dd50 100644 --- a/src/app/api/user/export/route.ts +++ b/src/app/api/user/export/route.ts @@ -10,6 +10,7 @@ import { getDocumentBlobStream } from '@/lib/server/documents/blobstore'; import { getAudiobookObjectStream, listAudiobookObjects } from '@/lib/server/audiobooks/blobstore'; import { isS3Configured } from '@/lib/server/storage/s3'; import { getOpenReaderTestNamespace } from '@/lib/server/testing/test-namespace'; +import { nowTimestampMs } from '@/lib/shared/timestamps'; export const dynamic = 'force-dynamic'; @@ -100,17 +101,17 @@ export async function GET(req: NextRequest) { (async () => { try { - const exportedAtIso = new Date().toISOString(); + const exportedAtMs = nowTimestampMs(); const profileData = { user: session.user, session: session.session, - exportedAt: exportedAtIso, + exportedAtMs, }; await appendUserExportArchive({ archive, userId, - exportedAtIso, + exportedAtMs, profileData, preferences: prefs[0] ?? null, readingHistory: progress, diff --git a/src/app/api/user/state/preferences/route.ts b/src/app/api/user/state/preferences/route.ts index bc9bfb4..6841e82 100644 --- a/src/app/api/user/state/preferences/route.ts +++ b/src/app/api/user/state/preferences/route.ts @@ -4,13 +4,10 @@ import { db } from '@/db'; import { userPreferences } from '@/db/schema'; import { SYNCED_PREFERENCE_KEYS, type SyncedPreferencesPatch } from '@/types/user-state'; import { resolveUserStateScope } from '@/lib/server/user/resolve-state-scope'; +import { coerceTimestampMs, nowTimestampMs } from '@/lib/shared/timestamps'; export const dynamic = 'force-dynamic'; -function nowForDb(): Date | number { - return process.env.POSTGRES_URL ? new Date() : Date.now(); -} - function serializePreferencesForDb(patch: SyncedPreferencesPatch): SyncedPreferencesPatch | string { if (process.env.POSTGRES_URL) return patch; return JSON.stringify(patch); @@ -92,10 +89,9 @@ function sanitizePreferencesPatch(input: unknown): SyncedPreferencesPatch { } function normalizeClientUpdatedAtMs(value: unknown): number { - if (!Number.isFinite(value)) return Date.now(); - const normalized = Number(value); - if (normalized <= 0) return Date.now(); - return Math.floor(normalized); + const normalized = coerceTimestampMs(value, nowTimestampMs()); + if (normalized <= 0) return nowTimestampMs(); + return normalized; } export async function GET(req: NextRequest) { @@ -164,7 +160,7 @@ export async function PUT(req: NextRequest) { const mergedPatch = { ...existingPatch, ...patch }; const dataJson = serializePreferencesForDb(mergedPatch); - const updatedAt = nowForDb(); + const updatedAt = nowTimestampMs(); await db .insert(userPreferences) diff --git a/src/app/api/user/state/progress/route.ts b/src/app/api/user/state/progress/route.ts index 69a75b3..f34953e 100644 --- a/src/app/api/user/state/progress/route.ts +++ b/src/app/api/user/state/progress/route.ts @@ -5,29 +5,19 @@ import { userDocumentProgress } from '@/db/schema'; import type { ReaderType } from '@/types/user-state'; import { isValidDocumentId } from '@/lib/server/documents/blobstore'; import { resolveUserStateScope } from '@/lib/server/user/resolve-state-scope'; +import { coerceTimestampMs, nowTimestampMs } from '@/lib/shared/timestamps'; export const dynamic = 'force-dynamic'; -function nowForDb(): Date | number { - return process.env.POSTGRES_URL ? new Date() : Date.now(); -} - function normalizeReaderType(value: unknown): ReaderType | null { if (value === 'pdf' || value === 'epub' || value === 'html') return value; return null; } function normalizeClientUpdatedAtMs(value: unknown): number { - if (!Number.isFinite(value)) return Date.now(); - const normalized = Number(value); - if (normalized <= 0) return Date.now(); - return Math.floor(normalized); -} - -function toUpdatedAtMs(value: unknown): number { - if (typeof value === 'number' && Number.isFinite(value)) return value; - if (value instanceof Date) return value.getTime(); - return Date.now(); + const normalized = coerceTimestampMs(value, nowTimestampMs()); + if (normalized <= 0) return nowTimestampMs(); + return normalized; } export async function GET(req: NextRequest) { @@ -68,7 +58,7 @@ export async function GET(req: NextRequest) { location: row.location, progress: row.progress == null ? null : Number(row.progress), clientUpdatedAtMs: Number(row.clientUpdatedAtMs ?? 0), - updatedAtMs: toUpdatedAtMs(row.updatedAt), + updatedAtMs: coerceTimestampMs(row.updatedAt, nowTimestampMs()), }, }); } catch (error) { @@ -140,13 +130,13 @@ export async function PUT(req: NextRequest) { location: existing.location, progress: existing.progress == null ? null : Number(existing.progress), clientUpdatedAtMs: existingUpdated, - updatedAtMs: toUpdatedAtMs(existing.updatedAt), + updatedAtMs: coerceTimestampMs(existing.updatedAt, nowTimestampMs()), }, applied: false, }); } - const updatedAt = nowForDb(); + const updatedAt = nowTimestampMs(); await db .insert(userDocumentProgress) .values({ @@ -177,7 +167,7 @@ export async function PUT(req: NextRequest) { location, progress, clientUpdatedAtMs, - updatedAtMs: toUpdatedAtMs(updatedAt), + updatedAtMs: updatedAt, }, applied: true, }); @@ -186,4 +176,3 @@ export async function PUT(req: NextRequest) { return NextResponse.json({ error: 'Failed to update user progress' }, { status: 500 }); } } - diff --git a/src/contexts/AuthRateLimitContext.tsx b/src/contexts/AuthRateLimitContext.tsx index 211f40c..75f8fa8 100644 --- a/src/contexts/AuthRateLimitContext.tsx +++ b/src/contexts/AuthRateLimitContext.tsx @@ -1,13 +1,14 @@ 'use client'; import React, { createContext, useContext, useState, useEffect, useCallback, useRef, ReactNode } from 'react'; +import { coerceTimestampMs, nextUtcMidnightTimestampMs, nowTimestampMs } from '@/lib/shared/timestamps'; export interface RateLimitStatus { allowed: boolean; currentCount: number; limit: number; remainingChars: number; - resetTime: Date; + resetTimeMs: number; userType: 'anonymous' | 'authenticated' | 'unauthenticated'; authEnabled: boolean; } @@ -52,9 +53,8 @@ export function useRateLimit() { return useAuthRateLimit(); } -function calculateTimeUntilReset(resetTime: Date): string { - const now = new Date(); - const timeDiff = resetTime.getTime() - now.getTime(); +function calculateTimeUntilReset(resetTimeMs: number): string { + const timeDiff = resetTimeMs - nowTimestampMs(); if (timeDiff <= 0) { return 'Soon'; @@ -70,6 +70,27 @@ function calculateTimeUntilReset(resetTime: Date): string { } } +function parseRateLimitStatus(raw: unknown): RateLimitStatus | null { + if (!raw || typeof raw !== 'object') return null; + const data = raw as Record; + + const userType = (() => { + const value = data.userType; + if (value === 'anonymous' || value === 'authenticated' || value === 'unauthenticated') return value; + return 'unauthenticated'; + })(); + + return { + allowed: Boolean(data.allowed), + currentCount: Number(data.currentCount ?? 0), + limit: Number(data.limit ?? 0), + remainingChars: Number(data.remainingChars ?? 0), + resetTimeMs: coerceTimestampMs(data.resetTimeMs ?? data.resetTime, nextUtcMidnightTimestampMs()), + userType, + authEnabled: Boolean(data.authEnabled), + }; +} + export function formatCharCount(count: number): string { if (count >= 1_000_000) { const m = count / 1_000_000; @@ -110,18 +131,13 @@ export function AuthRateLimitProvider({ const fetchStatus = useCallback(async () => { // Skip if auth is not enabled if (!authEnabled) { - const now = new Date(); - const tomorrow = new Date(now); - tomorrow.setUTCDate(now.getUTCDate() + 1); - tomorrow.setUTCHours(0, 0, 0, 0); - setStatus({ allowed: true, currentCount: 0, // Avoid Infinity to prevent JSON/serialization edge cases elsewhere. limit: Number.MAX_SAFE_INTEGER, remainingChars: Number.MAX_SAFE_INTEGER, - resetTime: tomorrow, + resetTimeMs: nextUtcMidnightTimestampMs(), userType: 'unauthenticated', authEnabled: false }); @@ -140,11 +156,7 @@ export function AuthRateLimitProvider({ } const data = await response.json(); - - setStatus({ - ...data, - resetTime: new Date(data.resetTime) - }); + setStatus(parseRateLimitStatus(data)); } catch (err) { console.error('Error fetching rate limit status:', err); setError(err instanceof Error ? err.message : 'Unknown error'); @@ -158,7 +170,7 @@ export function AuthRateLimitProvider({ }, [fetchStatus]); // Calculate time until reset - const timeUntilReset = status ? calculateTimeUntilReset(status.resetTime) : ''; + const timeUntilReset = status ? calculateTimeUntilReset(status.resetTimeMs) : ''; // Only treat the user as "at limit" when they are truly out of characters. // The server allows the final request that may cross the limit, then blocks subsequent ones. const isAtLimit = status ? (status.remainingChars <= 0 || !status.allowed) : false; diff --git a/src/db/schema_postgres.ts b/src/db/schema_postgres.ts index 864845d..b91792a 100644 --- a/src/db/schema_postgres.ts +++ b/src/db/schema_postgres.ts @@ -1,6 +1,9 @@ -import { pgTable, text, integer, real, timestamp, date, bigint, primaryKey, index, jsonb, foreignKey } from 'drizzle-orm/pg-core'; +import { sql } from 'drizzle-orm'; +import { pgTable, text, integer, real, date, bigint, primaryKey, index, jsonb, foreignKey } from 'drizzle-orm/pg-core'; import { user } from './schema_auth_postgres'; +const PG_NOW_MS = sql`(extract(epoch from now()) * 1000)::bigint`; + export const documents = pgTable('documents', { id: text('id').notNull(), userId: text('user_id').notNull().references(() => user.id, { onDelete: 'cascade' }), @@ -9,7 +12,7 @@ export const documents = pgTable('documents', { size: bigint('size', { mode: 'number' }).notNull(), lastModified: bigint('last_modified', { mode: 'number' }).notNull(), filePath: text('file_path').notNull(), - createdAt: timestamp('created_at').defaultNow(), + createdAt: bigint('created_at', { mode: 'number' }).default(PG_NOW_MS), }, (table) => [ primaryKey({ columns: [table.id, table.userId] }), index('idx_documents_user_id').on(table.userId), @@ -24,7 +27,7 @@ export const audiobooks = pgTable('audiobooks', { description: text('description'), coverPath: text('cover_path'), duration: real('duration').default(0), - createdAt: timestamp('created_at').defaultNow(), + createdAt: bigint('created_at', { mode: 'number' }).default(PG_NOW_MS), }, (table) => [ primaryKey({ columns: [table.id, table.userId] }), ]); @@ -54,8 +57,8 @@ 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(), + createdAt: bigint('created_at', { mode: 'number' }).default(PG_NOW_MS), + updatedAt: bigint('updated_at', { mode: 'number' }).default(PG_NOW_MS), }, (table) => [ primaryKey({ columns: [table.userId, table.date] }), index('idx_user_tts_chars_date').on(table.date), @@ -65,8 +68,8 @@ export const userPreferences = pgTable('user_preferences', { userId: text('user_id').primaryKey().references(() => user.id, { onDelete: 'cascade' }), dataJson: jsonb('data_json').notNull().default({}), clientUpdatedAtMs: bigint('client_updated_at_ms', { mode: 'number' }).notNull().default(0), - createdAt: timestamp('created_at').defaultNow(), - updatedAt: timestamp('updated_at').defaultNow(), + createdAt: bigint('created_at', { mode: 'number' }).default(PG_NOW_MS), + updatedAt: bigint('updated_at', { mode: 'number' }).default(PG_NOW_MS), }); export const userDocumentProgress = pgTable('user_document_progress', { @@ -76,8 +79,8 @@ export const userDocumentProgress = pgTable('user_document_progress', { location: text('location').notNull(), progress: real('progress'), clientUpdatedAtMs: bigint('client_updated_at_ms', { mode: 'number' }).notNull().default(0), - createdAt: timestamp('created_at').defaultNow(), - updatedAt: timestamp('updated_at').defaultNow(), + createdAt: bigint('created_at', { mode: 'number' }).default(PG_NOW_MS), + updatedAt: bigint('updated_at', { mode: 'number' }).default(PG_NOW_MS), }, (table) => [ primaryKey({ columns: [table.userId, table.documentId] }), index('idx_user_document_progress_user_id_updated_at').on(table.userId, table.updatedAt), diff --git a/src/db/schema_sqlite.ts b/src/db/schema_sqlite.ts index bbac0d9..d133a10 100644 --- a/src/db/schema_sqlite.ts +++ b/src/db/schema_sqlite.ts @@ -2,6 +2,8 @@ import { sqliteTable, text, integer, real, primaryKey, index, foreignKey } from import { sql } from 'drizzle-orm'; import { user } from './schema_auth_sqlite'; +const SQLITE_NOW_MS = sql`(cast(unixepoch('subsecond') * 1000 as integer))`; + export const documents = sqliteTable('documents', { id: text('id').notNull(), userId: text('user_id').notNull().references(() => user.id, { onDelete: 'cascade' }), @@ -10,7 +12,7 @@ export const documents = sqliteTable('documents', { 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)`), + createdAt: integer('created_at').default(SQLITE_NOW_MS), }, (table) => [ primaryKey({ columns: [table.id, table.userId] }), index('idx_documents_user_id').on(table.userId), @@ -25,7 +27,7 @@ export const audiobooks = sqliteTable('audiobooks', { 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)`), + createdAt: integer('created_at').default(SQLITE_NOW_MS), }, (table) => [ primaryKey({ columns: [table.id, table.userId] }), ]); @@ -55,8 +57,8 @@ 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)`), + createdAt: integer('created_at').default(SQLITE_NOW_MS), + updatedAt: integer('updated_at').default(SQLITE_NOW_MS), }, (table) => [ primaryKey({ columns: [table.userId, table.date] }), index('idx_user_tts_chars_date').on(table.date), @@ -66,8 +68,8 @@ export const userPreferences = sqliteTable('user_preferences', { userId: text('user_id').primaryKey().references(() => user.id, { onDelete: 'cascade' }), dataJson: text('data_json').notNull().default('{}'), clientUpdatedAtMs: integer('client_updated_at_ms').notNull().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)`), + createdAt: integer('created_at').default(SQLITE_NOW_MS), + updatedAt: integer('updated_at').default(SQLITE_NOW_MS), }); export const userDocumentProgress = sqliteTable('user_document_progress', { @@ -77,8 +79,8 @@ export const userDocumentProgress = sqliteTable('user_document_progress', { location: text('location').notNull(), progress: real('progress'), clientUpdatedAtMs: integer('client_updated_at_ms').notNull().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)`), + createdAt: integer('created_at').default(SQLITE_NOW_MS), + updatedAt: integer('updated_at').default(SQLITE_NOW_MS), }, (table) => [ primaryKey({ columns: [table.userId, table.documentId] }), index('idx_user_document_progress_user_id_updated_at').on(table.userId, table.updatedAt), diff --git a/src/lib/client/api/audiobooks.ts b/src/lib/client/api/audiobooks.ts index 3fb8079..d65a507 100644 --- a/src/lib/client/api/audiobooks.ts +++ b/src/lib/client/api/audiobooks.ts @@ -57,7 +57,7 @@ export const withRetry = async ( } return undefined; })(); - // Do not retry on user quota exceeded (server tells us when to retry via resetTime/Retry-After) + // Do not retry on user quota exceeded (server tells us when to retry via resetTimeMs/Retry-After) if (status === 429 && code === 'USER_DAILY_QUOTA_EXCEEDED') { break; } diff --git a/src/lib/server/rate-limit/rate-limiter.ts b/src/lib/server/rate-limit/rate-limiter.ts index a58050d..cf96e47 100644 --- a/src/lib/server/rate-limit/rate-limiter.ts +++ b/src/lib/server/rate-limit/rate-limiter.ts @@ -2,6 +2,7 @@ import { db } from '@/db'; import { userTtsChars } from '@/db/schema'; import { isAuthEnabled } from '@/lib/server/auth/config'; import { eq, and, lt, sql } from 'drizzle-orm'; +import { nextUtcMidnightTimestampMs, nowTimestampMs } from '@/lib/shared/timestamps'; function readPositiveIntEnv(name: string, fallback: number): number { const raw = process.env[name]; @@ -52,7 +53,7 @@ export interface RateLimitResult { allowed: boolean; currentCount: number; limit: number; - resetTime: Date; + resetTimeMs: number; remainingChars: number; } @@ -133,8 +134,8 @@ export class RateLimiter { return Boolean(process.env.POSTGRES_URL); } - private getUpdatedAtValue(): Date | number { - return this.isPostgres() ? new Date() : Date.now(); + private getUpdatedAtValue(): number { + return nowTimestampMs(); } // Use a transaction only when running with Postgres. @@ -157,7 +158,7 @@ export class RateLimiter { allowed: true, currentCount: 0, limit: Number.MAX_SAFE_INTEGER, - resetTime: this.getResetTime(), + resetTimeMs: this.getResetTimeMs(), remainingChars: Number.MAX_SAFE_INTEGER }; } @@ -236,7 +237,7 @@ export class RateLimiter { allowed: true, currentCount: effective.currentCount, limit: effective.limit, - resetTime: this.getResetTime(), + resetTimeMs: this.getResetTimeMs(), remainingChars: effective.remainingChars, }; }); @@ -258,7 +259,7 @@ export class RateLimiter { allowed: true, currentCount: 0, limit: Number.MAX_SAFE_INTEGER, - resetTime: this.getResetTime(), + resetTimeMs: this.getResetTimeMs(), remainingChars: Number.MAX_SAFE_INTEGER }; } @@ -300,7 +301,7 @@ export class RateLimiter { allowed: effective.allowed, currentCount: effective.currentCount, limit: effective.limit, - resetTime: this.getResetTime(), + resetTimeMs: this.getResetTimeMs(), remainingChars: effective.remainingChars, }; } @@ -356,12 +357,8 @@ export class RateLimiter { await safeDb().delete(userTtsChars).where(lt(userTtsChars.date, cutoffDateValue)); } - private getResetTime(): Date { - const now = new Date(); - const tomorrow = new Date(now); - tomorrow.setUTCDate(now.getUTCDate() + 1); - tomorrow.setUTCHours(0, 0, 0, 0); // Start of next day in UTC - return tomorrow; + private getResetTimeMs(): number { + return nextUtcMidnightTimestampMs(); } } diff --git a/src/lib/server/user/claim-data.ts b/src/lib/server/user/claim-data.ts index a850c2d..89cc319 100644 --- a/src/lib/server/user/claim-data.ts +++ b/src/lib/server/user/claim-data.ts @@ -20,7 +20,7 @@ type AudiobookRow = { description: string | null; coverPath: string | null; duration: number | null; - createdAt: unknown; + createdAt: number; }; type AudiobookChapterRow = { @@ -38,8 +38,8 @@ type UserPreferenceRow = { userId: string; dataJson: unknown; clientUpdatedAtMs: number; - createdAt: unknown; - updatedAt: unknown; + createdAt: number; + updatedAt: number; }; type UserDocumentProgressRow = { @@ -49,8 +49,8 @@ type UserDocumentProgressRow = { location: string; progress: number | null; clientUpdatedAtMs: number; - createdAt: unknown; - updatedAt: unknown; + createdAt: number; + updatedAt: number; }; function contentTypeForAudiobookObject(fileName: string): string { diff --git a/src/lib/server/user/data-export.ts b/src/lib/server/user/data-export.ts index 4394cf1..f81de15 100644 --- a/src/lib/server/user/data-export.ts +++ b/src/lib/server/user/data-export.ts @@ -44,7 +44,7 @@ type ExportAudiobookObject = { export type AppendUserExportArchiveInput = { archive: Archiver; userId: string; - exportedAtIso: string; + exportedAtMs: number; profileData: unknown; preferences: unknown | null; readingHistory: unknown[]; @@ -119,7 +119,7 @@ export async function appendUserExportArchive(input: AppendUserExportArchiveInpu const { archive, userId, - exportedAtIso, + exportedAtMs, profileData, preferences, readingHistory, @@ -217,7 +217,7 @@ export async function appendUserExportArchive(input: AppendUserExportArchiveInpu const manifest = { formatVersion: 2, - exportedAt: exportedAtIso, + exportedAtMs, userId, scope: 'owned', counts: { diff --git a/src/lib/shared/timestamps.ts b/src/lib/shared/timestamps.ts new file mode 100644 index 0000000..059de50 --- /dev/null +++ b/src/lib/shared/timestamps.ts @@ -0,0 +1,36 @@ +export type TimestampMs = number; + +export function nowTimestampMs(): TimestampMs { + return Date.now(); +} + +export function nextUtcMidnightTimestampMs(fromMs: TimestampMs = nowTimestampMs()): TimestampMs { + const now = new Date(fromMs); + const tomorrow = new Date(now); + tomorrow.setUTCDate(now.getUTCDate() + 1); + tomorrow.setUTCHours(0, 0, 0, 0); + return tomorrow.getTime(); +} + +export function coerceTimestampMs(value: unknown, fallback: TimestampMs = nowTimestampMs()): TimestampMs { + if (typeof value === 'number' && Number.isFinite(value)) { + return Math.floor(value); + } + + if (value instanceof Date) { + return value.getTime(); + } + + if (typeof value === 'string') { + const trimmed = value.trim(); + if (trimmed.length > 0) { + const asNumber = Number(trimmed); + if (Number.isFinite(asNumber)) return Math.floor(asNumber); + + const asDate = Date.parse(trimmed); + if (Number.isFinite(asDate)) return Math.floor(asDate); + } + } + + return fallback; +}