summaryrefslogtreecommitdiffstats
path: root/internal/media
diff options
context:
space:
mode:
Diffstat (limited to 'internal/media')
-rw-r--r--internal/media/handler.go221
-rw-r--r--internal/media/handler_test.go198
-rw-r--r--internal/media/process.go82
-rw-r--r--internal/media/process_test.go78
4 files changed, 579 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)
+}
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)
+ }
+ }
+}
diff --git a/internal/media/process.go b/internal/media/process.go
new file mode 100644
index 0000000..b295380
--- /dev/null
+++ b/internal/media/process.go
@@ -0,0 +1,82 @@
+package media
+
+import (
+ "bytes"
+ "encoding/base64"
+ "fmt"
+ "image/png"
+
+ "github.com/davidbyttow/govips/v2/vips"
+ thumbhash "go.n16f.net/thumbhash"
+)
+
+// ImageInfo returns the pixel dimensions and a base64-encoded thumbhash for
+// the image at path. The thumbhash is generated from a ≤100px thumbnail so
+// it is fast regardless of the original size.
+func ImageInfo(path string) (width, height int, thumbhashB64 string, err error) {
+ img, err := vips.NewImageFromFile(path)
+ if err != nil {
+ return 0, 0, "", fmt.Errorf("load %s: %w", path, err)
+ }
+ defer img.Close()
+
+ width = img.Width()
+ height = img.Height()
+
+ const maxThumbDim = 100
+ if width > maxThumbDim || height > maxThumbDim {
+ scale := float64(maxThumbDim) / float64(max(width, height))
+ if err := img.Resize(scale, vips.KernelLanczos3); err != nil {
+ return width, height, "", fmt.Errorf("resize for thumbhash: %w", err)
+ }
+ }
+
+ pngBytes, _, err := img.ExportPng(vips.NewPngExportParams())
+ if err != nil {
+ return width, height, "", fmt.Errorf("export png for thumbhash: %w", err)
+ }
+
+ goImg, err := png.Decode(bytes.NewReader(pngBytes))
+ if err != nil {
+ return width, height, "", fmt.Errorf("decode png for thumbhash: %w", err)
+ }
+
+ hash := thumbhash.EncodeImage(goImg)
+ return width, height, base64.StdEncoding.EncodeToString(hash), nil
+}
+
+// ConvertAndResize loads the image at src, optionally resizes it to width pixels
+// wide (maintaining aspect ratio), and exports it in the requested format.
+//
+// width <= 0 means no resize. format must be "webp" or "jpeg".
+func ConvertAndResize(src string, width int, format string) ([]byte, error) {
+ img, err := vips.NewImageFromFile(src)
+ if err != nil {
+ return nil, fmt.Errorf("load image %s: %w", src, err)
+ }
+ defer img.Close()
+
+ if width > 0 && width < img.Width() {
+ scale := float64(width) / float64(img.Width())
+ if err := img.Resize(scale, vips.KernelLanczos3); err != nil {
+ return nil, fmt.Errorf("resize: %w", err)
+ }
+ }
+
+ switch format {
+ case "webp":
+ buf, _, err := img.ExportWebp(vips.NewWebpExportParams())
+ if err != nil {
+ return nil, fmt.Errorf("export webp: %w", err)
+ }
+ return buf, nil
+ case "jpeg":
+ buf, _, err := img.ExportJpeg(vips.NewJpegExportParams())
+ if err != nil {
+ return nil, fmt.Errorf("export jpeg: %w", err)
+ }
+ return buf, nil
+ default:
+ return nil, fmt.Errorf("unsupported format %q: must be webp or jpeg", format)
+ }
+}
diff --git a/internal/media/process_test.go b/internal/media/process_test.go
new file mode 100644
index 0000000..da9d4a8
--- /dev/null
+++ b/internal/media/process_test.go
@@ -0,0 +1,78 @@
+package media
+
+import (
+ "image"
+ "image/png"
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/davidbyttow/govips/v2/vips"
+)
+
+func TestMain(m *testing.M) {
+ vips.Startup(nil)
+ code := m.Run()
+ vips.Shutdown()
+ os.Exit(code)
+}
+
+// testPNG writes a 100×60 PNG to a temp file and returns its path.
+func testPNG(t *testing.T) string {
+ t.Helper()
+ img := image.NewRGBA(image.Rect(0, 0, 100, 60))
+ path := filepath.Join(t.TempDir(), "src.png")
+ f, err := os.Create(path)
+ if err != nil {
+ t.Fatalf("create test png: %v", err)
+ }
+ if err := png.Encode(f, img); err != nil {
+ t.Fatalf("encode test png: %v", err)
+ }
+ f.Close()
+ return path
+}
+
+func TestConvertToWebP(t *testing.T) {
+ out, err := ConvertAndResize(testPNG(t), 0, "webp")
+ if err != nil {
+ t.Fatalf("ConvertAndResize: %v", err)
+ }
+ if len(out) == 0 {
+ t.Fatal("expected non-empty output")
+ }
+}
+
+func TestConvertToJPEG(t *testing.T) {
+ out, err := ConvertAndResize(testPNG(t), 0, "jpeg")
+ if err != nil {
+ t.Fatalf("ConvertAndResize: %v", err)
+ }
+ if len(out) == 0 {
+ t.Fatal("expected non-empty output")
+ }
+}
+
+func TestResizeAndConvert(t *testing.T) {
+ out, err := ConvertAndResize(testPNG(t), 50, "webp")
+ if err != nil {
+ t.Fatalf("ConvertAndResize resize: %v", err)
+ }
+ if len(out) == 0 {
+ t.Fatal("expected non-empty output")
+ }
+}
+
+func TestConvertUnsupportedFormat(t *testing.T) {
+ _, err := ConvertAndResize(testPNG(t), 0, "avif")
+ if err == nil {
+ t.Fatal("expected error for unsupported format")
+ }
+}
+
+func TestConvertMissingFile(t *testing.T) {
+ _, err := ConvertAndResize("/nonexistent/file.png", 0, "webp")
+ if err == nil {
+ t.Fatal("expected error for missing file")
+ }
+}