summaryrefslogtreecommitdiffstats
path: root/internal
diff options
context:
space:
mode:
authorivar <i@oiee.no>2026-04-04 16:46:19 +0200
committerivar <i@oiee.no>2026-04-04 16:46:19 +0200
commitf45d85cf59d13d419f18b98d25f8e3b8cf17d9e1 (patch)
tree9dd307636d41223e0ad4479685ce5a02d2b8bc77 /internal
parent0baeaa05d82b83cc290936b1c3068a7f0761efb5 (diff)
downloadnebbet.no-f45d85cf59d13d419f18b98d25f8e3b8cf17d9e1.tar.xz
nebbet.no-f45d85cf59d13d419f18b98d25f8e3b8cf17d9e1.zip
feat: add MediaHandler for image upload and on-the-fly serving
Implements HandleUpload (EditorJS-compatible multipart endpoint) and HandleServe (on-the-fly WebP/JPEG conversion with width-based resizing and file-system caching) backed by govips/ConvertAndResize. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Diffstat (limited to 'internal')
-rw-r--r--internal/media/handler.go209
-rw-r--r--internal/media/handler_test.go198
2 files changed, 407 insertions, 0 deletions
diff --git a/internal/media/handler.go b/internal/media/handler.go
new file mode 100644
index 0000000..3cca2bd
--- /dev/null
+++ b/internal/media/handler.go
@@ -0,0 +1,209 @@
+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/gif": ".gif",
+ "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/<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
+ 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
+ }
+ defer out.Close()
+
+ if _, err := io.Copy(out, file); err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"success": 0, "error": "write 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=<pixels> 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: "<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)
+}
diff --git a/internal/media/handler_test.go b/internal/media/handler_test.go
new file mode 100644
index 0000000..d65544c
--- /dev/null
+++ b/internal/media/handler_test.go
@@ -0,0 +1,198 @@
+package media
+
+import (
+ "bytes"
+ "encoding/json"
+ "image"
+ "image/jpeg"
+ "mime/multipart"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/gin-gonic/gin"
+)
+
+func init() {
+ gin.SetMode(gin.TestMode)
+}
+
+func newTestHandler(t *testing.T) (*MediaHandler, string) {
+ t.Helper()
+ dir := t.TempDir()
+ return NewMediaHandler(dir), dir
+}
+
+func testJPEGBytes(t *testing.T) []byte {
+ t.Helper()
+ img := image.NewRGBA(image.Rect(0, 0, 100, 60))
+ var buf bytes.Buffer
+ if err := jpeg.Encode(&buf, img, nil); err != nil {
+ t.Fatalf("encode jpeg: %v", err)
+ }
+ return buf.Bytes()
+}
+
+func multipartUpload(t *testing.T, field, filename string, data []byte) *http.Request {
+ t.Helper()
+ var body bytes.Buffer
+ w := multipart.NewWriter(&body)
+ fw, err := w.CreateFormFile(field, filename)
+ if err != nil {
+ t.Fatalf("create form file: %v", err)
+ }
+ fw.Write(data)
+ w.Close()
+ req, _ := http.NewRequest(http.MethodPost, "/admin/upload/image", &body)
+ req.Header.Set("Content-Type", w.FormDataContentType())
+ return req
+}
+
+// TestUploadValid verifies a valid JPEG upload returns success with a /media/ URL.
+func TestUploadValid(t *testing.T) {
+ h, dir := newTestHandler(t)
+ rr := httptest.NewRecorder()
+ c, _ := gin.CreateTestContext(rr)
+ c.Request = multipartUpload(t, "image", "photo.jpg", testJPEGBytes(t))
+
+ h.HandleUpload(c)
+
+ if rr.Code != http.StatusOK {
+ t.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body.String())
+ }
+ var resp map[string]interface{}
+ if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
+ t.Fatalf("parse response: %v", err)
+ }
+ if resp["success"] != float64(1) {
+ t.Fatalf("expected success=1, got %v", resp)
+ }
+ file, ok := resp["file"].(map[string]interface{})
+ if !ok {
+ t.Fatal("expected file object in response")
+ }
+ url, _ := file["url"].(string)
+ if len(url) < 7 || url[:7] != "/media/" {
+ t.Fatalf("expected /media/ URL, got %q", url)
+ }
+ entries, _ := os.ReadDir(dir)
+ if len(entries) == 0 {
+ t.Fatal("expected original to be written to storage dir")
+ }
+}
+
+// TestUploadInvalidMIME rejects non-image content.
+func TestUploadInvalidMIME(t *testing.T) {
+ h, _ := newTestHandler(t)
+ rr := httptest.NewRecorder()
+ c, _ := gin.CreateTestContext(rr)
+ c.Request = multipartUpload(t, "image", "evil.txt", []byte("not an image at all"))
+
+ h.HandleUpload(c)
+
+ if rr.Code != http.StatusBadRequest {
+ t.Fatalf("expected 400, got %d", rr.Code)
+ }
+}
+
+// TestUploadWrongField rejects request missing the "image" field.
+func TestUploadWrongField(t *testing.T) {
+ h, _ := newTestHandler(t)
+ rr := httptest.NewRecorder()
+ c, _ := gin.CreateTestContext(rr)
+ c.Request = multipartUpload(t, "file", "photo.jpg", testJPEGBytes(t))
+
+ h.HandleUpload(c)
+
+ if rr.Code != http.StatusBadRequest {
+ t.Fatalf("expected 400, got %d", rr.Code)
+ }
+}
+
+// TestServeCacheHit serves a pre-existing cache file directly.
+func TestServeCacheHit(t *testing.T) {
+ h, dir := newTestHandler(t)
+ if err := os.WriteFile(filepath.Join(dir, "abc123.webp"), []byte("FAKE_WEBP"), 0644); err != nil {
+ t.Fatal(err)
+ }
+ rr := httptest.NewRecorder()
+ c, _ := gin.CreateTestContext(rr)
+ c.Request = httptest.NewRequest(http.MethodGet, "/media/abc123.webp", nil)
+ c.Params = gin.Params{{Key: "filepath", Value: "/abc123.webp"}}
+
+ h.HandleServe(c)
+
+ if rr.Code != http.StatusOK {
+ t.Fatalf("expected 200, got %d", rr.Code)
+ }
+ if ct := rr.Header().Get("Content-Type"); ct != "image/webp" {
+ t.Fatalf("expected image/webp, got %q", ct)
+ }
+}
+
+// TestServeNotFound returns 404 when no original exists.
+func TestServeNotFound(t *testing.T) {
+ h, _ := newTestHandler(t)
+ rr := httptest.NewRecorder()
+ c, _ := gin.CreateTestContext(rr)
+ c.Request = httptest.NewRequest(http.MethodGet, "/media/missing.webp", nil)
+ c.Params = gin.Params{{Key: "filepath", Value: "/missing.webp"}}
+
+ h.HandleServe(c)
+
+ if rr.Code != http.StatusNotFound {
+ t.Fatalf("expected 404, got %d", rr.Code)
+ }
+}
+
+// TestServeInvalidWidth returns 400 for non-positive width.
+func TestServeInvalidWidth(t *testing.T) {
+ h, _ := newTestHandler(t)
+ rr := httptest.NewRecorder()
+ c, _ := gin.CreateTestContext(rr)
+ c.Request = httptest.NewRequest(http.MethodGet, "/media/abc.webp?w=0", nil)
+ c.Params = gin.Params{{Key: "filepath", Value: "/abc.webp"}}
+
+ h.HandleServe(c)
+
+ if rr.Code != http.StatusBadRequest {
+ t.Fatalf("expected 400, got %d", rr.Code)
+ }
+}
+
+// TestServeUnknownFormat returns 404 for unrecognised extensions.
+func TestServeUnknownFormat(t *testing.T) {
+ h, _ := newTestHandler(t)
+ rr := httptest.NewRecorder()
+ c, _ := gin.CreateTestContext(rr)
+ c.Request = httptest.NewRequest(http.MethodGet, "/media/abc.png", nil)
+ c.Params = gin.Params{{Key: "filepath", Value: "/abc.png"}}
+
+ h.HandleServe(c)
+
+ if rr.Code != http.StatusNotFound {
+ t.Fatalf("expected 404, got %d", rr.Code)
+ }
+}
+
+// TestCacheKey verifies the cache filename scheme.
+func TestCacheKey(t *testing.T) {
+ cases := []struct {
+ base, format string
+ width int
+ want string
+ }{
+ {"uuid1", "webp", 0, "uuid1.webp"},
+ {"uuid1", "webp", 800, "uuid1_800w.webp"},
+ {"uuid1", "jpeg", 0, "uuid1.jpg"},
+ {"uuid1", "jpeg", 400, "uuid1_400w.jpg"},
+ }
+ for _, tc := range cases {
+ got := cacheKey(tc.base, tc.width, tc.format)
+ if got != tc.want {
+ t.Errorf("cacheKey(%q, %d, %q) = %q, want %q", tc.base, tc.width, tc.format, got, tc.want)
+ }
+ }
+}