feat: real file upload to /opt/hotelsync/uploads + serve via /uploads/ on API
This commit is contained in:
@@ -13,7 +13,9 @@
|
|||||||
"@fastify/cors": "^9.0.1",
|
"@fastify/cors": "^9.0.1",
|
||||||
"@fastify/helmet": "^11.1.1",
|
"@fastify/helmet": "^11.1.1",
|
||||||
"@fastify/jwt": "^8.0.1",
|
"@fastify/jwt": "^8.0.1",
|
||||||
|
"@fastify/multipart": "^9.4.0",
|
||||||
"@fastify/rate-limit": "^9.1.0",
|
"@fastify/rate-limit": "^9.1.0",
|
||||||
|
"@fastify/static": "^9.0.0",
|
||||||
"@fastify/websocket": "^8.3.1",
|
"@fastify/websocket": "^8.3.1",
|
||||||
"bcryptjs": "^2.4.3",
|
"bcryptjs": "^2.4.3",
|
||||||
"fastify": "^4.28.1",
|
"fastify": "^4.28.1",
|
||||||
|
|||||||
@@ -4,6 +4,9 @@ import cookie from '@fastify/cookie'
|
|||||||
import cors from '@fastify/cors'
|
import cors from '@fastify/cors'
|
||||||
import helmet from '@fastify/helmet'
|
import helmet from '@fastify/helmet'
|
||||||
import rateLimit from '@fastify/rate-limit'
|
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 { config } from './config'
|
||||||
import './types' // side-effect: augments fastify types
|
import './types' // side-effect: augments fastify types
|
||||||
|
|
||||||
@@ -22,6 +25,7 @@ import hotelSettingsRoutes from './routes/hotel-settings'
|
|||||||
import rentalRoutes from './routes/rental'
|
import rentalRoutes from './routes/rental'
|
||||||
import categoriesRoutes from './routes/categories'
|
import categoriesRoutes from './routes/categories'
|
||||||
import tariffsRoutes from './routes/tariffs'
|
import tariffsRoutes from './routes/tariffs'
|
||||||
|
import uploadRoutes from './routes/upload'
|
||||||
|
|
||||||
export async function buildApp() {
|
export async function buildApp() {
|
||||||
const fastify = Fastify({
|
const fastify = Fastify({
|
||||||
@@ -34,6 +38,11 @@ export async function buildApp() {
|
|||||||
bodyLimit: 20 * 1024 * 1024, // 20MB — for base64 photo uploads
|
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 ───────────────────────────────────────────────────────────────
|
// ── Security ───────────────────────────────────────────────────────────────
|
||||||
await fastify.register(helmet, { contentSecurityPolicy: false })
|
await fastify.register(helmet, { contentSecurityPolicy: false })
|
||||||
|
|
||||||
@@ -86,6 +95,7 @@ export async function buildApp() {
|
|||||||
await fastify.register(rentalRoutes)
|
await fastify.register(rentalRoutes)
|
||||||
await fastify.register(categoriesRoutes)
|
await fastify.register(categoriesRoutes)
|
||||||
await fastify.register(tariffsRoutes)
|
await fastify.register(tariffsRoutes)
|
||||||
|
await fastify.register(uploadRoutes)
|
||||||
|
|
||||||
return fastify
|
return fastify
|
||||||
}
|
}
|
||||||
|
|||||||
42
backend/src/routes/upload.ts
Normal file
42
backend/src/routes/upload.ts
Normal file
@@ -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
|
||||||
@@ -344,6 +344,23 @@ export const api = {
|
|||||||
req<void>('DELETE', `/api/hotels/${slug}/rental-bookings/${id}`),
|
req<void>('DELETE', `/api/hotels/${slug}/rental-bookings/${id}`),
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// ── File Upload ───────────────────────────────────────────────────────────
|
||||||
|
upload: {
|
||||||
|
photo: async (file: File): Promise<string> => {
|
||||||
|
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 ────────────────────────────────────────────────────────────
|
||||||
categories: {
|
categories: {
|
||||||
list: (slug: string) =>
|
list: (slug: string) =>
|
||||||
|
|||||||
@@ -52,27 +52,35 @@ function CategoryForm({ category, onClose, onSave }: CategoryFormProps) {
|
|||||||
const toggleAmenity = (a: string) =>
|
const toggleAmenity = (a: string) =>
|
||||||
setAmenities(prev => prev.includes(a) ? prev.filter(x => x !== a) : [...prev, a])
|
setAmenities(prev => prev.includes(a) ? prev.filter(x => x !== a) : [...prev, a])
|
||||||
|
|
||||||
const readFiles = (files: FileList | null) => {
|
const [uploading, setUploading] = useState(false)
|
||||||
Array.from(files ?? []).forEach(file => {
|
|
||||||
if (!file.type.startsWith('image/')) return
|
const uploadFiles = async (files: FileList | null) => {
|
||||||
const reader = new FileReader()
|
const list = Array.from(files ?? []).filter(f => f.type.startsWith('image/'))
|
||||||
reader.onload = ev => {
|
if (!list.length) return
|
||||||
const url = ev.target?.result as string
|
setUploading(true)
|
||||||
setPhotos(prev => { const next = [...prev, url]; setPhotoIdx(next.length - 1); return next })
|
try {
|
||||||
}
|
const urls = await Promise.all(list.map(f => api.upload.photo(f)))
|
||||||
reader.readAsDataURL(file)
|
setPhotos(prev => {
|
||||||
})
|
const next = [...prev, ...urls]
|
||||||
|
setPhotoIdx(next.length - 1)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
setSaveError('Ошибка загрузки фото')
|
||||||
|
} finally {
|
||||||
|
setUploading(false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handlePhotoUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handlePhotoUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
readFiles(e.target.files)
|
uploadFiles(e.target.files)
|
||||||
e.target.value = ''
|
e.target.value = ''
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleDrop = (e: React.DragEvent) => {
|
const handleDrop = (e: React.DragEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
setDragging(false)
|
setDragging(false)
|
||||||
readFiles(e.dataTransfer.files)
|
uploadFiles(e.dataTransfer.files)
|
||||||
}
|
}
|
||||||
|
|
||||||
const removePhoto = (i: number) => {
|
const removePhoto = (i: number) => {
|
||||||
@@ -263,9 +271,9 @@ function CategoryForm({ category, onClose, onSave }: CategoryFormProps) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<input ref={fileRef} type="file" accept="image/*" multiple className="hidden" onChange={handlePhotoUpload} />
|
<input ref={fileRef} type="file" accept="image/*" multiple className="hidden" onChange={handlePhotoUpload} />
|
||||||
<button onClick={() => fileRef.current?.click()} className="btn-secondary w-full justify-center">
|
<button onClick={() => fileRef.current?.click()} className="btn-secondary w-full justify-center" disabled={uploading}>
|
||||||
<ImagePlus size={14} />
|
{uploading ? <Loader2 size={14} className="animate-spin" /> : <ImagePlus size={14} />}
|
||||||
Загрузить фото
|
{uploading ? 'Загрузка...' : 'Загрузить фото'}
|
||||||
</button>
|
</button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
Reference in New Issue
Block a user