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 {

View File

@@ -37,6 +37,7 @@ export const API_URLS = {
getBugs: (page: number, search: string, app: string, status: string) =>
`/api/bugs?page=${page}&search=${encodeURIComponent(search)}&app=${app}&status=${status}`,
createBug: () => `/api/bugs`,
uploadImage: () => `/api/upload/image`,
updateBugStatus: (id: string) => `/api/bugs/${id}/status`,
updateBugFeedback: (id: string) => `/api/bugs/${id}/feedback`,
createLog: () => `/api/logs`,

View File

@@ -6,6 +6,7 @@ import {
Button,
Code,
Collapse,
FileInput,
Group,
Image,
Loader,
@@ -79,9 +80,13 @@ function AppErrorsPage() {
queryFn: () => fetch('/api/apps').then((r) => r.json()),
})
// Image Preview
const [previewImage, setPreviewImage] = useState<string | null>(null)
// Create Bug Modal Logic
const [opened, { open, close }] = useDisclosure(false)
const [isSubmitting, setIsSubmitting] = useState(false)
const [imageFiles, setImageFiles] = useState<File[]>([])
const [createForm, setCreateForm] = useState({
description: '',
app: appId,
@@ -90,7 +95,6 @@ function AppErrorsPage() {
device: '',
os: '',
stackTrace: '',
imageUrl: '',
})
// Update Status Modal Logic
@@ -193,10 +197,20 @@ function AppErrorsPage() {
setIsSubmitting(true)
try {
const imageUrls: string[] = []
for (const file of imageFiles) {
const formData = new FormData()
formData.append('file', file)
const uploadRes = await fetch(API_URLS.uploadImage(), { method: 'POST', body: formData })
if (!uploadRes.ok) throw new Error('Gagal mengupload gambar')
const { url } = await uploadRes.json()
imageUrls.push(url)
}
const res = await fetch(API_URLS.createBug(), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(createForm),
body: JSON.stringify({ ...createForm, imageUrls: imageUrls.length ? imageUrls : undefined }),
})
if (res.ok) {
@@ -214,6 +228,7 @@ function AppErrorsPage() {
})
refetch()
close()
setImageFiles([])
setCreateForm({
description: '',
app: appId,
@@ -222,7 +237,6 @@ function AppErrorsPage() {
device: '',
os: '',
stackTrace: '',
imageUrl: '',
})
} else {
throw new Error('Failed to create error report')
@@ -259,6 +273,28 @@ function AppErrorsPage() {
</Button>
</Group>
{/* Image Preview Modal */}
<Modal
opened={!!previewImage}
onClose={() => setPreviewImage(null)}
size="xl"
radius="xl"
padding={0}
withCloseButton={false}
overlayProps={{ backgroundOpacity: 0.75, blur: 6 }}
styles={{ content: { background: 'transparent', boxShadow: 'none' } }}
onClick={() => setPreviewImage(null)}
>
{previewImage && (
<Image
src={previewImage}
alt="Preview"
fit="contain"
style={{ maxHeight: '85vh', width: '100%' }}
/>
)}
</Modal>
<Modal
opened={updateModalOpened}
onClose={closeUpdateModal}
@@ -334,7 +370,7 @@ function AppErrorsPage() {
<Modal
opened={opened}
onClose={close}
onClose={() => { close(); setImageFiles([]); }}
title={<Text fw={700} size="lg">Report New Error</Text>}
radius="xl"
size="lg"
@@ -396,11 +432,22 @@ function AppErrorsPage() {
/>
</SimpleGrid>
<TextInput
label="Image URL (Optional)"
placeholder="https://example.com/screenshot.png"
value={createForm.imageUrl}
onChange={(e) => setCreateForm({ ...createForm, imageUrl: e.target.value })}
<FileInput
label="Screenshot (Optional)"
placeholder="Klik untuk upload gambar..."
accept="image/*"
leftSection={<TbPhoto size={16} />}
description="Maks 3 gambar · 5MB per file · JPG, PNG, WEBP"
value={imageFiles}
onChange={(files) => {
if (files.length > 3) {
notifications.show({ title: 'Error', message: 'Maksimal 3 gambar', color: 'red' })
return
}
setImageFiles(files)
}}
clearable
multiple
/>
<Textarea
@@ -606,7 +653,13 @@ function AppErrorsPage() {
</Group>
<SimpleGrid cols={{ base: 2, sm: 4 }} spacing="xs">
{bug.images.map((img: any) => (
<Paper key={img.id} withBorder radius="md" style={{ overflow: 'hidden' }}>
<Paper
key={img.id}
withBorder
radius="md"
style={{ overflow: 'hidden', cursor: 'zoom-in' }}
onClick={() => setPreviewImage(img.imageUrl)}
>
<Image src={img.imageUrl} alt="Error screenshot" height={100} fit="cover" />
</Paper>
))}

View File

@@ -20,6 +20,7 @@ import {
Stack,
Text,
ThemeIcon,
FileInput,
TextInput,
Textarea,
Title,
@@ -76,9 +77,13 @@ function ListErrorsPage() {
queryFn: () => fetch('/api/apps').then((r) => r.json()),
})
// Image Preview
const [previewImage, setPreviewImage] = useState<string | null>(null)
// Create Bug Modal Logic
const [opened, { open, close }] = useDisclosure(false)
const [isSubmitting, setIsSubmitting] = useState(false)
const [imageFiles, setImageFiles] = useState<File[]>([])
const [createForm, setCreateForm] = useState({
description: '',
app: 'desa-plus',
@@ -87,7 +92,6 @@ function ListErrorsPage() {
device: '',
os: '',
stackTrace: '',
imageUrl: '',
})
// Update Status Modal Logic
@@ -190,10 +194,20 @@ function ListErrorsPage() {
setIsSubmitting(true)
try {
const imageUrls: string[] = []
for (const file of imageFiles) {
const formData = new FormData()
formData.append('file', file)
const uploadRes = await fetch(API_URLS.uploadImage(), { method: 'POST', body: formData })
if (!uploadRes.ok) throw new Error('Gagal mengupload gambar')
const { url } = await uploadRes.json()
imageUrls.push(url)
}
const res = await fetch(API_URLS.createBug(), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(createForm),
body: JSON.stringify({ ...createForm, imageUrls: imageUrls.length ? imageUrls : undefined }),
})
if (res.ok) {
@@ -211,6 +225,7 @@ function ListErrorsPage() {
})
refetch()
close()
setImageFiles([])
setCreateForm({
description: '',
app: 'desa-plus',
@@ -219,7 +234,6 @@ function ListErrorsPage() {
device: '',
os: '',
stackTrace: '',
imageUrl: '',
})
} else {
throw new Error('Failed to create error report')
@@ -267,6 +281,28 @@ function ListErrorsPage() {
</Group>
</Group>
{/* Image Preview Modal */}
<Modal
opened={!!previewImage}
onClose={() => setPreviewImage(null)}
size="xl"
radius="xl"
padding={0}
withCloseButton={false}
overlayProps={{ backgroundOpacity: 0.75, blur: 6 }}
styles={{ content: { background: 'transparent', boxShadow: 'none' } }}
onClick={() => setPreviewImage(null)}
>
{previewImage && (
<Image
src={previewImage}
alt="Preview"
fit="contain"
style={{ maxHeight: '85vh', width: '100%' }}
/>
)}
</Modal>
<Modal
opened={updateModalOpened}
onClose={closeUpdateModal}
@@ -342,7 +378,7 @@ function ListErrorsPage() {
<Modal
opened={opened}
onClose={close}
onClose={() => { close(); setImageFiles([]); }}
title={<Text fw={700} size="lg">Report New Error</Text>}
radius="xl"
size="lg"
@@ -404,11 +440,22 @@ function ListErrorsPage() {
/>
</SimpleGrid>
<TextInput
label="Image URL (Optional)"
placeholder="https://example.com/screenshot.png"
value={createForm.imageUrl}
onChange={(e) => setCreateForm({ ...createForm, imageUrl: e.target.value })}
<FileInput
label="Screenshot (Optional)"
placeholder="Klik untuk upload gambar..."
accept="image/*"
leftSection={<TbPhoto size={16} />}
description="Maks 3 gambar · 5MB per file · JPG, PNG, WEBP"
value={imageFiles}
onChange={(files) => {
if (files.length > 3) {
notifications.show({ title: 'Error', message: 'Maksimal 3 gambar', color: 'red' })
return
}
setImageFiles(files)
}}
clearable
multiple
/>
<Textarea
@@ -625,7 +672,13 @@ function ListErrorsPage() {
</Group>
<SimpleGrid cols={{ base: 2, sm: 4 }} spacing="xs">
{bug.images.map((img: any) => (
<Paper key={img.id} withBorder radius="md" style={{ overflow: 'hidden' }}>
<Paper
key={img.id}
withBorder
radius="md"
style={{ overflow: 'hidden', cursor: 'zoom-in' }}
onClick={() => setPreviewImage(img.imageUrl)}
>
<Image src={img.imageUrl} alt="Error screenshot" height={100} fit="cover" />
</Paper>
))}

View File

@@ -17,4 +17,11 @@ export const env = {
GOOGLE_CLIENT_SECRET: required('GOOGLE_CLIENT_SECRET'),
SUPER_ADMIN_EMAILS: optional('SUPER_ADMIN_EMAIL', '').split(',').map(e => e.trim()).filter(Boolean),
API_KEY: required('API_KEY'),
MINIO_ENDPOINT: required('MINIO_ENDPOINT'),
MINIO_PORT: parseInt(optional('MINIO_PORT', '443'), 10),
MINIO_USE_SSL: optional('MINIO_USE_SSL', 'true') === 'true',
MINIO_ACCESS_KEY: required('MINIO_ACCESS_KEY'),
MINIO_SECRET_KEY: required('MINIO_SECRET_KEY'),
MINIO_BUCKET: required('MINIO_BUCKET'),
MINIO_UPLOAD_DIR: optional('MINIO_UPLOAD_DIR', 'bug-reports'),
} as const

36
src/lib/minio.ts Normal file
View File

@@ -0,0 +1,36 @@
import { Client } from 'minio'
import { env } from './env'
const client = new Client({
endPoint: env.MINIO_ENDPOINT,
port: env.MINIO_PORT,
useSSL: env.MINIO_USE_SSL,
accessKey: env.MINIO_ACCESS_KEY,
secretKey: env.MINIO_SECRET_KEY,
})
// Auto-create bucket if it doesn't exist
client.bucketExists(env.MINIO_BUCKET).then(async (exists) => {
if (!exists) {
await client.makeBucket(env.MINIO_BUCKET)
console.log(`[MinIO] Bucket "${env.MINIO_BUCKET}" created.`)
}
}).catch((err) => {
console.error('[MinIO] Failed to check/create bucket:', err.message)
})
export async function uploadBugImage(file: File): Promise<string> {
const ext = file.name.split('.').pop()?.toLowerCase() ?? 'bin'
const objectName = `${env.MINIO_UPLOAD_DIR}/${crypto.randomUUID()}.${ext}`
const buffer = Buffer.from(await file.arrayBuffer())
await client.putObject(env.MINIO_BUCKET, objectName, buffer, file.size, {
'Content-Type': file.type,
})
return objectName // e.g. bug-reports/uuid.jpg
}
export async function getMinioDownloadUrl(objectName: string): Promise<string> {
return client.presignedGetObject(env.MINIO_BUCKET, objectName, 3600)
}

252
src/lib/seafile.ts Normal file
View File

@@ -0,0 +1,252 @@
#!/usr/bin/env bun
import { promises as fs } from 'fs';
import * as os from 'os';
import * as path from 'path';
// --- Constants ---
const CONFIG_FILE = path.join(os.homedir(), '.note.conf');
// --- Types ---
interface Config {
TOKEN?: string;
REPO?: string;
URL?: string;
}
export const defaultConfigSF: Config = {
TOKEN: process.env.SF_TOKEN,
REPO: process.env.SF_REPO,
URL: process.env.SF_URL,
}
export async function loadConfig(): Promise<Config> {
if (!(await fs.stat(CONFIG_FILE)).isFile()) {
console.error(`⚠️ Config file not found at ${CONFIG_FILE}`);
console.error('Run: bun note.ts config to create/edit it.');
process.exit(1);
}
const configContent = await fs.readFile(CONFIG_FILE, 'utf8');
const config: Config = {};
configContent.split('\n').forEach((line) => {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) return;
const [key, ...valueParts] = trimmed.split('=');
if (key && valueParts.length > 0) {
let value = valueParts.join('=').trim();
if (
(value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))
) {
value = value.slice(1, -1);
}
config[key as keyof Config] = value;
}
});
if (!config.TOKEN || !config.REPO || !config.URL) {
console.error(`❌ Config invalid. Please set TOKEN, REPO, and URL inside ${CONFIG_FILE}`);
process.exit(1);
}
return config;
}
// --- HTTP Helpers ---
export async function fetchWithAuth(config: Config, url: string, options: RequestInit = {}): Promise<Response> {
const headers = {
Authorization: `Token ${config.TOKEN}`,
...options.headers,
};
const response = await fetch(url, { ...options, headers });
if (!response.ok) {
console.error(`❌ Request failed: ${response.status} ${response.statusText}`);
console.error(`🔍 URL: ${url}`);
console.error(`🔍 Headers:`, headers);
try {
const errorText = await response.text();
console.error(`🔍 Response body: ${errorText}`);
} catch {
console.error('🔍 Could not read response body');
}
}
return response;
}
// --- Commands ---
export async function testConnection(config: Config): Promise<string> {
try {
const response = await fetchWithAuth(config, `${config.URL}/ping/`);
return `✅ API connection successful: ${await response.text()}`
} catch {
// return '⚠️ API ping failed, trying repo access...'
try {
await fetchWithAuth(config, `${config.URL}/${config.REPO}/`);
return `✅ Repo access successful`
} catch {
return '❌ Both API ping and repo access failed'
}
}
}
export async function listFiles(config: Config): Promise<{ name: string }[]> {
const url = `${config.URL}/${config.REPO}/dir/?p=/`;
const response = await fetchWithAuth(config, url);
try {
const files = (await response.json()) as { name: string }[];
return files
} catch {
console.error('❌ Failed to parse response');
process.exit(1);
}
}
export async function catFile(config: Config, folder: string, fileName: string): Promise<ArrayBuffer> {
const downloadUrlResponse = await fetchWithAuth(config, `${config.URL}/${config.REPO}/file/?p=/${folder}/${fileName}`);
const downloadUrl = (await downloadUrlResponse.text()).replace(/"/g, '');
// Download file sebagai binary, BUKAN text
const fileResponse = await fetchWithAuth(config, downloadUrl);
const buffer = await fileResponse.arrayBuffer();
return buffer;
}
export async function uploadFile(config: Config, file: File, folder: string): Promise<string> {
const remoteName = path.basename(file.name);
// 1. Dapatkan upload link (pakai Authorization)
const uploadUrlResponse = await fetchWithAuth(
config,
`${config.URL}/${config.REPO}/upload-link/`
);
const uploadUrl = (await uploadUrlResponse.text()).replace(/"/g, "");
// 2. Siapkan form-data
const formData = new FormData();
formData.append("parent_dir", "/");
formData.append("relative_path", folder); // tanpa slash di akhir
formData.append("file", file, remoteName); // file langsung, jangan pakai Blob
// 3. Upload file TANPA Authorization header, token di query param
const res = await fetch(`${uploadUrl}?token=${config.TOKEN}`, {
method: "POST",
body: formData,
});
const text = await res.text();
if (!res.ok) return 'gagal'
return `✅ Uploaded ${file.name} successfully`;
}
export async function uploadFileBase64(config: Config, base64File: { name: string; data: string; }): Promise<string> {
const remoteName = path.basename(base64File.name);
// 1. Dapatkan upload link (pakai Authorization)
const uploadUrlResponse = await fetchWithAuth(
config,
`${config.URL}/${config.REPO}/upload-link/`
);
const uploadUrl = (await uploadUrlResponse.text()).replace(/"/g, "");
// 2. Konversi base64 ke Blob
const binary = Buffer.from(base64File.data, "base64");
const blob = new Blob([binary]);
// 3. Siapkan form-data
const formData = new FormData();
formData.append("parent_dir", "/");
formData.append("relative_path", "syarat-dokumen"); // tanpa slash di akhir
formData.append("file", blob, remoteName);
// 4. Upload file TANPA Authorization header, token di query param
const res = await fetch(`${uploadUrl}?token=${config.TOKEN}`, {
method: "POST",
body: formData,
});
const text = await res.text();
if (!res.ok) throw new Error(`Upload failed: ${text}`);
return `✅ Uploaded ${base64File.name} successfully`;
}
export async function uploadFileToFolder(config: Config, base64File: { name: string; data: string; }, folder: 'syarat-dokumen' | 'pengaduan'): Promise<string> {
const remoteName = path.basename(base64File.name);
// 1. Dapatkan upload link (pakai Authorization)
const uploadUrlResponse = await fetchWithAuth(
config,
`${config.URL}/${config.REPO}/upload-link/`
);
const uploadUrl = (await uploadUrlResponse.text()).replace(/"/g, "");
// 2. Konversi base64 ke Blob
const binary = Buffer.from(base64File.data, "base64");
const blob = new Blob([binary]);
// 3. Siapkan form-data
const formData = new FormData();
formData.append("parent_dir", "/");
formData.append("relative_path", folder); // tanpa slash di akhir
formData.append("file", blob, remoteName);
// 4. Upload file TANPA Authorization header, token di query param
const res = await fetch(`${uploadUrl}?token=${config.TOKEN}`, {
method: "POST",
body: formData,
});
const text = await res.text();
if (!res.ok) throw new Error(`Upload failed: ${text}`);
return `✅ Uploaded ${base64File.name} successfully`;
}
export async function removeFile(config: Config, fileName: string, folder: string): Promise<string> {
const res = await fetchWithAuth(config, `${config.URL}/${config.REPO}/file/?p=/${folder}/${fileName}`, { method: 'DELETE' });
if (!res.ok) return 'gagal menghapus file';
return `🗑️ Removed ${fileName}`
}
export async function moveFile(config: Config, oldName: string, newName: string): Promise<string> {
const url = `${config.URL}/${config.REPO}/file/?p=/${oldName}`;
const formData = new FormData();
formData.append('operation', 'rename');
formData.append('newname', newName);
await fetchWithAuth(config, url, { method: 'POST', body: formData });
return `✏️ Renamed ${oldName}${newName}`
}
export async function downloadFile(config: Config, fileName: string, folder: string, localFile?: string): Promise<string> {
const localName = localFile || fileName;
// 🔹 gabungkan path folder + file
const filePath = `/${folder}/${fileName}`.replace(/\/+/g, "/");
// 🔹 encode path agar aman (spasi, dll)
const params = new URLSearchParams({
p: filePath,
});
const downloadUrlResponse = await fetchWithAuth(config, `${config.URL}/${config.REPO}/file/?${params.toString()}`);
if (!downloadUrlResponse.ok)
return 'gagal'
const downloadUrl = (await downloadUrlResponse.text()).replace(/"/g, '');
const buffer = Buffer.from(await (await fetchWithAuth(config, downloadUrl)).arrayBuffer());
await fs.writeFile(localName, buffer);
return `⬇️ Downloaded ${fileName}${localName}`
}
export async function getFileLink(config: Config, fileName: string): Promise<string> {
const downloadUrlResponse = await fetchWithAuth(config, `${config.URL}/${config.REPO}/file/?p=/${fileName}`);
return `🔗 Link for ${fileName}:\n${(await downloadUrlResponse.text()).replace(/"/g, '')}`
}