536dbcaebe
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
257 lines
13 KiB
Go
257 lines
13 KiB
Go
package api
|
|
|
|
import (
|
|
"database/sql"
|
|
"net/http"
|
|
|
|
"git.ryuvia.com/niklas/terdut-server/internal/config"
|
|
"git.ryuvia.com/niklas/terdut-server/internal/oidc"
|
|
"git.ryuvia.com/niklas/terdut-server/internal/web"
|
|
"github.com/go-chi/chi/v5"
|
|
"github.com/go-chi/chi/v5/middleware"
|
|
)
|
|
|
|
// NewRouter builds the HTTP surface. notify is passed through to the webhook,
|
|
// the only handler that has to decide where a new incident's page goes; a zero
|
|
// notify disables notifications. Dead man's switches are per team and read from
|
|
// the database, so nothing about them is wired in here. version is reported
|
|
// verbatim by GET /api/version, unauthenticated like /healthz: a client
|
|
// deciding whether it can talk to this server — terdut-tui, terdut-operator —
|
|
// needs to ask before it holds a credential for it, and the version is not a
|
|
// secret.
|
|
func NewRouter(db *sql.DB, notify NotifyConfig, cfg config.Config, version string) http.Handler {
|
|
// One limiter each, both process-wide for the life of the router: login
|
|
// counts failed passwords, sign-up counts account creation, and mixing the
|
|
// two would let a burst of sign-ups lock somebody out of logging in.
|
|
trustedProxies.Store(int64(cfg.TrustedProxies))
|
|
loginLimit := newLoginLimiter(db)
|
|
signupLimiter := newLoginLimiter(db)
|
|
oidcLimit := newLoginLimiter(db)
|
|
|
|
metrics := newMetricsService(cfg)
|
|
|
|
r := chi.NewRouter()
|
|
r.Use(requestLogger)
|
|
r.Use(middleware.Recoverer)
|
|
r.Use(securityHeaders(notify.PublicURL))
|
|
|
|
r.Get("/healthz", func(w http.ResponseWriter, r *http.Request) {
|
|
respond(w, http.StatusOK, map[string]string{"status": "ok"})
|
|
})
|
|
r.Get("/api/version", func(w http.ResponseWriter, r *http.Request) {
|
|
respond(w, http.StatusOK, map[string]string{"version": version})
|
|
})
|
|
|
|
// Unauthenticated: bootstrap, the Alertmanager webhook receiver, and the
|
|
// Acknowledge button in a push notification. The last one is authorised by
|
|
// the scoped token in its path rather than an API key, and has to stay
|
|
// reachable from outside the cluster for the button to work.
|
|
r.Post("/api/bootstrap", handleBootstrap(db))
|
|
r.Post("/api/notify/ack/{token}", handleNotifyAck(db))
|
|
|
|
// Alert ingestion. The key in the path says both that the sender may post
|
|
// and which team the alerts belong to, which is why it needs no session.
|
|
// This is the only way in.
|
|
r.Post("/api/integrations/{key}/alertmanager", handleIntegrationWebhook(db, notify))
|
|
|
|
// Signing up. Both are unauthenticated by necessity: the caller has no
|
|
// account yet. The info endpoint says whether the door is open and whether
|
|
// an invite link is good, so the form can say so before somebody picks a
|
|
// password.
|
|
r.Get("/api/signup", handleSignupInfo(db))
|
|
r.With(passwordLoginOnly(!cfg.DisablePasswordLogin)).
|
|
Post("/api/signup", handleSignup(db, signupLimiter, notify.PublicURL))
|
|
|
|
// How to sign in: what the login form and the TUI offer before anybody types.
|
|
r.Get("/api/auth/config", handleAuthConfig(cfg))
|
|
|
|
// Signing in to the web UI. Login trades a password for a session cookie,
|
|
// which AuthMiddleware accepts in place of an API key.
|
|
r.With(passwordLoginOnly(!cfg.DisablePasswordLogin)).
|
|
Post("/api/login", handleLogin(db, loginLimit, notify.PublicURL))
|
|
r.Post("/api/logout", handleLogout(db, notify.PublicURL))
|
|
|
|
// Single sign-on. Both routes are navigations the browser makes, to and from
|
|
// the provider, so they answer with redirects rather than JSON.
|
|
if cfg.OIDC.Enabled() {
|
|
prov := oidc.New(cfg.OIDC, notify.PublicURL)
|
|
r.Get("/api/oidc/login", handleOIDCLogin(db, prov, oidcLimit, notify.PublicURL))
|
|
r.Get("/api/oidc/callback", handleOIDCCallback(db, prov, notify.PublicURL))
|
|
|
|
// Device login, for a client with no browser of its own. Both are
|
|
// unauthenticated: the device code in the body is the credential.
|
|
r.Post("/api/oidc/device", handleDeviceStart(db, oidcLimit, notify.PublicURL))
|
|
r.Post("/api/oidc/device/token", handleDeviceToken(db, cfg.OIDC.SessionMaxAge, notify.PublicURL))
|
|
}
|
|
|
|
// All other /api routes require a valid API key.
|
|
r.Group(func(r chi.Router) {
|
|
r.Use(AuthMiddleware(db))
|
|
|
|
r.Get("/api/me", handleMe(db))
|
|
|
|
// Approving or refusing a device login is done by somebody signed in
|
|
// to a browser, and needs the same SSO configuration the flow does.
|
|
if cfg.OIDC.Enabled() {
|
|
r.Post("/api/oidc/device/approve", handleDeviceDecision(db, true))
|
|
r.Post("/api/oidc/device/deny", handleDeviceDecision(db, false))
|
|
}
|
|
r.Put("/api/me/onboarding", handleDismissOnboarding(db))
|
|
// Which kinds of notification channel this server can deliver, so the
|
|
// UI offers only those.
|
|
r.Get("/api/notify/providers", handleListProviders(notify.Providers))
|
|
|
|
// Readable by anyone signed in: the queue's assignment control and the
|
|
// on-call schedule both need to name people.
|
|
r.Get("/api/users", handleListUsers(db))
|
|
|
|
// Your own account, or anybody's if you are an admin. The handlers call
|
|
// requireSelfOrAdmin rather than sitting behind AdminOnly, because
|
|
// which rule applies depends on the {id} in the path.
|
|
r.Get("/api/users/{id}/teams", handleUserTeams(db))
|
|
r.Get("/api/users/{id}/channels", handleListChannels(db))
|
|
r.Post("/api/users/{id}/channels", handleCreateChannel(db, notify.Providers))
|
|
r.Patch("/api/users/{id}/channels/{channelID}", handleUpdateChannel(db, notify.Providers))
|
|
r.Delete("/api/users/{id}/channels/{channelID}", handleDeleteChannel(db))
|
|
// Proves the channel works, which is the only part of "notifications are
|
|
// 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))
|
|
|
|
// Administration: who exists, and who is an administrator.
|
|
r.Group(func(r chi.Router) {
|
|
r.Use(AdminOnly)
|
|
|
|
r.Post("/api/users", handleCreateUser(db))
|
|
r.Delete("/api/users/{id}", handleDeleteUser(db))
|
|
r.Put("/api/users/{id}/admin", handleSetAdmin(db))
|
|
r.Put("/api/users/{id}/disabled", handleSetUserDisabled(db))
|
|
|
|
// What exists on this server, and how it behaves. /api/teams
|
|
// answers "what am I in"; this one answers "what is there".
|
|
r.Get("/api/admin/teams", handleAdminListTeams(db))
|
|
// One team and who is in it. The member list under
|
|
// /api/teams/{id}/members stays member-only and still 404s
|
|
// an administrator from outside; this is a different
|
|
// question, so it is a different endpoint.
|
|
r.Get("/api/admin/teams/{teamID}", handleAdminGetTeam(db))
|
|
r.Get("/api/admin/settings", handleGetSettings(db, cfg, notify.Providers))
|
|
r.Put("/api/admin/settings", handleSetSettings(db))
|
|
})
|
|
|
|
// Alerts are read-only: they are Alertmanager's record, not a work
|
|
// queue. Everything a person does happens on the incident instead.
|
|
r.Get("/api/alerts", handleListAlerts(db))
|
|
r.Get("/api/alerts/{id}", handleGetAlert(db))
|
|
|
|
r.Get("/api/incidents", handleListIncidents(db))
|
|
r.Get("/api/incidents/clusters", handleListClusters(db))
|
|
r.Get("/api/incidents/{id}", handleGetIncident(db))
|
|
r.Get("/api/incidents/{id}/timeline", handleIncidentTimeline(db))
|
|
r.Get("/api/incidents/{id}/metrics", handleIncidentMetrics(db, metrics))
|
|
r.Get("/api/incidents/{id}/similar", handleIncidentSimilar(db))
|
|
r.Post("/api/incidents/{id}/acknowledge", handleIncidentAcknowledge(db))
|
|
r.Delete("/api/incidents/{id}/acknowledge", handleIncidentUnacknowledge(db))
|
|
r.Post("/api/incidents/{id}/resolve", handleIncidentResolve(db))
|
|
r.Post("/api/incidents/{id}/assign", handleIncidentAssign(db))
|
|
r.Post("/api/incidents/{id}/snooze", handleIncidentSnooze(db))
|
|
r.Delete("/api/incidents/{id}/snooze", handleIncidentUnsnooze(db))
|
|
r.Post("/api/incidents/{id}/archive", handleIncidentArchive(db))
|
|
r.Delete("/api/incidents/{id}/archive", handleIncidentUnarchive(db))
|
|
r.Post("/api/incidents/{id}/notes", handleCreateNote(db))
|
|
r.Delete("/api/incidents/{id}/notes/{eventID}", handleDeleteNote(db))
|
|
|
|
// Service accounts: a scoped, non-human credential for automation
|
|
// (terdut-operator, most likely) that needs to manage the resources
|
|
// below without impersonating a human user. See SERVICE-ACCOUNTS.md.
|
|
r.Get("/api/service-accounts", handleListServiceAccounts(db))
|
|
r.Post("/api/service-accounts", handleCreateServiceAccount(db))
|
|
r.Post("/api/service-accounts/{id}/keys", handleCreateServiceAccountKey(db))
|
|
r.Delete("/api/service-accounts/{id}/keys/{keyID}", handleDeleteServiceAccountKey(db))
|
|
|
|
// Operator mode (TERDUT_OPERATOR_MODE) makes every write below refuse a
|
|
// human caller (a session or a user's own API key) while still letting
|
|
// a service account through — see OperatorModeBlock. opMode is a no-op
|
|
// wrapper when the flag is off, so this costs nothing on a server that
|
|
// never sets it.
|
|
opMode := OperatorModeBlock(cfg)
|
|
|
|
// Teams. A user sees the teams they belong to; an owner configures one.
|
|
r.Get("/api/teams", handleListTeams(db))
|
|
r.With(opMode).Post("/api/teams", handleCreateTeam(db))
|
|
r.With(opMode).Put("/api/teams/{teamID}", handleRenameTeam(db))
|
|
r.With(opMode).Delete("/api/teams/{teamID}", handleDeleteTeam(db))
|
|
r.Get("/api/teams/{teamID}/members", handleListTeamMembers(db, notify.Providers))
|
|
r.Post("/api/teams/{teamID}/members", handleAddTeamMember(db))
|
|
r.Delete("/api/teams/{teamID}/members/{userID}", handleRemoveTeamMember(db))
|
|
|
|
// A team's own OIDC group binding: which provider groups grant member
|
|
// and owner access to it.
|
|
r.Get("/api/teams/{teamID}/oidc-groups", handleGetTeamOIDCGroups(db))
|
|
r.With(opMode).Put("/api/teams/{teamID}/oidc-groups", handleSetTeamOIDCGroups(db))
|
|
|
|
// Invite links into this team. Not operator-mode-gated: membership is
|
|
// deliberately never gitops-managed (see terdut-operator's DESIGN.md
|
|
// §4.2), so it stays editable regardless of this flag.
|
|
r.Get("/api/teams/{teamID}/invites", handleListInvites(db))
|
|
r.Post("/api/teams/{teamID}/invites", handleCreateInvite(db, notify.PublicURL))
|
|
r.Delete("/api/teams/{teamID}/invites/{inviteID}", handleRevokeInvite(db))
|
|
|
|
// A team's escalation ladder: who is paged when nobody answers.
|
|
r.Get("/api/teams/{teamID}/escalation", handleGetEscalation(db, notify.Providers))
|
|
r.With(opMode).Put("/api/teams/{teamID}/escalation", handleSetEscalation(db, notify.Providers))
|
|
|
|
// A team's own dead man's switches: which of its alerts are heartbeats,
|
|
// and how long a silence has to last before somebody is paged.
|
|
r.Get("/api/teams/{teamID}/deadman/switches", handleListTeamDeadman(db))
|
|
r.With(opMode).Post("/api/teams/{teamID}/deadman/switches", handleCreateTeamDeadman(db))
|
|
r.With(opMode).Put("/api/teams/{teamID}/deadman/switches/{switchID}", handleUpdateTeamDeadman(db))
|
|
r.With(opMode).Delete("/api/teams/{teamID}/deadman/switches/{switchID}", handleDeleteTeamDeadman(db))
|
|
|
|
// Integrations: where a team's alerts come in, and the key that says so.
|
|
r.Get("/api/teams/{teamID}/integrations", handleListIntegrations(db))
|
|
r.With(opMode).Post("/api/teams/{teamID}/integrations", handleCreateIntegration(db, notify.PublicURL))
|
|
r.With(opMode).Patch("/api/teams/{teamID}/integrations/{integrationID}", handleRenameIntegration(db))
|
|
r.With(opMode).Delete("/api/teams/{teamID}/integrations/{integrationID}", handleDeleteIntegration(db))
|
|
|
|
// The rota is per team. /api/schedule/current is the exception: it
|
|
// answers across every team the caller is in, which is what somebody on
|
|
// two rotas wants to see.
|
|
r.Get("/api/schedule/current", handleCurrentSchedule(db))
|
|
r.Post("/api/teams/{teamID}/schedule", handleCreateSchedule(db))
|
|
r.Get("/api/teams/{teamID}/schedule", handleListSchedule(db))
|
|
r.Delete("/api/teams/{teamID}/schedule/{id}", handleDeleteSchedule(db))
|
|
|
|
r.Get("/api/stats/incidents", handleStatsIncidents(db))
|
|
r.Get("/api/stats/alerts", handleStatsAlerts(db))
|
|
r.Get("/api/stats/alerts/top", handleStatsTop(db))
|
|
r.Get("/api/stats/alerts/by-hour", handleStatsByHour(db))
|
|
r.Get("/api/stats/alerts/by-day", handleStatsByDay(db))
|
|
r.Get("/api/stats/alerts/heatmap", handleStatsHeatmap(db))
|
|
r.Get("/api/stats/alerts/{name}", handleStatsAlert(db))
|
|
})
|
|
|
|
// Anything else under /api is a mistake in a client, and should say so in
|
|
// JSON rather than get the web UI's HTML.
|
|
r.Handle("/api/*", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
respond(w, http.StatusNotFound, errResp("not found"))
|
|
}))
|
|
|
|
// Everything outside /api is the web UI.
|
|
site, err := web.Handler()
|
|
if err != nil {
|
|
panic(err) // the site is embedded at build time; this cannot fail at runtime
|
|
}
|
|
r.Handle("/*", site)
|
|
|
|
return r
|
|
}
|