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 Prisma schema for potential migrations
|
||||||
COPY --from=builder /app/prisma ./prisma/
|
COPY --from=builder /app/prisma ./prisma/
|
||||||
|
|
||||||
# Create db directory
|
# Create db and uploads directories
|
||||||
RUN mkdir -p /app/db && chown nextjs:nodejs /app/db
|
RUN mkdir -p /app/db /app/uploads && chown nextjs:nodejs /app/db /app/uploads
|
||||||
|
|
||||||
# Switch to non-root
|
# Switch to non-root
|
||||||
USER nextjs
|
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 [formCategories, setFormCategories] = useState<TreeCategory[]>([]);
|
||||||
const [formSubcategories, setFormSubcategories] = useState<TreeSubcategory[]>([]);
|
const [formSubcategories, setFormSubcategories] = useState<TreeSubcategory[]>([]);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [uploadingPhoto, setUploadingPhoto] = useState(false);
|
||||||
|
const [uploadingHidden, setUploadingHidden] = useState(false);
|
||||||
|
|
||||||
// Delete confirm
|
// Delete confirm
|
||||||
const [deleteTarget, setDeleteTarget] = useState<{
|
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) => {
|
const handleRename = async (type: string, id: number) => {
|
||||||
if (!renameValue.trim()) {
|
if (!renameValue.trim()) {
|
||||||
setRenamingId(null);
|
setRenamingId(null);
|
||||||
@@ -1440,25 +1466,63 @@ export function CatalogPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Row 4: Photo URLs */}
|
{/* Row 4: Photo Uploads */}
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||||
<div className="space-y-1.5">
|
<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
|
<Input
|
||||||
value={formData.photoUrl}
|
type="file"
|
||||||
onChange={(e) => setFormData({ ...formData, photoUrl: e.target.value })}
|
accept="image/*"
|
||||||
placeholder="https://..."
|
onChange={(e) => handlePhotoUpload(e, "photoUrl")}
|
||||||
|
disabled={uploadingPhoto}
|
||||||
className="h-9 text-sm"
|
className="h-9 text-sm"
|
||||||
/>
|
/>
|
||||||
|
{uploadingPhoto && <span className="text-xs text-muted-foreground">Uploading...</span>}
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1.5">
|
<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
|
<Input
|
||||||
value={formData.hiddenPhotoUrl}
|
type="file"
|
||||||
onChange={(e) => setFormData({ ...formData, hiddenPhotoUrl: e.target.value })}
|
accept="image/*"
|
||||||
placeholder="https://..."
|
onChange={(e) => handlePhotoUpload(e, "hiddenPhotoUrl")}
|
||||||
|
disabled={uploadingHidden}
|
||||||
className="h-9 text-sm"
|
className="h-9 text-sm"
|
||||||
/>
|
/>
|
||||||
|
{uploadingHidden && <span className="text-xs text-muted-foreground">Uploading...</span>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -75,6 +75,7 @@ services:
|
|||||||
- WG_PRESHARED_KEY=${WG_PRESHARED_KEY}
|
- WG_PRESHARED_KEY=${WG_PRESHARED_KEY}
|
||||||
volumes:
|
volumes:
|
||||||
- ./db:/app/db
|
- ./db:/app/db
|
||||||
|
- ./uploads:/app/uploads
|
||||||
deploy:
|
deploy:
|
||||||
resources:
|
resources:
|
||||||
limits:
|
limits:
|
||||||
|
|||||||
Reference in New Issue
Block a user