Files
silo/internal/api/file_handlers.go
Forbes 50923cf56d feat: production release with React SPA, file attachments, and deploy tooling
Backend:
- Add file_handlers.go: presigned upload/download for item attachments
- Add item_files.go: item file and thumbnail DB operations
- Add migration 011: item_files table and thumbnail_key column
- Update items/projects/relationships DB with extended field support
- Update routes: React SPA serving from web/dist, file upload endpoints
- Update auth handlers and middleware for cookie + bearer token auth
- Remove Go HTML templates (replaced by React SPA)
- Update storage client for presigned URL generation

Frontend:
- Add TagInput component for tag/keyword entry
- Add SVG assets for Silo branding and UI icons
- Update API client and types for file uploads, auth, extended fields
- Update AuthContext for session-based auth flow
- Update LoginPage, ProjectsPage, SchemasPage, SettingsPage
- Fix tsconfig.node.json

Deployment:
- Update config.prod.yaml: single-binary SPA layout at /opt/silo
- Update silod.service: ReadOnlyPaths for /opt/silo
- Add scripts/deploy.sh: build, package, ship, migrate, start
- Update docker-compose.yaml and Dockerfile
- Add frontend-spec.md design document
2026-02-07 13:35:22 -06:00

317 lines
9.5 KiB
Go

package api
import (
"encoding/json"
"fmt"
"net/http"
"strings"
"time"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
"github.com/kindredsystems/silo/internal/db"
)
// presignUploadRequest is the request body for generating a presigned upload URL.
type presignUploadRequest struct {
Filename string `json:"filename"`
ContentType string `json:"content_type"`
Size int64 `json:"size"`
}
// HandlePresignUpload generates a presigned PUT URL for direct browser upload to MinIO.
func (s *Server) HandlePresignUpload(w http.ResponseWriter, r *http.Request) {
if s.storage == nil {
writeError(w, http.StatusServiceUnavailable, "storage_unavailable", "File storage not configured")
return
}
var req presignUploadRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid_json", err.Error())
return
}
if req.Filename == "" {
writeError(w, http.StatusBadRequest, "invalid_request", "Filename is required")
return
}
if req.Size > 500*1024*1024 {
writeError(w, http.StatusBadRequest, "invalid_request", "File size exceeds 500MB limit")
return
}
if req.ContentType == "" {
req.ContentType = "application/octet-stream"
}
// Generate a unique temp key
id := uuid.New().String()
objectKey := fmt.Sprintf("uploads/tmp/%s/%s", id, req.Filename)
expiry := 15 * time.Minute
u, err := s.storage.PresignPut(r.Context(), objectKey, expiry)
if err != nil {
s.logger.Error().Err(err).Msg("failed to generate presigned URL")
writeError(w, http.StatusInternalServerError, "presign_failed", "Failed to generate upload URL")
return
}
writeJSON(w, http.StatusOK, map[string]any{
"object_key": objectKey,
"upload_url": u.String(),
"expires_at": time.Now().Add(expiry).Format(time.RFC3339),
})
}
// itemFileResponse is the JSON representation of an item file attachment.
type itemFileResponse struct {
ID string `json:"id"`
Filename string `json:"filename"`
ContentType string `json:"content_type"`
Size int64 `json:"size"`
ObjectKey string `json:"object_key"`
CreatedAt string `json:"created_at"`
}
func itemFileToResponse(f *db.ItemFile) itemFileResponse {
return itemFileResponse{
ID: f.ID,
Filename: f.Filename,
ContentType: f.ContentType,
Size: f.Size,
ObjectKey: f.ObjectKey,
CreatedAt: f.CreatedAt.Format(time.RFC3339),
}
}
// HandleListItemFiles lists file attachments for an item.
func (s *Server) HandleListItemFiles(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
partNumber := chi.URLParam(r, "partNumber")
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
}
files, err := s.itemFiles.ListByItem(ctx, item.ID)
if err != nil {
s.logger.Error().Err(err).Msg("failed to list item files")
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list files")
return
}
result := make([]itemFileResponse, 0, len(files))
for _, f := range files {
result = append(result, itemFileToResponse(f))
}
writeJSON(w, http.StatusOK, result)
}
// associateFileRequest is the request body for associating an uploaded file with an item.
type associateFileRequest struct {
ObjectKey string `json:"object_key"`
Filename string `json:"filename"`
ContentType string `json:"content_type"`
Size int64 `json:"size"`
}
// HandleAssociateItemFile moves a temp upload to permanent storage and creates a DB record.
func (s *Server) HandleAssociateItemFile(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
}
var req associateFileRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid_json", err.Error())
return
}
if req.ObjectKey == "" || req.Filename == "" {
writeError(w, http.StatusBadRequest, "invalid_request", "object_key and filename are required")
return
}
// Security: only allow associating files from the temp upload prefix
if !strings.HasPrefix(req.ObjectKey, "uploads/tmp/") {
writeError(w, http.StatusBadRequest, "invalid_request", "object_key must be a temp upload")
return
}
if req.ContentType == "" {
req.ContentType = "application/octet-stream"
}
// Create DB record first to get the file ID
itemFile := &db.ItemFile{
ItemID: item.ID,
Filename: req.Filename,
ContentType: req.ContentType,
Size: req.Size,
ObjectKey: "", // will be set after copy
}
// Generate permanent key
fileID := uuid.New().String()
permanentKey := fmt.Sprintf("items/%s/files/%s/%s", item.ID, fileID, req.Filename)
// Copy from temp to permanent location
if err := s.storage.Copy(ctx, req.ObjectKey, permanentKey); err != nil {
s.logger.Error().Err(err).Str("src", req.ObjectKey).Str("dst", permanentKey).Msg("failed to copy file")
writeError(w, http.StatusInternalServerError, "copy_failed", "Failed to move file to permanent storage")
return
}
// Delete the temp object (best-effort)
_ = s.storage.Delete(ctx, req.ObjectKey)
// Save DB record with permanent key
itemFile.ObjectKey = permanentKey
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", req.Filename).
Int64("size", req.Size).
Msg("file associated with item")
writeJSON(w, http.StatusCreated, itemFileToResponse(itemFile))
}
// HandleDeleteItemFile deletes a file attachment and its storage object.
func (s *Server) HandleDeleteItemFile(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
partNumber := chi.URLParam(r, "partNumber")
fileID := chi.URLParam(r, "fileId")
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
}
// Get the file record to find the storage key
file, err := s.itemFiles.Get(ctx, fileID)
if err != nil {
writeError(w, http.StatusNotFound, "not_found", "File not found")
return
}
// Verify the file belongs to this item
if file.ItemID != item.ID {
writeError(w, http.StatusNotFound, "not_found", "File not found")
return
}
// Delete from storage (best-effort)
if s.storage != nil {
_ = s.storage.Delete(ctx, file.ObjectKey)
}
// Delete DB record
if err := s.itemFiles.Delete(ctx, fileID); err != nil {
s.logger.Error().Err(err).Msg("failed to delete item file record")
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to delete file")
return
}
w.WriteHeader(http.StatusNoContent)
}
// setThumbnailRequest is the request body for setting an item thumbnail.
type setThumbnailRequest struct {
ObjectKey string `json:"object_key"`
}
// HandleSetItemThumbnail copies a temp upload to the item thumbnail location.
func (s *Server) HandleSetItemThumbnail(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
}
var req setThumbnailRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid_json", err.Error())
return
}
if req.ObjectKey == "" {
writeError(w, http.StatusBadRequest, "invalid_request", "object_key is required")
return
}
// Security: only allow from temp upload prefix
if !strings.HasPrefix(req.ObjectKey, "uploads/tmp/") {
writeError(w, http.StatusBadRequest, "invalid_request", "object_key must be a temp upload")
return
}
// Copy to permanent thumbnail location
thumbnailKey := fmt.Sprintf("items/%s/thumbnail.png", item.ID)
if err := s.storage.Copy(ctx, req.ObjectKey, thumbnailKey); err != nil {
s.logger.Error().Err(err).Msg("failed to copy thumbnail")
writeError(w, http.StatusInternalServerError, "copy_failed", "Failed to set thumbnail")
return
}
// Delete temp object (best-effort)
_ = s.storage.Delete(ctx, req.ObjectKey)
// Update DB
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)
}