Task 4 rewrote src/db.js and src/jobs/jobRepository.js to use Prisma instead of the hand-rolled mariadb pool. This updates every remaining call site (app.js, server.js, worker.js, cleanup.js) and the 5 test files that still referenced getPool/closePool/pool.query, so the app and full test suite compile and run against Prisma Client. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
204 lines
6.7 KiB
JavaScript
204 lines
6.7 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 { resolveInputFormat } from './mime.js';
|
|
import { deleteIfExists } from './storage.js';
|
|
import { createJob, getJobByUuid } from './jobs/jobRepository.js';
|
|
import { outputPath } from './storage.js';
|
|
|
|
function isValidQuality(targetFormat, quality) {
|
|
if (quality === null || quality === undefined) return true;
|
|
if (!Number.isInteger(quality)) return false;
|
|
if (targetFormat === 'gif') return false;
|
|
if (targetFormat === 'png') return quality >= 0 && quality <= 9;
|
|
return quality >= 1 && quality <= 100;
|
|
}
|
|
|
|
let convertersRegistered = false;
|
|
|
|
function registerAllConverters() {
|
|
if (convertersRegistered) return;
|
|
registerImageConverters();
|
|
registerImageToPdfConverter();
|
|
registerDocumentConverters();
|
|
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' });
|
|
}
|
|
|
|
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 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,
|
|
});
|
|
|
|
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;
|
|
}
|