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:
55
src/app.ts
55
src/app.ts
@@ -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 {
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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
36
src/lib/minio.ts
Normal 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
252
src/lib/seafile.ts
Normal 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, '')}`
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user