summaryrefslogtreecommitdiffstats
path: root/internal/media/handler.go
diff options
context:
space:
mode:
authorivar <i@oiee.no>2026-04-07 00:23:24 +0200
committerivar <i@oiee.no>2026-04-07 00:23:24 +0200
commit85920b8c7a2696115d1f77c046f48f6f00d639f1 (patch)
tree14ed2043796eadd6ed5b0a95c55e38e48713d638 /internal/media/handler.go
downloadiblog-85920b8c7a2696115d1f77c046f48f6f00d639f1.tar.xz
iblog-85920b8c7a2696115d1f77c046f48f6f00d639f1.zip
Init
Diffstat (limited to 'internal/media/handler.go')
-rw-r--r--internal/media/handler.go221
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)
+}