Let users upload a profile picture, cropped in the browser
Users now have an avatar. The account page takes an image, lets the user
drag and zoom it under a circular mask, and uploads exactly what the circle
shows as a 256x256 PNG. The picture replaces the initial-letter circle in
the sidebar, on the account page, on the on-call hero and week strip, on
the schedule's day chips and in the incident assign menu; someone without
one keeps the initial.
The picture lives in Postgres (user_avatars, bytea) rather than on a
volume: the chart runs two replicas on a read-only root filesystem, and
the k8up pg_dump backup then covers it without any chart or operator
change. The server does not trust the client's crop. It decodes the PNG or
JPEG, checks the declared size before decoding, centre-crops to a square,
scales to 256 and re-encodes, which drops EXIF and anything that is not
pixels. Standard library only; WebP is cropped by the browser and arrives
as PNG, a raw WebP sent to the API is refused.
GET/PUT/DELETE /api/users/{id}/avatar: anyone signed in may read, only the
owner or an administrator may change. /api/me and /api/users gain
avatar_version, absent when there is no picture, which is also the ?v= that
busts the cache after a change. Incident timeline entries and the queue are
text-only and are unchanged.
Claude-Session: https://claude.ai/code/session_016mBLURvJoMuUEr9cB2RpUN
This commit is contained in:
@@ -0,0 +1,199 @@
|
||||
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{uint8(r >> 8), uint8(g >> 8), uint8(b >> 8), uint8(a >> 8)})
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
package api_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"image"
|
||||
"image/color"
|
||||
"image/jpeg"
|
||||
"image/png"
|
||||
"io"
|
||||
"net/http"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func testImage(w, h int) *image.RGBA {
|
||||
img := image.NewRGBA(image.Rect(0, 0, w, h))
|
||||
for y := 0; y < h; y++ {
|
||||
for x := 0; x < w; x++ {
|
||||
img.Set(x, y, color.RGBA{R: uint8(x), G: uint8(y), B: 200, A: 255})
|
||||
}
|
||||
}
|
||||
return img
|
||||
}
|
||||
|
||||
func pngBytes(t *testing.T, img image.Image) []byte {
|
||||
t.Helper()
|
||||
var b bytes.Buffer
|
||||
if err := png.Encode(&b, img); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return b.Bytes()
|
||||
}
|
||||
|
||||
// putRaw sends a non-JSON body with the administrator's key.
|
||||
func (s *ts) putRaw(t *testing.T, path string, body []byte) *http.Response {
|
||||
t.Helper()
|
||||
req, _ := http.NewRequest(http.MethodPut, s.URL+path, bytes.NewReader(body))
|
||||
req.Header.Set("Authorization", "Bearer "+s.key)
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return resp
|
||||
}
|
||||
|
||||
func TestAvatar_UploadNormalizesAndServes(t *testing.T) {
|
||||
s := newTS(t)
|
||||
uid, call := member(t, s, "ava")
|
||||
path := "/api/users/" + id64(uid) + "/avatar"
|
||||
|
||||
if resp := call(http.MethodGet, path, nil); resp.StatusCode != http.StatusNotFound {
|
||||
t.Fatalf("GET before upload: %d, want 404", resp.StatusCode)
|
||||
}
|
||||
|
||||
// A non-square JPEG, bigger than the stored size: cropped and scaled to 256².
|
||||
var jb bytes.Buffer
|
||||
if err := jpeg.Encode(&jb, testImage(600, 400), nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp := s.putRaw(t, path, jb.Bytes()) // an administrator may set anybody's
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("PUT: %d", resp.StatusCode)
|
||||
}
|
||||
var out struct {
|
||||
Version int64 `json:"avatar_version"`
|
||||
}
|
||||
decode(t, resp, &out)
|
||||
|
||||
resp = call(http.MethodGet, path, nil)
|
||||
if resp.StatusCode != http.StatusOK || resp.Header.Get("Content-Type") != "image/png" {
|
||||
t.Fatalf("GET: %d %q", resp.StatusCode, resp.Header.Get("Content-Type"))
|
||||
}
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
img, err := png.Decode(bytes.NewReader(body))
|
||||
if err != nil {
|
||||
t.Fatalf("served bytes are not a PNG: %v", err)
|
||||
}
|
||||
if b := img.Bounds(); b.Dx() != 256 || b.Dy() != 256 {
|
||||
t.Errorf("stored size %v, want 256x256", b)
|
||||
}
|
||||
|
||||
var users []struct {
|
||||
ID int64 `json:"id"`
|
||||
AvatarVersion *int64 `json:"avatar_version"`
|
||||
}
|
||||
decode(t, call(http.MethodGet, "/api/users", nil), &users)
|
||||
found := false
|
||||
for _, u := range users {
|
||||
if u.ID == uid {
|
||||
found = true
|
||||
if u.AvatarVersion == nil || *u.AvatarVersion != out.Version {
|
||||
t.Errorf("user list avatar_version = %v, want %d", u.AvatarVersion, out.Version)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("user not listed")
|
||||
}
|
||||
|
||||
if resp := call(http.MethodDelete, path, nil); resp.StatusCode != http.StatusNoContent {
|
||||
t.Fatalf("DELETE: %d", resp.StatusCode)
|
||||
}
|
||||
if resp := call(http.MethodGet, path, nil); resp.StatusCode != http.StatusNotFound {
|
||||
t.Fatalf("GET after delete: %d, want 404", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAvatar_Rejections(t *testing.T) {
|
||||
s := newTS(t)
|
||||
_, other := member(t, s, "other")
|
||||
uid, _ := member(t, s, "ava2")
|
||||
path := "/api/users/" + id64(uid) + "/avatar"
|
||||
|
||||
if resp := other(http.MethodPut, path, nil); resp.StatusCode != http.StatusForbidden {
|
||||
t.Errorf("another member PUT: %d, want 403", resp.StatusCode)
|
||||
}
|
||||
if resp := other(http.MethodDelete, path, nil); resp.StatusCode != http.StatusForbidden {
|
||||
t.Errorf("another member DELETE: %d, want 403", resp.StatusCode)
|
||||
}
|
||||
if resp := s.putRaw(t, path, []byte("not an image")); resp.StatusCode != http.StatusBadRequest {
|
||||
t.Errorf("garbage: %d, want 400", resp.StatusCode)
|
||||
}
|
||||
if resp := s.putRaw(t, path, make([]byte, 2<<20)); resp.StatusCode != http.StatusRequestEntityTooLarge {
|
||||
t.Errorf("oversize: %d, want 413", resp.StatusCode)
|
||||
}
|
||||
if resp := s.putRaw(t, "/api/users/999999/avatar", pngBytes(t, testImage(32, 32))); resp.StatusCode != http.StatusNotFound {
|
||||
t.Errorf("unknown user: %d, want 404", resp.StatusCode)
|
||||
}
|
||||
|
||||
req, _ := http.NewRequest(http.MethodGet, s.URL+path, nil)
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusUnauthorized {
|
||||
t.Errorf("unauthenticated GET: %d, want 401", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAvatar_RemovedWithTheUser(t *testing.T) {
|
||||
s := newTS(t)
|
||||
uid, _ := member(t, s, "gone")
|
||||
path := "/api/users/" + id64(uid) + "/avatar"
|
||||
if resp := s.putRaw(t, path, pngBytes(t, testImage(64, 64))); resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("PUT: %d", resp.StatusCode)
|
||||
}
|
||||
if resp := s.req(t, http.MethodDelete, "/api/users/"+id64(uid), nil); resp.StatusCode >= 300 {
|
||||
t.Fatalf("delete user: %d", resp.StatusCode)
|
||||
}
|
||||
var n int
|
||||
if err := s.db.QueryRow("SELECT COUNT(*) FROM user_avatars WHERE user_id = $1", uid).Scan(&n); err != nil || n != 0 {
|
||||
t.Errorf("avatar rows after user delete = %d (%v), want 0", n, err)
|
||||
}
|
||||
}
|
||||
@@ -117,6 +117,11 @@ func NewRouter(db *sql.DB, notify NotifyConfig, cfg config.Config, version strin
|
||||
// set up" that the person holding the phone can confirm.
|
||||
r.Post("/api/users/{id}/channels/{channelID}/test", handleTestChannel(db, notify))
|
||||
r.Put("/api/users/{id}/password", handleSetPassword(db))
|
||||
// The picture itself is readable by anyone signed in (like the user
|
||||
// list), changeable only by its owner or an administrator.
|
||||
r.Get("/api/users/{id}/avatar", handleGetAvatar(db))
|
||||
r.Put("/api/users/{id}/avatar", handleSetAvatar(db))
|
||||
r.Delete("/api/users/{id}/avatar", handleDeleteAvatar(db))
|
||||
r.Get("/api/users/{id}/api-keys", handleListAPIKeys(db))
|
||||
r.Post("/api/users/{id}/api-keys", handleCreateAPIKey(db))
|
||||
r.Delete("/api/users/{id}/api-keys/{keyID}", handleDeleteAPIKey(db))
|
||||
|
||||
@@ -117,7 +117,7 @@ func handleListUsers(db *sql.DB) http.HandlerFunc {
|
||||
caller, _ := userFromContext(r.Context())
|
||||
seeAll := caller.IsAdmin
|
||||
rows, err := db.QueryContext(r.Context(),
|
||||
"SELECT id, username, email, created_at, is_admin, admin_source, disabled_at FROM users ORDER BY id")
|
||||
"SELECT id, username, email, created_at, is_admin, admin_source, disabled_at, (SELECT updated_at FROM user_avatars WHERE user_id = users.id) FROM users ORDER BY id")
|
||||
if err != nil {
|
||||
serverError(w, r, err)
|
||||
return
|
||||
@@ -129,7 +129,7 @@ func handleListUsers(db *sql.DB) http.HandlerFunc {
|
||||
var u models.User
|
||||
var ts int64
|
||||
var disabled *int64
|
||||
if err := rows.Scan(&u.ID, &u.Username, &u.Email, &ts, &u.IsAdmin, &u.AdminSource, &disabled); err != nil {
|
||||
if err := rows.Scan(&u.ID, &u.Username, &u.Email, &ts, &u.IsAdmin, &u.AdminSource, &disabled, &u.AvatarVersion); err != nil {
|
||||
serverError(w, r, err)
|
||||
return
|
||||
}
|
||||
@@ -397,8 +397,8 @@ func fetchUser(ctx context.Context, db *sql.DB, id int64) (models.User, error) {
|
||||
var ts int64
|
||||
var disabled *int64
|
||||
err := db.QueryRowContext(ctx,
|
||||
"SELECT id, username, email, created_at, is_admin, admin_source, disabled_at FROM users WHERE id = $1", id).
|
||||
Scan(&u.ID, &u.Username, &u.Email, &ts, &u.IsAdmin, &u.AdminSource, &disabled)
|
||||
"SELECT id, username, email, created_at, is_admin, admin_source, disabled_at, (SELECT updated_at FROM user_avatars WHERE user_id = users.id) FROM users WHERE id = $1", id).
|
||||
Scan(&u.ID, &u.Username, &u.Email, &ts, &u.IsAdmin, &u.AdminSource, &disabled, &u.AvatarVersion)
|
||||
if err != nil {
|
||||
return u, err
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user