import { FastifyPluginAsync } from 'fastify' import { createWriteStream, mkdirSync, unlink } from 'fs' import { join, extname, basename } from 'path' import { randomUUID } from 'crypto' import { pipeline } from 'stream/promises' const UPLOADS_DIR = process.env.UPLOADS_DIR ?? join(process.cwd(), 'uploads') const CDN_HOST = process.env.CDN_HOST ?? 'cdn.hotelsync.ru' const ALLOWED_FOLDERS = ['categories', 'rooms', 'hotels', 'guests', 'tasks', 'chat'] as const type UploadFolder = typeof ALLOWED_FOLDERS[number] const ALLOWED_TYPES = ['image/jpeg', 'image/png', 'image/webp', 'image/gif'] const MAX_SIZE = 10 * 1024 * 1024 // 10MB // Ensure base uploads directory exists mkdirSync(UPLOADS_DIR, { recursive: true }) const upload: FastifyPluginAsync = async (fastify) => { // POST /api/upload?folder=categories — upload a single image, returns { url } fastify.post( '/api/upload', { onRequest: [fastify.authenticate] }, async (request, reply) => { const { folder } = request.query as { folder?: string } const dir: UploadFolder = ALLOWED_FOLDERS.includes(folder as UploadFolder) ? (folder as UploadFolder) : 'rooms' const data = await request.file({ limits: { fileSize: MAX_SIZE } }) if (!data) return reply.code(400).send({ error: 'No file provided' }) if (!ALLOWED_TYPES.includes(data.mimetype)) { return reply.code(400).send({ error: 'Only images allowed (jpg, png, webp, gif)' }) } const subDir = join(UPLOADS_DIR, dir) mkdirSync(subDir, { recursive: true }) const ext = extname(data.filename) || '.jpg' const filename = `${randomUUID()}${ext}` const filepath = join(subDir, filename) await pipeline(data.file, createWriteStream(filepath)) return { url: `https://${CDN_HOST}/${dir}/${filename}` } }, ) // DELETE /api/upload?url=https://cdn.hotelsync.ru/categories/uuid.jpg fastify.delete( '/api/upload', { onRequest: [fastify.authenticate] }, async (request, reply) => { const { url } = request.query as { url?: string } if (!url) return reply.code(400).send({ error: 'url is required' }) // Extract path after CDN host: /categories/uuid.jpg → categories/uuid.jpg let relPath: string try { const parsed = new URL(url) // pathname is like /categories/uuid.jpg — strip leading slash relPath = parsed.pathname.replace(/^\//, '') } catch { return reply.code(400).send({ error: 'Invalid url' }) } // Security: ensure path stays within UPLOADS_DIR (no ../.. traversal) const filepath = join(UPLOADS_DIR, relPath) if (!filepath.startsWith(UPLOADS_DIR + '/') && filepath !== UPLOADS_DIR) { return reply.code(400).send({ error: 'Invalid path' }) } // Only allow known folders const folder = relPath.split('/')[0] if (!ALLOWED_FOLDERS.includes(folder as UploadFolder)) { return reply.code(400).send({ error: 'Invalid folder' }) } // Only allow uuid-like filenames to prevent abuse const file = basename(relPath) if (!/^[0-9a-f-]{36}\.[a-z]+$/.test(file)) { return reply.code(400).send({ error: 'Invalid filename' }) } await new Promise((resolve, reject) => unlink(filepath, err => err ? reject(err) : resolve()) ) return { ok: true } }, ) } export default upload