feat(api): direct multipart upload endpoints for filesystem backend

Add three new endpoints that bypass the MinIO presigned URL flow:
- POST /api/items/{pn}/files/upload — multipart file upload
- POST /api/items/{pn}/thumbnail/upload — multipart thumbnail upload
- GET /api/items/{pn}/files/{fileId}/download — stream file download

Rewrite frontend upload flow: files are held in browser memory on drop
and uploaded directly after item creation via multipart POST. The old
presign+associate endpoints remain for MinIO backward compatibility.

Closes #129
This commit is contained in:
2026-02-17 13:04:44 -06:00
parent 9181673554
commit ffa01ebeb7
4 changed files with 250 additions and 109 deletions

View File

@@ -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)
}

View File

@@ -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)