Files
hotdog-cms/internal/media/media.go
T

330 lines
9.5 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Package media prepares pictures that come in through the editor.
//
// Nothing is published as uploaded. Each picture is decoded (in Go, never by
// a C library), turned the right way up, resized to a few widths and encoded
// afresh, so what reaches the site is pixels and nothing else: no EXIF, no
// location, no comments, nothing hidden after the image data. SVG is refused,
// because it's a document that can carry scripts; it goes in through git,
// where a person reviews it.
package media
import (
"bytes"
"errors"
"fmt"
"image"
"image/gif"
"image/jpeg"
"image/png"
"math"
"net/http"
"path"
"regexp"
"strings"
"golang.org/x/image/draw"
xwebp "golang.org/x/image/webp"
"git.coffeylabs.org/coffey-labs/hotdog-cms/internal/media/webpenc"
)
const (
// MaxBytes is the largest file accepted.
MaxBytes = 25 << 20
// MaxPixels bounds the decoded size, checked from the header before any
// decoding, so a small file that claims to be enormous is refused cheaply.
MaxPixels = 60_000_000
// JPEGQuality is the re-encoding quality for photographs.
JPEGQuality = 82
)
// WebPQuality is the lossy WebP quality for photographs. Pictures kept as
// PNG get lossless WebP instead.
const WebPQuality = 80
// Widths are the sizes a picture is published at. The largest is the main
// file; the smaller ones are offered to browsers through srcset. A picture
// narrower than one of these isn't enlarged to it.
var Widths = []int{480, 960, 1600}
// File is one encoded size of a picture.
type File struct {
Path string // under the site's static folder, e.g. media/2026/10/dock-960w.jpg
Width int
Height int
Data []byte
WebP bool // the WebP copy of the size before it
}
// Image is a picture ready to commit: its files, main file first.
type Image struct {
URL string // the main file's address on the site
Width int
Height int
Type string // image/jpeg or image/png
Files []File // each size, followed by its WebP copy where that's smaller
Notes []string // what was done to it, in words, for the person who uploaded it
}
// ErrNotPicture is returned for anything that isn't a JPEG, PNG, GIF or WebP.
var ErrNotPicture = errors.New("only JPEG, PNG, GIF and WebP pictures can be uploaded here; SVG and other files go in through git, where they're reviewed")
var sem = make(chan struct{}, 2) // decoding is memory-hungry; two at a time
// Process prepares an uploaded picture. name is the uploaded file name, used
// for the published name; dir is the folder under media/ (such as
// "2026/10"); taken reports whether a path is already used in the site, so
// an existing picture is never overwritten.
func Process(data []byte, name, dir string, taken func(string) bool) (*Image, error) {
if len(data) > MaxBytes {
return nil, fmt.Errorf("the picture is %d MB; the limit is %d MB", len(data)>>20, MaxBytes>>20)
}
sem <- struct{}{}
defer func() { <-sem }()
kind := http.DetectContentType(data)
var (
cfg image.Config
decode func() (image.Image, error)
err error
notes []string
meta = JPEGMeta{Orientation: 1}
)
r := func() *bytes.Reader { return bytes.NewReader(data) }
switch kind {
case "image/jpeg":
cfg, err = jpeg.DecodeConfig(r())
decode = func() (image.Image, error) { return jpeg.Decode(r()) }
meta = ReadJPEGMeta(data)
case "image/png":
cfg, err = png.DecodeConfig(r())
decode = func() (image.Image, error) { return png.Decode(r()) }
case "image/gif":
cfg, err = gif.DecodeConfig(r())
decode = func() (image.Image, error) { return gif.Decode(r()) }
if gifFrames(data) > 1 {
notes = append(notes, "This GIF was animated; only its first frame is kept.")
}
case "image/webp":
cfg, err = xwebp.DecodeConfig(r())
decode = func() (image.Image, error) { return xwebp.Decode(r()) }
default:
return nil, ErrNotPicture
}
if err != nil {
return nil, fmt.Errorf("the picture couldn't be read: %v", err)
}
if cfg.Width <= 0 || cfg.Height <= 0 || cfg.Width*cfg.Height > MaxPixels {
return nil, fmt.Errorf("the picture is %d×%d; the limit is %d megapixels", cfg.Width, cfg.Height, MaxPixels/1_000_000)
}
src, err := decode()
if err != nil {
return nil, fmt.Errorf("the picture couldn't be read: %v", err)
}
if meta.GPS {
notes = append(notes, "Removed the location it was taken at.")
}
if meta.EXIF {
notes = append(notes, "Removed camera details (EXIF).")
}
if meta.Orientation != 1 {
notes = append(notes, "Turned it the right way up.")
}
// Sizes, worked out the right way up.
ow, oh := cfg.Width, cfg.Height
if meta.Orientation >= 5 {
ow, oh = oh, ow
}
mainW := min(ow, Widths[len(Widths)-1])
widths := []int{mainW}
for _, w := range Widths {
if w < mainW {
widths = append(widths, w)
}
}
// The format: photographs stay JPEG; PNG and GIF stay PNG, since they're
// usually screenshots and drawings that JPEG would smear; WebP becomes
// JPEG unless it has transparency.
typ, ext := "image/png", ".png"
if kind == "image/jpeg" {
typ, ext = "image/jpeg", ".jpg"
}
base := slug(strings.TrimSuffix(path.Base(name), path.Ext(name)))
stem := path.Join("media", dir, base)
for i := 2; taken("static/"+stem+ext) && i < 1000; i++ {
stem = path.Join("media", dir, fmt.Sprintf("%s-%d", base, i))
}
img := &Image{URL: "/" + stem + ext}
for k, w := range widths {
h := max(1, int(math.Round(float64(oh)*float64(w)/float64(ow))))
rw, rh := w, h
if meta.Orientation >= 5 {
rw, rh = h, w
}
px := orient(scale(src, rw, rh), meta.Orientation)
if k == 0 && kind == "image/webp" && px.Opaque() {
typ, ext = "image/jpeg", ".jpg"
img.URL = "/" + stem + ext
}
var buf bytes.Buffer
if typ == "image/jpeg" {
err = jpeg.Encode(&buf, px, &jpeg.Options{Quality: JPEGQuality})
} else {
err = (&png.Encoder{CompressionLevel: png.BestCompression}).Encode(&buf, px)
}
if err != nil {
return nil, err
}
p := stem + ext
if k > 0 {
p = fmt.Sprintf("%s-%dw%s", stem, w, ext)
}
img.Files = append(img.Files, File{Path: p, Width: w, Height: h, Data: buf.Bytes()})
// A WebP beside it, for browsers that take one, when it's smaller.
var wb bytes.Buffer
if err := webpenc.Encode(&wb, px, webpenc.Options{Quality: WebPQuality, Lossless: typ == "image/png"}); err != nil {
return nil, err
}
if wb.Len() < buf.Len() {
img.Files = append(img.Files, File{Path: strings.TrimSuffix(p, ext) + ".webp", Width: w, Height: h, Data: wb.Bytes(), WebP: true})
}
}
img.Width, img.Height, img.Type = img.Files[0].Width, img.Files[0].Height, typ
if mainW < ow {
notes = append(notes, fmt.Sprintf("Resized from %d px wide to %d.", ow, mainW))
}
img.Notes = notes
return img, nil
}
// VariantRe matches the smaller sizes Process writes beside a main file:
// name-480w.jpg next to name.jpg.
var VariantRe = regexp.MustCompile(`^(.+)-(\d+)w\.(jpg|png|webp)$`)
// scale resizes to w×h. A large reduction is done in halving steps, each an
// exact two-by-two average, and the last step with a careful filter: quick,
// and without the shimmer that one big jump leaves in fine detail.
func scale(src image.Image, w, h int) *image.RGBA {
b := src.Bounds()
cur := src
for cw, ch := b.Dx(), b.Dy(); cw/2 >= w*2 && ch/2 >= h*2; {
cw, ch = cw/2, ch/2
half := image.NewRGBA(image.Rect(0, 0, cw, ch))
draw.ApproxBiLinear.Scale(half, half.Rect, cur, cur.Bounds(), draw.Src, nil)
cur = half
}
dst := image.NewRGBA(image.Rect(0, 0, w, h))
if cur.Bounds().Dx() == w && cur.Bounds().Dy() == h {
draw.Draw(dst, dst.Rect, cur, cur.Bounds().Min, draw.Src)
} else {
draw.CatmullRom.Scale(dst, dst.Rect, cur, cur.Bounds(), draw.Src, nil)
}
return dst
}
// orient turns pixels the way an EXIF orientation says the picture should be
// shown, so the published file needs no orientation tag.
func orient(src *image.RGBA, o int) *image.RGBA {
if o <= 1 || o > 8 {
return src
}
w, h := src.Rect.Dx(), src.Rect.Dy()
dw, dh := w, h
if o >= 5 {
dw, dh = h, w
}
dst := image.NewRGBA(image.Rect(0, 0, dw, dh))
for y := 0; y < h; y++ {
for x := 0; x < w; x++ {
var dx, dy int
switch o {
case 2: // mirrored
dx, dy = w-1-x, y
case 3: // upside down
dx, dy = w-1-x, h-1-y
case 4: // mirrored, upside down
dx, dy = x, h-1-y
case 5: // mirrored, on its side
dx, dy = y, x
case 6: // on its side: a quarter turn clockwise puts it right
dx, dy = h-1-y, x
case 7:
dx, dy = h-1-y, w-1-x
case 8: // a quarter turn anticlockwise puts it right
dx, dy = y, w-1-x
}
si := src.PixOffset(x, y)
di := dst.PixOffset(dx, dy)
copy(dst.Pix[di:di+4], src.Pix[si:si+4])
}
}
return dst
}
// gifFrames counts a GIF's frames by walking its blocks, without decoding
// any of them.
func gifFrames(d []byte) int {
if len(d) < 13 {
return 0
}
i := 13
if d[10]&0x80 != 0 {
i += 3 << (d[10]&7 + 1)
}
skipBlocks := func() bool {
for i < len(d) {
n := int(d[i])
i++
if n == 0 {
return true
}
i += n
}
return false
}
frames := 0
for i < len(d) {
switch d[i] {
case 0x21: // extension: label, then sub-blocks
i += 2
if !skipBlocks() {
return frames
}
case 0x2C: // a frame: descriptor, local colors, LZW size, sub-blocks
frames++
if i+10 > len(d) {
return frames
}
flags := d[i+9]
i += 10
if flags&0x80 != 0 {
i += 3 << (flags&7 + 1)
}
i++
if !skipBlocks() {
return frames
}
default: // 0x3B, the end, or something unreadable
return frames
}
}
return frames
}
var slugRe = regexp.MustCompile(`[^a-z0-9]+`)
func slug(s string) string {
s = strings.Trim(slugRe.ReplaceAllString(strings.ToLower(s), "-"), "-")
if len(s) > 60 {
s = strings.TrimRight(s[:60], "-")
}
if s == "" {
return "image"
}
return s
}