all repos — weimar @ 30c984bbd9a52787565d44cc121088cf1ee25029

Unnamed repository; edit this file 'description' to name the repository.

internal/api/upload.go (view raw)

  1package api
  2
  3import (
  4	"fmt"
  5	"io"
  6	"net/http"
  7	"strings"
  8
  9	"codeberg.org/maxwelljensen/weimar/internal/db"
 10	"codeberg.org/maxwelljensen/weimar/internal/storage"
 11)
 12
 13func (a *API) HandleUpload(w http.ResponseWriter, r *http.Request) {
 14	user := UserFromContext(r.Context())
 15	if user == nil {
 16		writeError(w, http.StatusUnauthorized, "unauthorized")
 17		return
 18	}
 19
 20	if err := r.ParseMultipartForm(32 << 20); err != nil {
 21		writeError(w, http.StatusBadRequest, "invalid multipart form")
 22		return
 23	}
 24
 25	file, header, err := r.FormFile("file")
 26	if err != nil {
 27		writeError(w, http.StatusBadRequest, "file field is required")
 28		return
 29	}
 30	defer file.Close()
 31
 32	// Read uploaded data.
 33	data, err := io.ReadAll(file)
 34	if err != nil {
 35		writeError(w, http.StatusInternalServerError, "failed to read file")
 36		return
 37	}
 38
 39	// Validate file size against config.
 40	maxBytes := int64(a.Cfg.Upload.MaxSizeMB) * 1024 * 1024
 41	if maxBytes > 0 && int64(len(data)) > maxBytes {
 42		writeError(w, http.StatusRequestEntityTooLarge,
 43			fmt.Sprintf("file exceeds max size of %d MB", a.Cfg.Upload.MaxSizeMB))
 44		return
 45	}
 46
 47	// Detect MIME type from magic bytes.
 48	mimeType := storage.DetectContentType(data)
 49	if !storage.IsImage(mimeType) && !storage.IsVideo(mimeType) {
 50		writeError(w, http.StatusBadRequest, "unsupported file type")
 51		return
 52	}
 53
 54	ext := storage.ExtensionFromMIME(mimeType)
 55
 56	// Process the data: strip EXIF for images.
 57	var processed []byte
 58	if storage.IsImage(mimeType) {
 59		stripped, err := storage.StripEXIF(data)
 60		if err != nil {
 61			writeError(w, http.StatusInternalServerError, "failed to process image")
 62			return
 63		}
 64		processed = stripped
 65	} else {
 66		processed = data
 67	}
 68
 69	// Store original to disk.
 70	_, storagePath, err := a.Storage.Store(processed, ext)
 71	if err != nil {
 72		writeError(w, http.StatusInternalServerError, "failed to store file")
 73		return
 74	}
 75	storagePathAbs := a.Storage.AbsPath(storagePath)
 76
 77	// Get image dimensions and duration.
 78	var width, height int
 79	var duration float64
 80	if storage.IsImage(mimeType) {
 81		img, _, _ := storage.DecodeImage(processed)
 82		if img != nil {
 83			bounds := img.Bounds()
 84			width = bounds.Dx()
 85			height = bounds.Dy()
 86		}
 87	} else if storage.IsVideo(mimeType) {
 88		w, h := storage.GetVideoDimensions(storagePathAbs)
 89		width, height = w, h
 90		duration = storage.GetVideoDuration(storagePathAbs)
 91	}
 92
 93	// Generate thumbnail.
 94	var thumbnailPath *string
 95	if storage.IsImage(mimeType) {
 96		thumbData, err := storage.GenerateThumbnail(processed, storage.ThumbnailMaxDimension)
 97		if err == nil {
 98			thumbRel := storage.ThumbnailPath(storagePath)
 99			if err := a.Storage.StoreAt(thumbRel, thumbData); err == nil {
100				thumbnailPath = &thumbRel
101			}
102		}
103	} else if storage.IsVideo(mimeType) && storage.FFmpegAvailable() {
104		thumbRel := storage.ThumbnailPath(storagePath)
105		thumbAbs := a.Storage.AbsPath(thumbRel)
106		if err := storage.GenerateVideoThumbnail(storagePathAbs, thumbAbs); err == nil {
107			thumbnailPath = &thumbRel
108		}
109	}
110
111	// Read tags from form field(s). The frontend sends each tag as a
112	// separate FormData entry (e.g., tags=foo&tags=bar) and a single
113	// comma-separated string is also supported for REST clients.
114	var tagList []string
115	for _, v := range r.Form["tags"] {
116		for _, t := range strings.Split(v, ",") {
117			t = strings.TrimSpace(t)
118			if t != "" {
119				tagList = append(tagList, t)
120			}
121		}
122	}
123
124	// Compute innate tags based on media characteristics.
125	videoPath := ""
126	if storage.IsVideo(mimeType) {
127		videoPath = storagePathAbs
128	}
129	innateTags := storage.ComputeInnateTags(mimeType, width, height, videoPath)
130
131	// Create DB record.
132	img, err := a.DB.CreateImage(r.Context(), &db.Image{
133		Filename:      header.Filename,
134		StoragePath:   storagePath,
135		ThumbnailPath: thumbnailPath,
136		UploadedBy:    user.ID,
137		FileSize:      int64(len(data)),
138		Width:         width,
139		Height:        height,
140		MimeType:      mimeType,
141		InnateTags:    innateTags,
142		Duration:      duration,
143	})
144	if err != nil {
145		writeError(w, http.StatusInternalServerError, "failed to save metadata")
146		return
147	}
148
149	// Set tags.
150	if len(tagList) > 0 {
151		if err := a.DB.SetImageTags(r.Context(), img.ID, tagList); err != nil {
152			writeError(w, http.StatusInternalServerError, "failed to save tags")
153			return
154		}
155		img.Tags = tagList
156	}
157
158	writeJSON(w, http.StatusCreated, img)
159}