From 301763f428f28dcb393ed19b88254a985184965a Mon Sep 17 00:00:00 2001 From: NW Date: Mon, 10 Aug 2026 09:38:31 +0100 Subject: [PATCH] =?UTF-8?q?feat(admin):=20issue=20#146=20=E2=80=94=20photo?= =?UTF-8?q?=20upload=20with=20sharp=20crop/optimization=20(restore=20old?= =?UTF-8?q?=20behavior)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - POST /api/upload: FormData, 10MB limit, sharp resize 800px inside + webp@80, saves to /app/uploads, returns /uploads/url - GET /uploads/[...path]: serves files with Content-Type, path traversal protected - compose: ./uploads mounted in tg_shop_admin - Dockerfile: /app/uploads created + chown nextjs - catalog-page: file input + thumbnail preview + remove for photoUrl/hiddenPhotoUrl - Bot resolvePhotoSource already supports /uploads/ paths — no bot changes --- admin-next/Dockerfile | 4 +- admin-next/src/app/api/upload/route.ts | 50 +++++++++++ admin-next/src/app/uploads/[...path]/route.ts | 50 +++++++++++ .../src/components/catalog/catalog-page.tsx | 82 +++++++++++++++++-- docker-compose.yml | 1 + 5 files changed, 176 insertions(+), 11 deletions(-) create mode 100644 admin-next/src/app/api/upload/route.ts create mode 100644 admin-next/src/app/uploads/[...path]/route.ts diff --git a/admin-next/Dockerfile b/admin-next/Dockerfile index 8cabb90..6891ead 100644 --- a/admin-next/Dockerfile +++ b/admin-next/Dockerfile @@ -54,8 +54,8 @@ COPY --from=builder /app/.next/standalone/.next ./.next # Copy Prisma schema for potential migrations COPY --from=builder /app/prisma ./prisma/ -# Create db directory -RUN mkdir -p /app/db && chown nextjs:nodejs /app/db +# Create db and uploads directories +RUN mkdir -p /app/db /app/uploads && chown nextjs:nodejs /app/db /app/uploads # Switch to non-root USER nextjs diff --git a/admin-next/src/app/api/upload/route.ts b/admin-next/src/app/api/upload/route.ts new file mode 100644 index 0000000..9f19106 --- /dev/null +++ b/admin-next/src/app/api/upload/route.ts @@ -0,0 +1,50 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getAuth } from '@/lib/auth-middleware'; +import sharp from 'sharp'; +import { mkdir, writeFile } from 'fs/promises'; +import { randomBytes } from 'crypto'; +import path from 'path'; + +const UPLOADS_DIR = '/app/uploads'; +const MAX_SIZE = 10 * 1024 * 1024; // 10MB + +export async function POST(request: NextRequest) { + const auth = getAuth(request); + if ('status' in auth) return auth; + + try { + const formData = await request.formData(); + const file = formData.get('file') as File | null; + + if (!file) { + return NextResponse.json({ error: 'No file provided' }, { status: 400 }); + } + + if (file.size > MAX_SIZE) { + return NextResponse.json({ error: 'File too large (max 10MB)' }, { status: 400 }); + } + + if (!file.type.startsWith('image/')) { + return NextResponse.json({ error: 'Only image files are allowed' }, { status: 400 }); + } + + const buf = Buffer.from(await file.arrayBuffer()); + + const optimized = await sharp(buf) + .resize(800, 800, { fit: 'inside', withoutEnlargement: true }) + .webp({ quality: 80 }) + .toBuffer(); + + await mkdir(UPLOADS_DIR, { recursive: true }); + + const filename = `${Date.now()}-${randomBytes(4).toString('hex')}.webp`; + const filePath = path.join(UPLOADS_DIR, filename); + + await writeFile(filePath, optimized); + + return NextResponse.json({ url: `/uploads/${filename}` }); + } catch (err) { + console.error('Upload error:', err); + return NextResponse.json({ error: 'Upload failed' }, { status: 500 }); + } +} diff --git a/admin-next/src/app/uploads/[...path]/route.ts b/admin-next/src/app/uploads/[...path]/route.ts new file mode 100644 index 0000000..e58d2d3 --- /dev/null +++ b/admin-next/src/app/uploads/[...path]/route.ts @@ -0,0 +1,50 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { readFile, stat } from 'fs/promises'; +import path from 'path'; + +const UPLOADS_DIR = '/app/uploads'; + +const MIME_MAP: Record = { + '.webp': 'image/webp', + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.png': 'image/png', + '.gif': 'image/gif', +}; + +export async function GET( + _request: NextRequest, + { params }: { params: Promise<{ path: string[] }> } +) { + const { path: pathSegments } = await params; + + // Path traversal protection + if (pathSegments.some(seg => seg === '..' || seg.includes('..'))) { + return NextResponse.json({ error: 'Not found' }, { status: 404 }); + } + + const filePath = path.join(UPLOADS_DIR, ...pathSegments); + + // Ensure resolved path is within UPLOADS_DIR + if (!filePath.startsWith(UPLOADS_DIR)) { + return NextResponse.json({ error: 'Not found' }, { status: 404 }); + } + + try { + await stat(filePath); + } catch { + return NextResponse.json({ error: 'Not found' }, { status: 404 }); + } + + const ext = path.extname(filePath).toLowerCase(); + const contentType = MIME_MAP[ext] || 'application/octet-stream'; + + const buffer = await readFile(filePath); + + return new NextResponse(buffer, { + headers: { + 'Content-Type': contentType, + 'Cache-Control': 'public, max-age=31536000, immutable', + }, + }); +} diff --git a/admin-next/src/components/catalog/catalog-page.tsx b/admin-next/src/components/catalog/catalog-page.tsx index 285afc8..91c786a 100755 --- a/admin-next/src/components/catalog/catalog-page.tsx +++ b/admin-next/src/components/catalog/catalog-page.tsx @@ -204,6 +204,8 @@ export function CatalogPage() { const [formCategories, setFormCategories] = useState([]); const [formSubcategories, setFormSubcategories] = useState([]); const [saving, setSaving] = useState(false); + const [uploadingPhoto, setUploadingPhoto] = useState(false); + const [uploadingHidden, setUploadingHidden] = useState(false); // Delete confirm const [deleteTarget, setDeleteTarget] = useState<{ @@ -404,6 +406,30 @@ export function CatalogPage() { } }; + const handlePhotoUpload = async (e: React.ChangeEvent, field: 'photoUrl' | 'hiddenPhotoUrl') => { + const file = e.target.files?.[0]; + if (!file) return; + + const setter = field === 'photoUrl' ? setUploadingPhoto : setUploadingHidden; + setter(true); + try { + const fd = new FormData(); + fd.append('file', file); + const res = await fetch('/api/upload', { method: 'POST', body: fd }); + if (!res.ok) { + const data = await res.json(); + throw new Error(data.error || 'Upload failed'); + } + const data = await res.json(); + setFormData(prev => ({ ...prev, [field]: data.url })); + toast.success('Photo uploaded'); + } catch (err) { + toast.error(err instanceof Error ? err.message : 'Upload failed'); + } finally { + setter(false); + } + }; + const handleRename = async (type: string, id: number) => { if (!renameValue.trim()) { setRenamingId(null); @@ -1440,25 +1466,63 @@ export function CatalogPage() { /> - {/* Row 4: Photo URLs */} + {/* Row 4: Photo Uploads */}
- + + {formData.photoUrl ? ( +
+ Product photo + +
+ ) : null} setFormData({ ...formData, photoUrl: e.target.value })} - placeholder="https://..." + type="file" + accept="image/*" + onChange={(e) => handlePhotoUpload(e, "photoUrl")} + disabled={uploadingPhoto} className="h-9 text-sm" /> + {uploadingPhoto && Uploading...}
- + + {formData.hiddenPhotoUrl ? ( +
+ Hidden photo + +
+ ) : null} setFormData({ ...formData, hiddenPhotoUrl: e.target.value })} - placeholder="https://..." + type="file" + accept="image/*" + onChange={(e) => handlePhotoUpload(e, "hiddenPhotoUrl")} + disabled={uploadingHidden} className="h-9 text-sm" /> + {uploadingHidden && Uploading...}
diff --git a/docker-compose.yml b/docker-compose.yml index 73b699e..bda2ee7 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -75,6 +75,7 @@ services: - WG_PRESHARED_KEY=${WG_PRESHARED_KEY} volumes: - ./db:/app/db + - ./uploads:/app/uploads deploy: resources: limits: