feat: delete photo from server on remove + fix file drag-and-drop
- DELETE /api/upload?url= endpoint removes file from disk (with path traversal protection)
- api.upload.deletePhoto() helper in frontend
- removePhoto() now calls deletePhoto for cdn.hotelsync.ru URLs (best-effort)
- handleDragOver only highlights drop zone for external file drags (not thumbnail DnD)
- <img> in preview gets draggable={false} + pointer-events-none to not interfere with drops
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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<void>((resolve, reject) =>
|
||||
unlink(filepath, err => err ? reject(err) : resolve())
|
||||
)
|
||||
|
||||
return { ok: true }
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
export default upload
|
||||
|
||||
@@ -359,6 +359,10 @@ export const api = {
|
||||
const data = await res.json() as { url: string }
|
||||
return data.url
|
||||
},
|
||||
|
||||
deletePhoto: async (url: string): Promise<void> => {
|
||||
await req('DELETE', `/api/upload?url=${encodeURIComponent(url)}`)
|
||||
},
|
||||
},
|
||||
|
||||
// ── Categories ────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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}
|
||||
>
|
||||
<img src={photos[photoIdx]} alt="" className="w-full h-full object-cover" />
|
||||
<img src={photos[photoIdx]} alt="" draggable={false} className="w-full h-full object-cover pointer-events-none" />
|
||||
{dragging && (
|
||||
<div className="absolute inset-0 bg-brand-600/40 flex items-center justify-center">
|
||||
<p className="text-white font-semibold text-sm">Отпустите для добавления</p>
|
||||
@@ -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}
|
||||
>
|
||||
|
||||
Reference in New Issue
Block a user