Files
convert/src/app.js
T
anthony 86b555b334 feat: register ebook converter in the app so the API serves the new family
Also fixes a real bug found via the fb2 API test: file-type sniffs a
real fb2 file's XML declaration as generic "xml", not "fb2" and not
undetected, so it never reached the undetectable-format fallback added
in the previous commit. Added an fb2->xml alias in normalizeFormat,
same pattern as the existing azw3->mobi one.
2026-07-31 13:29:06 +02:00

248 lines
8.2 KiB
JavaScript

import path from 'node:path';
import fs from 'node:fs';
import express from 'express';
import multer from 'multer';
import { rateLimit } from 'express-rate-limit';
import { v4 as uuidv4 } from 'uuid';
import { resolve as resolveConverter, listTargetFormats } from './converters/registry.js';
import { registerImageConverters } from './converters/image.js';
import { registerImageToPdfConverter } from './converters/imageToPdf.js';
import { registerDocumentConverters } from './converters/document.js';
import { registerIcoConverter } from './converters/ico.js';
import { registerHeicConverter } from './converters/heic.js';
import { registerFontConverter } from './converters/font.js';
import { registerDfontConverter } from './converters/dfont.js';
import { registerEbookConverter } from './converters/ebook.js';
import { resolveInputFormat } from './mime.js';
import { deleteIfExists } from './storage.js';
import { createJob, getJobByUuid } from './jobs/jobRepository.js';
import { outputPath } from './storage.js';
const VALID_ICON_SIZES = [16, 32, 48, 256, 512];
function isValidQuality(targetFormat, quality) {
if (quality === null || quality === undefined) return true;
if (!Number.isInteger(quality)) return false;
if (targetFormat === 'gif' || targetFormat === 'ico') return false;
if (targetFormat === 'png') return quality >= 0 && quality <= 9;
return quality >= 1 && quality <= 100;
}
function isValidIconSize(targetFormat, iconSize) {
if (iconSize === null || iconSize === undefined) return true;
if (targetFormat !== 'ico') return false;
return VALID_ICON_SIZES.includes(iconSize);
}
let convertersRegistered = false;
function registerAllConverters() {
if (convertersRegistered) return;
registerImageConverters();
registerImageToPdfConverter();
registerDocumentConverters();
registerIcoConverter();
registerHeicConverter();
registerFontConverter();
registerDfontConverter();
registerEbookConverter();
convertersRegistered = true;
}
export function createApp(config, prisma) {
registerAllConverters();
const app = express();
app.set('trust proxy', '1');
const storage = multer.diskStorage({
destination: (req, file, cb) => cb(null, path.join(config.storageDir, 'uploads')),
filename: (req, file, cb) => {
const ext = path.extname(file.originalname).slice(1).toLowerCase();
cb(null, `${uuidv4()}.${ext}`);
},
});
const upload = multer({
storage,
limits: {
fileSize: config.maxFileSizeMb * 1024 * 1024,
files: 10,
},
});
const jobsRateLimiter = rateLimit({
windowMs: config.rateLimitWindowMinutes * 60 * 1000,
limit: config.rateLimitMaxJobs,
standardHeaders: 'draft-8',
legacyHeaders: false,
});
app.get('/api/formats', (req, res) => {
const source = String(req.query.source ?? '').toLowerCase();
res.json({ targets: listTargetFormats(source) });
});
app.post('/api/jobs', jobsRateLimiter, upload.array('files', 10), async (req, res) => {
if (!req.files || req.files.length === 0) {
return res.status(400).json({ error: 'No files uploaded' });
}
let targetFormats;
try {
targetFormats = JSON.parse(req.body.targetFormats ?? '[]');
} catch {
return res.status(400).json({ error: 'targetFormats must be a JSON array' });
}
if (!Array.isArray(targetFormats) || targetFormats.length !== req.files.length) {
return res.status(400).json({ error: 'targetFormats must have one entry per uploaded file' });
}
let qualities;
try {
qualities = JSON.parse(req.body.qualities ?? '[]');
} catch {
return res.status(400).json({ error: 'qualities must be a JSON array' });
}
if (!Array.isArray(qualities)) {
return res.status(400).json({ error: 'qualities must be a JSON array' });
}
if (qualities.length > 0 && qualities.length !== req.files.length) {
return res.status(400).json({ error: 'qualities must have one entry per uploaded file, or be omitted' });
}
let iconSizes;
try {
iconSizes = JSON.parse(req.body.iconSizes ?? '[]');
} catch {
return res.status(400).json({ error: 'iconSizes must be a JSON array' });
}
if (!Array.isArray(iconSizes)) {
return res.status(400).json({ error: 'iconSizes must be a JSON array' });
}
if (iconSizes.length > 0 && iconSizes.length !== req.files.length) {
return res.status(400).json({ error: 'iconSizes must have one entry per uploaded file, or be omitted' });
}
const results = [];
for (let i = 0; i < req.files.length; i += 1) {
const file = req.files[i];
const targetFormat = targetFormats[i];
const uuid = path.basename(file.filename, path.extname(file.filename));
const sourceFormat = path.extname(file.filename).slice(1).toLowerCase();
const { mime, valid } = await resolveInputFormat(file.path, sourceFormat);
if (!valid) {
await deleteIfExists(file.path);
results.push({ file: file.originalname, error: 'File content does not match its extension' });
continue;
}
const registryEntry = resolveConverter(sourceFormat, targetFormat);
if (!registryEntry) {
await deleteIfExists(file.path);
results.push({
file: file.originalname,
error: `Unsupported conversion: ${sourceFormat} to ${targetFormat}`,
});
continue;
}
const requestedQuality = qualities[i] ?? null;
if (!isValidQuality(targetFormat, requestedQuality)) {
await deleteIfExists(file.path);
results.push({
file: file.originalname,
error: `Invalid quality for target format ${targetFormat}`,
});
continue;
}
const requestedIconSize = iconSizes[i] ?? null;
if (!isValidIconSize(targetFormat, requestedIconSize)) {
await deleteIfExists(file.path);
results.push({
file: file.originalname,
error: `Invalid iconSize for target format ${targetFormat}`,
});
continue;
}
const expiresAt = new Date(Date.now() + config.retentionHours * 3600 * 1000);
await createJob(prisma, {
uuid,
family: registryEntry.family,
sourceFormat,
targetFormat,
originalFilename: file.originalname,
inputPath: file.filename,
inputMimeType: mime,
inputSizeBytes: file.size,
expiresAt,
quality: requestedQuality,
iconSize: requestedIconSize,
});
results.push({ file: file.originalname, id: uuid, status: 'pending' });
}
res.status(201).json({ jobs: results });
});
app.get('/api/jobs/:id', async (req, res) => {
const job = await getJobByUuid(prisma, req.params.id);
if (!job) {
return res.status(404).json({ error: 'Job not found' });
}
res.json({
id: job.uuid,
status: job.status,
originalFilename: job.originalFilename,
sourceFormat: job.sourceFormat,
targetFormat: job.targetFormat,
errorMessage: job.errorMessage,
});
});
function contentDispositionHeader(filename) {
const asciiFallback = filename.replace(/[^\x20-\x7E]/g, '_').replace(/"/g, "'");
const encoded = encodeURIComponent(filename);
return `attachment; filename="${asciiFallback}"; filename*=UTF-8''${encoded}`;
}
app.get('/api/jobs/:id/download', async (req, res) => {
const job = await getJobByUuid(prisma, req.params.id);
if (!job) {
return res.status(404).json({ error: 'Job not found' });
}
if (job.status !== 'done') {
return res.status(409).json({ error: `Job is not ready yet (status: ${job.status})` });
}
const filePath = outputPath(config, job.uuid, job.targetFormat);
const downloadFilename = `${path.parse(job.originalFilename).name}.${job.targetFormat}`;
res.set('Content-Type', job.outputMimeType);
res.set('Content-Disposition', contentDispositionHeader(downloadFilename));
fs.createReadStream(filePath).pipe(res);
});
const frontendDist = path.join(import.meta.dirname, '..', 'frontend', 'dist');
app.use(express.static(frontendDist));
app.get(/^\/(?!api\/).*/, (req, res) => {
res.sendFile(path.join(frontendDist, 'index.html'));
});
app.use((err, req, res, next) => {
console.error('Unhandled API error:', err);
res.status(500).json({ error: 'Internal server error' });
});
return app;
}