feat: organize uploads by folder + cdn.hotelsync.ru active

- Upload route now accepts ?folder=categories|rooms|hotels|guests
- Files saved to /uploads/{folder}/uuid.ext, served at cdn.hotelsync.ru/{folder}/uuid.ext
- api.upload.photo() accepts optional folder param (default: 'rooms')
- RoomCategoriesPage passes folder='categories' on photo upload
- SSL cert issued and nginx config active for cdn.hotelsync.ru

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-23 12:41:27 +03:00
parent f625d82cc5
commit dd75794a78
3 changed files with 21 additions and 10 deletions

View File

@@ -5,19 +5,28 @@ import { randomUUID } from 'crypto'
import { pipeline } from 'stream/promises'
const UPLOADS_DIR = process.env.UPLOADS_DIR ?? join(process.cwd(), 'uploads')
const CDN_HOST = process.env.CDN_HOST ?? 'cdn.hotelsync.ru'
// Ensure uploads directory exists
mkdirSync(UPLOADS_DIR, { recursive: true })
const ALLOWED_FOLDERS = ['categories', 'rooms', 'hotels', 'guests'] as const
type UploadFolder = typeof ALLOWED_FOLDERS[number]
const ALLOWED_TYPES = ['image/jpeg', 'image/png', 'image/webp', 'image/gif']
const MAX_SIZE = 10 * 1024 * 1024 // 10MB
// Ensure base uploads directory exists
mkdirSync(UPLOADS_DIR, { recursive: true })
const upload: FastifyPluginAsync = async (fastify) => {
// POST /api/upload — upload a single image, returns { url }
// POST /api/upload?folder=categories — upload a single image, returns { url }
fastify.post(
'/api/upload',
{ onRequest: [fastify.authenticate] },
async (request, reply) => {
const { folder } = request.query as { folder?: string }
const dir: UploadFolder = ALLOWED_FOLDERS.includes(folder as UploadFolder)
? (folder as UploadFolder)
: 'rooms'
const data = await request.file({ limits: { fileSize: MAX_SIZE } })
if (!data) return reply.code(400).send({ error: 'No file provided' })
@@ -25,14 +34,16 @@ const upload: FastifyPluginAsync = async (fastify) => {
return reply.code(400).send({ error: 'Only images allowed (jpg, png, webp, gif)' })
}
const ext = extname(data.filename) || '.jpg'
const subDir = join(UPLOADS_DIR, dir)
mkdirSync(subDir, { recursive: true })
const ext = extname(data.filename) || '.jpg'
const filename = `${randomUUID()}${ext}`
const filepath = join(UPLOADS_DIR, filename)
const filepath = join(subDir, filename)
await pipeline(data.file, createWriteStream(filepath))
const cdnHost = process.env.CDN_HOST ?? 'cdn.hotelsync.ru'
return { url: `https://${cdnHost}/${filename}` }
return { url: `https://${CDN_HOST}/${dir}/${filename}` }
},
)
}

View File

@@ -346,11 +346,11 @@ export const api = {
// ── File Upload ───────────────────────────────────────────────────────────
upload: {
photo: async (file: File): Promise<string> => {
photo: async (file: File, folder: 'categories' | 'rooms' | 'hotels' | 'guests' = 'rooms'): Promise<string> => {
const token = localStorage.getItem('access_token')
const fd = new FormData()
fd.append('file', file)
const res = await fetch(`${BASE}/api/upload`, {
const res = await fetch(`${BASE}/api/upload?folder=${folder}`, {
method: 'POST',
headers: token ? { Authorization: `Bearer ${token}` } : {},
body: fd,

View File

@@ -59,7 +59,7 @@ function CategoryForm({ category, onClose, onSave }: CategoryFormProps) {
if (!list.length) return
setUploading(true)
try {
const urls = await Promise.all(list.map(f => api.upload.photo(f)))
const urls = await Promise.all(list.map(f => api.upload.photo(f, 'categories')))
setPhotos(prev => {
const next = [...prev, ...urls]
setPhotoIdx(next.length - 1)