openreader/src/app/api/user/export/route.ts

151 lines
4.8 KiB
TypeScript

import { NextRequest, NextResponse } from 'next/server';
import { PassThrough, Readable } from 'stream';
import { auth } from '@/lib/server/auth/auth';
import { db } from '@/db';
import { documents, audiobooks, audiobookChapters, userPreferences, userDocumentProgress, userTtsChars } from '@/db/schema';
import { and, desc, eq, inArray } from 'drizzle-orm';
import archiver from 'archiver';
import { appendUserExportArchive } from '@/lib/server/user/data-export';
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';
import { serverLogger } from '@/lib/server/logger';
export const dynamic = 'force-dynamic';
export async function GET(req: NextRequest) {
if (!isS3Configured()) {
return NextResponse.json(
{ error: 'Export storage is not configured. Set S3_* environment variables.' },
{ status: 503 },
);
}
if (!auth) {
return NextResponse.json({ error: 'Auth not initialized' }, { status: 500 });
}
const session = await auth.api.getSession({
headers: req.headers,
});
if (!session?.user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const userId = session.user.id;
const testNamespace = getOpenReaderTestNamespace(req.headers);
const [
prefs,
progress,
ttsUsage,
userDocs,
userAudiobooks,
] = await Promise.all([
db.select().from(userPreferences).where(eq(userPreferences.userId, userId)).limit(1),
db
.select()
.from(userDocumentProgress)
.where(eq(userDocumentProgress.userId, userId))
.orderBy(desc(userDocumentProgress.updatedAt)),
db
.select()
.from(userTtsChars)
.where(eq(userTtsChars.userId, userId))
.orderBy(desc(userTtsChars.date)),
db
.select()
.from(documents)
.where(eq(documents.userId, userId))
.orderBy(desc(documents.lastModified)),
db
.select()
.from(audiobooks)
.where(eq(audiobooks.userId, userId))
.orderBy(desc(audiobooks.createdAt)),
]);
const archive = archiver('zip', {
zlib: { level: 0 },
forceZip64: true,
});
const output = new PassThrough();
archive.pipe(output);
archive.on('warning', (warning) => {
if ((warning as NodeJS.ErrnoException).code !== 'ENOENT') {
serverLogger.warn({ err: warning }, 'User export warning:');
}
});
archive.on('error', (error) => {
output.destroy(error);
});
const bookIds = userAudiobooks.map((book: typeof audiobooks.$inferSelect) => book.id);
const allChapters = bookIds.length > 0
? await db
.select()
.from(audiobookChapters)
.where(and(eq(audiobookChapters.userId, userId), inArray(audiobookChapters.bookId, bookIds)))
: [];
const onAbort = () => {
archive.abort();
output.destroy(new Error('Export request aborted'));
};
req.signal.addEventListener('abort', onAbort, { once: true });
(async () => {
try {
const exportedAtMs = nowTimestampMs();
const profileData = {
user: session.user,
session: session.session,
exportedAtMs,
};
await appendUserExportArchive({
archive,
userId,
exportedAtMs,
profileData,
preferences: prefs[0] ?? null,
readingHistory: progress,
ttsUsage,
documents: userDocs,
audiobooks: userAudiobooks,
audiobookChapters: allChapters,
getDocumentBlobStream: async (documentId: string) => getDocumentBlobStream(documentId, testNamespace),
listAudiobookObjects: async (bookId: string, ownerId: string) => listAudiobookObjects(bookId, ownerId, testNamespace),
getAudiobookObjectStream: async (bookId: string, ownerId: string, fileName: string) =>
getAudiobookObjectStream(bookId, ownerId, fileName, testNamespace),
});
if (!req.signal.aborted) {
await archive.finalize();
}
} catch (error) {
serverLogger.error({ err: error }, 'Export generation failed:');
archive.abort();
output.destroy(error instanceof Error ? error : new Error('Failed to generate export archive'));
} finally {
req.signal.removeEventListener('abort', onAbort);
if (req.signal.aborted) {
output.destroy(new Error('Export request aborted'));
}
}
})();
return new NextResponse(Readable.toWeb(output) as ReadableStream, {
headers: {
'Content-Type': 'application/zip',
'Content-Disposition': `attachment; filename="openreader-data-${userId.slice(0, 8)}.zip"`,
'Cache-Control': 'no-store',
},
});
}