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:
@@ -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
|
||||
|
||||
50
admin-next/src/app/api/upload/route.ts
Normal file
50
admin-next/src/app/api/upload/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
50
admin-next/src/app/uploads/[...path]/route.ts
Normal file
50
admin-next/src/app/uploads/[...path]/route.ts
Normal 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',
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user