openreader/src/app/api/documents/route.ts

217 lines
6.2 KiB
TypeScript

import { createHash } from 'crypto';
import { readdir, readFile, stat, unlink, utimes, writeFile } from 'fs/promises';
import { NextRequest, NextResponse } from 'next/server';
import path from 'path';
import { DOCUMENTS_V1_DIR, isDocumentsV1Ready } from '@/lib/server/docstore';
import type { BaseDocument, DocumentType, SyncedDocument } from '@/types/documents';
export const dynamic = 'force-dynamic';
const SYNC_DIR = DOCUMENTS_V1_DIR;
async function trySetFileMtime(filePath: string, lastModifiedMs: number): Promise<void> {
if (!Number.isFinite(lastModifiedMs)) return;
const mtime = new Date(lastModifiedMs);
if (Number.isNaN(mtime.getTime())) return;
try {
await utimes(filePath, mtime, mtime);
} catch (error) {
console.warn('Failed to set document mtime:', filePath, error);
}
}
function toDocumentTypeFromName(name: string): DocumentType {
const ext = path.extname(name).toLowerCase();
if (ext === '.pdf') return 'pdf';
if (ext === '.epub') return 'epub';
if (ext === '.docx') return 'docx';
return 'html';
}
function parseSyncedFileName(fileName: string): { id: string; name: string } | null {
const match = /^([a-f0-9]{64})__(.+)$/i.exec(fileName);
if (!match) return null;
try {
return { id: match[1].toLowerCase(), name: decodeURIComponent(match[2]) };
} catch {
return null;
}
}
async function loadSyncedDocuments(includeData: boolean, targetIds?: Set<string>): Promise<(BaseDocument | SyncedDocument)[]> {
const results: (BaseDocument | SyncedDocument)[] = [];
let files: string[] = [];
try {
files = await readdir(SYNC_DIR);
} catch {
return results;
}
for (const file of files) {
const parsed = parseSyncedFileName(file);
if (!parsed) continue;
// Filter by ID if specific IDs are requested
if (targetIds && !targetIds.has(parsed.id)) continue;
const filePath = path.join(SYNC_DIR, file);
let fileStat: Awaited<ReturnType<typeof stat>>;
try {
fileStat = await stat(filePath);
} catch {
continue;
}
if (!fileStat.isFile()) continue;
const type = toDocumentTypeFromName(parsed.name);
const metadata: BaseDocument = {
id: parsed.id,
name: parsed.name,
size: fileStat.size,
lastModified: fileStat.mtimeMs,
type,
};
if (!includeData) {
results.push(metadata);
continue;
}
const content = await readFile(filePath);
results.push({
...metadata,
data: Array.from(new Uint8Array(content)),
});
}
return results;
}
export async function POST(req: NextRequest) {
try {
if (!(await isDocumentsV1Ready())) {
return NextResponse.json(
{ error: 'Documents storage is not migrated; run /api/migrations/v1 first.' },
{ status: 409 },
);
}
const data = await req.json();
const documents = data.documents as SyncedDocument[];
let existingFiles: string[] = [];
try {
existingFiles = await readdir(SYNC_DIR);
} catch {
existingFiles = [];
}
const existingById = new Map<string, string>();
for (const file of existingFiles) {
const parsed = parseSyncedFileName(file);
if (!parsed) continue;
if (!existingById.has(parsed.id)) {
existingById.set(parsed.id, file);
}
}
const stored: Array<{ oldId: string; id: string; name: string }> = [];
for (const doc of documents) {
const content = Buffer.from(new Uint8Array(doc.data));
const id = createHash('sha256').update(content).digest('hex');
const baseName = path.basename(doc.name || `${id}.${doc.type}`);
const safeName = baseName.replaceAll('\u0000', '').slice(0, 240) || `${id}.${doc.type}`;
const existingFile = existingById.get(id);
const targetFileName = existingFile ?? `${id}__${encodeURIComponent(safeName)}`;
const targetPath = path.join(SYNC_DIR, targetFileName);
if (!existingFile) {
await writeFile(targetPath, content);
existingById.set(id, targetFileName);
}
await trySetFileMtime(targetPath, doc.lastModified);
stored.push({ oldId: doc.id, id, name: safeName });
}
return NextResponse.json({ success: true, stored });
} catch (error) {
console.error('Error saving documents:', error);
return NextResponse.json({ error: 'Failed to save documents' }, { status: 500 });
}
}
export async function GET(req: NextRequest) {
try {
if (!(await isDocumentsV1Ready())) {
return NextResponse.json(
{ error: 'Documents storage is not migrated; run /api/migrations/v1 first.' },
{ status: 409 },
);
}
const url = new URL(req.url);
const list = url.searchParams.get('list') === 'true';
const format = url.searchParams.get('format');
const idsParam = url.searchParams.get('ids');
// If list=true, force metadata only.
// If format=metadata, force metadata only.
// Otherwise include data.
const includeData = !list && format !== 'metadata';
let targetIds: Set<string> | undefined;
if (idsParam) {
targetIds = new Set(idsParam.split(',').filter(Boolean));
}
const documents = await loadSyncedDocuments(includeData, targetIds);
return NextResponse.json({ documents });
} catch (error) {
console.error('Error loading documents:', error);
return NextResponse.json({ error: 'Failed to load documents' }, { status: 500 });
}
}
export async function DELETE() {
try {
if (!(await isDocumentsV1Ready())) {
return NextResponse.json(
{ error: 'Documents storage is not migrated; run /api/migrations/v1 first.' },
{ status: 409 },
);
}
// Delete synced docs (new format) safely without touching unrelated docstore data.
let syncFiles: string[] = [];
try {
syncFiles = await readdir(SYNC_DIR);
} catch {
syncFiles = [];
}
for (const file of syncFiles) {
const filePath = path.join(SYNC_DIR, file);
try {
const st = await stat(filePath);
if (st.isFile()) {
await unlink(filePath);
}
} catch {
continue;
}
}
return NextResponse.json({ success: true });
} catch (error) {
console.error('Error deleting documents:', error);
return NextResponse.json({ error: 'Failed to delete documents' }, { status: 500 });
}
}