Files
Pete/internal/web/thumbs.go
prosolis 8e0d6aff3e Adventure news: fix posting gates, digest counts, and section gating
- Don't post adventure beats when posting.enabled=false (PostNow ignores the flag)
- Keep the adventure channel out of the round-robin rotation so it can't steal bulletins from the digest
- no_push now retires a fact against the digest, not just the live post
- Default a missing occurred_at to now instead of the Unix epoch
- Keep protocol-relative image URLs on the guarded thumbnailer
- Make digest_hour=0 (midnight UTC) settable
- Quote the true window total in the digest, not the truncated slice
- Hide the Adventure section entirely when it's disabled
2026-07-11 08:07:40 -07:00

469 lines
13 KiB
Go

package web
import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"fmt"
"image"
_ "image/gif"
_ "image/jpeg"
"image/png"
"io"
"log/slog"
"net/http"
"net/url"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"sync"
"time"
"golang.org/x/image/draw"
_ "golang.org/x/image/webp"
"pete/internal/safehttp"
"pete/internal/storage"
)
const (
thumbWidth = 800
thumbMaxBytes = 12 << 20 // 12 MiB cap on source download for images
videoMaxBytes = 64 << 20 // 64 MiB cap when the source is a video file
thumbFetchTimeout = 12 * time.Second
thumbCacheDir = "./data/img-cache"
thumbQuality = "45"
thumbSpeed = "8"
ffmpegTimeout = 20 * time.Second
)
// videoExtensions are file extensions we route through the ffmpeg
// frame-extraction path instead of trying to decode as an image.
var videoExtensions = map[string]bool{
".mp4": true,
".m4v": true,
".mov": true,
".webm": true,
".mkv": true,
}
var (
thumbClient = safehttp.NewClient(thumbFetchTimeout)
thumbMu sync.Mutex
thumbInFlight = map[string]*sync.WaitGroup{}
)
func thumbKey(u string) string {
sum := sha256.Sum256([]byte(u))
return hex.EncodeToString(sum[:16])
}
// thumbURL maps a source image URL to the /img proxy path. Empty input → "".
func thumbURL(src string) string {
if src == "" {
return ""
}
// Root-relative sources are our own local assets (e.g. the adventure
// emblems) — serve them directly; the thumbnailer only handles remote
// http(s) images and would 404 a local path. "//host/x.jpg" is NOT local:
// it's a protocol-relative remote image, and it has to keep going through
// the guarded thumbnailer like any other third-party URL.
if strings.HasPrefix(src, "/") && !strings.HasPrefix(src, "//") {
return src
}
return "/img/" + thumbKey(src) + ".avif?u=" + url.QueryEscape(src)
}
func (s *Server) handleImg(w http.ResponseWriter, r *http.Request) {
name := r.PathValue("name")
hash, ok := strings.CutSuffix(name, ".avif")
if !ok {
http.NotFound(w, r)
return
}
src := r.URL.Query().Get("u")
if src == "" || thumbKey(src) != hash {
http.NotFound(w, r)
return
}
if !(strings.HasPrefix(src, "http://") || strings.HasPrefix(src, "https://")) {
http.NotFound(w, r)
return
}
if !storage.IsKnownImageURL(src) {
http.NotFound(w, r)
return
}
if err := os.MkdirAll(thumbCacheDir, 0o755); err != nil {
http.Error(w, "cache dir", http.StatusInternalServerError)
return
}
cachePath := filepath.Join(thumbCacheDir, hash+".avif")
if fi, err := os.Stat(cachePath); err == nil && fi.Size() > 0 {
serveAvif(w, r, cachePath)
return
}
// Single-flight: collapse concurrent requests for the same image.
thumbMu.Lock()
wg, busy := thumbInFlight[hash]
if !busy {
wg = &sync.WaitGroup{}
wg.Add(1)
thumbInFlight[hash] = wg
}
thumbMu.Unlock()
if busy {
wg.Wait()
if fi, err := os.Stat(cachePath); err == nil && fi.Size() > 0 {
serveAvif(w, r, cachePath)
return
}
http.Redirect(w, r, src, http.StatusFound)
return
}
defer func() {
thumbMu.Lock()
delete(thumbInFlight, hash)
thumbMu.Unlock()
wg.Done()
}()
if err := buildAvifThumb(src, cachePath); err != nil {
slog.Warn("thumb build failed", "url", src, "err", err)
http.Redirect(w, r, src, http.StatusFound)
return
}
serveAvif(w, r, cachePath)
}
func serveAvif(w http.ResponseWriter, r *http.Request, path string) {
w.Header().Set("Content-Type", "image/avif")
w.Header().Set("Cache-Control", "public, max-age=2592000, immutable")
http.ServeFile(w, r, path)
}
// buildAvifThumb downloads src, decodes + resizes in Go, then shells out to
// avifenc to produce a compact AVIF at outPath. Video URLs (mp4, webm, ...)
// take a parallel path that extracts a frame with ffmpeg before re-encoding.
func buildAvifThumb(src, outPath string) error {
req, err := http.NewRequest(http.MethodGet, src, nil)
if err != nil {
return err
}
req.Header.Set("User-Agent", "Pete-Thumb/1.0 (+https://pete.example)")
resp, err := thumbClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("upstream %d", resp.StatusCode)
}
// URL extension is enough for the cap decision (Content-Type may be
// missing or generic on some CDNs); Content-Type is checked again below
// to decide which decode path to take.
maxBytes := int64(thumbMaxBytes)
urlIsVideo := isVideoURL(src)
if urlIsVideo {
maxBytes = videoMaxBytes
}
body, err := io.ReadAll(io.LimitReader(resp.Body, maxBytes+1))
if err != nil {
return err
}
if int64(len(body)) > maxBytes {
if urlIsVideo {
return fmt.Errorf("video too big (>%d bytes)", maxBytes)
}
return fmt.Errorf("image too big (>%d bytes)", maxBytes)
}
if urlIsVideo || isVideoContentType(resp.Header.Get("Content-Type")) {
return buildAvifFromFFmpeg(body, outPath)
}
if isAvif(body) {
// If the source AVIF is already within our width budget, skip the
// decode/resize/re-encode roundtrip and cache the original bytes.
w, _, infoErr := avifInfo(body)
if infoErr == nil && w > 0 && w <= thumbWidth {
return writeFile(outPath, body)
}
body, err = avifToPNG(body)
if err != nil {
return fmt.Errorf("avifdec: %w", err)
}
}
src2, _, err := image.Decode(bytes.NewReader(body))
if err != nil {
// Go's stdlib JPEG decoder rejects some valid-but-rare features
// (e.g. 4:1:1 luma/chroma subsampling served by ANN's CDN). Hand
// the bytes to ffmpeg as a permissive fallback — it produces the
// same shape of output as the video path.
if ffErr := buildAvifFromFFmpeg(body, outPath); ffErr == nil {
return nil
}
return fmt.Errorf("decode: %w", err)
}
b := src2.Bounds()
srcW, srcH := b.Dx(), b.Dy()
if srcW <= 0 || srcH <= 0 {
return fmt.Errorf("empty image")
}
dstW, dstH := srcW, srcH
if srcW > thumbWidth {
dstW = thumbWidth
dstH = int(float64(srcH) * float64(thumbWidth) / float64(srcW))
if dstH < 1 {
dstH = 1
}
}
var resized image.Image = src2
if dstW != srcW || dstH != srcH {
dst := image.NewRGBA(image.Rect(0, 0, dstW, dstH))
draw.CatmullRom.Scale(dst, dst.Bounds(), src2, b, draw.Over, nil)
resized = dst
}
tmp, err := os.CreateTemp("", "pete-thumb-*.png")
if err != nil {
return err
}
tmpPath := tmp.Name()
defer os.Remove(tmpPath)
if err := png.Encode(tmp, resized); err != nil {
tmp.Close()
return err
}
if err := tmp.Close(); err != nil {
return err
}
return avifEncodeFromPNG(tmpPath, outPath)
}
// avifEncodeFromPNG runs avifenc on pngPath and atomically renames the
// result into outPath so partial outputs never appear in the cache.
func avifEncodeFromPNG(pngPath, outPath string) error {
stagedOut := outPath + ".tmp"
cmd := exec.Command("avifenc",
"-q", thumbQuality,
"-s", thumbSpeed,
pngPath, stagedOut,
)
if out, err := cmd.CombinedOutput(); err != nil {
os.Remove(stagedOut)
return fmt.Errorf("avifenc: %w: %s", err, strings.TrimSpace(string(out)))
}
return os.Rename(stagedOut, outPath)
}
// isVideoURL reports whether the URL's path extension is one we treat as a
// video source for thumbnail extraction.
func isVideoURL(src string) bool {
u, err := url.Parse(src)
if err != nil {
return false
}
return videoExtensions[strings.ToLower(filepath.Ext(u.Path))]
}
// isVideoContentType reports whether a Content-Type header announces a video.
func isVideoContentType(ct string) bool {
return strings.HasPrefix(strings.ToLower(strings.TrimSpace(ct)), "video/")
}
// buildAvifFromFFmpeg writes the downloaded bytes to a temp file, asks
// ffmpeg to extract a single resized frame as PNG, then hands it to avifenc.
// Used both for video sources and as a permissive fallback for images whose
// JPEG features Go's stdlib decoder rejects. ffmpeg is optional — if it's
// missing we return an error and the caller falls back to redirecting to
// the source URL, just like any other build failure.
func buildAvifFromFFmpeg(body []byte, outPath string) error {
if _, err := exec.LookPath("ffmpeg"); err != nil {
return fmt.Errorf("ffmpeg not available: %w", err)
}
in, err := os.CreateTemp("", "pete-vid-*.bin")
if err != nil {
return err
}
inPath := in.Name()
defer os.Remove(inPath)
if _, err := in.Write(body); err != nil {
in.Close()
return err
}
if err := in.Close(); err != nil {
return err
}
outPNG, err := os.CreateTemp("", "pete-vid-frame-*.png")
if err != nil {
return err
}
pngPath := outPNG.Name()
outPNG.Close()
defer os.Remove(pngPath)
// Aim ~5s in — past the typical fade-in / first-GOP blockiness while
// still likely within the clip. For shorter clips we fall back to the
// midpoint (when ffprobe is available) so we don't seek off the end.
seek := "5"
if d := probeVideoDuration(inPath); d > 0 && d < 5 {
seek = strconv.FormatFloat(d/2, 'f', 2, 64)
}
if err := extractVideoFrame(inPath, pngPath, seek); err != nil {
// Seek overshot or container's funky — try 2s, then frame 0.
if err := extractVideoFrame(inPath, pngPath, "2"); err != nil {
if err := extractVideoFrame(inPath, pngPath, "0"); err != nil {
return err
}
}
}
return avifEncodeFromPNG(pngPath, outPath)
}
// probeVideoDuration returns the clip length in seconds, or 0 if ffprobe is
// missing or can't read it (image-as-video fallback, malformed containers).
func probeVideoDuration(inPath string) float64 {
if _, err := exec.LookPath("ffprobe"); err != nil {
return 0
}
ctx, cancel := context.WithTimeout(context.Background(), ffmpegTimeout)
defer cancel()
cmd := exec.CommandContext(ctx, "ffprobe",
"-v", "error",
"-show_entries", "format=duration",
"-of", "default=noprint_wrappers=1:nokey=1",
inPath,
)
out, err := cmd.Output()
if err != nil {
return 0
}
d, err := strconv.ParseFloat(strings.TrimSpace(string(out)), 64)
if err != nil || d <= 0 {
return 0
}
return d
}
// extractVideoFrame asks ffmpeg to seek to seekSec, grab one frame, and
// downscale it to at most thumbWidth wide before writing pngPath.
func extractVideoFrame(inPath, pngPath, seekSec string) error {
ctx, cancel := context.WithTimeout(context.Background(), ffmpegTimeout)
defer cancel()
cmd := exec.CommandContext(ctx, "ffmpeg",
"-y",
"-ss", seekSec,
"-i", inPath,
"-frames:v", "1",
"-update", "1",
"-vf", fmt.Sprintf("scale='min(%d,iw)':-2", thumbWidth),
"-f", "image2",
pngPath,
)
if out, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("ffmpeg: %w: %s", err, strings.TrimSpace(string(out)))
}
if fi, err := os.Stat(pngPath); err != nil || fi.Size() == 0 {
return fmt.Errorf("ffmpeg produced empty frame")
}
return nil
}
// isAvif reports whether body looks like an AVIF file via the ISO-BMFF "ftyp"
// brand at bytes 4..12 ("ftypavif"/"ftypavis").
func isAvif(body []byte) bool {
if len(body) < 12 {
return false
}
if string(body[4:8]) != "ftyp" {
return false
}
brand := string(body[8:12])
return brand == "avif" || brand == "avis"
}
// avifInfo returns the width and height of an AVIF by shelling out to
// `avifdec --info`. It still decodes the image internally but doesn't write
// pixels to disk, so it's cheaper than full avifToPNG.
func avifInfo(body []byte) (int, int, error) {
in, err := os.CreateTemp("", "pete-avif-info-*.avif")
if err != nil {
return 0, 0, err
}
inPath := in.Name()
defer os.Remove(inPath)
if _, err := in.Write(body); err != nil {
in.Close()
return 0, 0, err
}
if err := in.Close(); err != nil {
return 0, 0, err
}
out, err := exec.Command("avifdec", "--info", inPath).CombinedOutput()
if err != nil {
return 0, 0, fmt.Errorf("%w: %s", err, strings.TrimSpace(string(out)))
}
for _, line := range strings.Split(string(out), "\n") {
line = strings.TrimSpace(line)
rest, ok := strings.CutPrefix(line, "* Resolution")
if !ok {
continue
}
_, dims, ok := strings.Cut(rest, ":")
if !ok {
continue
}
var w, h int
if _, err := fmt.Sscanf(strings.TrimSpace(dims), "%dx%d", &w, &h); err == nil {
return w, h, nil
}
}
return 0, 0, fmt.Errorf("no resolution in avifdec output")
}
// writeFile atomically writes body to path via a sibling temp + rename.
func writeFile(path string, body []byte) error {
staged := path + ".tmp"
if err := os.WriteFile(staged, body, 0o644); err != nil {
os.Remove(staged)
return err
}
return os.Rename(staged, path)
}
// avifToPNG decodes AVIF bytes to PNG bytes by shelling out to avifdec.
func avifToPNG(body []byte) ([]byte, error) {
in, err := os.CreateTemp("", "pete-avif-in-*.avif")
if err != nil {
return nil, err
}
inPath := in.Name()
defer os.Remove(inPath)
if _, err := in.Write(body); err != nil {
in.Close()
return nil, err
}
if err := in.Close(); err != nil {
return nil, err
}
outPath := inPath + ".png"
defer os.Remove(outPath)
cmd := exec.Command("avifdec", inPath, outPath)
if out, err := cmd.CombinedOutput(); err != nil {
return nil, fmt.Errorf("%w: %s", err, strings.TrimSpace(string(out)))
}
return os.ReadFile(outPath)
}