feat: image upload & preview untuk bug reports via MinIO

- Upload hingga 3 gambar per bug report (FileInput multi-select)
- Backend: POST /api/upload/image → MinIO, GET /api/bugs/images → presigned URL redirect
- Auto-create bucket jika belum ada saat server start
- Preview gambar fullscreen saat thumbnail diklik
- Diterapkan di /bug-reports dan /apps/$appId/errors
- Migrasi storage dari Seafile ke MinIO (minio SDK v8)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-24 17:36:32 +08:00
parent a0ca6be8e1
commit 63c0a6acff
9 changed files with 535 additions and 24 deletions

View File

@@ -6,6 +6,7 @@ import { BugSource } from '../generated/prisma'
import { prisma } from './lib/db'
import { env } from './lib/env'
import { createSystemLog } from './lib/logger'
import { getMinioDownloadUrl, uploadBugImage } from './lib/minio'
interface AuthResult {
actingUserId: string
@@ -624,8 +625,8 @@ export function createApp() {
description: body.description,
stackTrace: body.stackTrace,
userId: reporterUserId,
images: body.imageUrl ? {
create: { imageUrl: body.imageUrl }
images: body.imageUrls?.length ? {
createMany: { data: body.imageUrls.map(imageUrl => ({ imageUrl })) }
} : undefined,
logs: {
create: {
@@ -649,7 +650,7 @@ export function createApp() {
source: t.Optional(t.String({
description: 'Sumber laporan: QC | SYSTEM | USER',
})),
imageUrl: t.Optional(t.String({ description: 'URL gambar screenshot bug (opsional)' })),
imageUrls: t.Optional(t.Array(t.String(), { description: 'URL gambar screenshot bug, maks 3 (opsional)' })),
}),
detail: {
summary: 'Create Bug Report',
@@ -756,6 +757,54 @@ export function createApp() {
},
})
// ─── Image Upload & Proxy ──────────────────────────
.post('/api/upload/image', async ({ body, request, set }) => {
const auth = await checkAuth(request)
if (!auth) {
set.status = 401
return { error: 'Unauthorized' }
}
const { file } = body
if (!file.type.startsWith('image/')) {
set.status = 400
return { error: 'Hanya file gambar yang diperbolehkan' }
}
if (file.size > 5 * 1024 * 1024) {
set.status = 400
return { error: 'Ukuran file maksimal 5MB' }
}
const path = await uploadBugImage(file)
return { url: `/api/bugs/images?path=${encodeURIComponent(path)}` }
}, {
body: t.Object({
file: t.File({ description: 'File gambar screenshot bug (maks 5MB)' }),
}),
detail: {
summary: 'Upload Bug Image',
description: 'Upload gambar screenshot bug ke MinIO. Mengembalikan URL proxy yang dapat langsung dipakai sebagai imageUrl pada POST /api/bugs.',
tags: ['Bugs'],
},
})
.get('/api/bugs/images', async ({ query, set }) => {
try {
const downloadUrl = await getMinioDownloadUrl(query.path)
return new Response(null, { status: 302, headers: { Location: downloadUrl } })
} catch {
set.status = 404
return { error: 'Gambar tidak ditemukan' }
}
}, {
query: t.Object({
path: t.String({ description: 'Path file di Seafile (contoh: /bug-reports/uuid.jpg)' }),
}),
detail: {
summary: 'Get Bug Image',
description: 'Proxy gambar bug dari MinIO. Meredirect ke presigned download URL MinIO (valid 1 jam).',
tags: ['Bugs'],
},
})
// ─── System Status API ─────────────────────────────
.get('/api/system/status', async () => {
try {