diff --git a/internal/api/avatars.go b/internal/api/avatars.go new file mode 100644 index 0000000..292a80a --- /dev/null +++ b/internal/api/avatars.go @@ -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) + } +} diff --git a/internal/api/avatars_test.go b/internal/api/avatars_test.go new file mode 100644 index 0000000..da7d9b8 --- /dev/null +++ b/internal/api/avatars_test.go @@ -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) + } +} diff --git a/internal/api/router.go b/internal/api/router.go index cb768ae..4c30aa6 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -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)) diff --git a/internal/api/users.go b/internal/api/users.go index e588324..c7cfb22 100644 --- a/internal/api/users.go +++ b/internal/api/users.go @@ -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 } diff --git a/internal/db/migrations/003_user_avatars.sql b/internal/db/migrations/003_user_avatars.sql new file mode 100644 index 0000000..e76683e --- /dev/null +++ b/internal/db/migrations/003_user_avatars.sql @@ -0,0 +1,11 @@ +-- A user's profile picture: one already-cropped, re-encoded 256x256 PNG per +-- user. Kept in Postgres rather than on a volume so every replica serves it and +-- the pg_dump backup covers it. updated_at is in milliseconds, not seconds like +-- the other timestamps, because it doubles as the cache-busting version of the +-- image URL and a re-upload within the same second must change it. + +CREATE TABLE user_avatars ( + user_id bigint PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, + data bytea NOT NULL, + updated_at bigint NOT NULL +); diff --git a/internal/models/user.go b/internal/models/user.go index 51023d2..3267bef 100644 --- a/internal/models/user.go +++ b/internal/models/user.go @@ -19,6 +19,11 @@ type User struct { // every acknowledgement and timeline entry they made. DisabledAt *time.Time `json:"disabled_at,omitempty"` + // AvatarVersion identifies the user's current profile picture, or is nil + // when they have none and clients show their initials. It changes on every + // upload, so it goes in the image URL (?v=) as a cache buster. + AvatarVersion *int64 `json:"avatar_version,omitempty"` + // IsAdmin is the system administrator flag: managing users and API keys. // Not omitempty — a client has to be able to tell "false" from "this server // is too old to have the field", and the web UI decides what to show from diff --git a/internal/web/static/app.css b/internal/web/static/app.css index 3aa0bbe..b295b83 100644 --- a/internal/web/static/app.css +++ b/internal/web/static/app.css @@ -789,6 +789,18 @@ details[open] > summary { margin-bottom: 8px; } font-weight: 750; font-size: 17px; text-transform: uppercase; } .avatar.none { background: var(--surface-2); color: var(--faint); } +/* A picture lies over the initial, which stays underneath as the fallback. */ +.avatar, .rota-chip, .nav-avatar { position: relative; overflow: hidden; } +.avatar-img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; border-radius: 50%; } +.avatar-sm { width: 24px; height: 24px; font-size: 12px; } +.avatar-actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 8px; } + +.crop-stage { position: relative; width: 256px; max-width: 100%; aspect-ratio: 1; margin: 0 auto 12px; overflow: hidden; border-radius: var(--radius); background: var(--surface-2); touch-action: none; } +.crop-canvas { display: block; width: 100%; height: 100%; cursor: grab; } +.crop-canvas:active { cursor: grabbing; } +/* The circle the picture will be shown in: everything outside it is dimmed. */ +.crop-mask { position: absolute; inset: 0; border-radius: 50%; box-shadow: 0 0 0 999px rgba(0, 0, 0, 0.5); pointer-events: none; } +.crop-zoom { display: grid; gap: 4px; width: 256px; max-width: 100%; margin: 0 auto 12px; } .you { color: var(--accent); font-weight: 650; font-size: 13px; margin-left: 6px; } /* Oncall's own "you" indicator only (see you() in oncall.js) — a pill badge is easier to spot there than this plain accent-coloured text. */ diff --git a/internal/web/static/js/account.js b/internal/web/static/js/account.js index 80034dc..bfc6a37 100644 --- a/internal/web/static/js/account.js +++ b/internal/web/static/js/account.js @@ -1,10 +1,11 @@ // Account: who you are signed in as, changing your password, signing out. import * as api from './api.js'; -import { h, clear, icon, toast } from './ui.js'; +import { h, clear, icon, toast, openSheet, closeSheet } from './ui.js'; import { initial } from './format.js'; -import { state } from './state.js'; -import { signOut } from './app.js'; +import { state, forgetUsers } from './state.js'; +import { withAvatar, setAvatarVersion } from './avatar.js'; +import { signOut, renderAccountNav } from './app.js'; const view = () => document.getElementById('view-more'); @@ -24,10 +25,11 @@ function render(providers) { const { user, has_password: hasPassword } = state.me; clear(view(), h('div', { class: 'card account-card' }, - h('div', { class: 'avatar', text: initial(user.username) }), + withAvatar(h('div', { class: 'avatar', text: initial(user.username) }), user.id), h('div', {}, h('div', { class: 'account-name', text: user.username }), - h('div', { class: 'account-email', text: user.email }))), + h('div', { class: 'account-email', text: user.email }), + avatarControls(user))), h('div', { class: 'page-head' }, h('h2', { text: 'Notifications' })), notifyCard(user, providers), @@ -299,3 +301,125 @@ function shortcuts() { h('td', {}, k.split(' / ').map((x, i) => [i ? ' / ' : '', h('kbd', { text: x })])), h('td', { text: v }))))); } + +// ---------- profile picture ---------- + +const CROP = 256; // the stored picture is CROP x CROP; the stage shows it 1:1 + +function avatarControls(user) { + const input = h('input', { type: 'file', accept: 'image/png,image/jpeg,image/webp,image/gif', hidden: true }); + input.addEventListener('change', async () => { + const file = input.files[0]; + input.value = ''; + if (!file) return; + let bitmap; + try { + bitmap = await createImageBitmap(file); + } catch { + toast('That file is not an image this browser can read.', 'error'); + return; + } + const blob = await cropSheet(bitmap); + if (!blob) return; + try { + const { avatar_version: v } = await api.setAvatar(user.id, blob); + changed(user, v); + toast('Picture updated'); + } catch (err) { + toast(err.message, 'error'); + } + }); + const remove = async () => { + try { + await api.removeAvatar(user.id); + changed(user, null); + toast('Picture removed'); + } catch (err) { + toast(err.message, 'error'); + } + }; + return h('div', { class: 'avatar-actions' }, + input, + h('button', { class: 'btn btn-sm', type: 'button', onclick: () => input.click(), text: user.avatar_version != null ? 'Change picture' : 'Upload picture' }), + user.avatar_version != null && h('button', { class: 'btn btn-sm', type: 'button', onclick: remove, text: 'Remove' })); +} + +// The picture changed: keep the cached account and user list in step and repaint. +function changed(user, version) { + user.avatar_version = version ?? undefined; + setAvatarVersion(user.id, version); + forgetUsers(); + renderAccountNav(); + show(); +} + +// A square stage with the image under it: drag to move, slider or wheel to +// zoom. Resolves with a CROP x CROP PNG of exactly what the circle shows, or +// null if dismissed. +function cropSheet(bitmap) { + const base = CROP / Math.min(bitmap.width, bitmap.height); // scale at which the image just covers the stage + let zoom = 1; + let cx = bitmap.width / 2; // the image point shown at the stage's centre + let cy = bitmap.height / 2; + + const canvas = h('canvas', { class: 'crop-canvas', width: CROP, height: CROP }); + const clamp = () => { + const half = CROP / 2 / (base * zoom); + cx = Math.min(bitmap.width - half, Math.max(half, cx)); + cy = Math.min(bitmap.height - half, Math.max(half, cy)); + }; + const paint = (ctx = canvas.getContext('2d'), size = CROP) => { + clamp(); + const s = base * zoom * (size / CROP); + ctx.clearRect(0, 0, size, size); + ctx.imageSmoothingQuality = 'high'; + ctx.drawImage(bitmap, size / 2 - cx * s, size / 2 - cy * s, bitmap.width * s, bitmap.height * s); + }; + + const slider = h('input', { type: 'range', min: '1', max: '4', step: '0.01', value: '1', 'aria-label': 'Zoom' }); + slider.addEventListener('input', () => { zoom = Number(slider.value); paint(); }); + + let drag = null; + canvas.addEventListener('pointerdown', (e) => { + canvas.setPointerCapture(e.pointerId); + drag = { x: e.clientX, y: e.clientY }; + }); + canvas.addEventListener('pointermove', (e) => { + if (!drag) return; + const k = CROP / canvas.getBoundingClientRect().width / (base * zoom); + cx -= (e.clientX - drag.x) * k; + cy -= (e.clientY - drag.y) * k; + drag = { x: e.clientX, y: e.clientY }; + paint(); + }); + const end = () => { drag = null; }; + canvas.addEventListener('pointerup', end); + canvas.addEventListener('pointercancel', end); + canvas.addEventListener('wheel', (e) => { + e.preventDefault(); + zoom = Math.min(4, Math.max(1, zoom * (e.deltaY < 0 ? 1.08 : 1 / 1.08))); + slider.value = String(zoom); + paint(); + }, { passive: false }); + + return openSheet(() => { + queueMicrotask(() => paint()); + return [ + h('h2', { class: 'sheet-title', text: 'Crop your picture' }), + h('div', { class: 'crop-stage' }, canvas, h('div', { class: 'crop-mask', 'aria-hidden': 'true' })), + h('label', { class: 'crop-zoom' }, h('span', { class: 'muted small', text: 'Zoom' }), slider), + h('div', { class: 'sheet-actions' }, + h('button', { class: 'btn', type: 'button', onclick: () => closeSheet(null), text: 'Cancel' }), + h('button', { + class: 'btn btn-primary', type: 'button', text: 'Save', + onclick: () => { + const out = document.createElement('canvas'); + out.width = CROP; + out.height = CROP; + paint(out.getContext('2d')); + out.toBlob((b) => closeSheet(b), 'image/png'); + }, + })), + ]; + }); +} diff --git a/internal/web/static/js/api.js b/internal/web/static/js/api.js index 94953e7..2e7138c 100644 --- a/internal/web/static/js/api.js +++ b/internal/web/static/js/api.js @@ -72,6 +72,30 @@ export const setPassword = (userID, password, currentPassword) => // users export const users = () => call('GET', '/users'); +// A profile picture is raw image bytes, not JSON, so it does not go through call. +export async function setAvatar(userID, blob) { + let resp; + try { + resp = await fetch(`/api/users/${userID}/avatar`, { + method: 'PUT', headers: { 'Content-Type': blob.type || 'image/png' }, body: blob, credentials: 'same-origin', + }); + } catch { + throw new ApiError(0, 'Cannot reach the server.'); + } + let data = null; + try { + data = await resp.json(); + } catch { + /* keep null */ + } + if (!resp.ok) { + if (resp.status === 401) onUnauthorized(); + throw new ApiError(resp.status, (data && data.error) || `Server answered ${resp.status}.`); + } + return data; +} +export const removeAvatar = (userID) => call('DELETE', `/users/${userID}/avatar`); + // What one person is in. /teams answers "what am I in" and cannot be asked // about anybody else, which is what the admin page's per-user view needs. export const userTeams = (id) => call('GET', `/users/${id}/teams`); diff --git a/internal/web/static/js/app.js b/internal/web/static/js/app.js index 62d5d7b..e40097b 100644 --- a/internal/web/static/js/app.js +++ b/internal/web/static/js/app.js @@ -3,7 +3,7 @@ import * as api from './api.js'; import * as ui from './ui.js'; import * as poll from './poll.js'; -import { state, reset, loadTeams } from './state.js'; +import { state, reset, loadTeams, users as ui_users } from './state.js'; import * as queue from './queue.js'; import * as incident from './incident.js'; import * as oncall from './oncall.js'; @@ -17,6 +17,7 @@ import * as adminuser from './adminuser.js'; import * as adminteam from './adminteam.js'; import * as device from './device.js'; import { initial } from './format.js'; +import { withAvatar, setAvatarVersion } from './avatar.js'; const $ = (id) => document.getElementById(id); @@ -473,10 +474,13 @@ export async function signOut() { // The sidebar's Account link shows who is signed in: an avatar and the name, // at the foot of the desktop sidebar. The link keeps its aria-label, so a // screen reader still hears "Account". -function renderAccountNav() { +export function renderAccountNav() { const name = state.me?.user?.username; if (!name) return; - $('nav-avatar').textContent = initial(name); + $('nav-avatar').replaceChildren(initial(name)); + $('nav-avatar').classList.remove('has-img'); + setAvatarVersion(state.me.user.id, state.me.user.avatar_version); + withAvatar($('nav-avatar'), state.me.user.id); $('nav-avatar').hidden = false; $('nav-account-label').textContent = name; } @@ -488,6 +492,8 @@ function showApp() { $('login').hidden = true; $('app').hidden = false; render(); + // Who has a picture: the first paint shows initials, the list repaints it. + ui_users().then(render).catch(() => {}); poll.start(refresh); poll.now(); } diff --git a/internal/web/static/js/avatar.js b/internal/web/static/js/avatar.js new file mode 100644 index 0000000..cb724ff --- /dev/null +++ b/internal/web/static/js/avatar.js @@ -0,0 +1,35 @@ +// Profile pictures. A user either has one (the server says so with an +// avatar_version, which is also the cache buster in the image URL) or shows +// their initial in a coloured circle, as before. + +const versions = new Map(); // user id -> avatar_version + +// Learn from a user list (or a single user) which people have a picture. +export function noteAvatars(list) { + for (const u of list || []) setAvatarVersion(u.id, u.avatar_version); +} + +export function setAvatarVersion(userID, version) { + if (version == null) versions.delete(userID); + else versions.set(userID, version); +} + +export function clearAvatars() { + versions.clear(); +} + +// withAvatar lays the person's picture over el, which keeps its initial +// underneath: if the image fails to load, the initial is what shows. +export function withAvatar(el, userID) { + const v = versions.get(userID); + if (v == null) return el; + el.classList.add('has-img'); + const img = document.createElement('img'); + img.className = 'avatar-img'; + img.alt = ''; + img.decoding = 'async'; + img.src = `/api/users/${userID}/avatar?v=${v}`; + img.addEventListener('error', () => img.remove()); + el.append(img); + return el; +} diff --git a/internal/web/static/js/incident.js b/internal/web/static/js/incident.js index 40caf6b..4ab7929 100644 --- a/internal/web/static/js/incident.js +++ b/internal/web/static/js/incident.js @@ -7,9 +7,10 @@ import { h, clear, icon, badge, severityBadge, originChip, labelChip, openSheet, closeSheet, confirm, toast, spinner, emptyState, } from './ui.js'; import { - ago, when, until, duration, isFuture, originOf, STATUS_LABEL, + ago, when, until, duration, isFuture, originOf, STATUS_LABEL, initial, } from './format.js'; import { myID, users } from './state.js'; +import { withAvatar } from './avatar.js'; import { back } from './app.js'; import { alertMetrics } from './metricchart.js'; @@ -651,7 +652,7 @@ async function assign() { 'aria-checked': String(u.id === inc.assigned_to_id), onclick: () => closeSheet(u.id), }, - icon('user'), + withAvatar(h('span', { class: 'avatar avatar-sm', text: initial(u.username) }), u.id), u.id === me ? `${u.username} (you)` : u.username, onCall && onCall.user_id === u.id ? h('span', { class: 'menu-sub', text: 'on call' }) : null, )))), diff --git a/internal/web/static/js/oncall.js b/internal/web/static/js/oncall.js index 72eb1a6..b709d50 100644 --- a/internal/web/static/js/oncall.js +++ b/internal/web/static/js/oncall.js @@ -10,6 +10,7 @@ import * as api from './api.js'; import { h, clear, badge, icon, spinner } from './ui.js'; import { isoDate, mondayOf, addDays, isoWeek, initial, duration } from './format.js'; import { myID, currentTeam } from './state.js'; +import { withAvatar } from './avatar.js'; const view = () => document.getElementById('view-oncall'); @@ -96,7 +97,7 @@ function nowCard() { return h('div', { class: 'card hero' }, h('div', { class: 'hero-label', text: showTeam ? `On call now · ${n.team_name}` : 'On call now' }), h('div', { class: 'hero-top' }, - h('div', { class: 'avatar hero-avatar', text: initial(n.username) }), + withAvatar(h('div', { class: 'avatar hero-avatar', text: initial(n.username) }), n.user_id), h('div', {}, h('div', { class: 'hero-name' }, n.username, you(n.user_id)), until && h('div', { class: 'hero-until', text: until })))); @@ -143,7 +144,7 @@ function weekCard() { }, h('span', { class: 'strip-name', text: dayName.format(d) }), h('span', { class: 'strip-date', text: String(d.getDate()) }), - h('span', { class: `avatar strip-avatar ${e ? personClass(e.user_id) : 'none'}`, text: e ? initial(e.username) : '–' }), + (el => (e ? withAvatar(el, e.user_id) : el))(h('span', { class: `avatar strip-avatar ${e ? personClass(e.user_id) : 'none'}`, text: e ? initial(e.username) : '–' })), h('span', { class: `strip-who${e ? '' : ' nobody'}`, text: e ? (e.user_id === mine ? 'You' : e.username) : 'nobody' }))); } const thisWeek = isoDate(weekStart) === isoDate(mondayOf(new Date())); diff --git a/internal/web/static/js/state.js b/internal/web/static/js/state.js index 5d806d4..7dac970 100644 --- a/internal/web/static/js/state.js +++ b/internal/web/static/js/state.js @@ -2,6 +2,7 @@ // queue that drives the badges. import * as api from './api.js'; +import { noteAvatars, clearAvatars } from './avatar.js'; export const state = { me: null, // { user, has_password } @@ -70,6 +71,7 @@ let usersAt = 0; export async function users() { if (!usersCache || Date.now() - usersAt > 5 * 60 * 1000) { usersCache = await api.users(); + noteAvatars(usersCache); usersAt = Date.now(); } return usersCache; @@ -91,4 +93,10 @@ export function reset() { state.open = []; state.teams = []; usersCache = null; + clearAvatars(); +} + +// After a picture changes: the cached list carries the old avatar_version. +export function forgetUsers() { + usersCache = null; } diff --git a/internal/web/static/js/team.js b/internal/web/static/js/team.js index 2da2ba5..9b1bf67 100644 --- a/internal/web/static/js/team.js +++ b/internal/web/static/js/team.js @@ -20,6 +20,7 @@ import * as api from './api.js'; import { h, clear, spinner, confirm, icon, openSheet, closeSheet, menuCard, badge, labelChip, ssoBadge, SSO_MANAGED, fadeOnOverflow } from './ui.js'; import { state, currentTeam, onTeamChange, users as allUsers, myID } from './state.js'; +import { withAvatar } from './avatar.js'; import { isoDate, addDays, mondayOf, isoWeek, initial, ago, when, duration } from './format.js'; const view = () => document.getElementById('view-team'); @@ -466,7 +467,7 @@ function weekSheet(monday, byDate) { }, h('span', { class: 'rota-num', text: weekdayFmt.format(d) }), e - ? h('span', { class: `rota-chip ${colorClass(e.user_id)}`, text: initial(e.username) }) + ? withAvatar(h('span', { class: `rota-chip ${colorClass(e.user_id)}`, text: initial(e.username) }), e.user_id) : h('span', { class: 'rota-chip none' })); }));