From 3ef7f8265a1e08edee46278474dd982bf1405718 Mon Sep 17 00:00:00 2001 From: HotelSync Date: Mon, 23 Mar 2026 12:23:13 +0300 Subject: [PATCH] feat: real file upload to /opt/hotelsync/uploads + serve via /uploads/ on API --- backend/package.json | 2 ++ backend/src/app.ts | 10 ++++++++ backend/src/routes/upload.ts | 42 ++++++++++++++++++++++++++++++++ src/lib/api.ts | 17 +++++++++++++ src/pages/RoomCategoriesPage.tsx | 38 +++++++++++++++++------------ 5 files changed, 94 insertions(+), 15 deletions(-) create mode 100644 backend/src/routes/upload.ts diff --git a/backend/package.json b/backend/package.json index 1aa4dd1..82e9af7 100644 --- a/backend/package.json +++ b/backend/package.json @@ -13,7 +13,9 @@ "@fastify/cors": "^9.0.1", "@fastify/helmet": "^11.1.1", "@fastify/jwt": "^8.0.1", + "@fastify/multipart": "^9.4.0", "@fastify/rate-limit": "^9.1.0", + "@fastify/static": "^9.0.0", "@fastify/websocket": "^8.3.1", "bcryptjs": "^2.4.3", "fastify": "^4.28.1", diff --git a/backend/src/app.ts b/backend/src/app.ts index 83284e2..b4ca9c7 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -4,6 +4,9 @@ import cookie from '@fastify/cookie' import cors from '@fastify/cors' import helmet from '@fastify/helmet' import rateLimit from '@fastify/rate-limit' +import multipart from '@fastify/multipart' +import staticFiles from '@fastify/static' +import { join } from 'path' import { config } from './config' import './types' // side-effect: augments fastify types @@ -22,6 +25,7 @@ import hotelSettingsRoutes from './routes/hotel-settings' import rentalRoutes from './routes/rental' import categoriesRoutes from './routes/categories' import tariffsRoutes from './routes/tariffs' +import uploadRoutes from './routes/upload' export async function buildApp() { const fastify = Fastify({ @@ -34,6 +38,11 @@ export async function buildApp() { bodyLimit: 20 * 1024 * 1024, // 20MB — for base64 photo uploads }) + // ── File uploads & static ───────────────────────────────────────────────── + await fastify.register(multipart) + const uploadsDir = process.env.UPLOADS_DIR ?? join(process.cwd(), 'uploads') + await fastify.register(staticFiles, { root: uploadsDir, prefix: '/uploads/' }) + // ── Security ─────────────────────────────────────────────────────────────── await fastify.register(helmet, { contentSecurityPolicy: false }) @@ -86,6 +95,7 @@ export async function buildApp() { await fastify.register(rentalRoutes) await fastify.register(categoriesRoutes) await fastify.register(tariffsRoutes) + await fastify.register(uploadRoutes) return fastify } diff --git a/backend/src/routes/upload.ts b/backend/src/routes/upload.ts new file mode 100644 index 0000000..bb61887 --- /dev/null +++ b/backend/src/routes/upload.ts @@ -0,0 +1,42 @@ +import { FastifyPluginAsync } from 'fastify' +import { createWriteStream, mkdirSync } from 'fs' +import { join, extname } from 'path' +import { randomUUID } from 'crypto' +import { pipeline } from 'stream/promises' + +const UPLOADS_DIR = process.env.UPLOADS_DIR ?? join(process.cwd(), 'uploads') + +// Ensure uploads directory exists +mkdirSync(UPLOADS_DIR, { recursive: true }) + +const ALLOWED_TYPES = ['image/jpeg', 'image/png', 'image/webp', 'image/gif'] +const MAX_SIZE = 10 * 1024 * 1024 // 10MB + +const upload: FastifyPluginAsync = async (fastify) => { + // POST /api/upload — upload a single image, returns { url } + fastify.post( + '/api/upload', + { onRequest: [fastify.authenticate] }, + async (request, reply) => { + 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 ext = extname(data.filename) || '.jpg' + const filename = `${randomUUID()}${ext}` + const filepath = join(UPLOADS_DIR, filename) + + await pipeline(data.file, createWriteStream(filepath)) + + const host = (request.headers['x-forwarded-proto'] ?? 'https') + '://' + + (request.headers['x-forwarded-host'] ?? request.headers.host ?? 'api.hotelsync.ru') + + return { url: `${host}/uploads/${filename}` } + }, + ) +} + +export default upload diff --git a/src/lib/api.ts b/src/lib/api.ts index 644d6d0..43f570e 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -344,6 +344,23 @@ export const api = { req('DELETE', `/api/hotels/${slug}/rental-bookings/${id}`), }, + // ── File Upload ─────────────────────────────────────────────────────────── + upload: { + photo: async (file: File): Promise => { + const token = localStorage.getItem('access_token') + const fd = new FormData() + fd.append('file', file) + const res = await fetch(`${BASE}/api/upload`, { + method: 'POST', + headers: token ? { Authorization: `Bearer ${token}` } : {}, + body: fd, + }) + if (!res.ok) throw new Error(`Upload failed: ${res.statusText}`) + const data = await res.json() as { url: string } + return data.url + }, + }, + // ── Categories ──────────────────────────────────────────────────────────── categories: { list: (slug: string) => diff --git a/src/pages/RoomCategoriesPage.tsx b/src/pages/RoomCategoriesPage.tsx index 82ef339..b08dc4e 100644 --- a/src/pages/RoomCategoriesPage.tsx +++ b/src/pages/RoomCategoriesPage.tsx @@ -52,27 +52,35 @@ function CategoryForm({ category, onClose, onSave }: CategoryFormProps) { const toggleAmenity = (a: string) => setAmenities(prev => prev.includes(a) ? prev.filter(x => x !== a) : [...prev, a]) - const readFiles = (files: FileList | null) => { - Array.from(files ?? []).forEach(file => { - if (!file.type.startsWith('image/')) return - const reader = new FileReader() - reader.onload = ev => { - const url = ev.target?.result as string - setPhotos(prev => { const next = [...prev, url]; setPhotoIdx(next.length - 1); return next }) - } - reader.readAsDataURL(file) - }) + const [uploading, setUploading] = useState(false) + + const uploadFiles = async (files: FileList | null) => { + const list = Array.from(files ?? []).filter(f => f.type.startsWith('image/')) + if (!list.length) return + setUploading(true) + try { + const urls = await Promise.all(list.map(f => api.upload.photo(f))) + setPhotos(prev => { + const next = [...prev, ...urls] + setPhotoIdx(next.length - 1) + return next + }) + } catch { + setSaveError('Ошибка загрузки фото') + } finally { + setUploading(false) + } } const handlePhotoUpload = (e: React.ChangeEvent) => { - readFiles(e.target.files) + uploadFiles(e.target.files) e.target.value = '' } const handleDrop = (e: React.DragEvent) => { e.preventDefault() setDragging(false) - readFiles(e.dataTransfer.files) + uploadFiles(e.dataTransfer.files) } const removePhoto = (i: number) => { @@ -263,9 +271,9 @@ function CategoryForm({ category, onClose, onSave }: CategoryFormProps) { )} - )}