From 0bef02bc6dd5e40d44075d264a234095f6a2ce36 Mon Sep 17 00:00:00 2001 From: Kirill Date: Fri, 15 May 2026 13:24:16 +0500 Subject: [PATCH] feat: add uploads-resized route with sharp resizing and cache headers --- server/src/index.js | 10 ++++ server/src/routes/uploads-resized.js | 72 ++++++++++++++++++++++++++++ 2 files changed, 82 insertions(+) create mode 100644 server/src/routes/uploads-resized.js diff --git a/server/src/index.js b/server/src/index.js index 5ea1985..3fc5684 100644 --- a/server/src/index.js +++ b/server/src/index.js @@ -17,6 +17,7 @@ import { registerUserMessageRoutes } from './routes/user-messages.js' import { registerUserOrderRoutes } from './routes/user-orders.js' import { registerUserPaymentRoutes } from './routes/user-payments.js' import { registerOAuthSocialRoutes } from './routes/oauth-social.js' +import { registerUploadsResized } from './routes/uploads-resized.js' const port = Number(process.env.PORT) || 3333 const origin = (process.env.CORS_ORIGIN ?? '') @@ -46,10 +47,19 @@ await fastify.register(multipart, { }, }) +registerUploadsResized(fastify) + const uploadsDir = path.join(process.cwd(), 'uploads') await fastify.register(fastifyStatic, { root: uploadsDir, prefix: '/uploads/', + setHeaders(res, filePath) { + if (filePath.includes('/.cache/')) { + res.setHeader('Cache-Control', 'public, max-age=31536000, immutable') + } else { + res.setHeader('Cache-Control', 'public, max-age=86400') + } + }, }) fastify.decorate('authenticate', async function authenticate(request, reply) { diff --git a/server/src/routes/uploads-resized.js b/server/src/routes/uploads-resized.js new file mode 100644 index 0000000..962028a --- /dev/null +++ b/server/src/routes/uploads-resized.js @@ -0,0 +1,72 @@ +// server/src/routes/uploads-resized.js +import fs from 'node:fs' +import path from 'node:path' +import { findOriginalFile, getOrCreateResized, SUPPORTED_FORMATS, VALID_WIDTHS } from '../lib/image-resize.js' + +const CACHE_CONTROL_IMMUTABLE = 'public, max-age=31536000, immutable' +const CACHE_CONTROL_SHORT = 'public, max-age=86400' + +/** + * Register GET /uploads-resized/* route for on-demand image resizing. + * Must be registered BEFORE fastify-static for /uploads/. + */ +export function registerUploadsResized(fastify) { + fastify.get('/uploads-resized/*', async (request, reply) => { + const rawPath = request.params['*'] + const url = new URL(request.url, 'http://localhost') + const widthParam = url.searchParams.get('w') + + // Parse: [subdir/]filename.format + const parts = rawPath.split('/') + let filename, subdir = '' + + if (parts.length > 1) { + subdir = parts.slice(0, -1).join('/') + '/' + filename = parts[parts.length - 1] + } else { + filename = parts[0] + } + + const dotIdx = filename.lastIndexOf('.') + if (dotIdx === -1) { + return reply.code(400).send({ error: 'Invalid request: no format specified' }) + } + + const uuid = filename.slice(0, dotIdx) + const format = filename.slice(dotIdx + 1).toLowerCase() + + if (!SUPPORTED_FORMATS.has(format)) { + return reply.code(400).send({ error: `Unsupported format: ${format}. Use avif or webp.` }) + } + + // Validate width + let width = null + if (widthParam) { + const w = parseInt(widthParam, 10) + if (!VALID_WIDTHS.includes(w)) { + return reply.code(400).send({ error: `Invalid width: ${widthParam}. Use: ${VALID_WIDTHS.join(', ')}` }) + } + width = w + } + + // If no width requested, serve original with short cache + if (!width) { + const originalPath = await findOriginalFile(uuid, subdir || undefined) + if (!originalPath) { + return reply.code(404).send({ error: 'Image not found' }) + } + reply.header('Cache-Control', CACHE_CONTROL_SHORT) + reply.header('Content-Type', format === 'avif' ? 'image/avif' : 'image/webp') + return reply.send(fs.createReadStream(originalPath)) + } + + const result = await getOrCreateResized(uuid, width, format, subdir || undefined) + if (!result) { + return reply.code(404).send({ error: 'Image not found' }) + } + + reply.header('Cache-Control', CACHE_CONTROL_IMMUTABLE) + reply.header('Content-Type', format === 'avif' ? 'image/avif' : 'image/webp') + return reply.send(fs.createReadStream(result.path)) + }) +}