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 ? ( Thumbnail preview - ) : 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, };