diff --git a/internal/api/file_handlers.go b/internal/api/file_handlers.go
index 1482d8a..9f7c07a 100644
--- a/internal/api/file_handlers.go
+++ b/internal/api/file_handlers.go
@@ -3,7 +3,9 @@ package api
import (
"encoding/json"
"fmt"
+ "io"
"net/http"
+ "strconv"
"strings"
"time"
@@ -314,3 +316,188 @@ func (s *Server) HandleSetItemThumbnail(w http.ResponseWriter, r *http.Request)
w.WriteHeader(http.StatusNoContent)
}
+
+// storageBackend returns the configured storage backend name, defaulting to "minio".
+func (s *Server) storageBackend() string {
+ if s.cfg != nil && s.cfg.Storage.Backend != "" {
+ return s.cfg.Storage.Backend
+ }
+ return "minio"
+}
+
+// HandleUploadItemFile accepts a multipart file upload and stores it as an item attachment.
+func (s *Server) HandleUploadItemFile(w http.ResponseWriter, r *http.Request) {
+ ctx := r.Context()
+ partNumber := chi.URLParam(r, "partNumber")
+
+ if s.storage == nil {
+ writeError(w, http.StatusServiceUnavailable, "storage_unavailable", "File storage not configured")
+ return
+ }
+
+ item, err := s.items.GetByPartNumber(ctx, partNumber)
+ if err != nil {
+ s.logger.Error().Err(err).Msg("failed to get item")
+ writeError(w, http.StatusInternalServerError, "internal_error", "Failed to get item")
+ return
+ }
+ if item == nil {
+ writeError(w, http.StatusNotFound, "not_found", "Item not found")
+ return
+ }
+
+ // Parse multipart form (max 500MB)
+ if err := r.ParseMultipartForm(500 << 20); err != nil {
+ writeError(w, http.StatusBadRequest, "invalid_form", err.Error())
+ return
+ }
+
+ file, header, err := r.FormFile("file")
+ if err != nil {
+ writeError(w, http.StatusBadRequest, "missing_file", "File is required")
+ return
+ }
+ defer file.Close()
+
+ contentType := header.Header.Get("Content-Type")
+ if contentType == "" {
+ contentType = "application/octet-stream"
+ }
+
+ // Generate permanent key
+ fileID := uuid.New().String()
+ permanentKey := fmt.Sprintf("items/%s/files/%s/%s", item.ID, fileID, header.Filename)
+
+ // Write directly to storage
+ result, err := s.storage.Put(ctx, permanentKey, file, header.Size, contentType)
+ if err != nil {
+ s.logger.Error().Err(err).Msg("failed to upload file")
+ writeError(w, http.StatusInternalServerError, "upload_failed", "Failed to store file")
+ return
+ }
+
+ // Create DB record
+ itemFile := &db.ItemFile{
+ ItemID: item.ID,
+ Filename: header.Filename,
+ ContentType: contentType,
+ Size: result.Size,
+ ObjectKey: permanentKey,
+ StorageBackend: s.storageBackend(),
+ }
+ if err := s.itemFiles.Create(ctx, itemFile); err != nil {
+ s.logger.Error().Err(err).Msg("failed to create item file record")
+ writeError(w, http.StatusInternalServerError, "internal_error", "Failed to save file record")
+ return
+ }
+
+ s.logger.Info().
+ Str("part_number", partNumber).
+ Str("file_id", itemFile.ID).
+ Str("filename", header.Filename).
+ Int64("size", result.Size).
+ Msg("file uploaded to item")
+
+ writeJSON(w, http.StatusCreated, itemFileToResponse(itemFile))
+}
+
+// HandleUploadItemThumbnail accepts a multipart file upload and sets it as the item thumbnail.
+func (s *Server) HandleUploadItemThumbnail(w http.ResponseWriter, r *http.Request) {
+ ctx := r.Context()
+ partNumber := chi.URLParam(r, "partNumber")
+
+ if s.storage == nil {
+ writeError(w, http.StatusServiceUnavailable, "storage_unavailable", "File storage not configured")
+ return
+ }
+
+ item, err := s.items.GetByPartNumber(ctx, partNumber)
+ if err != nil {
+ s.logger.Error().Err(err).Msg("failed to get item")
+ writeError(w, http.StatusInternalServerError, "internal_error", "Failed to get item")
+ return
+ }
+ if item == nil {
+ writeError(w, http.StatusNotFound, "not_found", "Item not found")
+ return
+ }
+
+ // Parse multipart form (max 10MB for thumbnails)
+ if err := r.ParseMultipartForm(10 << 20); err != nil {
+ writeError(w, http.StatusBadRequest, "invalid_form", err.Error())
+ return
+ }
+
+ file, header, err := r.FormFile("file")
+ if err != nil {
+ writeError(w, http.StatusBadRequest, "missing_file", "File is required")
+ return
+ }
+ defer file.Close()
+
+ contentType := header.Header.Get("Content-Type")
+ if contentType == "" {
+ contentType = "image/png"
+ }
+
+ thumbnailKey := fmt.Sprintf("items/%s/thumbnail.png", item.ID)
+
+ if _, err := s.storage.Put(ctx, thumbnailKey, file, header.Size, contentType); err != nil {
+ s.logger.Error().Err(err).Msg("failed to upload thumbnail")
+ writeError(w, http.StatusInternalServerError, "upload_failed", "Failed to store thumbnail")
+ return
+ }
+
+ if err := s.items.SetThumbnailKey(ctx, item.ID, thumbnailKey); err != nil {
+ s.logger.Error().Err(err).Msg("failed to update thumbnail key")
+ writeError(w, http.StatusInternalServerError, "internal_error", "Failed to save thumbnail")
+ return
+ }
+
+ w.WriteHeader(http.StatusNoContent)
+}
+
+// HandleDownloadItemFile streams an item file attachment to the client.
+func (s *Server) HandleDownloadItemFile(w http.ResponseWriter, r *http.Request) {
+ ctx := r.Context()
+ partNumber := chi.URLParam(r, "partNumber")
+ fileID := chi.URLParam(r, "fileId")
+
+ if s.storage == nil {
+ writeError(w, http.StatusServiceUnavailable, "storage_unavailable", "File storage not configured")
+ return
+ }
+
+ item, err := s.items.GetByPartNumber(ctx, partNumber)
+ if err != nil || item == nil {
+ writeError(w, http.StatusNotFound, "not_found", "Item not found")
+ return
+ }
+
+ file, err := s.itemFiles.Get(ctx, fileID)
+ if err != nil {
+ writeError(w, http.StatusNotFound, "not_found", "File not found")
+ return
+ }
+
+ if file.ItemID != item.ID {
+ writeError(w, http.StatusNotFound, "not_found", "File not found")
+ return
+ }
+
+ reader, err := s.storage.Get(ctx, file.ObjectKey)
+ if err != nil {
+ s.logger.Error().Err(err).Str("key", file.ObjectKey).Msg("failed to get file")
+ writeError(w, http.StatusInternalServerError, "download_failed", "Failed to retrieve file")
+ return
+ }
+ defer reader.Close()
+
+ w.Header().Set("Content-Type", file.ContentType)
+ w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, file.Filename))
+ if file.Size > 0 {
+ w.Header().Set("Content-Length", strconv.FormatInt(file.Size, 10))
+ }
+
+ io.Copy(w, reader)
+}
diff --git a/internal/api/routes.go b/internal/api/routes.go
index a7e7fc6..178999d 100644
--- a/internal/api/routes.go
+++ b/internal/api/routes.go
@@ -162,6 +162,7 @@ func NewRouter(server *Server, logger zerolog.Logger) http.Handler {
r.Get("/revisions/compare", server.HandleCompareRevisions)
r.Get("/revisions/{revision}", server.HandleGetRevision)
r.Get("/files", server.HandleListItemFiles)
+ r.Get("/files/{fileId}/download", server.HandleDownloadItemFile)
r.Get("/file", server.HandleDownloadLatestFile)
r.Get("/file/{revision}", server.HandleDownloadFile)
r.Get("/bom", server.HandleGetBOM)
@@ -199,8 +200,10 @@ func NewRouter(server *Server, logger zerolog.Logger) http.Handler {
r.Post("/revisions/{revision}/rollback", server.HandleRollbackRevision)
r.Post("/file", server.HandleUploadFile)
r.Post("/files", server.HandleAssociateItemFile)
+ r.Post("/files/upload", server.HandleUploadItemFile)
r.Delete("/files/{fileId}", server.HandleDeleteItemFile)
r.Put("/thumbnail", server.HandleSetItemThumbnail)
+ r.Post("/thumbnail/upload", server.HandleUploadItemThumbnail)
r.Post("/bom", server.HandleAddBOMEntry)
r.Post("/bom/import", server.HandleImportBOMCSV)
r.Post("/bom/merge", server.HandleMergeBOM)
diff --git a/web/src/components/items/CreateItemPane.tsx b/web/src/components/items/CreateItemPane.tsx
index 30e0953..51f979a 100644
--- a/web/src/components/items/CreateItemPane.tsx
+++ b/web/src/components/items/CreateItemPane.tsx
@@ -1,5 +1,5 @@
import { useState, useCallback } from "react";
-import { get, post, put } from "../../api/client";
+import { get, post } from "../../api/client";
import type {
Project,
FormFieldDescriptor,
@@ -95,34 +95,9 @@ export function CreateItemPane({ onCreated, onCancel }: CreateItemPaneProps) {
[],
);
- const handleFilesAdded = useCallback(
- (files: PendingAttachment[]) => {
- const startIdx = attachments.length;
- setAttachments((prev) => [...prev, ...files]);
-
- files.forEach((f, i) => {
- const idx = startIdx + i;
- setAttachments((prev) =>
- prev.map((a, j) =>
- j === idx ? { ...a, uploadStatus: "uploading" } : a,
- ),
- );
-
- upload(f.file, (progress) => {
- setAttachments((prev) =>
- prev.map((a, j) =>
- j === idx ? { ...a, uploadProgress: progress } : a,
- ),
- );
- }).then((result) => {
- setAttachments((prev) =>
- prev.map((a, j) => (j === idx ? result : a)),
- );
- });
- });
- },
- [attachments.length, upload],
- );
+ const handleFilesAdded = useCallback((files: PendingAttachment[]) => {
+ setAttachments((prev) => [...prev, ...files]);
+ }, []);
const handleFileRemoved = useCallback((index: number) => {
setAttachments((prev) => prev.filter((_, i) => i !== index));
@@ -136,24 +111,15 @@ export function CreateItemPane({ onCreated, onCancel }: CreateItemPaneProps) {
const file = input.files?.[0];
if (!file) return;
- const pending: PendingAttachment = {
+ setThumbnailFile({
file,
objectKey: "",
uploadProgress: 0,
- uploadStatus: "uploading",
- };
- setThumbnailFile(pending);
-
- upload(file, (progress) => {
- setThumbnailFile((prev) =>
- prev ? { ...prev, uploadProgress: progress } : null,
- );
- }).then((result) => {
- setThumbnailFile(result);
+ uploadStatus: "pending",
});
};
input.click();
- }, [upload]);
+ }, []);
const handleSubmit = async () => {
if (!category) {
@@ -188,33 +154,24 @@ export function CreateItemPane({ onCreated, onCancel }: CreateItemPaneProps) {
});
const pn = result.part_number;
+ const encodedPN = encodeURIComponent(pn);
- // Associate uploaded attachments.
- const completed = attachments.filter(
- (a) => a.uploadStatus === "complete" && a.objectKey,
- );
- for (const att of completed) {
+ // Upload attachments via direct multipart POST.
+ for (const att of attachments) {
try {
- await post(`/api/items/${encodeURIComponent(pn)}/files`, {
- object_key: att.objectKey,
- filename: att.file.name,
- content_type: att.file.type || "application/octet-stream",
- size: att.file.size,
- });
+ await upload(att.file, `/api/items/${encodedPN}/files/upload`);
} catch {
- // File association failure is non-blocking.
+ // File upload failure is non-blocking.
}
}
- // Set thumbnail.
- if (
- thumbnailFile?.uploadStatus === "complete" &&
- thumbnailFile.objectKey
- ) {
+ // Upload thumbnail via direct multipart POST.
+ if (thumbnailFile) {
try {
- await put(`/api/items/${encodeURIComponent(pn)}/thumbnail`, {
- object_key: thumbnailFile.objectKey,
- });
+ await upload(
+ thumbnailFile.file,
+ `/api/items/${encodedPN}/thumbnail/upload`,
+ );
} catch {
// Thumbnail failure is non-blocking.
}
@@ -392,21 +349,12 @@ export function CreateItemPane({ onCreated, onCancel }: CreateItemPaneProps) {
backgroundColor: "var(--ctp-mantle)",
}}
>
- {thumbnailFile?.uploadStatus === "complete" ? (
+ {thumbnailFile ? (
- ) : thumbnailFile?.uploadStatus === "uploading" ? (
-
- Uploading... {thumbnailFile.uploadProgress}%
-
) : (
- Click to upload
+ Click to select
)}
diff --git a/web/src/hooks/useFileUpload.ts b/web/src/hooks/useFileUpload.ts
index 7647b2a..121e554 100644
--- a/web/src/hooks/useFileUpload.ts
+++ b/web/src/hooks/useFileUpload.ts
@@ -1,5 +1,4 @@
import { useState, useCallback } from "react";
-import { post } from "../api/client";
export interface PendingAttachment {
file: File;
@@ -9,61 +8,65 @@ export interface PendingAttachment {
error?: string;
}
-interface PresignResponse {
- object_key: string;
- upload_url: string;
- expires_at: string;
+interface UploadResponse {
+ id?: number;
+ object_key?: string;
}
+/**
+ * Hook for uploading files via direct multipart POST.
+ *
+ * Callers provide the target URL; the hook builds a FormData body and uses
+ * XMLHttpRequest so that upload progress events are available.
+ */
export function useFileUpload() {
const [uploading, setUploading] = useState(false);
const upload = useCallback(
(
file: File,
+ url: string,
onProgress?: (progress: number) => void,
): Promise => {
setUploading(true);
return (async () => {
try {
- // Get presigned URL.
- const presign = await post(
- "/api/uploads/presign",
- {
- filename: file.name,
- content_type: file.type || "application/octet-stream",
- size: file.size,
+ const form = new FormData();
+ form.append("file", file);
+
+ const result = await new Promise(
+ (resolve, reject) => {
+ const xhr = new XMLHttpRequest();
+ xhr.open("POST", url);
+ xhr.withCredentials = true;
+
+ xhr.upload.onprogress = (e) => {
+ if (e.lengthComputable) {
+ onProgress?.(Math.round((e.loaded / e.total) * 100));
+ }
+ };
+
+ xhr.onload = () => {
+ if (xhr.status >= 200 && xhr.status < 300) {
+ try {
+ resolve(JSON.parse(xhr.responseText) as UploadResponse);
+ } catch {
+ resolve({});
+ }
+ } else {
+ reject(new Error(`Upload failed: HTTP ${xhr.status}`));
+ }
+ };
+
+ xhr.onerror = () => reject(new Error("Upload failed"));
+ xhr.send(form);
},
);
- // Upload via XMLHttpRequest for progress events.
- await new Promise((resolve, reject) => {
- const xhr = new XMLHttpRequest();
- xhr.open("PUT", presign.upload_url);
- xhr.setRequestHeader(
- "Content-Type",
- file.type || "application/octet-stream",
- );
-
- xhr.upload.onprogress = (e) => {
- if (e.lengthComputable) {
- onProgress?.(Math.round((e.loaded / e.total) * 100));
- }
- };
-
- xhr.onload = () => {
- if (xhr.status >= 200 && xhr.status < 300) resolve();
- else reject(new Error(`Upload failed: HTTP ${xhr.status}`));
- };
-
- xhr.onerror = () => reject(new Error("Upload failed"));
- xhr.send(file);
- });
-
return {
file,
- objectKey: presign.object_key,
+ objectKey: result.object_key ?? "",
uploadProgress: 100,
uploadStatus: "complete" as const,
};