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