Files
hotelsync/backend/src/routes/upload.ts
HotelSync e51d776b3a feat: photos for tech tasks, 3 priorities, remove cleaning status from context menu
- Remove 'Убирается' (cleaning) from manually selectable HK statuses in calendar context menu — it now only shows automatically when a task is in_progress
- Reduce priorities from 4 to 3: remove 'high', keep urgent/medium/low across context menu, tech tasks modal, and HousekeepingTask type
- Add photo upload to technical tasks: upload via camera button per task, display thumbnails with hover-to-remove, stored as TEXT[] in DB (migration 026)
- Fix HousekeepingPage WS handler to filter by category — no longer adds maintenance tasks to housekeeping board
- Add 'tasks' folder support to upload route

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 10:38:51 +03:00

96 lines
3.3 KiB
TypeScript

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'] 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<void>((resolve, reject) =>
unlink(filepath, err => err ? reject(err) : resolve())
)
return { ok: true }
},
)
}
export default upload