cd3b2c2bea
CI / chart (push) Successful in 2s
CI / security (push) Successful in 33s
CI / test (push) Successful in 6m36s
Release / test (push) Successful in 8s
Release / chart (push) Successful in 2s
Release / image (push) Successful in 2m31s
Release / scan-image (push) Successful in 32s
Release / binaries (push) Successful in 3m16s
gosec's G115 failed the security job on v0.50.0's first push: the box filter narrows uint64 channel sums to uint8. They cannot overflow, since a premultiplied channel never exceeds its alpha, but the conversion now goes through to8, which clamps to 16 bits first so that holds by construction rather than by argument. No behaviour change, and nothing else in the avatar work moves. Claude-Session: https://claude.ai/code/session_016mBLURvJoMuUEr9cB2RpUN
207 lines
6.3 KiB
Go
207 lines
6.3 KiB
Go
package api
|
|
|
|
import (
|
|
"bytes"
|
|
"database/sql"
|
|
"errors"
|
|
"image"
|
|
"image/color"
|
|
"image/draw"
|
|
_ "image/jpeg" // registers the JPEG decoder with image.Decode
|
|
"image/png"
|
|
"io"
|
|
"net/http"
|
|
"strconv"
|
|
"time"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
)
|
|
|
|
const (
|
|
// avatarSize is the side of the stored square picture. The web UI crops to
|
|
// it before uploading; the server enforces it anyway.
|
|
avatarSize = 256
|
|
|
|
// maxAvatarBytes caps the upload. The web UI sends a ~256x256 PNG, a few
|
|
// tens of KiB; this leaves room for a client that sends a photo as is.
|
|
maxAvatarBytes = 1 << 20
|
|
|
|
// maxAvatarPixels bounds the decoded size, checked from the header before
|
|
// decoding: a small file can declare an enormous canvas.
|
|
maxAvatarPixels = 16 << 20
|
|
)
|
|
|
|
// normalizeAvatar decodes a PNG or JPEG, crops it to its centred square, scales
|
|
// it to avatarSize and encodes it as PNG. Decoding and re-encoding is what
|
|
// makes the stored bytes trustworthy: EXIF, trailing data and anything that is
|
|
// not pixels do not survive it.
|
|
func normalizeAvatar(raw []byte) ([]byte, error) {
|
|
cfg, _, err := image.DecodeConfig(bytes.NewReader(raw))
|
|
if err != nil {
|
|
return nil, errors.New("the picture must be a PNG or JPEG image")
|
|
}
|
|
if cfg.Width <= 0 || cfg.Height <= 0 || cfg.Width*cfg.Height > maxAvatarPixels {
|
|
return nil, errors.New("the picture is too large")
|
|
}
|
|
img, _, err := image.Decode(bytes.NewReader(raw))
|
|
if err != nil {
|
|
return nil, errors.New("the picture must be a PNG or JPEG image")
|
|
}
|
|
|
|
b := img.Bounds()
|
|
side := min(b.Dx(), b.Dy())
|
|
src := image.Rect(
|
|
b.Min.X+(b.Dx()-side)/2, b.Min.Y+(b.Dy()-side)/2,
|
|
b.Min.X+(b.Dx()-side)/2+side, b.Min.Y+(b.Dy()-side)/2+side)
|
|
|
|
var out *image.NRGBA
|
|
if side <= avatarSize {
|
|
out = image.NewNRGBA(image.Rect(0, 0, side, side))
|
|
draw.Draw(out, out.Bounds(), img, src.Min, draw.Src)
|
|
} else {
|
|
out = boxScale(img, src, avatarSize)
|
|
}
|
|
|
|
var buf bytes.Buffer
|
|
if err := png.Encode(&buf, out); err != nil {
|
|
return nil, err
|
|
}
|
|
return buf.Bytes(), nil
|
|
}
|
|
|
|
// boxScale shrinks the square region src of img to size x size by averaging the
|
|
// source pixels each output pixel covers. Plain area averaging is enough for a
|
|
// downscale and keeps the standard library the only dependency.
|
|
func boxScale(img image.Image, src image.Rectangle, size int) *image.NRGBA {
|
|
out := image.NewNRGBA(image.Rect(0, 0, size, size))
|
|
n := src.Dx()
|
|
for y := 0; y < size; y++ {
|
|
y0, y1 := src.Min.Y+y*n/size, src.Min.Y+(y+1)*n/size
|
|
for x := 0; x < size; x++ {
|
|
x0, x1 := src.Min.X+x*n/size, src.Min.X+(x+1)*n/size
|
|
var r, g, b, a, cnt uint64
|
|
for sy := y0; sy < y1; sy++ {
|
|
for sx := x0; sx < x1; sx++ {
|
|
pr, pg, pb, pa := img.At(sx, sy).RGBA()
|
|
r, g, b, a, cnt = r+uint64(pr), g+uint64(pg), b+uint64(pb), a+uint64(pa), cnt+1
|
|
}
|
|
}
|
|
if cnt == 0 {
|
|
continue
|
|
}
|
|
// RGBA() is alpha-premultiplied 16-bit; undo that for NRGBA.
|
|
a /= cnt
|
|
if a == 0 {
|
|
continue
|
|
}
|
|
r, g, b = r/cnt*0xffff/a, g/cnt*0xffff/a, b/cnt*0xffff/a
|
|
out.SetNRGBA(x, y, color.NRGBA{to8(r), to8(g), to8(b), to8(a)})
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// to8 narrows a 16-bit channel to 8 bits. The clamp is what makes the
|
|
// conversion safe by construction: a premultiplied channel never exceeds its
|
|
// alpha, so it is not expected to bite, but nothing here relies on that.
|
|
func to8(v uint64) uint8 {
|
|
return uint8(min(v, 0xffff) >> 8)
|
|
}
|
|
|
|
func handleGetAvatar(db *sql.DB) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
|
|
if err != nil {
|
|
respond(w, http.StatusBadRequest, errResp("invalid user id"))
|
|
return
|
|
}
|
|
var data []byte
|
|
var version int64
|
|
err = db.QueryRowContext(r.Context(),
|
|
"SELECT data, updated_at FROM user_avatars WHERE user_id = $1", id).Scan(&data, &version)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
respond(w, http.StatusNotFound, errResp("no avatar"))
|
|
return
|
|
}
|
|
if err != nil {
|
|
serverError(w, r, err)
|
|
return
|
|
}
|
|
etag := `"` + strconv.FormatInt(version, 10) + `"`
|
|
w.Header().Set("ETag", etag)
|
|
// private: it is behind a login. The URL carries ?v=, so the browser can
|
|
// keep it for a long time without ever showing a stale picture.
|
|
w.Header().Set("Cache-Control", "private, max-age=86400")
|
|
if r.Header.Get("If-None-Match") == etag {
|
|
w.WriteHeader(http.StatusNotModified)
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "image/png")
|
|
w.Header().Set("X-Content-Type-Options", "nosniff")
|
|
_, _ = w.Write(data)
|
|
}
|
|
}
|
|
|
|
// handleSetAvatar stores the request body, a PNG or JPEG, as the user's picture.
|
|
func handleSetAvatar(db *sql.DB) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
|
|
if err != nil {
|
|
respond(w, http.StatusBadRequest, errResp("invalid user id"))
|
|
return
|
|
}
|
|
if !requireSelfOrAdmin(w, r, id) {
|
|
return
|
|
}
|
|
raw, err := io.ReadAll(http.MaxBytesReader(w, r.Body, maxAvatarBytes))
|
|
if err != nil {
|
|
var tooBig *http.MaxBytesError
|
|
if errors.As(err, &tooBig) {
|
|
respond(w, http.StatusRequestEntityTooLarge, errResp("the picture is too large"))
|
|
return
|
|
}
|
|
respond(w, http.StatusBadRequest, errResp("could not read the picture"))
|
|
return
|
|
}
|
|
data, err := normalizeAvatar(raw)
|
|
if err != nil {
|
|
respond(w, http.StatusBadRequest, errResp(err.Error()))
|
|
return
|
|
}
|
|
version := time.Now().UnixMilli()
|
|
res, err := db.ExecContext(r.Context(),
|
|
`INSERT INTO user_avatars (user_id, data, updated_at)
|
|
SELECT id, $2, $3 FROM users WHERE id = $1
|
|
ON CONFLICT (user_id) DO UPDATE SET data = EXCLUDED.data, updated_at = EXCLUDED.updated_at`,
|
|
id, data, version)
|
|
if err != nil {
|
|
serverError(w, r, err)
|
|
return
|
|
}
|
|
if n, _ := res.RowsAffected(); n == 0 {
|
|
respond(w, http.StatusNotFound, errResp("user not found"))
|
|
return
|
|
}
|
|
respond(w, http.StatusOK, map[string]int64{"avatar_version": version})
|
|
}
|
|
}
|
|
|
|
func handleDeleteAvatar(db *sql.DB) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
|
|
if err != nil {
|
|
respond(w, http.StatusBadRequest, errResp("invalid user id"))
|
|
return
|
|
}
|
|
if !requireSelfOrAdmin(w, r, id) {
|
|
return
|
|
}
|
|
if _, err := db.ExecContext(r.Context(),
|
|
"DELETE FROM user_avatars WHERE user_id = $1", id); err != nil {
|
|
serverError(w, r, err)
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
}
|