Add photo galleries API and frontend for public and admin viewing.

Introduces the Go photo-api service, nginx/systemd deploy wiring, and Next.js gallery/lightbox pages so event photos can be managed and browsed.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Michilis
2026-07-25 17:32:23 +00:00
co-authored by Cursor
parent 4772b85f3d
commit c9a600b6d6
61 changed files with 6912 additions and 7 deletions
+60
View File
@@ -0,0 +1,60 @@
package imaging
import (
"fmt"
"os/exec"
)
// HEIC cannot be decoded in pure Go (HEVC), so conversion shells out to a
// host-installed CLI. The Go binary itself stays cgo-free; the converter is
// a runtime dependency (Debian: libvips-tools or libheif-examples).
type HeicConverter struct {
kind string // "vips" | "heif-convert" | "custom"
path string
}
// DetectHeicConverter honors an explicit HEIC_CONVERTER command, else
// autodetects vips then heif-convert. Returns nil when none is available;
// uploads of HEIC are then rejected with a clear message.
func DetectHeicConverter(explicit string) *HeicConverter {
if explicit != "" {
if p, err := exec.LookPath(explicit); err == nil {
return &HeicConverter{kind: "custom", path: p}
}
return nil
}
if p, err := exec.LookPath("vips"); err == nil {
return &HeicConverter{kind: "vips", path: p}
}
if p, err := exec.LookPath("heif-convert"); err == nil {
return &HeicConverter{kind: "heif-convert", path: p}
}
return nil
}
func (h *HeicConverter) Name() string { return h.path }
// ToJPEG converts src (a .heic file) to a JPEG at dst.
func (h *HeicConverter) ToJPEG(src, dst string) error {
var cmd *exec.Cmd
switch h.kind {
case "vips":
cmd = exec.Command(h.path, "copy", src, dst+"[Q=95]")
case "heif-convert":
cmd = exec.Command(h.path, "-q", "95", src, dst)
default:
// custom converter contract: <cmd> <src> <dst>
cmd = exec.Command(h.path, src, dst)
}
if out, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("heic conversion failed: %v: %s", err, truncate(string(out), 300))
}
return nil
}
func truncate(s string, n int) string {
if len(s) > n {
return s[:n]
}
return s
}
+159
View File
@@ -0,0 +1,159 @@
// Package imaging produces the two derived variants from an original:
// thumb (512px long edge, JPEG q78) for the grid and preview (2048px long
// edge, JPEG q85) for the lightbox. Originals are never modified.
// Re-encoding strips EXIF (including GPS) from variants; orientation is
// applied to pixels first.
package imaging
import (
"fmt"
"image"
"image/jpeg"
"io"
"os"
"time"
_ "image/gif"
_ "image/png"
"github.com/rwcarlsen/goexif/exif"
"golang.org/x/image/draw"
_ "golang.org/x/image/webp"
)
const (
ThumbLongEdge = 512
ThumbQuality = 78
PreviewLongEdge = 2048
PreviewQuality = 85
)
type Result struct {
Width int // of the original, after orientation
Height int
TakenAt time.Time // zero when EXIF has no usable timestamp
}
// ProcessFile decodes the image at path (JPEG/PNG/GIF/WebP — HEIC must be
// converted to JPEG first), applies EXIF orientation, and writes both
// variants as JPEG to thumbPath and previewPath.
func ProcessFile(path, thumbPath, previewPath string) (Result, error) {
f, err := os.Open(path)
if err != nil {
return Result{}, err
}
orientation, takenAt := readExif(f)
if _, err := f.Seek(0, io.SeekStart); err != nil {
f.Close()
return Result{}, err
}
img, _, err := image.Decode(f)
f.Close()
if err != nil {
return Result{}, fmt.Errorf("decode: %w", err)
}
img = applyOrientation(img, orientation)
bounds := img.Bounds()
res := Result{Width: bounds.Dx(), Height: bounds.Dy(), TakenAt: takenAt}
if err := writeVariant(img, previewPath, PreviewLongEdge, PreviewQuality); err != nil {
return res, fmt.Errorf("preview: %w", err)
}
if err := writeVariant(img, thumbPath, ThumbLongEdge, ThumbQuality); err != nil {
return res, fmt.Errorf("thumb: %w", err)
}
return res, nil
}
func writeVariant(img image.Image, path string, longEdge, quality int) error {
out, err := os.Create(path)
if err != nil {
return err
}
defer out.Close()
return jpeg.Encode(out, resize(img, longEdge), &jpeg.Options{Quality: quality})
}
// resize scales so the long edge is at most longEdge, never upscaling.
func resize(img image.Image, longEdge int) image.Image {
b := img.Bounds()
w, h := b.Dx(), b.Dy()
long := max(w, h)
if long <= longEdge {
return img
}
scale := float64(longEdge) / float64(long)
nw, nh := max(1, int(float64(w)*scale)), max(1, int(float64(h)*scale))
dst := image.NewRGBA(image.Rect(0, 0, nw, nh))
draw.CatmullRom.Scale(dst, dst.Bounds(), img, b, draw.Over, nil)
return dst
}
func readExif(r io.Reader) (orientation int, takenAt time.Time) {
orientation = 1
x, err := exif.Decode(r)
if err != nil {
return orientation, takenAt
}
if tag, err := x.Get(exif.Orientation); err == nil {
if v, err := tag.Int(0); err == nil && v >= 1 && v <= 8 {
orientation = v
}
}
if t, err := x.DateTime(); err == nil {
takenAt = t
}
return orientation, takenAt
}
// applyOrientation bakes the EXIF orientation into pixels (values 2-8 per
// the EXIF spec; 1 is identity).
func applyOrientation(img image.Image, orientation int) image.Image {
switch orientation {
case 2:
return transform(img, func(x, y, w, h int) (int, int) { return w - 1 - x, y })
case 3:
return transform(img, func(x, y, w, h int) (int, int) { return w - 1 - x, h - 1 - y })
case 4:
return transform(img, func(x, y, w, h int) (int, int) { return x, h - 1 - y })
case 5:
return transformSwap(img, func(x, y, w, h int) (int, int) { return y, x })
case 6:
return transformSwap(img, func(x, y, w, h int) (int, int) { return y, h - 1 - x })
case 7:
return transformSwap(img, func(x, y, w, h int) (int, int) { return w - 1 - y, h - 1 - x })
case 8:
return transformSwap(img, func(x, y, w, h int) (int, int) { return w - 1 - y, x })
default:
return img
}
}
// transform maps destination (x,y) to source coordinates, same dimensions.
func transform(img image.Image, srcAt func(x, y, w, h int) (int, int)) image.Image {
b := img.Bounds()
w, h := b.Dx(), b.Dy()
dst := image.NewRGBA(image.Rect(0, 0, w, h))
for y := 0; y < h; y++ {
for x := 0; x < w; x++ {
sx, sy := srcAt(x, y, w, h)
dst.Set(x, y, img.At(b.Min.X+sx, b.Min.Y+sy))
}
}
return dst
}
// transformSwap is for orientations that swap width and height.
func transformSwap(img image.Image, srcAt func(x, y, w, h int) (int, int)) image.Image {
b := img.Bounds()
w, h := b.Dx(), b.Dy()
dst := image.NewRGBA(image.Rect(0, 0, h, w))
for y := 0; y < w; y++ {
for x := 0; x < h; x++ {
sx, sy := srcAt(x, y, w, h)
dst.Set(x, y, img.At(b.Min.X+sx, b.Min.Y+sy))
}
}
return dst
}
+30
View File
@@ -0,0 +1,30 @@
package imaging
import "bytes"
// Sniff identifies an image by magic bytes, ignoring the client-supplied
// filename and Content-Type — same policy as backend/src/routes/media.ts
// detectImageType. Returns the canonical content type and extension, or
// ok=false with a human-readable reason.
func Sniff(head []byte) (contentType, ext string, ok bool, reason string) {
switch {
case len(head) >= 3 && bytes.Equal(head[:3], []byte{0xFF, 0xD8, 0xFF}):
return "image/jpeg", ".jpg", true, ""
case len(head) >= 8 && bytes.Equal(head[:8], []byte{0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A}):
return "image/png", ".png", true, ""
case len(head) >= 6 && (bytes.Equal(head[:6], []byte("GIF87a")) || bytes.Equal(head[:6], []byte("GIF89a"))):
return "image/gif", ".gif", true, ""
case len(head) >= 12 && bytes.Equal(head[:4], []byte("RIFF")) && bytes.Equal(head[8:12], []byte("WEBP")):
return "image/webp", ".webp", true, ""
}
if len(head) >= 12 && bytes.Equal(head[4:8], []byte("ftyp")) {
brand := string(head[8:12])
switch brand {
case "heic", "heix", "hevc", "hevx", "mif1", "msf1":
return "image/heic", ".heic", true, ""
case "avif", "avis":
return "", "", false, "AVIF is not supported, please upload JPEG, PNG, WebP, GIF or HEIC"
}
}
return "", "", false, "unsupported file type, please upload JPEG, PNG, WebP, GIF or HEIC"
}