recipes.PhotoCache.CachePhoto downloads an image through the safe
Fetcher's new FetchImage (JPEG/PNG/WebP/GIF content types only, 8 MB
cap, uncached), decodes it (GIF first frame), refuses images under
300 px on the short side, scales to 1200 px and 400 px on the long side
with golang.org/x/image/draw, encodes JPEG quality 82 under
$MEALQUEST_DATA_DIR/media/photos/{id}-{size}.jpg, deduplicates by the
SHA-256 of the original bytes and records a photos row.
GET /media/photos/{id}-{400|1200}.jpg serves a stored size behind the
session with Cache-Control "private, max-age=31536000, immutable";
anything unknown is 404.
Adds golang.org/x/image v0.45.0 (approved; v0.46.0 needs Go 1.26).
Maestro task: 8a432108-d3ba-4013-ae8c-11963981d641
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
75 lines
2.1 KiB
Go
75 lines
2.1 KiB
Go
package server
|
|
|
|
import (
|
|
"errors"
|
|
"io/fs"
|
|
"log/slog"
|
|
"net/http"
|
|
"os"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
)
|
|
|
|
// photoCacheControl lets the browser keep a photo for a year without
|
|
// revalidating: a photo's files never change once stored, and a new image
|
|
// gets a new id.
|
|
const photoCacheControl = "private, max-age=31536000, immutable"
|
|
|
|
// PhotoFiles opens a cached photo's file at one of its stored sizes. An
|
|
// unknown photo or size is an error matching fs.ErrNotExist.
|
|
type PhotoFiles interface {
|
|
OpenPhoto(id string, size int) (*os.File, error)
|
|
}
|
|
|
|
// getPhoto answers GET /media/photos/{id}-{size}.jpg with the stored JPEG.
|
|
func getPhoto(logger *slog.Logger, photos PhotoFiles) http.HandlerFunc {
|
|
notFound := apiErrorHandler(logger, http.StatusNotFound, "not_found", "not found")
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
id, size, ok := parsePhotoName(chi.URLParam(r, "name"))
|
|
if !ok {
|
|
notFound(w, r)
|
|
return
|
|
}
|
|
f, err := photos.OpenPhoto(id, size)
|
|
if errors.Is(err, fs.ErrNotExist) {
|
|
notFound(w, r)
|
|
return
|
|
}
|
|
if err != nil {
|
|
logger.ErrorContext(r.Context(), "open photo", "id", id, "size", size, "error", err)
|
|
writeError(w, r, logger, http.StatusInternalServerError, "internal", "internal error")
|
|
return
|
|
}
|
|
defer f.Close()
|
|
info, err := f.Stat()
|
|
if err != nil {
|
|
logger.ErrorContext(r.Context(), "stat photo", "id", id, "size", size, "error", err)
|
|
writeError(w, r, logger, http.StatusInternalServerError, "internal", "internal error")
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "image/jpeg")
|
|
w.Header().Set("Cache-Control", photoCacheControl)
|
|
http.ServeContent(w, r, "", info.ModTime(), f)
|
|
}
|
|
}
|
|
|
|
// parsePhotoName splits "{id}-{size}.jpg" into its id and size, refusing
|
|
// a size not written in its canonical decimal form.
|
|
func parsePhotoName(name string) (id string, size int, ok bool) {
|
|
stem, ok := strings.CutSuffix(name, ".jpg")
|
|
if !ok {
|
|
return "", 0, false
|
|
}
|
|
i := strings.LastIndexByte(stem, '-')
|
|
if i < 0 {
|
|
return "", 0, false
|
|
}
|
|
size, err := strconv.Atoi(stem[i+1:])
|
|
if err != nil || strconv.Itoa(size) != stem[i+1:] {
|
|
return "", 0, false
|
|
}
|
|
return stem[:i], size, true
|
|
}
|