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:
2026-03-23 13:39:10 +03:00
parent 036885dd24
commit 5d17ad0579
3 changed files with 70 additions and 6 deletions

View File

@@ -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