fluid-telegram/internal/avatar/avatar.go

136 lines
4.3 KiB
Go
Raw Normal View History

// 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
}