Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cd3b2c2bea | |||
| 525947e809 | |||
| 536dbcaebe | |||
| 50e9ebcbc2 | |||
| 83837421e7 |
@@ -15,5 +15,5 @@ type: application
|
|||||||
# appVersion and image.tag in values.yaml no longer agree, and that is not an oversight:
|
# appVersion and image.tag in values.yaml no longer agree, and that is not an oversight:
|
||||||
# image.tag stays "latest", which is what a local install actually pulls. appVersion is
|
# image.tag stays "latest", which is what a local install actually pulls. appVersion is
|
||||||
# metadata and drives nothing.
|
# metadata and drives nothing.
|
||||||
version: 0.49.0
|
version: 0.50.0
|
||||||
appVersion: "v0.49.0"
|
appVersion: "v0.50.0"
|
||||||
|
|||||||
@@ -120,8 +120,11 @@ operatorMode: false
|
|||||||
# empty turns the feature off. Entries are host names, with "*" as one DNS label
|
# empty turns the feature off. Entries are host names, with "*" as one DNS label
|
||||||
# and a leading "**" as one or more (e.g. "*.monitoring.svc.cluster.local",
|
# and a leading "**" as one or more (e.g. "*.monitoring.svc.cluster.local",
|
||||||
# "prometheus-*.monitoring"), IPs or CIDR ranges, each with an optional ":port".
|
# "prometheus-*.monitoring"), IPs or CIDR ranges, each with an optional ":port".
|
||||||
|
# The default is the Prometheus that kube-prometheus-stack installs; set it to
|
||||||
|
# [] to turn the feature off.
|
||||||
prometheus:
|
prometheus:
|
||||||
allowedHosts: []
|
allowedHosts:
|
||||||
|
- kube-prometheus-stack-prometheus.kube-prometheus-stack:9090
|
||||||
# How far before an alert started its graph begins.
|
# How far before an alert started its graph begins.
|
||||||
preroll: 1h
|
preroll: 1h
|
||||||
timeout: 10s
|
timeout: 10s
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ over an administrator's edit.
|
|||||||
| `TERDUT_TRUSTED_PROXIES` | `1` | How many reverse proxies in front of the server append to `X-Forwarded-For`; the per-address rate limits use the entry that many hops from the right. `0` ignores the header |
|
| `TERDUT_TRUSTED_PROXIES` | `1` | How many reverse proxies in front of the server append to `X-Forwarded-For`; the per-address rate limits use the entry that many hops from the right. `0` ignores the header |
|
||||||
| `TERDUT_OPERATOR_KEY` | — | At least 32 characters. When set, the instance-scoped service account `terdut-operator` is created if missing and its `seed` key replaced with this value at every start — how terdut-operator authenticates without a bootstrap handshake. An instance-scoped account acts as owner of every team (team configuration) but is not a member of any, so it reads no incidents |
|
| `TERDUT_OPERATOR_KEY` | — | At least 32 characters. When set, the instance-scoped service account `terdut-operator` is created if missing and its `seed` key replaced with this value at every start — how terdut-operator authenticates without a bootstrap handshake. An instance-scoped account acts as owner of every team (team configuration) but is not a member of any, so it reads no incidents |
|
||||||
| `TERDUT_OPERATOR_MODE` | `false` | Declares this install gitops-managed: a session's or a user's own API key's writes to teams, escalation policies, dead man's switches and integrations are refused (`403 reason:"operator_managed"`); a [service account](./api.md#service-accounts)'s are not. Team membership and the schedule stay editable regardless |
|
| `TERDUT_OPERATOR_MODE` | `false` | Declares this install gitops-managed: a session's or a user's own API key's writes to teams, escalation policies, dead man's switches and integrations are refused (`403 reason:"operator_managed"`); a [service account](./api.md#service-accounts)'s are not. Team membership and the schedule stay editable regardless |
|
||||||
| `TERDUT_PROMETHEUS_ALLOWED_HOSTS` | — | Comma-separated Prometheus servers the [metrics on an incident](./metrics.md) may be fetched from, matched against the host in each alert's `generatorURL`. Host names (`prometheus.monitoring.svc`), wildcards (`*` is one DNS label, so `*.example.com` or `prom-*.monitoring.svc`; a leading `**` is one or more labels), IPs or CIDR ranges, each with an optional `:port`. Empty turns the feature off. A bare `*` is refused at startup |
|
| `TERDUT_PROMETHEUS_ALLOWED_HOSTS` | — (the Helm chart defaults to `kube-prometheus-stack-prometheus.kube-prometheus-stack:9090`) | Comma-separated Prometheus servers the [metrics on an incident](./metrics.md) may be fetched from, matched against the host in each alert's `generatorURL`. Host names (`prometheus.monitoring.svc`), wildcards (`*` is one DNS label, so `*.example.com` or `prom-*.monitoring.svc`; a leading `**` is one or more labels), IPs or CIDR ranges, each with an optional `:port`. Empty turns the feature off. A bare `*` is refused at startup |
|
||||||
| `TERDUT_PROMETHEUS_PREROLL` | `1h` | How far before an alert started its metrics graph begins |
|
| `TERDUT_PROMETHEUS_PREROLL` | `1h` | How far before an alert started its metrics graph begins |
|
||||||
| `TERDUT_PROMETHEUS_TIMEOUT` | `10s` | Per-request timeout for a Prometheus query |
|
| `TERDUT_PROMETHEUS_TIMEOUT` | `10s` | Per-request timeout for a Prometheus query |
|
||||||
| `TERDUT_OIDC_ISSUER` | — | Turns single sign-on on. The provider's issuer URL; discovery is read from `<issuer>/.well-known/openid-configuration`. See [Single sign-on](./single-sign-on.md#single-sign-on-oidc) |
|
| `TERDUT_OIDC_ISSUER` | — | Turns single sign-on on. The provider's issuer URL; discovery is read from `<issuer>/.well-known/openid-configuration`. See [Single sign-on](./single-sign-on.md#single-sign-on-oidc) |
|
||||||
|
|||||||
@@ -0,0 +1,206 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.
|
// set up" that the person holding the phone can confirm.
|
||||||
r.Post("/api/users/{id}/channels/{channelID}/test", handleTestChannel(db, notify))
|
r.Post("/api/users/{id}/channels/{channelID}/test", handleTestChannel(db, notify))
|
||||||
r.Put("/api/users/{id}/password", handleSetPassword(db))
|
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.Get("/api/users/{id}/api-keys", handleListAPIKeys(db))
|
||||||
r.Post("/api/users/{id}/api-keys", handleCreateAPIKey(db))
|
r.Post("/api/users/{id}/api-keys", handleCreateAPIKey(db))
|
||||||
r.Delete("/api/users/{id}/api-keys/{keyID}", handleDeleteAPIKey(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())
|
caller, _ := userFromContext(r.Context())
|
||||||
seeAll := caller.IsAdmin
|
seeAll := caller.IsAdmin
|
||||||
rows, err := db.QueryContext(r.Context(),
|
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 {
|
if err != nil {
|
||||||
serverError(w, r, err)
|
serverError(w, r, err)
|
||||||
return
|
return
|
||||||
@@ -129,7 +129,7 @@ func handleListUsers(db *sql.DB) http.HandlerFunc {
|
|||||||
var u models.User
|
var u models.User
|
||||||
var ts int64
|
var ts int64
|
||||||
var disabled *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)
|
serverError(w, r, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -397,8 +397,8 @@ func fetchUser(ctx context.Context, db *sql.DB, id int64) (models.User, error) {
|
|||||||
var ts int64
|
var ts int64
|
||||||
var disabled *int64
|
var disabled *int64
|
||||||
err := db.QueryRowContext(ctx,
|
err := db.QueryRowContext(ctx,
|
||||||
"SELECT id, username, email, created_at, is_admin, admin_source, disabled_at FROM users WHERE id = $1", 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 WHERE id = $1", id).
|
||||||
Scan(&u.ID, &u.Username, &u.Email, &ts, &u.IsAdmin, &u.AdminSource, &disabled)
|
Scan(&u.ID, &u.Username, &u.Email, &ts, &u.IsAdmin, &u.AdminSource, &disabled, &u.AvatarVersion)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return u, err
|
return u, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
);
|
||||||
@@ -19,6 +19,11 @@ type User struct {
|
|||||||
// every acknowledgement and timeline entry they made.
|
// every acknowledgement and timeline entry they made.
|
||||||
DisabledAt *time.Time `json:"disabled_at,omitempty"`
|
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.
|
// 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
|
// 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
|
// is too old to have the field", and the web UI decides what to show from
|
||||||
|
|||||||
@@ -789,6 +789,18 @@ details[open] > summary { margin-bottom: 8px; }
|
|||||||
font-weight: 750; font-size: 17px; text-transform: uppercase;
|
font-weight: 750; font-size: 17px; text-transform: uppercase;
|
||||||
}
|
}
|
||||||
.avatar.none { background: var(--surface-2); color: var(--faint); }
|
.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; }
|
.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
|
/* 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. */
|
is easier to spot there than this plain accent-coloured text. */
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
// Account: who you are signed in as, changing your password, signing out.
|
// Account: who you are signed in as, changing your password, signing out.
|
||||||
|
|
||||||
import * as api from './api.js';
|
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 { initial } from './format.js';
|
||||||
import { state } from './state.js';
|
import { state, forgetUsers } from './state.js';
|
||||||
import { signOut } from './app.js';
|
import { withAvatar, setAvatarVersion } from './avatar.js';
|
||||||
|
import { signOut, renderAccountNav } from './app.js';
|
||||||
|
|
||||||
const view = () => document.getElementById('view-more');
|
const view = () => document.getElementById('view-more');
|
||||||
|
|
||||||
@@ -24,10 +25,11 @@ function render(providers) {
|
|||||||
const { user, has_password: hasPassword } = state.me;
|
const { user, has_password: hasPassword } = state.me;
|
||||||
clear(view(),
|
clear(view(),
|
||||||
h('div', { class: 'card account-card' },
|
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', {},
|
||||||
h('div', { class: 'account-name', text: user.username }),
|
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' })),
|
h('div', { class: 'page-head' }, h('h2', { text: 'Notifications' })),
|
||||||
notifyCard(user, providers),
|
notifyCard(user, providers),
|
||||||
@@ -299,3 +301,125 @@ function shortcuts() {
|
|||||||
h('td', {}, k.split(' / ').map((x, i) => [i ? ' / ' : '', h('kbd', { text: x })])),
|
h('td', {}, k.split(' / ').map((x, i) => [i ? ' / ' : '', h('kbd', { text: x })])),
|
||||||
h('td', { text: v })))));
|
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');
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -72,6 +72,30 @@ export const setPassword = (userID, password, currentPassword) =>
|
|||||||
// users
|
// users
|
||||||
export const users = () => call('GET', '/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
|
// 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.
|
// about anybody else, which is what the admin page's per-user view needs.
|
||||||
export const userTeams = (id) => call('GET', `/users/${id}/teams`);
|
export const userTeams = (id) => call('GET', `/users/${id}/teams`);
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import * as api from './api.js';
|
import * as api from './api.js';
|
||||||
import * as ui from './ui.js';
|
import * as ui from './ui.js';
|
||||||
import * as poll from './poll.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 queue from './queue.js';
|
||||||
import * as incident from './incident.js';
|
import * as incident from './incident.js';
|
||||||
import * as oncall from './oncall.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 adminteam from './adminteam.js';
|
||||||
import * as device from './device.js';
|
import * as device from './device.js';
|
||||||
import { initial } from './format.js';
|
import { initial } from './format.js';
|
||||||
|
import { withAvatar, setAvatarVersion } from './avatar.js';
|
||||||
|
|
||||||
const $ = (id) => document.getElementById(id);
|
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,
|
// 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
|
// at the foot of the desktop sidebar. The link keeps its aria-label, so a
|
||||||
// screen reader still hears "Account".
|
// screen reader still hears "Account".
|
||||||
function renderAccountNav() {
|
export function renderAccountNav() {
|
||||||
const name = state.me?.user?.username;
|
const name = state.me?.user?.username;
|
||||||
if (!name) return;
|
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-avatar').hidden = false;
|
||||||
$('nav-account-label').textContent = name;
|
$('nav-account-label').textContent = name;
|
||||||
}
|
}
|
||||||
@@ -488,6 +492,8 @@ function showApp() {
|
|||||||
$('login').hidden = true;
|
$('login').hidden = true;
|
||||||
$('app').hidden = false;
|
$('app').hidden = false;
|
||||||
render();
|
render();
|
||||||
|
// Who has a picture: the first paint shows initials, the list repaints it.
|
||||||
|
ui_users().then(render).catch(() => {});
|
||||||
poll.start(refresh);
|
poll.start(refresh);
|
||||||
poll.now();
|
poll.now();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -7,9 +7,10 @@ import {
|
|||||||
h, clear, icon, badge, severityBadge, originChip, labelChip, openSheet, closeSheet, confirm, toast, spinner, emptyState,
|
h, clear, icon, badge, severityBadge, originChip, labelChip, openSheet, closeSheet, confirm, toast, spinner, emptyState,
|
||||||
} from './ui.js';
|
} from './ui.js';
|
||||||
import {
|
import {
|
||||||
ago, when, until, duration, isFuture, originOf, STATUS_LABEL,
|
ago, when, until, duration, isFuture, originOf, STATUS_LABEL, initial,
|
||||||
} from './format.js';
|
} from './format.js';
|
||||||
import { myID, users } from './state.js';
|
import { myID, users } from './state.js';
|
||||||
|
import { withAvatar } from './avatar.js';
|
||||||
import { back } from './app.js';
|
import { back } from './app.js';
|
||||||
import { alertMetrics } from './metricchart.js';
|
import { alertMetrics } from './metricchart.js';
|
||||||
|
|
||||||
@@ -651,7 +652,7 @@ async function assign() {
|
|||||||
'aria-checked': String(u.id === inc.assigned_to_id),
|
'aria-checked': String(u.id === inc.assigned_to_id),
|
||||||
onclick: () => closeSheet(u.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,
|
u.id === me ? `${u.username} (you)` : u.username,
|
||||||
onCall && onCall.user_id === u.id ? h('span', { class: 'menu-sub', text: 'on call' }) : null,
|
onCall && onCall.user_id === u.id ? h('span', { class: 'menu-sub', text: 'on call' }) : null,
|
||||||
)))),
|
)))),
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import * as api from './api.js';
|
|||||||
import { h, clear, badge, icon, spinner } from './ui.js';
|
import { h, clear, badge, icon, spinner } from './ui.js';
|
||||||
import { isoDate, mondayOf, addDays, isoWeek, initial, duration } from './format.js';
|
import { isoDate, mondayOf, addDays, isoWeek, initial, duration } from './format.js';
|
||||||
import { myID, currentTeam } from './state.js';
|
import { myID, currentTeam } from './state.js';
|
||||||
|
import { withAvatar } from './avatar.js';
|
||||||
|
|
||||||
const view = () => document.getElementById('view-oncall');
|
const view = () => document.getElementById('view-oncall');
|
||||||
|
|
||||||
@@ -96,7 +97,7 @@ function nowCard() {
|
|||||||
return h('div', { class: 'card hero' },
|
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-label', text: showTeam ? `On call now · ${n.team_name}` : 'On call now' }),
|
||||||
h('div', { class: 'hero-top' },
|
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', {},
|
||||||
h('div', { class: 'hero-name' }, n.username, you(n.user_id)),
|
h('div', { class: 'hero-name' }, n.username, you(n.user_id)),
|
||||||
until && h('div', { class: 'hero-until', text: until }))));
|
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-name', text: dayName.format(d) }),
|
||||||
h('span', { class: 'strip-date', text: String(d.getDate()) }),
|
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' })));
|
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()));
|
const thisWeek = isoDate(weekStart) === isoDate(mondayOf(new Date()));
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
// queue that drives the badges.
|
// queue that drives the badges.
|
||||||
|
|
||||||
import * as api from './api.js';
|
import * as api from './api.js';
|
||||||
|
import { noteAvatars, clearAvatars } from './avatar.js';
|
||||||
|
|
||||||
export const state = {
|
export const state = {
|
||||||
me: null, // { user, has_password }
|
me: null, // { user, has_password }
|
||||||
@@ -70,6 +71,7 @@ let usersAt = 0;
|
|||||||
export async function users() {
|
export async function users() {
|
||||||
if (!usersCache || Date.now() - usersAt > 5 * 60 * 1000) {
|
if (!usersCache || Date.now() - usersAt > 5 * 60 * 1000) {
|
||||||
usersCache = await api.users();
|
usersCache = await api.users();
|
||||||
|
noteAvatars(usersCache);
|
||||||
usersAt = Date.now();
|
usersAt = Date.now();
|
||||||
}
|
}
|
||||||
return usersCache;
|
return usersCache;
|
||||||
@@ -91,4 +93,10 @@ export function reset() {
|
|||||||
state.open = [];
|
state.open = [];
|
||||||
state.teams = [];
|
state.teams = [];
|
||||||
usersCache = null;
|
usersCache = null;
|
||||||
|
clearAvatars();
|
||||||
|
}
|
||||||
|
|
||||||
|
// After a picture changes: the cached list carries the old avatar_version.
|
||||||
|
export function forgetUsers() {
|
||||||
|
usersCache = null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,7 @@
|
|||||||
import * as api from './api.js';
|
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 { 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 { 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';
|
import { isoDate, addDays, mondayOf, isoWeek, initial, ago, when, duration } from './format.js';
|
||||||
|
|
||||||
const view = () => document.getElementById('view-team');
|
const view = () => document.getElementById('view-team');
|
||||||
@@ -466,7 +467,7 @@ function weekSheet(monday, byDate) {
|
|||||||
},
|
},
|
||||||
h('span', { class: 'rota-num', text: weekdayFmt.format(d) }),
|
h('span', { class: 'rota-num', text: weekdayFmt.format(d) }),
|
||||||
e
|
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' }));
|
: h('span', { class: 'rota-chip none' }));
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user