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. func NewMediaHandler(storageDir string) *MediaHandler { 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/.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 if seeker, ok := file.(io.Seeker); ok { 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 } c.JSON(http.StatusOK, gin.H{ "success": 1, "file": gin.H{"url": "/media/" + id + ".webp"}, }) } // HandleServe handles GET /media/*filepath. // Format is determined by the file extension (.webp or .jpg/.jpeg). // The optional ?w= query param resizes the image (clamped to 3000; error if <= 0). func (h *MediaHandler) HandleServe(c *gin.Context) { filename := 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, _ := os.Stat(cachePath) 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, _ := os.Stat(origPath) 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: ".webp" / ".jpg" // - With width: "_w.webp" / "_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) }