feat(admin): issue #146 — photo upload with sharp crop/optimization (restore old behavior)

- 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
This commit is contained in:
NW
2026-08-10 09:38:31 +01:00
parent 792efc07ce
commit 301763f428
5 changed files with 176 additions and 11 deletions

View File

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

View File

@@ -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 });
}
}

View File

@@ -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<string, string> = {
'.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',
},
});
}

View File

@@ -204,6 +204,8 @@ export function CatalogPage() {
const [formCategories, setFormCategories] = useState<TreeCategory[]>([]);
const [formSubcategories, setFormSubcategories] = useState<TreeSubcategory[]>([]);
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<HTMLInputElement>, 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() {
/>
</div>
{/* Row 4: Photo URLs */}
{/* Row 4: Photo Uploads */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label className="text-xs">Photo URL</Label>
<Label className="text-xs">Photo</Label>
{formData.photoUrl ? (
<div className="flex items-center gap-2">
<img
src={formData.photoUrl}
alt="Product photo"
className="h-16 w-16 object-cover rounded border"
/>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => setFormData({ ...formData, photoUrl: "" })}
>
Remove
</Button>
</div>
) : null}
<Input
value={formData.photoUrl}
onChange={(e) => 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 && <span className="text-xs text-muted-foreground">Uploading...</span>}
</div>
<div className="space-y-1.5">
<Label className="text-xs">Hidden Photo URL</Label>
<Label className="text-xs">Hidden Photo</Label>
{formData.hiddenPhotoUrl ? (
<div className="flex items-center gap-2">
<img
src={formData.hiddenPhotoUrl}
alt="Hidden photo"
className="h-16 w-16 object-cover rounded border"
/>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => setFormData({ ...formData, hiddenPhotoUrl: "" })}
>
Remove
</Button>
</div>
) : null}
<Input
value={formData.hiddenPhotoUrl}
onChange={(e) => 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 && <span className="text-xs text-muted-foreground">Uploading...</span>}
</div>
</div>