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; }