refactor(api): remove all legacy unclaimed user scope logic and enforce strict userId scoping
Eliminate all code, tests, and utilities related to the legacy 'unclaimed' user scope. All API endpoints, document and audiobook storage, and access logic now require a valid authenticated userId and only operate on resources owned by that user. Remove related helper functions, test cases, and conditional flows for anonymous/unclaimed data. Update types, client APIs, and UI logic to reflect that only 'user' scope is supported. This simplifies ownership checks and removes ambiguity around document and audiobook access.
This commit is contained in:
parent
20fa820952
commit
936aa50f9a
26 changed files with 120 additions and 272 deletions
|
|
@ -61,3 +61,4 @@ Admin assignment is reconciled on every session resolution, so removing an email
|
|||
## Claim modal note
|
||||
|
||||
- You may still see old anonymous settings/progress available to claim from older deployments.
|
||||
- Legacy `unclaimed` data is only surfaced through the claim flow; normal authenticated routes are scoped to your current user id.
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { mkdtemp, readFile, rm, writeFile } from 'fs/promises';
|
|||
import { tmpdir } from 'os';
|
||||
import { join } from 'path';
|
||||
import { randomUUID } from 'crypto';
|
||||
import { and, eq, inArray } from 'drizzle-orm';
|
||||
import { and, eq } from 'drizzle-orm';
|
||||
import { db } from '@/db';
|
||||
import { audiobooks, audiobookChapters } from '@/db/schema';
|
||||
import { requireAuthContext } from '@/lib/server/auth/auth';
|
||||
|
|
@ -26,8 +26,7 @@ import {
|
|||
ffprobeAudio,
|
||||
} from '@/lib/server/audiobooks/chapters';
|
||||
import { isS3Configured } from '@/lib/server/storage/s3';
|
||||
import { getOpenReaderTestNamespace, getUnclaimedUserIdForNamespace } from '@/lib/server/testing/test-namespace';
|
||||
import { buildAllowedAudiobookUserIds, pickAudiobookOwner } from '@/lib/server/audiobooks/user-scope';
|
||||
import { getOpenReaderTestNamespace } from '@/lib/server/testing/test-namespace';
|
||||
import { getFFmpegPath } from '@/lib/server/audiobooks/ffmpeg-bin';
|
||||
import { generateTTSBuffer } from '@/lib/server/tts/generate';
|
||||
import { resolveTtsCredentials } from '@/lib/server/admin/resolve-credentials';
|
||||
|
|
@ -285,29 +284,18 @@ export async function POST(request: NextRequest) {
|
|||
|
||||
const ctxOrRes = await requireAuthContext(request);
|
||||
if (ctxOrRes instanceof Response) return ctxOrRes;
|
||||
if (!ctxOrRes.userId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
|
||||
const { userId, user } = ctxOrRes;
|
||||
const { user } = ctxOrRes;
|
||||
const storageUserId = ctxOrRes.userId;
|
||||
const runtimeConfig = await getResolvedRuntimeConfig();
|
||||
const testNamespace = getOpenReaderTestNamespace(request.headers);
|
||||
const unclaimedUserId = getUnclaimedUserIdForNamespace(testNamespace);
|
||||
const { preferredUserId, allowedUserIds } = buildAllowedAudiobookUserIds(userId, unclaimedUserId);
|
||||
const bookId = data.bookId || randomUUID();
|
||||
|
||||
if (!isSafeId(bookId)) {
|
||||
return NextResponse.json({ error: 'Invalid bookId parameter' }, { status: 400 });
|
||||
}
|
||||
|
||||
const existingBookRows = await db
|
||||
.select({ userId: audiobooks.userId })
|
||||
.from(audiobooks)
|
||||
.where(and(eq(audiobooks.id, bookId), inArray(audiobooks.userId, allowedUserIds)));
|
||||
const storageUserId =
|
||||
pickAudiobookOwner(
|
||||
existingBookRows.map((book: { userId: string }) => book.userId),
|
||||
preferredUserId,
|
||||
unclaimedUserId,
|
||||
) ?? preferredUserId;
|
||||
|
||||
await db
|
||||
.insert(audiobooks)
|
||||
.values({
|
||||
|
|
@ -540,7 +528,7 @@ export async function POST(request: NextRequest) {
|
|||
ipAuthenticated: runtimeConfig.ttsIpDailyLimitAuthenticated,
|
||||
});
|
||||
|
||||
if (userId && ttsRateLimitEnabled) {
|
||||
if (ttsRateLimitEnabled) {
|
||||
const isAnonymous = Boolean(user?.isAnonymous);
|
||||
const charCount = data.text.length;
|
||||
const ip = getClientIp(request);
|
||||
|
|
@ -551,7 +539,7 @@ export async function POST(request: NextRequest) {
|
|||
}
|
||||
|
||||
const rateLimitResult = await rateLimiter.checkAndIncrementLimit(
|
||||
{ id: userId, isAnonymous },
|
||||
{ id: storageUserId, isAnonymous },
|
||||
charCount,
|
||||
{
|
||||
deviceId: device?.deviceId ?? null,
|
||||
|
|
@ -808,26 +796,20 @@ export async function GET(request: NextRequest) {
|
|||
|
||||
const ctxOrRes = await requireAuthContext(request);
|
||||
if (ctxOrRes instanceof Response) return ctxOrRes;
|
||||
if (!ctxOrRes.userId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
|
||||
const { userId } = ctxOrRes;
|
||||
const storageUserId = ctxOrRes.userId;
|
||||
const testNamespace = getOpenReaderTestNamespace(request.headers);
|
||||
const unclaimedUserId = getUnclaimedUserIdForNamespace(testNamespace);
|
||||
const { preferredUserId, allowedUserIds } = buildAllowedAudiobookUserIds(userId, unclaimedUserId);
|
||||
const existingBookRows = await db
|
||||
.select({ userId: audiobooks.userId })
|
||||
.from(audiobooks)
|
||||
.where(and(eq(audiobooks.id, bookId), inArray(audiobooks.userId, allowedUserIds)));
|
||||
const existingBookUserId = pickAudiobookOwner(
|
||||
existingBookRows.map((book: { userId: string }) => book.userId),
|
||||
preferredUserId,
|
||||
unclaimedUserId,
|
||||
);
|
||||
.where(and(eq(audiobooks.id, bookId), eq(audiobooks.userId, storageUserId)));
|
||||
|
||||
if (!existingBookUserId) {
|
||||
if (existingBookRows.length === 0) {
|
||||
return NextResponse.json({ error: 'Book not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
const objects = await listAudiobookObjects(bookId, existingBookUserId, testNamespace);
|
||||
const objects = await listAudiobookObjects(bookId, storageUserId, testNamespace);
|
||||
const chapter = findChapterFileNameByIndex(
|
||||
objects.map((object) => object.fileName),
|
||||
chapterIndex,
|
||||
|
|
@ -839,7 +821,7 @@ export async function GET(request: NextRequest) {
|
|||
.where(
|
||||
and(
|
||||
eq(audiobookChapters.bookId, bookId),
|
||||
eq(audiobookChapters.userId, existingBookUserId),
|
||||
eq(audiobookChapters.userId, storageUserId),
|
||||
eq(audiobookChapters.chapterIndex, chapterIndex),
|
||||
),
|
||||
);
|
||||
|
|
@ -848,7 +830,7 @@ export async function GET(request: NextRequest) {
|
|||
|
||||
let buffer: Buffer;
|
||||
try {
|
||||
buffer = await getAudiobookObjectBuffer(bookId, existingBookUserId, chapter.fileName, testNamespace);
|
||||
buffer = await getAudiobookObjectBuffer(bookId, storageUserId, chapter.fileName, testNamespace);
|
||||
} catch (error) {
|
||||
if (isMissingBlobError(error)) {
|
||||
await db
|
||||
|
|
@ -856,7 +838,7 @@ export async function GET(request: NextRequest) {
|
|||
.where(
|
||||
and(
|
||||
eq(audiobookChapters.bookId, bookId),
|
||||
eq(audiobookChapters.userId, existingBookUserId),
|
||||
eq(audiobookChapters.userId, storageUserId),
|
||||
eq(audiobookChapters.chapterIndex, chapterIndex),
|
||||
),
|
||||
);
|
||||
|
|
@ -905,22 +887,16 @@ export async function DELETE(request: NextRequest) {
|
|||
|
||||
const ctxOrRes = await requireAuthContext(request);
|
||||
if (ctxOrRes instanceof Response) return ctxOrRes;
|
||||
if (!ctxOrRes.userId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
|
||||
const { userId } = ctxOrRes;
|
||||
const storageUserId = ctxOrRes.userId;
|
||||
const testNamespace = getOpenReaderTestNamespace(request.headers);
|
||||
const unclaimedUserId = getUnclaimedUserIdForNamespace(testNamespace);
|
||||
const { preferredUserId, allowedUserIds } = buildAllowedAudiobookUserIds(userId, unclaimedUserId);
|
||||
const existingBookRows = await db
|
||||
.select({ userId: audiobooks.userId })
|
||||
.from(audiobooks)
|
||||
.where(and(eq(audiobooks.id, bookId), inArray(audiobooks.userId, allowedUserIds)));
|
||||
const storageUserId = pickAudiobookOwner(
|
||||
existingBookRows.map((book: { userId: string }) => book.userId),
|
||||
preferredUserId,
|
||||
unclaimedUserId,
|
||||
);
|
||||
.where(and(eq(audiobooks.id, bookId), eq(audiobooks.userId, storageUserId)));
|
||||
|
||||
if (!storageUserId) {
|
||||
if (existingBookRows.length === 0) {
|
||||
return NextResponse.json({ error: 'Book not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { spawn } from 'child_process';
|
|||
import { mkdtemp, readFile, rm, writeFile } from 'fs/promises';
|
||||
import { tmpdir } from 'os';
|
||||
import { join } from 'path';
|
||||
import { and, eq, inArray } from 'drizzle-orm';
|
||||
import { and, eq } from 'drizzle-orm';
|
||||
import { db } from '@/db';
|
||||
import { audiobooks, audiobookChapters } from '@/db/schema';
|
||||
import { requireAuthContext } from '@/lib/server/auth/auth';
|
||||
|
|
@ -23,9 +23,8 @@ import {
|
|||
ffprobeAudio,
|
||||
} from '@/lib/server/audiobooks/chapters';
|
||||
import { isS3Configured } from '@/lib/server/storage/s3';
|
||||
import { getOpenReaderTestNamespace, getUnclaimedUserIdForNamespace } from '@/lib/server/testing/test-namespace';
|
||||
import { getOpenReaderTestNamespace } from '@/lib/server/testing/test-namespace';
|
||||
import { getFFmpegPath } from '@/lib/server/audiobooks/ffmpeg-bin';
|
||||
import { buildAllowedAudiobookUserIds, pickAudiobookOwner } from '@/lib/server/audiobooks/user-scope';
|
||||
import type { TTSAudiobookFormat } from '@/types/tts';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
|
@ -168,25 +167,19 @@ export async function GET(request: NextRequest) {
|
|||
|
||||
const ctxOrRes = await requireAuthContext(request);
|
||||
if (ctxOrRes instanceof Response) return ctxOrRes;
|
||||
if (!ctxOrRes.userId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
|
||||
const { userId } = ctxOrRes;
|
||||
const storageUserId = ctxOrRes.userId;
|
||||
const testNamespace = getOpenReaderTestNamespace(request.headers);
|
||||
const unclaimedUserId = getUnclaimedUserIdForNamespace(testNamespace);
|
||||
const { preferredUserId, allowedUserIds } = buildAllowedAudiobookUserIds(userId, unclaimedUserId);
|
||||
const existingBookRows = await db
|
||||
.select({ userId: audiobooks.userId })
|
||||
.from(audiobooks)
|
||||
.where(and(eq(audiobooks.id, bookId), inArray(audiobooks.userId, allowedUserIds)));
|
||||
const existingBookUserId = pickAudiobookOwner(
|
||||
existingBookRows.map((book: { userId: string }) => book.userId),
|
||||
preferredUserId,
|
||||
unclaimedUserId,
|
||||
);
|
||||
if (!existingBookUserId) {
|
||||
.where(and(eq(audiobooks.id, bookId), eq(audiobooks.userId, storageUserId)));
|
||||
if (existingBookRows.length === 0) {
|
||||
return NextResponse.json({ error: 'Book not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
const objects = await listAudiobookObjects(bookId, existingBookUserId, testNamespace);
|
||||
const objects = await listAudiobookObjects(bookId, storageUserId, testNamespace);
|
||||
const objectNames = objects.map((item) => item.fileName);
|
||||
const chapters = listChapterObjects(objectNames);
|
||||
if (chapters.length === 0) {
|
||||
|
|
@ -205,9 +198,9 @@ export async function GET(request: NextRequest) {
|
|||
|
||||
if (objectNames.includes(completeName) && objectNames.includes(manifestName)) {
|
||||
try {
|
||||
const manifest = JSON.parse((await getAudiobookObjectBuffer(bookId, existingBookUserId, manifestName, testNamespace)).toString('utf8'));
|
||||
const manifest = JSON.parse((await getAudiobookObjectBuffer(bookId, storageUserId, manifestName, testNamespace)).toString('utf8'));
|
||||
if (JSON.stringify(manifest) === JSON.stringify(signature)) {
|
||||
const cached = await getAudiobookObjectBuffer(bookId, existingBookUserId, completeName, testNamespace);
|
||||
const cached = await getAudiobookObjectBuffer(bookId, storageUserId, completeName, testNamespace);
|
||||
return new NextResponse(streamBuffer(cached), {
|
||||
headers: {
|
||||
'Content-Type': chapterFileMimeType(format),
|
||||
|
|
@ -220,14 +213,14 @@ export async function GET(request: NextRequest) {
|
|||
// Force regeneration below.
|
||||
}
|
||||
|
||||
await deleteAudiobookObject(bookId, existingBookUserId, completeName, testNamespace).catch(() => {});
|
||||
await deleteAudiobookObject(bookId, existingBookUserId, manifestName, testNamespace).catch(() => {});
|
||||
await deleteAudiobookObject(bookId, storageUserId, completeName, testNamespace).catch(() => {});
|
||||
await deleteAudiobookObject(bookId, storageUserId, manifestName, testNamespace).catch(() => {});
|
||||
}
|
||||
|
||||
const chapterRows = await db
|
||||
.select({ chapterIndex: audiobookChapters.chapterIndex, duration: audiobookChapters.duration })
|
||||
.from(audiobookChapters)
|
||||
.where(and(eq(audiobookChapters.bookId, bookId), eq(audiobookChapters.userId, existingBookUserId)));
|
||||
.where(and(eq(audiobookChapters.bookId, bookId), eq(audiobookChapters.userId, storageUserId)));
|
||||
const durationByIndex = new Map<number, number>();
|
||||
for (const row of chapterRows) {
|
||||
durationByIndex.set(row.chapterIndex, Number(row.duration ?? 0));
|
||||
|
|
@ -241,7 +234,7 @@ export async function GET(request: NextRequest) {
|
|||
const localChapters: Array<{ index: number; title: string; localPath: string; duration: number }> = [];
|
||||
for (const chapter of chapters) {
|
||||
const localPath = join(workDir, chapter.fileName);
|
||||
const bytes = await getAudiobookObjectBuffer(bookId, existingBookUserId, chapter.fileName, testNamespace);
|
||||
const bytes = await getAudiobookObjectBuffer(bookId, storageUserId, chapter.fileName, testNamespace);
|
||||
await writeFile(localPath, bytes);
|
||||
|
||||
let duration = 0;
|
||||
|
|
@ -356,10 +349,10 @@ export async function GET(request: NextRequest) {
|
|||
await ensurePositiveDuration(outputPath, request.signal);
|
||||
|
||||
const outputBytes = await readFile(outputPath);
|
||||
await putAudiobookObject(bookId, existingBookUserId, completeName, outputBytes, chapterFileMimeType(format), testNamespace);
|
||||
await putAudiobookObject(bookId, storageUserId, completeName, outputBytes, chapterFileMimeType(format), testNamespace);
|
||||
await putAudiobookObject(
|
||||
bookId,
|
||||
existingBookUserId,
|
||||
storageUserId,
|
||||
manifestName,
|
||||
Buffer.from(JSON.stringify(signature, null, 2), 'utf8'),
|
||||
'application/json; charset=utf-8',
|
||||
|
|
@ -404,21 +397,15 @@ export async function DELETE(request: NextRequest) {
|
|||
|
||||
const ctxOrRes = await requireAuthContext(request);
|
||||
if (ctxOrRes instanceof Response) return ctxOrRes;
|
||||
const { userId } = ctxOrRes;
|
||||
if (!ctxOrRes.userId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
const storageUserId = ctxOrRes.userId;
|
||||
const testNamespace = getOpenReaderTestNamespace(request.headers);
|
||||
const unclaimedUserId = getUnclaimedUserIdForNamespace(testNamespace);
|
||||
const { preferredUserId, allowedUserIds } = buildAllowedAudiobookUserIds(userId, unclaimedUserId);
|
||||
const existingBookRows = await db
|
||||
.select({ userId: audiobooks.userId })
|
||||
.from(audiobooks)
|
||||
.where(and(eq(audiobooks.id, bookId), inArray(audiobooks.userId, allowedUserIds)));
|
||||
const storageUserId = pickAudiobookOwner(
|
||||
existingBookRows.map((book: { userId: string }) => book.userId),
|
||||
preferredUserId,
|
||||
unclaimedUserId,
|
||||
);
|
||||
.where(and(eq(audiobooks.id, bookId), eq(audiobooks.userId, storageUserId)));
|
||||
|
||||
if (!storageUserId) {
|
||||
if (existingBookRows.length === 0) {
|
||||
return NextResponse.json({ error: 'Book not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { and, eq, inArray } from 'drizzle-orm';
|
||||
import { and, eq } from 'drizzle-orm';
|
||||
import { db } from '@/db';
|
||||
import { audiobooks, audiobookChapters } from '@/db/schema';
|
||||
import { requireAuthContext } from '@/lib/server/auth/auth';
|
||||
|
|
@ -7,8 +7,7 @@ import { getAudiobookObjectBuffer, isMissingBlobError, listAudiobookObjects } fr
|
|||
import { decodeChapterFileName } from '@/lib/server/audiobooks/chapters';
|
||||
import { pruneAudiobookChaptersNotOnDisk } from '@/lib/server/audiobooks/prune';
|
||||
import { isS3Configured } from '@/lib/server/storage/s3';
|
||||
import { getOpenReaderTestNamespace, getUnclaimedUserIdForNamespace } from '@/lib/server/testing/test-namespace';
|
||||
import { buildAllowedAudiobookUserIds, pickAudiobookOwner } from '@/lib/server/audiobooks/user-scope';
|
||||
import { getOpenReaderTestNamespace } from '@/lib/server/testing/test-namespace';
|
||||
import type { AudiobookGenerationSettings } from '@/types/client';
|
||||
import type { TTSAudiobookChapter, TTSAudiobookFormat } from '@/types/tts';
|
||||
import { errorToLog, serverLogger } from '@/lib/server/logger';
|
||||
|
|
@ -73,22 +72,16 @@ export async function GET(request: NextRequest) {
|
|||
|
||||
const ctxOrRes = await requireAuthContext(request);
|
||||
if (ctxOrRes instanceof Response) return ctxOrRes;
|
||||
if (!ctxOrRes.userId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
|
||||
const { userId } = ctxOrRes;
|
||||
const storageUserId = ctxOrRes.userId;
|
||||
const testNamespace = getOpenReaderTestNamespace(request.headers);
|
||||
const unclaimedUserId = getUnclaimedUserIdForNamespace(testNamespace);
|
||||
const { preferredUserId, allowedUserIds } = buildAllowedAudiobookUserIds(userId, unclaimedUserId);
|
||||
const existingBookRows = await db
|
||||
.select({ userId: audiobooks.userId })
|
||||
.from(audiobooks)
|
||||
.where(and(eq(audiobooks.id, bookId), inArray(audiobooks.userId, allowedUserIds)));
|
||||
const existingBookUserId = pickAudiobookOwner(
|
||||
existingBookRows.map((book: { userId: string }) => book.userId),
|
||||
preferredUserId,
|
||||
unclaimedUserId,
|
||||
);
|
||||
.where(and(eq(audiobooks.id, bookId), eq(audiobooks.userId, storageUserId)));
|
||||
|
||||
if (!existingBookUserId) {
|
||||
if (existingBookRows.length === 0) {
|
||||
return NextResponse.json({
|
||||
chapters: [],
|
||||
exists: false,
|
||||
|
|
@ -98,20 +91,20 @@ export async function GET(request: NextRequest) {
|
|||
});
|
||||
}
|
||||
|
||||
const objects = await listAudiobookObjects(bookId, existingBookUserId, testNamespace);
|
||||
const objects = await listAudiobookObjects(bookId, storageUserId, testNamespace);
|
||||
const objectNames = objects.map((object) => object.fileName);
|
||||
const chapterObjects = listChapterObjects(objectNames);
|
||||
|
||||
await pruneAudiobookChaptersNotOnDisk(
|
||||
bookId,
|
||||
existingBookUserId,
|
||||
storageUserId,
|
||||
chapterObjects.map((chapter) => chapter.index),
|
||||
);
|
||||
|
||||
const chapterRows = await db
|
||||
.select({ chapterIndex: audiobookChapters.chapterIndex, duration: audiobookChapters.duration })
|
||||
.from(audiobookChapters)
|
||||
.where(and(eq(audiobookChapters.bookId, bookId), eq(audiobookChapters.userId, existingBookUserId)));
|
||||
.where(and(eq(audiobookChapters.bookId, bookId), eq(audiobookChapters.userId, storageUserId)));
|
||||
const durationByIndex = new Map<number, number>();
|
||||
for (const row of chapterRows) {
|
||||
durationByIndex.set(row.chapterIndex, Number(row.duration ?? 0));
|
||||
|
|
@ -128,7 +121,7 @@ export async function GET(request: NextRequest) {
|
|||
|
||||
let settings: AudiobookGenerationSettings | null = null;
|
||||
try {
|
||||
settings = JSON.parse((await getAudiobookObjectBuffer(bookId, existingBookUserId, 'audiobook.meta.json', testNamespace)).toString('utf8')) as AudiobookGenerationSettings;
|
||||
settings = JSON.parse((await getAudiobookObjectBuffer(bookId, storageUserId, 'audiobook.meta.json', testNamespace)).toString('utf8')) as AudiobookGenerationSettings;
|
||||
} catch (error) {
|
||||
if (!isMissingBlobError(error)) throw error;
|
||||
settings = null;
|
||||
|
|
@ -139,7 +132,7 @@ export async function GET(request: NextRequest) {
|
|||
|
||||
if (!exists) {
|
||||
// Deleting the audiobook row cascades to audiobookChapters via bookFk
|
||||
await db.delete(audiobooks).where(and(eq(audiobooks.id, bookId), eq(audiobooks.userId, existingBookUserId)));
|
||||
await db.delete(audiobooks).where(and(eq(audiobooks.id, bookId), eq(audiobooks.userId, storageUserId)));
|
||||
return NextResponse.json({
|
||||
chapters: [],
|
||||
exists: false,
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ import { fetchWorkerOperationState } from '@/lib/server/compute/worker-op-state'
|
|||
import { isValidDocumentId } from '@/lib/server/documents/blobstore';
|
||||
import { normalizeParseStatus, parseDocumentParseState } from '@/lib/server/documents/parse-state';
|
||||
import { healStaleDocumentParseState } from '@/lib/server/documents/parse-state-healing';
|
||||
import { getOpenReaderTestNamespace, getUnclaimedUserIdForNamespace } from '@/lib/server/testing/test-namespace';
|
||||
import { getOpenReaderTestNamespace } from '@/lib/server/testing/test-namespace';
|
||||
import { isS3Configured } from '@/lib/server/storage/s3';
|
||||
import { createRequestLogger, hashForLog } from '@/lib/server/logger';
|
||||
import { errorResponse } from '@/lib/server/errors/next-response';
|
||||
|
|
@ -147,6 +147,7 @@ export async function GET(req: NextRequest, ctx: { params: Promise<{ id: string
|
|||
|
||||
const authCtxOrRes = await requireAuthContext(req);
|
||||
if (authCtxOrRes instanceof Response) return authCtxOrRes;
|
||||
if (!authCtxOrRes.userId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
|
||||
const params = await ctx.params;
|
||||
const id = (params.id || '').trim().toLowerCase();
|
||||
|
|
@ -159,10 +160,9 @@ export async function GET(req: NextRequest, ctx: { params: Promise<{ id: string
|
|||
: null;
|
||||
|
||||
const testNamespace = getOpenReaderTestNamespace(req.headers);
|
||||
const unclaimedUserId = getUnclaimedUserIdForNamespace(testNamespace);
|
||||
const storageUserId = authCtxOrRes.userId ?? unclaimedUserId;
|
||||
const storageUserId = authCtxOrRes.userId;
|
||||
const storageUserIdHash = hashForLog(storageUserId);
|
||||
const allowedUserIds = [storageUserId, unclaimedUserId];
|
||||
const allowedUserIds = [storageUserId];
|
||||
|
||||
const row = await loadPreferredRow({
|
||||
documentId: id,
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ import {
|
|||
stringifyDocumentParseState,
|
||||
} from '@/lib/server/documents/parse-state';
|
||||
import { healStaleDocumentParseState } from '@/lib/server/documents/parse-state-healing';
|
||||
import { getOpenReaderTestNamespace, getUnclaimedUserIdForNamespace } from '@/lib/server/testing/test-namespace';
|
||||
import { getOpenReaderTestNamespace } from '@/lib/server/testing/test-namespace';
|
||||
import { checkJobRate, recordJobEvent, getPdfLayoutRateConfig } from '@/lib/server/rate-limit/job-rate-limiter';
|
||||
import { buildComputeRateLimitedResponse } from '@/lib/server/rate-limit/problem-response';
|
||||
import { getResolvedRuntimeConfig } from '@/lib/server/runtime-config';
|
||||
|
|
@ -196,6 +196,7 @@ export async function GET(req: NextRequest, ctx: { params: Promise<{ id: string
|
|||
|
||||
const authCtxOrRes = await requireAuthContext(req);
|
||||
if (authCtxOrRes instanceof Response) return authCtxOrRes;
|
||||
if (!authCtxOrRes.userId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
|
||||
const params = await ctx.params;
|
||||
const id = (params.id || '').trim().toLowerCase();
|
||||
|
|
@ -206,9 +207,8 @@ export async function GET(req: NextRequest, ctx: { params: Promise<{ id: string
|
|||
}
|
||||
|
||||
const testNamespace = getOpenReaderTestNamespace(req.headers);
|
||||
const unclaimedUserId = getUnclaimedUserIdForNamespace(testNamespace);
|
||||
const storageUserId = authCtxOrRes.userId ?? unclaimedUserId;
|
||||
const allowedUserIds = [storageUserId, unclaimedUserId];
|
||||
const storageUserId = authCtxOrRes.userId;
|
||||
const allowedUserIds = [storageUserId];
|
||||
|
||||
const rows = await loadRows({ documentId: id, allowedUserIds });
|
||||
const row = pickPreferredRow(rows, storageUserId);
|
||||
|
|
@ -347,6 +347,7 @@ export async function POST(req: NextRequest, ctx: { params: Promise<{ id: string
|
|||
|
||||
const authCtxOrRes = await requireAuthContext(req);
|
||||
if (authCtxOrRes instanceof Response) return authCtxOrRes;
|
||||
if (!authCtxOrRes.userId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
|
||||
const params = await ctx.params;
|
||||
const id = (params.id || '').trim().toLowerCase();
|
||||
|
|
@ -363,9 +364,8 @@ export async function POST(req: NextRequest, ctx: { params: Promise<{ id: string
|
|||
}
|
||||
|
||||
const testNamespace = getOpenReaderTestNamespace(req.headers);
|
||||
const unclaimedUserId = getUnclaimedUserIdForNamespace(testNamespace);
|
||||
const storageUserId = authCtxOrRes.userId ?? unclaimedUserId;
|
||||
const allowedUserIds = [storageUserId, unclaimedUserId];
|
||||
const storageUserId = authCtxOrRes.userId;
|
||||
const allowedUserIds = [storageUserId];
|
||||
|
||||
const rows = await loadRows({ documentId: id, allowedUserIds });
|
||||
const row = pickPreferredRow(rows, storageUserId);
|
||||
|
|
|
|||
|
|
@ -1,9 +1,8 @@
|
|||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { and, eq, inArray } from 'drizzle-orm';
|
||||
import { and, eq } from 'drizzle-orm';
|
||||
import { db } from '@/db';
|
||||
import { documentSettings, documents } from '@/db/schema';
|
||||
import { requireAuthContext } from '@/lib/server/auth/auth';
|
||||
import { getOpenReaderTestNamespace, getUnclaimedUserIdForNamespace } from '@/lib/server/testing/test-namespace';
|
||||
import { mergeDocumentSettings } from '@/lib/shared/document-settings';
|
||||
import { DEFAULT_DOCUMENT_SETTINGS, type DocumentSettings } from '@/types/document-settings';
|
||||
import { coerceTimestampMs, nowTimestampMs } from '@/lib/shared/timestamps';
|
||||
|
|
@ -35,21 +34,19 @@ function parseStored(value: unknown): DocumentSettings {
|
|||
}
|
||||
|
||||
async function resolveDocumentAccess(req: NextRequest, documentId: string): Promise<
|
||||
| { ownerUserId: string; allowedUserIds: string[] }
|
||||
| { ownerUserId: string }
|
||||
| Response
|
||||
> {
|
||||
const authCtxOrRes = await requireAuthContext(req);
|
||||
if (authCtxOrRes instanceof Response) return authCtxOrRes;
|
||||
if (!authCtxOrRes.userId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
|
||||
const testNamespace = getOpenReaderTestNamespace(req.headers);
|
||||
const unclaimedUserId = getUnclaimedUserIdForNamespace(testNamespace);
|
||||
const storageUserId = authCtxOrRes.userId ?? unclaimedUserId;
|
||||
const allowedUserIds = [storageUserId, unclaimedUserId];
|
||||
const storageUserId = authCtxOrRes.userId;
|
||||
|
||||
const rows = await db
|
||||
.select({ userId: documents.userId })
|
||||
.from(documents)
|
||||
.where(and(eq(documents.id, documentId), inArray(documents.userId, allowedUserIds)))
|
||||
.where(and(eq(documents.id, documentId), eq(documents.userId, storageUserId)))
|
||||
.limit(1);
|
||||
|
||||
if (!rows[0]) {
|
||||
|
|
@ -58,7 +55,6 @@ async function resolveDocumentAccess(req: NextRequest, documentId: string): Prom
|
|||
|
||||
return {
|
||||
ownerUserId: rows[0].userId,
|
||||
allowedUserIds,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import { documents } from '@/db/schema';
|
|||
import { requireAuthContext } from '@/lib/server/auth/auth';
|
||||
import { contentTypeForName } from '@/lib/server/storage/library-mount';
|
||||
import { getDocumentBlob, isMissingBlobError, isValidDocumentId } from '@/lib/server/documents/blobstore';
|
||||
import { getOpenReaderTestNamespace, getUnclaimedUserIdForNamespace } from '@/lib/server/testing/test-namespace';
|
||||
import { getOpenReaderTestNamespace } from '@/lib/server/testing/test-namespace';
|
||||
import { isS3Configured } from '@/lib/server/storage/s3';
|
||||
import { errorToLog, serverLogger } from '@/lib/server/logger';
|
||||
import { errorResponse } from '@/lib/server/errors/next-response';
|
||||
|
|
@ -34,11 +34,11 @@ export async function GET(req: NextRequest) {
|
|||
|
||||
const ctxOrRes = await requireAuthContext(req);
|
||||
if (ctxOrRes instanceof Response) return ctxOrRes;
|
||||
if (!ctxOrRes.userId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
|
||||
const testNamespace = getOpenReaderTestNamespace(req.headers);
|
||||
const unclaimedUserId = getUnclaimedUserIdForNamespace(testNamespace);
|
||||
const storageUserId = ctxOrRes.userId ?? unclaimedUserId;
|
||||
const allowedUserIds = [storageUserId, unclaimedUserId];
|
||||
const storageUserId = ctxOrRes.userId;
|
||||
const allowedUserIds = [storageUserId];
|
||||
|
||||
const url = new URL(req.url);
|
||||
const id = (url.searchParams.get('id') || '').trim().toLowerCase();
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { db } from '@/db';
|
|||
import { documents } from '@/db/schema';
|
||||
import { requireAuthContext } from '@/lib/server/auth/auth';
|
||||
import { isValidDocumentId, presignGet } from '@/lib/server/documents/blobstore';
|
||||
import { getOpenReaderTestNamespace, getUnclaimedUserIdForNamespace } from '@/lib/server/testing/test-namespace';
|
||||
import { getOpenReaderTestNamespace } from '@/lib/server/testing/test-namespace';
|
||||
import { isS3Configured } from '@/lib/server/storage/s3';
|
||||
import { errorToLog, serverLogger } from '@/lib/server/logger';
|
||||
import { errorResponse } from '@/lib/server/errors/next-response';
|
||||
|
|
@ -24,11 +24,11 @@ export async function GET(req: NextRequest) {
|
|||
|
||||
const ctxOrRes = await requireAuthContext(req);
|
||||
if (ctxOrRes instanceof Response) return ctxOrRes;
|
||||
if (!ctxOrRes.userId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
|
||||
const testNamespace = getOpenReaderTestNamespace(req.headers);
|
||||
const unclaimedUserId = getUnclaimedUserIdForNamespace(testNamespace);
|
||||
const storageUserId = ctxOrRes.userId ?? unclaimedUserId;
|
||||
const allowedUserIds = [storageUserId, unclaimedUserId];
|
||||
const storageUserId = ctxOrRes.userId;
|
||||
const allowedUserIds = [storageUserId];
|
||||
|
||||
const url = new URL(req.url);
|
||||
const id = (url.searchParams.get('id') || '').trim().toLowerCase();
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ import {
|
|||
isPreviewableDocumentType,
|
||||
} from '@/lib/server/documents/previews';
|
||||
import { extractRawTextSnippet } from '@/lib/server/documents/text-snippets';
|
||||
import { getOpenReaderTestNamespace, getUnclaimedUserIdForNamespace } from '@/lib/server/testing/test-namespace';
|
||||
import { getOpenReaderTestNamespace } from '@/lib/server/testing/test-namespace';
|
||||
import { isS3Configured } from '@/lib/server/storage/s3';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
|
@ -52,11 +52,11 @@ export async function GET(req: NextRequest) {
|
|||
|
||||
const ctxOrRes = await requireAuthContext(req);
|
||||
if (ctxOrRes instanceof Response) return ctxOrRes;
|
||||
if (!ctxOrRes.userId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
|
||||
const testNamespace = getOpenReaderTestNamespace(req.headers);
|
||||
const unclaimedUserId = getUnclaimedUserIdForNamespace(testNamespace);
|
||||
const storageUserId = ctxOrRes.userId ?? unclaimedUserId;
|
||||
const allowedUserIds = [storageUserId, unclaimedUserId];
|
||||
const storageUserId = ctxOrRes.userId;
|
||||
const allowedUserIds = [storageUserId];
|
||||
|
||||
const url = new URL(req.url);
|
||||
const id = (url.searchParams.get('id') || '').trim().toLowerCase();
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import { documents } from '@/db/schema';
|
|||
import { requireAuthContext } from '@/lib/server/auth/auth';
|
||||
import { isValidDocumentId } from '@/lib/server/documents/blobstore';
|
||||
import { isPreviewableDocumentType } from '@/lib/server/documents/previews';
|
||||
import { getOpenReaderTestNamespace, getUnclaimedUserIdForNamespace } from '@/lib/server/testing/test-namespace';
|
||||
import { getOpenReaderTestNamespace } from '@/lib/server/testing/test-namespace';
|
||||
import { isS3Configured } from '@/lib/server/storage/s3';
|
||||
|
||||
export function s3NotConfiguredResponse(): NextResponse {
|
||||
|
|
@ -37,15 +37,11 @@ export async function validatePreviewRequest(req: NextRequest): Promise<Validate
|
|||
|
||||
const ctxOrRes = await requireAuthContext(req);
|
||||
if (ctxOrRes instanceof Response) return { errorResponse: ctxOrRes };
|
||||
if (!ctxOrRes.userId) return { errorResponse: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) };
|
||||
|
||||
const testNamespace = getOpenReaderTestNamespace(req.headers);
|
||||
const unclaimedUserId = getUnclaimedUserIdForNamespace(testNamespace);
|
||||
const storageUserId = ctxOrRes.userId ?? unclaimedUserId;
|
||||
|
||||
// Deduplicate allowedUserIds
|
||||
const allowedUserIds = Array.from(new Set(
|
||||
[storageUserId, unclaimedUserId]
|
||||
));
|
||||
const storageUserId = ctxOrRes.userId;
|
||||
const allowedUserIds = [storageUserId];
|
||||
|
||||
const url = new URL(req.url);
|
||||
const id = (url.searchParams.get('id') || '').trim().toLowerCase();
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ import { enqueueParsePdfJob } from '@/lib/server/jobs/user-pdf-layout-job';
|
|||
import { recordJobEvent, getPdfLayoutRateConfig } from '@/lib/server/rate-limit/job-rate-limiter';
|
||||
import { getResolvedRuntimeConfig } from '@/lib/server/runtime-config';
|
||||
import { stringifyDocumentParseState } from '@/lib/server/documents/parse-state';
|
||||
import { getOpenReaderTestNamespace, getUnclaimedUserIdForNamespace } from '@/lib/server/testing/test-namespace';
|
||||
import { getOpenReaderTestNamespace } from '@/lib/server/testing/test-namespace';
|
||||
import { isS3Configured } from '@/lib/server/storage/s3';
|
||||
import { putDocumentBlob } from '@/lib/server/documents/blobstore';
|
||||
import { errorToLog, serverLogger } from '@/lib/server/logger';
|
||||
|
|
@ -82,11 +82,11 @@ export async function POST(req: NextRequest) {
|
|||
}
|
||||
|
||||
const testNamespace = getOpenReaderTestNamespace(req.headers);
|
||||
const unclaimedUserId = getUnclaimedUserIdForNamespace(testNamespace);
|
||||
|
||||
const ctxOrRes = await requireAuthContext(req);
|
||||
if (ctxOrRes instanceof Response) return ctxOrRes;
|
||||
const storageUserId = ctxOrRes.userId ?? unclaimedUserId;
|
||||
if (!ctxOrRes.userId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
const storageUserId = ctxOrRes.userId;
|
||||
|
||||
await ensureTempDir();
|
||||
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ import {
|
|||
parseDocumentParseState,
|
||||
stringifyDocumentParseState,
|
||||
} from '@/lib/server/documents/parse-state';
|
||||
import { getOpenReaderTestNamespace, getUnclaimedUserIdForNamespace } from '@/lib/server/testing/test-namespace';
|
||||
import { getOpenReaderTestNamespace } from '@/lib/server/testing/test-namespace';
|
||||
import { isS3Configured } from '@/lib/server/storage/s3';
|
||||
import type { BaseDocument, DocumentType } from '@/types/documents';
|
||||
|
||||
|
|
@ -80,10 +80,10 @@ export async function POST(req: NextRequest) {
|
|||
|
||||
const ctxOrRes = await requireAuthContext(req);
|
||||
if (ctxOrRes instanceof Response) return ctxOrRes;
|
||||
if (!ctxOrRes.userId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
|
||||
const testNamespace = getOpenReaderTestNamespace(req.headers);
|
||||
const unclaimedUserId = getUnclaimedUserIdForNamespace(testNamespace);
|
||||
const storageUserId = ctxOrRes.userId ?? unclaimedUserId;
|
||||
const storageUserId = ctxOrRes.userId;
|
||||
|
||||
const body = await req.json().catch(() => null);
|
||||
const documentsData = parseDocumentPayload(body);
|
||||
|
|
@ -171,7 +171,7 @@ export async function POST(req: NextRequest) {
|
|||
type: doc.type,
|
||||
size: headSize,
|
||||
lastModified: doc.lastModified,
|
||||
scope: storageUserId === unclaimedUserId ? 'unclaimed' : 'user',
|
||||
scope: 'user',
|
||||
});
|
||||
|
||||
await enqueueDocumentPreview(
|
||||
|
|
@ -224,11 +224,10 @@ export async function GET(req: NextRequest) {
|
|||
|
||||
const ctxOrRes = await requireAuthContext(req);
|
||||
if (ctxOrRes instanceof Response) return ctxOrRes;
|
||||
if (!ctxOrRes.userId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
|
||||
const testNamespace = getOpenReaderTestNamespace(req.headers);
|
||||
const unclaimedUserId = getUnclaimedUserIdForNamespace(testNamespace);
|
||||
const storageUserId = ctxOrRes.userId ?? unclaimedUserId;
|
||||
const allowedUserIds = [storageUserId, unclaimedUserId];
|
||||
const storageUserId = ctxOrRes.userId;
|
||||
const allowedUserIds = [storageUserId];
|
||||
|
||||
const url = new URL(req.url);
|
||||
const idsParam = url.searchParams.get('ids');
|
||||
|
|
@ -259,21 +258,7 @@ export async function GET(req: NextRequest) {
|
|||
parsedJsonKey: string | null;
|
||||
}>;
|
||||
|
||||
const preferredById = new Map<string, (typeof rows)[number]>();
|
||||
for (const row of rows) {
|
||||
const existing = preferredById.get(row.id);
|
||||
if (!existing) {
|
||||
preferredById.set(row.id, row);
|
||||
continue;
|
||||
}
|
||||
const isRowPrimary = row.userId === storageUserId;
|
||||
const isExistingPrimary = existing.userId === storageUserId;
|
||||
if (isRowPrimary && !isExistingPrimary) {
|
||||
preferredById.set(row.id, row);
|
||||
}
|
||||
}
|
||||
|
||||
const results: BaseDocument[] = Array.from(preferredById.values()).map((doc) => {
|
||||
const results: BaseDocument[] = rows.map((doc) => {
|
||||
const type = normalizeDocumentType(doc.type, doc.name);
|
||||
return {
|
||||
id: doc.id,
|
||||
|
|
@ -283,7 +268,7 @@ export async function GET(req: NextRequest) {
|
|||
type,
|
||||
parseStatus: type === 'pdf' ? normalizeParseStatus(parseDocumentParseState(doc.parseState).status) : null,
|
||||
parsedJsonKey: doc.parsedJsonKey,
|
||||
scope: doc.userId === unclaimedUserId ? 'unclaimed' : 'user',
|
||||
scope: 'user',
|
||||
};
|
||||
});
|
||||
|
||||
|
|
@ -306,37 +291,19 @@ export async function DELETE(req: NextRequest) {
|
|||
|
||||
const ctxOrRes = await requireAuthContext(req);
|
||||
if (ctxOrRes instanceof Response) return ctxOrRes;
|
||||
if (!ctxOrRes.userId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
|
||||
const testNamespace = getOpenReaderTestNamespace(req.headers);
|
||||
const unclaimedUserId = getUnclaimedUserIdForNamespace(testNamespace);
|
||||
const storageUserId = ctxOrRes.userId ?? unclaimedUserId;
|
||||
const storageUserId = ctxOrRes.userId;
|
||||
|
||||
const url = new URL(req.url);
|
||||
const idsParam = url.searchParams.get('ids');
|
||||
const scopeParam = (url.searchParams.get('scope') || '').toLowerCase().trim();
|
||||
|
||||
const wantsUnclaimed = scopeParam === 'unclaimed';
|
||||
const wantsUser = scopeParam === '' || scopeParam === 'user';
|
||||
|
||||
if (!wantsUser && !wantsUnclaimed) {
|
||||
return NextResponse.json(
|
||||
{ error: "Invalid scope. Expected 'user' (default) or 'unclaimed'." },
|
||||
{ status: 400 },
|
||||
);
|
||||
if (scopeParam && scopeParam !== 'user') {
|
||||
return NextResponse.json({ error: "Invalid scope. Expected 'user' (default)." }, { status: 400 });
|
||||
}
|
||||
|
||||
if (wantsUnclaimed && ctxOrRes.user?.isAnonymous) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
|
||||
const targetUserIds = Array.from(
|
||||
new Set(
|
||||
[
|
||||
...(wantsUser ? [storageUserId] : []),
|
||||
...(wantsUnclaimed ? [unclaimedUserId] : []),
|
||||
].filter(Boolean),
|
||||
),
|
||||
);
|
||||
const targetUserIds = [storageUserId];
|
||||
|
||||
if (targetUserIds.length === 0) {
|
||||
return NextResponse.json({ success: true, deleted: 0 });
|
||||
|
|
|
|||
|
|
@ -468,7 +468,7 @@ export async function POST(request: NextRequest) {
|
|||
segmentId: segment.segmentId,
|
||||
});
|
||||
|
||||
if (scope.authEnabled && scope.userId && ttsRateLimitEnabled) {
|
||||
if (ttsRateLimitEnabled) {
|
||||
const charCount = segment.text.length;
|
||||
const ip = getClientIp(request);
|
||||
const device = scope.isAnonymousUser ? getOrCreateDeviceId(request) : null;
|
||||
|
|
|
|||
|
|
@ -6,8 +6,6 @@ import { Button } from '@headlessui/react';
|
|||
import { PDFIcon, EPUBIcon, FileIcon } from '@/components/icons/Icons';
|
||||
import type { DocumentListDocument, IconSize } from '@/types/documents';
|
||||
import { DocumentPreview } from '@/components/doclist/DocumentPreview';
|
||||
import { useAuthSession } from '@/hooks/useAuthSession';
|
||||
import { useAuthConfig } from '@/contexts/AuthRateLimitContext';
|
||||
import { useDocumentSelection } from '../dnd/DocumentSelectionContext';
|
||||
import { DND_DOCUMENT, documentIdentityKey, type DocumentDragItem } from '../dnd/dndTypes';
|
||||
|
||||
|
|
@ -69,13 +67,10 @@ export function DocumentTile({
|
|||
onDelete,
|
||||
onMergeIntoFolder,
|
||||
}: DocumentTileProps) {
|
||||
const { authEnabled } = useAuthConfig();
|
||||
const { data: session } = useAuthSession();
|
||||
const href = `/${doc.type}/${encodeURIComponent(doc.id)}`;
|
||||
const selection = useDocumentSelection();
|
||||
|
||||
const isAnonymousAuthed = Boolean(authEnabled && session?.user?.isAnonymous);
|
||||
const showDeleteButton = !(isAnonymousAuthed && doc.scope === 'unclaimed');
|
||||
const showDeleteButton = true;
|
||||
const isSelected = selection.isSelected(doc);
|
||||
const isInFolder = Boolean(doc.folderId);
|
||||
|
||||
|
|
|
|||
|
|
@ -234,7 +234,6 @@ export function OnboardingFlowProvider({ children }: { children: ReactNode }) {
|
|||
|
||||
useEffect(() => {
|
||||
return scheduleChangelogCheck({
|
||||
authEnabled: true,
|
||||
isSessionPending,
|
||||
sessionUserId: userId,
|
||||
appVersion: runtimeConfig.appVersion,
|
||||
|
|
|
|||
|
|
@ -23,9 +23,10 @@ const seededUserIds = new Set<string>();
|
|||
/**
|
||||
* Ensure a system/placeholder user row exists in the user table for `userId`.
|
||||
* All user-facing tables now have userId foreign keys with ON DELETE CASCADE
|
||||
* referencing the user table. When auth is disabled the app stores data under
|
||||
* the 'unclaimed' userId (and namespace variants like 'unclaimed::ns'), so
|
||||
* those rows must exist before any data can be inserted.
|
||||
* referencing the user table. The legacy first-enable-auth claim migration can
|
||||
* still move rows from the historical 'unclaimed' userId (and namespaced
|
||||
* variants like 'unclaimed::ns'), so those placeholder rows must exist before
|
||||
* any migration transfer can run.
|
||||
*
|
||||
* This is safe to call repeatedly — it short-circuits via an in-memory Set
|
||||
* and uses INSERT … ON CONFLICT/OR IGNORE at the SQL level.
|
||||
|
|
@ -109,4 +110,3 @@ export const db: any = new Proxy({} as any, {
|
|||
return typeof value === 'function' ? value.bind(instance) : value;
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -341,14 +341,11 @@ export async function uploadDocuments(files: File[], options?: UploadOptions): P
|
|||
return uploadDocumentSources(sources, options);
|
||||
}
|
||||
|
||||
export async function deleteDocuments(options?: { ids?: string[]; scope?: 'user' | 'unclaimed'; signal?: AbortSignal }): Promise<void> {
|
||||
export async function deleteDocuments(options?: { ids?: string[]; signal?: AbortSignal }): Promise<void> {
|
||||
const params = new URLSearchParams();
|
||||
if (options?.ids?.length) {
|
||||
params.set('ids', options.ids.join(','));
|
||||
}
|
||||
if (options?.scope) {
|
||||
params.set('scope', options.scope);
|
||||
}
|
||||
|
||||
const url = params.toString() ? `/api/documents?${params.toString()}` : '/api/documents';
|
||||
const res = await fetch(url, { method: 'DELETE', signal: options?.signal });
|
||||
|
|
|
|||
|
|
@ -9,7 +9,6 @@ export type ChangelogVersionCheckResponse = {
|
|||
type RefLike = { current: string | null };
|
||||
|
||||
export type RunChangelogCheckArgs = {
|
||||
authEnabled: boolean;
|
||||
isSessionPending: boolean;
|
||||
sessionUserId: string | null | undefined;
|
||||
appVersion: string | null | undefined;
|
||||
|
|
@ -23,13 +22,12 @@ export type RunChangelogCheckArgs = {
|
|||
|
||||
export async function runChangelogCheck(args: RunChangelogCheckArgs): Promise<void> {
|
||||
const sessionUserId = args.sessionUserId ?? null;
|
||||
if (args.authEnabled && (args.isSessionPending || !sessionUserId)) return;
|
||||
if (args.isSessionPending || !sessionUserId) return;
|
||||
|
||||
const currentVersion = normalizeVersion(args.appVersion || '');
|
||||
if (!currentVersion) return;
|
||||
|
||||
const userKey = sessionUserId ?? 'server-unclaimed';
|
||||
const checkKey = `${userKey}:${currentVersion}`;
|
||||
const checkKey = `${sessionUserId}:${currentVersion}`;
|
||||
if (args.completedRef.current === checkKey) return;
|
||||
if (args.inFlightRef.current === checkKey) return;
|
||||
args.inFlightRef.current = checkKey;
|
||||
|
|
|
|||
|
|
@ -1,20 +0,0 @@
|
|||
export function buildAllowedAudiobookUserIds(
|
||||
userId: string | null,
|
||||
unclaimedUserId: string,
|
||||
): { preferredUserId: string; allowedUserIds: string[] } {
|
||||
const preferredUserId = userId ?? unclaimedUserId;
|
||||
const allowedUserIds = Array.from(new Set([preferredUserId, unclaimedUserId]));
|
||||
return { preferredUserId, allowedUserIds };
|
||||
}
|
||||
|
||||
export function pickAudiobookOwner(
|
||||
existingUserIds: string[],
|
||||
preferredUserId: string,
|
||||
unclaimedUserId: string,
|
||||
): string | null {
|
||||
const existing = new Set(existingUserIds);
|
||||
// Keep resumed writes on unclaimed scope when the book already exists there.
|
||||
if (existing.has(unclaimedUserId)) return unclaimedUserId;
|
||||
if (existing.has(preferredUserId)) return preferredUserId;
|
||||
return existingUserIds[0] ?? null;
|
||||
}
|
||||
|
|
@ -3,14 +3,13 @@ import type { NextRequest } from 'next/server';
|
|||
import { db } from '@/db';
|
||||
import { documents } from '@/db/schema';
|
||||
import { requireAuthContext } from '@/lib/server/auth/auth';
|
||||
import { getOpenReaderTestNamespace, getUnclaimedUserIdForNamespace } from '@/lib/server/testing/test-namespace';
|
||||
import { getOpenReaderTestNamespace } from '@/lib/server/testing/test-namespace';
|
||||
import type { ReaderType } from '@/types/user-state';
|
||||
|
||||
export type ResolvedSegmentDocumentScope = {
|
||||
testNamespace: string | null;
|
||||
storageUserId: string;
|
||||
authEnabled: true;
|
||||
userId: string | null;
|
||||
userId: string;
|
||||
isAnonymousUser: boolean;
|
||||
documentVersion: number;
|
||||
readerType: ReaderType;
|
||||
|
|
@ -28,11 +27,11 @@ export async function resolveSegmentDocumentScope(
|
|||
): Promise<ResolvedSegmentDocumentScope | Response> {
|
||||
const ctxOrRes = await requireAuthContext(request);
|
||||
if (ctxOrRes instanceof Response) return ctxOrRes;
|
||||
if (!ctxOrRes.userId) return Response.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
|
||||
const testNamespace = getOpenReaderTestNamespace(request.headers);
|
||||
const unclaimedUserId = getUnclaimedUserIdForNamespace(testNamespace);
|
||||
const storageUserId = ctxOrRes.userId ?? unclaimedUserId;
|
||||
const allowedUserIds = [storageUserId, unclaimedUserId];
|
||||
const storageUserId = ctxOrRes.userId;
|
||||
const allowedUserIds = [storageUserId];
|
||||
|
||||
const rows = (await db
|
||||
.select({
|
||||
|
|
@ -55,7 +54,6 @@ export async function resolveSegmentDocumentScope(
|
|||
return {
|
||||
testNamespace,
|
||||
storageUserId: doc.userId,
|
||||
authEnabled: true,
|
||||
userId: ctxOrRes.userId,
|
||||
isAnonymousUser: Boolean(ctxOrRes.user?.isAnonymous),
|
||||
documentVersion: Number(doc.lastModified),
|
||||
|
|
|
|||
|
|
@ -1,13 +1,12 @@
|
|||
import type { NextRequest } from 'next/server';
|
||||
import type { AuthContext } from '@/lib/server/auth/auth';
|
||||
import { requireAuthContext } from '@/lib/server/auth/auth';
|
||||
import { getOpenReaderTestNamespace, getUnclaimedUserIdForNamespace } from '@/lib/server/testing/test-namespace';
|
||||
import { getOpenReaderTestNamespace } from '@/lib/server/testing/test-namespace';
|
||||
|
||||
export type ResolvedUserStateScope = {
|
||||
auth: AuthContext;
|
||||
namespace: string | null;
|
||||
ownerUserId: string;
|
||||
unclaimedUserId: string;
|
||||
};
|
||||
|
||||
export async function resolveUserStateScope(
|
||||
|
|
@ -15,16 +14,14 @@ export async function resolveUserStateScope(
|
|||
): Promise<ResolvedUserStateScope | Response> {
|
||||
const auth = await requireAuthContext(req);
|
||||
if (auth instanceof Response) return auth;
|
||||
if (!auth.userId) return Response.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
|
||||
const namespace = getOpenReaderTestNamespace(req.headers);
|
||||
const unclaimedUserId = getUnclaimedUserIdForNamespace(namespace);
|
||||
const ownerUserId = auth.userId ?? unclaimedUserId;
|
||||
const ownerUserId = auth.userId;
|
||||
|
||||
return {
|
||||
auth,
|
||||
namespace,
|
||||
ownerUserId,
|
||||
unclaimedUserId,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ export interface BaseDocument {
|
|||
type: DocumentType;
|
||||
parseStatus?: 'pending' | 'running' | 'ready' | 'failed' | null;
|
||||
parsedJsonKey?: string | null;
|
||||
scope?: 'user' | 'unclaimed';
|
||||
scope?: 'user';
|
||||
folderId?: string;
|
||||
isConverting?: boolean;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,30 +0,0 @@
|
|||
import { describe, expect, test } from 'vitest';
|
||||
import { buildAllowedAudiobookUserIds, pickAudiobookOwner } from '../../src/lib/server/audiobooks/user-scope';
|
||||
|
||||
describe('audiobook scope selection', () => {
|
||||
test('includes both preferred and unclaimed scopes', () => {
|
||||
const result = buildAllowedAudiobookUserIds('user-123', 'unclaimed::ns');
|
||||
expect(result.preferredUserId).toBe('user-123');
|
||||
expect(result.allowedUserIds).toEqual(['user-123', 'unclaimed::ns']);
|
||||
});
|
||||
|
||||
test('deduplicates preferred/unclaimed ids when they are the same', () => {
|
||||
const result = buildAllowedAudiobookUserIds('unclaimed::ns', 'unclaimed::ns');
|
||||
expect(result.allowedUserIds).toEqual(['unclaimed::ns']);
|
||||
});
|
||||
|
||||
test('prefers unclaimed owner when both scopes exist', () => {
|
||||
const owner = pickAudiobookOwner(['user-123', 'unclaimed::ns'], 'user-123', 'unclaimed::ns');
|
||||
expect(owner).toBe('unclaimed::ns');
|
||||
});
|
||||
|
||||
test('falls back to preferred owner when unclaimed is missing', () => {
|
||||
const owner = pickAudiobookOwner(['user-123'], 'user-123', 'unclaimed::ns');
|
||||
expect(owner).toBe('user-123');
|
||||
});
|
||||
|
||||
test('returns null when no matching owners exist', () => {
|
||||
const owner = pickAudiobookOwner([], 'user-123', 'unclaimed::ns');
|
||||
expect(owner).toBeNull();
|
||||
});
|
||||
});
|
||||
|
|
@ -14,7 +14,6 @@ describe('changelog check scheduling', () => {
|
|||
let openCalls = 0;
|
||||
|
||||
const args = {
|
||||
authEnabled: true,
|
||||
isSessionPending: false,
|
||||
sessionUserId: 'u1',
|
||||
appVersion: '3.3.0',
|
||||
|
|
@ -48,13 +47,12 @@ describe('changelog check scheduling', () => {
|
|||
expect(inFlightRef.current).toBeNull();
|
||||
});
|
||||
|
||||
test('does not run when auth is enabled and session is pending', async () => {
|
||||
test('does not run while session is pending', async () => {
|
||||
const completedRef = { current: null as string | null };
|
||||
const inFlightRef = { current: null as string | null };
|
||||
let apiCalls = 0;
|
||||
|
||||
const cleanup = scheduleChangelogCheck({
|
||||
authEnabled: true,
|
||||
isSessionPending: true,
|
||||
sessionUserId: null,
|
||||
appVersion: '3.3.0',
|
||||
|
|
|
|||
|
|
@ -64,7 +64,7 @@ describe('onboarding flow resolver', () => {
|
|||
migrationRequired: false,
|
||||
changelogPending: false,
|
||||
});
|
||||
const noAuthStep = resolveNextOnboardingStep({
|
||||
const minimalStep = resolveNextOnboardingStep({
|
||||
privacyRequired: false,
|
||||
privacyAccepted: false,
|
||||
claimEligible: false,
|
||||
|
|
@ -74,7 +74,7 @@ describe('onboarding flow resolver', () => {
|
|||
});
|
||||
|
||||
expect(authStep).toBe('done');
|
||||
expect(noAuthStep).toBe('done');
|
||||
expect(minimalStep).toBe('done');
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue