diff --git a/nginx.conf.template b/nginx.conf.template index 6412ea9..799fc10 100644 --- a/nginx.conf.template +++ b/nginx.conf.template @@ -6,6 +6,8 @@ server { index index.html; location /api/ { + # Matches the backend FILE_MAX_SIZE_MB (default 1m would reject uploads) + client_max_body_size 25m; proxy_pass http://${BACKEND_UPSTREAM}/api/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; diff --git a/src/api/file.api.ts b/src/api/file.api.ts new file mode 100644 index 0000000..97c25cd --- /dev/null +++ b/src/api/file.api.ts @@ -0,0 +1,51 @@ +import { apiClient } from '@/lib/api-client.ts' + +export interface StoredFile { + id: string + fileName: string + mimeType: string + size: number + /** null — the uploader deleted their account */ + uploaderId: string | null + createdAt: string +} + +export interface IssueAttachment { + issueId: number + file: StoredFile + /** When the file was attached to the issue (may differ from file.createdAt) */ + attachedAt: string +} + +/** The server serves only these types inline, everything else as a download */ +const INLINE_IMAGE_TYPES = ['image/png', 'image/jpeg', 'image/gif', 'image/webp'] + +export function isPreviewable(file: StoredFile) { + return INLINE_IMAGE_TYPES.includes(file.mimeType) +} + +export function downloadUrl(fileId: string) { + return `/api/files/${fileId}/download` +} + +// CLIENT REQUEST + +/** Uploads a file without an owner; it must be attached afterwards or GC removes it */ +export async function fileUpload(file: File) { + const form = new FormData() + form.append('file', file) + return apiClient.upload('/api/files', form) +} + +export async function attachmentsList(issueId: number) { + return apiClient.get(`/api/issues/${issueId}/attachments`) +} + +export async function attachmentCreate(issueId: number, fileId: string) { + return apiClient.post(`/api/issues/${issueId}/attachments`, { fileId }) +} + +export async function attachmentDelete(issueId: number, fileId: string) { + await apiClient.delete(`/api/issues/${issueId}/attachments/${fileId}`) + return true +} diff --git a/src/components/FileUpload.vue b/src/components/FileUpload.vue new file mode 100644 index 0000000..22a9530 --- /dev/null +++ b/src/components/FileUpload.vue @@ -0,0 +1,225 @@ + + + + + diff --git a/src/components/IssueDetails.vue b/src/components/IssueDetails.vue index 1d32d93..4efebf7 100644 --- a/src/components/IssueDetails.vue +++ b/src/components/IssueDetails.vue @@ -13,6 +13,7 @@ import CommentsList from '@/components/CommentsList.vue' import HistoryList from '@/components/HistoryList.vue' import SplitHandle from '@/components/SplitHandle.vue' import { useSplitPane } from '@/composables/useSplitPane.ts' +import FileUpload from '@/components/FileUpload.vue' interface Props { ckey: string @@ -132,6 +133,10 @@ defineExpose({ + + + +
diff --git a/src/lib/api-client.ts b/src/lib/api-client.ts index e426de1..d637abe 100644 --- a/src/lib/api-client.ts +++ b/src/lib/api-client.ts @@ -5,15 +5,18 @@ interface ApiEnvelope { } async function request(path: string, init?: RequestInit): Promise { + // FormData needs the browser to set multipart Content-Type with its boundary + const isForm = init?.body instanceof FormData const res = await fetch(path, { ...init, credentials: 'same-origin', - headers: { 'Content-Type': 'application/json', ...(init?.headers ?? {}) }, + headers: { ...(isForm ? {} : { 'Content-Type': 'application/json' }), ...(init?.headers ?? {}) }, }) if (res.status === 204) return undefined as T - const body = (await res.json()) as ApiEnvelope + // A proxy (e.g. nginx 413) may answer with a non-JSON body + const body = (await res.json().catch(() => ({}))) as Partial> if (!res.ok) { const message = Array.isArray(body.message) ? body.message.join(', ') : body.message @@ -36,5 +39,6 @@ export const apiClient = { get: (path: string) => request(path), post: (path: string, body?: unknown) => request(path, { method: 'POST', body: JSON.stringify(body) }), patch: (path: string, body?: unknown) => request(path, { method: 'PATCH', body: JSON.stringify(body) }), + upload: (path: string, form: FormData) => request(path, { method: 'POST', body: form }), delete: (path: string) => request(path, { method: 'DELETE' }), }