diff --git a/backend/src/routes/upload.ts b/backend/src/routes/upload.ts index 0008d45..8a23527 100644 --- a/backend/src/routes/upload.ts +++ b/backend/src/routes/upload.ts @@ -1,6 +1,6 @@ import { FastifyPluginAsync } from 'fastify' -import { createWriteStream, mkdirSync } from 'fs' -import { join, extname } from 'path' +import { createWriteStream, mkdirSync, unlink } from 'fs' +import { join, extname, basename } from 'path' import { randomUUID } from 'crypto' import { pipeline } from 'stream/promises' @@ -46,6 +46,50 @@ const upload: FastifyPluginAsync = async (fastify) => { 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 diff --git a/src/lib/api.ts b/src/lib/api.ts index 563b56f..3e36d49 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -359,6 +359,10 @@ export const api = { const data = await res.json() as { url: string } return data.url }, + + deletePhoto: async (url: string): Promise => { + await req('DELETE', `/api/upload?url=${encodeURIComponent(url)}`) + }, }, // ── Categories ──────────────────────────────────────────────────────────── diff --git a/src/pages/RoomCategoriesPage.tsx b/src/pages/RoomCategoriesPage.tsx index b8cadbe..da6cca5 100644 --- a/src/pages/RoomCategoriesPage.tsx +++ b/src/pages/RoomCategoriesPage.tsx @@ -81,12 +81,28 @@ function CategoryForm({ category, onClose, onSave }: CategoryFormProps) { const handleDrop = (e: React.DragEvent) => { e.preventDefault() setDragging(false) - uploadFiles(e.dataTransfer.files) + // Only handle external file drops (not internal thumbnail DnD) + if (e.dataTransfer.files.length > 0) { + uploadFiles(e.dataTransfer.files) + } + } + + const handleDragOver = (e: React.DragEvent) => { + e.preventDefault() + // Only show drop highlight for external files, not internal thumbnail reorder + if (e.dataTransfer.types.includes('Files')) { + setDragging(true) + } } const removePhoto = (i: number) => { + const url = photos[i] setPhotos(prev => prev.filter((_, idx) => idx !== i)) setPhotoIdx(p => Math.max(0, p - 1)) + // Delete from server (best-effort, don't block UI) + if (url.startsWith('https://cdn.hotelsync.ru/')) { + api.upload.deletePhoto(url).catch(console.error) + } } const handleSave = async () => { @@ -224,11 +240,11 @@ function CategoryForm({ category, onClose, onSave }: CategoryFormProps) { dragging && 'ring-2 ring-brand-500 ring-offset-2', )} style={{ height: 220 }} - onDragOver={e => { e.preventDefault(); setDragging(true) }} + onDragOver={handleDragOver} onDragLeave={() => setDragging(false)} onDrop={handleDrop} > - + {dragging && (

Отпустите для добавления

@@ -290,7 +306,7 @@ function CategoryForm({ category, onClose, onSave }: CategoryFormProps) { : 'border-slate-300 dark:border-slate-600 text-slate-400 hover:border-slate-400', )} onClick={() => fileRef.current?.click()} - onDragOver={e => { e.preventDefault(); setDragging(true) }} + onDragOver={handleDragOver} onDragLeave={() => setDragging(false)} onDrop={handleDrop} >