330 lines
9.5 KiB
Go
330 lines
9.5 KiB
Go
// 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
|
||
}
|