import fs from 'fs'; import path from 'path'; import { S3 } from '@aws-sdk/client-s3'; import { loadImage, createCanvas } from 'canvas'; import type { Canvas } from 'canvas'; import { Router } from 'express'; import { getH3Event } from 'h3-express'; import sharp from 'sharp'; import SQL from 'sql-template-strings'; import { ulid } from 'ulid'; import { handleErrorAsync } from '../../src/helpers.ts'; import { auditLog } from '../audit.ts'; import { awsConfig, awsParams } from '../aws.ts'; import { rootDir } from '../paths.ts'; const sizes = { big: [1200, false], flag: [256, false], thumb: [192, true], avatar: [144, true], } as const; const resizeImage = ( image: Canvas, width: number, height: number, sx: number | null = null, sy: number | null = null, ): Canvas => { const canvas = createCanvas(width, height); if (sx === null || sy === null) { canvas.getContext('2d').drawImage(image, 0, 0, width, height); } else { canvas.getContext('2d').drawImage(image, sx, sy, width, height, 0, 0, width, height); } return canvas; }; const cropToSquare = (image: Canvas): Canvas => { return image.width > image.height ? resizeImage(image, image.height, image.height, (image.width - image.height) / 2, 0) : resizeImage(image, image.width, image.width, 0, (image.height - image.width) / 2); }; const scaleDownTo = (image: Canvas, size: number): Canvas => { if (image.width > image.height) { return image.width > size ? resizeImage(image, size, image.height * size / image.width) : image; } return image.height > size ? resizeImage(image, image.width * size / image.height, size) : image; }; const router = Router(); router.post('/images/upload', handleErrorAsync(async (req, res) => { const files = await readMultipartFormData(getH3Event(req)); if (!files) { return res.status(400); } const s3 = new S3(awsConfig); const requestedSizes = req.query.sizes === undefined || req.query.sizes === 'all' ? null : (req.query.sizes as string).split(','); const ids = []; for (const file of files) { const id = ulid(); let image; try { image = await loadImage(await sharp(file.data).png() .toBuffer()); } catch { return res.status(400).json({ error: 'error.invalidImage' }); } for (const [name, [size, square]] of Object.entries(sizes)) { if (requestedSizes !== null && !requestedSizes.includes(name)) { continue; } let canvas = createCanvas(image.width, image.height); canvas.getContext('2d').drawImage(image, 0, 0); if (square) { canvas = cropToSquare(canvas); } canvas = scaleDownTo(canvas, size); await s3.putObject({ Key: `images/${id}-${name}.png`, Body: canvas.toBuffer('image/png'), ContentType: 'image/png', ACL: 'public-read', ...awsParams, }); } ids.push(id); } await auditLog(req, 'images/uploaded', { ids }); return res.json(ids); })); router.get('/download/:filename*', handleErrorAsync(async (req, res) => { const filename = req.params.filename + req.params[0]; const filepath = `${rootDir}/locale/${global.config.locale}/files/${filename}`; if (!fs.existsSync(filepath)) { return res.status(404).json({ error: 'Not found' }); } await req.db.get(SQL`INSERT INTO downloads (id, locale, filename) VALUES (${ulid()}, ${global.config.locale}, ${filename});`); return res.download(path.resolve(filepath)); })); router.get('/downloads', handleErrorAsync(async (req, res) => { if (!req.isGranted('users') && !req.isGranted('community')) { return res.status(401).json({ error: 'Unauthorised' }); } const stats: Record = {}; const downloads = await req.db.all<{ filename: string; c: number }>(SQL` SELECT filename, count(*) as c FROM downloads WHERE locale = ${global.config.locale} GROUP BY filename; `); for (const { filename, c } of downloads) { stats[filename] = c; } return res.json(stats); })); export default router;