136 lines
4.3 KiB
Go
136 lines
4.3 KiB
Go
|
|
// Package avatar validates and fingerprints the image a presence declares.
|
||
|
|
//
|
||
|
|
// It is checked before anything reaches Telegram. A bot's picture is the first
|
||
|
|
// thing a stranger sees, and an image that is rejected halfway through a
|
||
|
|
// BotFather conversation leaves the bot registered with no picture and the
|
||
|
|
// conversation in a state the tool did not plan for.
|
||
|
|
package avatar
|
||
|
|
|
||
|
|
import (
|
||
|
|
"crypto/sha256"
|
||
|
|
"encoding/binary"
|
||
|
|
"encoding/hex"
|
||
|
|
"errors"
|
||
|
|
"fmt"
|
||
|
|
"os"
|
||
|
|
"path/filepath"
|
||
|
|
)
|
||
|
|
|
||
|
|
// Telegram's own limits, plus a floor of its own. Telegram accepts smaller
|
||
|
|
// images, but a profile picture is rendered at a range of sizes and upscaling a
|
||
|
|
// small one looks worse everywhere than not having one.
|
||
|
|
const (
|
||
|
|
MinDimension = 512
|
||
|
|
MaxBytes = 10 << 20 // 10 MiB
|
||
|
|
)
|
||
|
|
|
||
|
|
type Image struct {
|
||
|
|
Path string
|
||
|
|
Digest string // content address: replacing the file is what triggers an update
|
||
|
|
Width int
|
||
|
|
Height int
|
||
|
|
Bytes int
|
||
|
|
}
|
||
|
|
|
||
|
|
// Square reports whether the image needs no cropping. Telegram centre-crops a
|
||
|
|
// non-square picture to a circle, so a rectangular one silently loses its edges.
|
||
|
|
func (i Image) Square() bool { return i.Width == i.Height }
|
||
|
|
|
||
|
|
// CropNote describes what a viewer will actually see, for a plan to print.
|
||
|
|
func (i Image) CropNote() string {
|
||
|
|
if i.Square() {
|
||
|
|
return ""
|
||
|
|
}
|
||
|
|
side := i.Width
|
||
|
|
if i.Height < side {
|
||
|
|
side = i.Height
|
||
|
|
}
|
||
|
|
lost := (i.Width - side) + (i.Height - side)
|
||
|
|
return fmt.Sprintf("%dx%d is not square: telegram will centre-crop to %dx%d, "+
|
||
|
|
"losing %dpx of the longer side", i.Width, i.Height, side, side, lost)
|
||
|
|
}
|
||
|
|
|
||
|
|
// ErrMissing is returned when the declared file does not exist, so callers can
|
||
|
|
// tell "not provided yet" from "provided and wrong".
|
||
|
|
var ErrMissing = errors.New("avatar file does not exist")
|
||
|
|
|
||
|
|
// Load reads the image declared by a spec.
|
||
|
|
//
|
||
|
|
// The path is repo-relative, as the schema says: it is resolved against the root
|
||
|
|
// of the repository containing the spec, not against the spec's own directory.
|
||
|
|
// That is what "presence/assets/x.png" means to someone reading the campaign
|
||
|
|
// repo, and resolving it any other way makes a correct-looking spec fail.
|
||
|
|
func Load(specPath, declared string) (Image, error) {
|
||
|
|
full := resolve(specPath, declared)
|
||
|
|
raw, err := os.ReadFile(full)
|
||
|
|
if errors.Is(err, os.ErrNotExist) {
|
||
|
|
return Image{Path: full}, fmt.Errorf("%w: %s", ErrMissing, full)
|
||
|
|
}
|
||
|
|
if err != nil {
|
||
|
|
return Image{Path: full}, err
|
||
|
|
}
|
||
|
|
if len(raw) > MaxBytes {
|
||
|
|
return Image{Path: full}, fmt.Errorf("avatar is %d bytes, over telegram's %d limit",
|
||
|
|
len(raw), MaxBytes)
|
||
|
|
}
|
||
|
|
|
||
|
|
w, h, err := pngDimensions(raw)
|
||
|
|
if err != nil {
|
||
|
|
return Image{Path: full}, fmt.Errorf("%s: %w", full, err)
|
||
|
|
}
|
||
|
|
if w < MinDimension || h < MinDimension {
|
||
|
|
return Image{Path: full}, fmt.Errorf(
|
||
|
|
"avatar is %dx%d; at least %dpx on each side is wanted, because a profile "+
|
||
|
|
"picture is rendered at many sizes and upscaling shows", w, h, MinDimension)
|
||
|
|
}
|
||
|
|
|
||
|
|
sum := sha256.Sum256(raw)
|
||
|
|
return Image{
|
||
|
|
Path: full,
|
||
|
|
Digest: "sha256:" + hex.EncodeToString(sum[:]),
|
||
|
|
Width: w, Height: h, Bytes: len(raw),
|
||
|
|
}, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
// resolve finds the file a repo-relative path names. It walks up from the spec
|
||
|
|
// looking for a repository root, and falls back to the spec's directory when
|
||
|
|
// there is none -- a spec outside a repository still has to work in tests.
|
||
|
|
func resolve(specPath, declared string) string {
|
||
|
|
if filepath.IsAbs(declared) {
|
||
|
|
return declared
|
||
|
|
}
|
||
|
|
dir := filepath.Dir(specPath)
|
||
|
|
if abs, err := filepath.Abs(dir); err == nil {
|
||
|
|
for d := abs; ; {
|
||
|
|
if _, err := os.Stat(filepath.Join(d, ".git")); err == nil {
|
||
|
|
return filepath.Join(d, declared)
|
||
|
|
}
|
||
|
|
parent := filepath.Dir(d)
|
||
|
|
if parent == d {
|
||
|
|
break
|
||
|
|
}
|
||
|
|
d = parent
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return filepath.Join(dir, declared)
|
||
|
|
}
|
||
|
|
|
||
|
|
// pngDimensions reads the IHDR header. Only PNG is accepted: the format is
|
||
|
|
// declared in the spec's own file extension convention, and guessing at others
|
||
|
|
// invites a file that uploads but renders badly.
|
||
|
|
func pngDimensions(raw []byte) (int, int, error) {
|
||
|
|
const sig = "\x89PNG\r\n\x1a\n"
|
||
|
|
if len(raw) < 24 || string(raw[:8]) != sig {
|
||
|
|
return 0, 0, errors.New("not a PNG; the avatar must be a PNG")
|
||
|
|
}
|
||
|
|
if string(raw[12:16]) != "IHDR" {
|
||
|
|
return 0, 0, errors.New("PNG has no IHDR header where one is required")
|
||
|
|
}
|
||
|
|
w := binary.BigEndian.Uint32(raw[16:20])
|
||
|
|
h := binary.BigEndian.Uint32(raw[20:24])
|
||
|
|
if w == 0 || h == 0 {
|
||
|
|
return 0, 0, errors.New("PNG declares a zero dimension")
|
||
|
|
}
|
||
|
|
return int(w), int(h), nil
|
||
|
|
}
|