diff options
| author | ivar <i@oiee.no> | 2026-04-07 00:23:24 +0200 |
|---|---|---|
| committer | ivar <i@oiee.no> | 2026-04-07 00:23:24 +0200 |
| commit | 85920b8c7a2696115d1f77c046f48f6f00d639f1 (patch) | |
| tree | 14ed2043796eadd6ed5b0a95c55e38e48713d638 /internal/media/handler.go | |
| download | iblog-85920b8c7a2696115d1f77c046f48f6f00d639f1.tar.xz iblog-85920b8c7a2696115d1f77c046f48f6f00d639f1.zip | |
Init
Diffstat (limited to 'internal/media/handler.go')
| -rw-r--r-- | internal/media/handler.go | 221 |
1 files changed, 221 insertions, 0 deletions
diff --git a/internal/media/handler.go b/internal/media/handler.go new file mode 100644 index 0000000..6b4d114 --- /dev/null +++ b/internal/media/handler.go @@ -0,0 +1,221 @@ +package media + +import ( + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "strconv" + "strings" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" +) + +const ( + maxUploadSize = 20 << 20 // 20 MB + maxWidth = 3000 +) + +// allowedMIMEs maps detected content types to canonical file extensions. +var allowedMIMEs = map[string]string{ + "image/jpeg": ".jpg", + "image/png": ".png", + "image/webp": ".webp", +} + +// MediaHandler handles image uploads and on-the-fly image serving. +type MediaHandler struct { + storageDir string +} + +// NewMediaHandler returns a MediaHandler that stores files in storageDir. +// The directory is created if it does not exist. +func NewMediaHandler(storageDir string) *MediaHandler { + _ = os.MkdirAll(storageDir, 0755) + return &MediaHandler{storageDir: storageDir} +} + +// HandleUpload handles POST /admin/upload/image. +// Expects multipart/form-data with an "image" field. +// Returns EditorJS-compatible JSON: {"success":1,"file":{"url":"/media/<uuid>.webp"}} +func (h *MediaHandler) HandleUpload(c *gin.Context) { + c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, maxUploadSize) + + file, _, err := c.Request.FormFile("image") + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"success": 0, "error": err.Error()}) + return + } + defer file.Close() + + // Sniff MIME type from first 512 bytes + sniff := make([]byte, 512) + n, _ := file.Read(sniff) + mimeType := http.DetectContentType(sniff[:n]) + ext, ok := allowedMIMEs[mimeType] + if !ok { + c.JSON(http.StatusBadRequest, gin.H{"success": 0, "error": "unsupported image type: " + mimeType}) + return + } + + // Rewind so the full file is copied to disk + seeker, ok := file.(io.Seeker) + if !ok { + c.JSON(http.StatusInternalServerError, gin.H{"success": 0, "error": "file not seekable"}) + return + } + if _, err := seeker.Seek(0, io.SeekStart); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"success": 0, "error": "seek error"}) + return + } + + if err := os.MkdirAll(h.storageDir, 0755); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"success": 0, "error": "storage error"}) + return + } + + id := uuid.New().String() + destPath := filepath.Join(h.storageDir, id+ext) + + out, err := os.Create(destPath) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"success": 0, "error": "storage error"}) + return + } + + if _, err := io.Copy(out, file); err != nil { + out.Close() + c.JSON(http.StatusInternalServerError, gin.H{"success": 0, "error": "write error"}) + return + } + if err := out.Close(); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"success": 0, "error": "finalize error"}) + return + } + + fileInfo := gin.H{"url": "/media/" + id + ".webp"} + if w, h, th, err := ImageInfo(destPath); err == nil { + fileInfo["width"] = w + fileInfo["height"] = h + fileInfo["thumbhash"] = th + } + + c.JSON(http.StatusOK, gin.H{"success": 1, "file": fileInfo}) +} + +// HandleServe handles GET /media/*filepath. +// Format is determined by the file extension (.webp or .jpg/.jpeg). +// The optional ?w=<pixels> query param resizes the image (clamped to 3000; error if <= 0). +func (h *MediaHandler) HandleServe(c *gin.Context) { + filename := filepath.Base(strings.TrimPrefix(c.Param("filepath"), "/")) + ext := strings.ToLower(filepath.Ext(filename)) + base := strings.TrimSuffix(filename, filepath.Ext(filename)) + + var format, contentType string + switch ext { + case ".webp": + format, contentType = "webp", "image/webp" + case ".jpg", ".jpeg": + format, contentType = "jpeg", "image/jpeg" + default: + c.AbortWithStatus(http.StatusNotFound) + return + } + + width := 0 + if wStr := c.Query("w"); wStr != "" { + w, err := strconv.Atoi(wStr) + if err != nil || w <= 0 { + c.AbortWithStatus(http.StatusBadRequest) + return + } + if w > maxWidth { + w = maxWidth + } + width = w + } + + cacheName := cacheKey(base, width, format) + cachePath := filepath.Join(h.storageDir, cacheName) + + // Cache hit: serve the existing variant directly + if f, err := os.Open(cachePath); err == nil { + defer f.Close() + info, _ := f.Stat() + c.Header("Content-Type", contentType) + http.ServeContent(c.Writer, c.Request, cacheName, info.ModTime(), f) + return + } + + origPath, err := findOriginal(h.storageDir, base) + if err != nil { + c.AbortWithStatus(http.StatusNotFound) + return + } + + // Skip processing if format matches original and no resize is requested + origExt := strings.ToLower(filepath.Ext(origPath)) + sameFormat := origExt == ext || + (origExt == ".jpg" && ext == ".jpeg") || + (origExt == ".jpeg" && ext == ".jpg") + if width == 0 && sameFormat { + f, err := os.Open(origPath) + if err != nil { + c.AbortWithStatus(http.StatusInternalServerError) + return + } + defer f.Close() + info, _ := f.Stat() + c.Header("Content-Type", contentType) + http.ServeContent(c.Writer, c.Request, filename, info.ModTime(), f) + return + } + + result, err := ConvertAndResize(origPath, width, format) + if err != nil { + c.AbortWithStatus(http.StatusInternalServerError) + return + } + + // Atomic cache write — concurrent writers are harmless (last rename wins) + tmp := cachePath + ".tmp" + if err := os.WriteFile(tmp, result, 0644); err == nil { + _ = os.Rename(tmp, cachePath) + } + + c.Header("Content-Type", contentType) + c.Data(http.StatusOK, contentType, result) +} + +// cacheKey returns the filename for a processed image variant. +// - No width: "<base>.webp" / "<base>.jpg" +// - With width: "<base>_<width>w.webp" / "<base>_<width>w.jpg" +func cacheKey(base string, width int, format string) string { + ext := ".webp" + if format == "jpeg" { + ext = ".jpg" + } + if width > 0 { + return fmt.Sprintf("%s_%dw%s", base, width, ext) + } + return base + ext +} + +// findOriginal finds the original upload file for a given UUID base name. +// Cache variants (which contain '_' before the extension) are excluded. +func findOriginal(dir, base string) (string, error) { + matches, err := filepath.Glob(filepath.Join(dir, base+".*")) + if err != nil { + return "", err + } + for _, m := range matches { + name := filepath.Base(m) + nameBase := strings.TrimSuffix(name, filepath.Ext(name)) + if !strings.Contains(nameBase, "_") { + return m, nil + } + } + return "", fmt.Errorf("original not found for %q in %s", base, dir) +} |
