diff options
| author | ivar <i@oiee.no> | 2026-04-04 16:46:19 +0200 |
|---|---|---|
| committer | ivar <i@oiee.no> | 2026-04-04 16:46:19 +0200 |
| commit | f45d85cf59d13d419f18b98d25f8e3b8cf17d9e1 (patch) | |
| tree | 9dd307636d41223e0ad4479685ce5a02d2b8bc77 /internal | |
| parent | 0baeaa05d82b83cc290936b1c3068a7f0761efb5 (diff) | |
| download | nebbet.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.go | 209 | ||||
| -rw-r--r-- | internal/media/handler_test.go | 198 |
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) + } + } +} |
