d827ceedff
First half of #7. Until now the only way to get an account was for somebody who already had one to create it, and the login page told people to "ask an admin" -- workable for one operator, impossible for a team. Two modes, chosen by an administrator in the settings table: invite_only, which is the default, and open. A third domain-restricted mode was considered and dropped, because with no email in this server there is nothing to verify an address against and it would only check the domain of a string somebody typed. The default is the closed door. An install that gets a public hostname before anybody has thought about sign-up should not be collecting accounts from the internet, and the failure mode of a typo in the setting is invite_only rather than open. An invite is a link, not an email. Adding SMTP to send one message would be a subsystem to run, secure and monitor; the person inviting sends the link however they already talk to the person they are inviting. A link carries the team and the role, because an account in no team sees an empty queue and can be paged by nobody -- that is not a state to invite somebody into. Links are single-use by default, expire after seven days, and can be revoked before that: a link that works forever is a credential nobody remembers issuing, sitting in a chat log. The uses counter is incremented inside the sign-up transaction and guarded by `uses < max_uses`, so two people redeeming the last use at once cannot both get in. GET /api/signup reports the mode and whether a link is usable, so the form can say "this link has expired" before somebody picks a password rather than after. It gives one answer for expired, revoked, used up and never existed: telling a stranger which it was tells them something about links they do not hold. Sign-up signs you in. The alternative is a form that says "now go and log in", which is the same credential typed twice. login and signup now share startSession rather than each minting a cookie. Rate-limited per address on its own limiter, not login's: a burst of sign-ups must not lock somebody out of logging in. The settings table grew a second shape for this. It held only durations; signup_mode is a word from a fixed list, so the admin endpoint now validates everything before writing anything -- a request that sets two settings and gets one wrong changes neither. Still to come in #7: the sign-up and invite-redemption pages, the first-run checklist, and the in-app integration instructions. The schema carries onboarding_dismissed_at for the checklist already. Claude-Session: https://claude.ai/code/session_01RHPj4ggeFdEjKKfm4SHbD7
385 lines
13 KiB
Go
385 lines
13 KiB
Go
package api
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"errors"
|
|
"net/http"
|
|
"strconv"
|
|
"time"
|
|
|
|
"git.ryuvia.com/niklas/terdut-server/internal/config"
|
|
"github.com/go-chi/chi/v5"
|
|
)
|
|
|
|
// The settings an administrator can change at runtime. Each is behaviour rather
|
|
// than infrastructure: what the server does, not where it is plugged in.
|
|
//
|
|
// The values are seconds, stored as text. A duration string would be friendlier
|
|
// to read in psql and worse everywhere else — it can be stored unparseable, and
|
|
// then the question is what a background loop should do at 02:00 with a
|
|
// tuning knob it cannot understand.
|
|
const (
|
|
SettingNotifyRepeat = "notify_repeat_seconds"
|
|
SettingStaleAfter = "stale_after_seconds"
|
|
SettingArchiveAfter = "archive_after_seconds"
|
|
)
|
|
|
|
// settingBounds keeps an edit from producing a server that cannot work. The
|
|
// ceilings are loose — they exist to catch a slipped decimal point, not to have
|
|
// an opinion about anybody's rota.
|
|
var settingBounds = map[string]struct {
|
|
min, max time.Duration
|
|
label string
|
|
}{
|
|
SettingNotifyRepeat: {0, 24 * time.Hour, "how long an incident may sit unacknowledged before it is paged again; 0 disables reminders"},
|
|
SettingStaleAfter: {5 * time.Minute, 30 * 24 * time.Hour, "how long a firing alert may go without a refreshing webhook before the sweeper resolves it"},
|
|
SettingArchiveAfter: {time.Minute, 365 * 24 * time.Hour, "how long a resolved alert or incident stays in the default list"},
|
|
}
|
|
|
|
// Settings reads the runtime configuration. It holds no cache: the readers are
|
|
// two background loops that tick every 30 seconds and 15 minutes, and handlers
|
|
// that run once per request, so a query each time costs nothing measurable and
|
|
// means an administrator's change takes effect on the next tick rather than at
|
|
// the next restart.
|
|
type Settings struct{ db *sql.DB }
|
|
|
|
// NewSettings returns a reader over db.
|
|
func NewSettings(db *sql.DB) *Settings { return &Settings{db: db} }
|
|
|
|
// Duration reads one setting, falling back to def when the row is missing or
|
|
// unreadable. A tuning knob is never worth failing a sweep over: the fallback
|
|
// is the value the server started with.
|
|
func (s *Settings) Duration(ctx context.Context, key string, def time.Duration) time.Duration {
|
|
var raw string
|
|
err := s.db.QueryRowContext(ctx, "SELECT value FROM settings WHERE key = $1", key).Scan(&raw)
|
|
if err != nil {
|
|
return def
|
|
}
|
|
secs, err := strconv.ParseInt(raw, 10, 64)
|
|
if err != nil {
|
|
return def
|
|
}
|
|
return time.Duration(secs) * time.Second
|
|
}
|
|
|
|
// SeedSettings writes each key from the server's environment configuration,
|
|
// once. Never overwrites: after the first start the database owns these, and a
|
|
// redeploy must not put a chart's default back over an administrator's edit —
|
|
// the same rule as the per-team dead man's switches.
|
|
func SeedSettings(ctx context.Context, db *sql.DB, cfg config.Config) error {
|
|
seeds := map[string]time.Duration{
|
|
SettingNotifyRepeat: cfg.NotifyRepeat,
|
|
SettingStaleAfter: cfg.StaleAfter,
|
|
SettingArchiveAfter: cfg.ArchiveAfter,
|
|
}
|
|
for key, d := range seeds {
|
|
if _, err := db.ExecContext(ctx, `
|
|
INSERT INTO settings (key, value) VALUES ($1, $2)
|
|
ON CONFLICT (key) DO NOTHING`,
|
|
key, strconv.FormatInt(int64(d.Seconds()), 10)); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// settingsResponse is what the admin page renders. The environment half is
|
|
// included and marked read-only, so somebody looking for the ntfy URL finds out
|
|
// where it lives rather than concluding the server does not have one.
|
|
type settingsResponse struct {
|
|
Editable map[string]settingValue `json:"editable"`
|
|
FromEnv map[string]string `json:"from_env"`
|
|
|
|
// Choices are settings that are a word from a fixed list rather than a
|
|
// duration. One so far: who may create an account.
|
|
Choices map[string]choiceValue `json:"choices"`
|
|
}
|
|
|
|
type choiceValue struct {
|
|
Value string `json:"value"`
|
|
Options []string `json:"options"`
|
|
Description string `json:"description"`
|
|
}
|
|
|
|
type settingValue struct {
|
|
Seconds int64 `json:"seconds"`
|
|
Description string `json:"description"`
|
|
MinSeconds int64 `json:"min_seconds"`
|
|
MaxSeconds int64 `json:"max_seconds"`
|
|
}
|
|
|
|
func handleGetSettings(db *sql.DB, cfg config.Config) http.HandlerFunc {
|
|
settings := NewSettings(db)
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
out := settingsResponse{
|
|
Editable: map[string]settingValue{},
|
|
Choices: map[string]choiceValue{
|
|
SettingSignupMode: {
|
|
Value: signupMode(r.Context(), db),
|
|
Options: []string{SignupInviteOnly, SignupOpen},
|
|
Description: "who may create an account: invite_only means a link from a team owner, " +
|
|
"open means anybody who can reach this server",
|
|
},
|
|
},
|
|
FromEnv: map[string]string{
|
|
// Never the ntfy token or the DSN: both are credentials, and an
|
|
// admin page that renders them turns a browser tab into a place
|
|
// they leak from.
|
|
"ntfy_url": cfg.NtfyURL,
|
|
"ntfy_configured": strconv.FormatBool(cfg.NtfyURL != ""),
|
|
"ntfy_token_set": strconv.FormatBool(cfg.NtfyToken != ""),
|
|
"public_url": cfg.PublicURL,
|
|
"listen_address": cfg.Addr,
|
|
},
|
|
}
|
|
for key, b := range settingBounds {
|
|
def := map[string]time.Duration{
|
|
SettingNotifyRepeat: cfg.NotifyRepeat,
|
|
SettingStaleAfter: cfg.StaleAfter,
|
|
SettingArchiveAfter: cfg.ArchiveAfter,
|
|
}[key]
|
|
out.Editable[key] = settingValue{
|
|
Seconds: int64(settings.Duration(r.Context(), key, def).Seconds()),
|
|
Description: b.label,
|
|
MinSeconds: int64(b.min.Seconds()),
|
|
MaxSeconds: int64(b.max.Seconds()),
|
|
}
|
|
}
|
|
respond(w, http.StatusOK, out)
|
|
}
|
|
}
|
|
|
|
// handleSetSettings changes one or more settings. Unknown keys are refused
|
|
// rather than stored: a typo that writes notify_repeat_second would otherwise
|
|
// sit in the table looking like configuration and doing nothing.
|
|
func handleSetSettings(db *sql.DB) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
var req map[string]any
|
|
if err := decodeJSON(r, &req); err != nil {
|
|
respond(w, http.StatusBadRequest, errResp("invalid request body"))
|
|
return
|
|
}
|
|
if len(req) == 0 {
|
|
respond(w, http.StatusBadRequest, errResp("no settings given"))
|
|
return
|
|
}
|
|
|
|
// Validate everything before writing anything: a request that sets two
|
|
// settings and gets one wrong should change neither.
|
|
values := map[string]string{}
|
|
for key, raw := range req {
|
|
switch key {
|
|
case SettingSignupMode:
|
|
mode, _ := raw.(string)
|
|
if mode != SignupOpen && mode != SignupInviteOnly {
|
|
respond(w, http.StatusBadRequest,
|
|
errResp("signup_mode must be "+SignupInviteOnly+" or "+SignupOpen))
|
|
return
|
|
}
|
|
values[key] = mode
|
|
default:
|
|
b, known := settingBounds[key]
|
|
if !known {
|
|
respond(w, http.StatusBadRequest, errResp("unknown setting: "+key))
|
|
return
|
|
}
|
|
secs, ok := raw.(float64) // JSON numbers decode as float64
|
|
if !ok {
|
|
respond(w, http.StatusBadRequest, errResp(key+" must be a number of seconds"))
|
|
return
|
|
}
|
|
d := time.Duration(int64(secs)) * time.Second
|
|
if d < b.min || d > b.max {
|
|
respond(w, http.StatusBadRequest, errResp(
|
|
key+" must be between "+b.min.String()+" and "+b.max.String()))
|
|
return
|
|
}
|
|
values[key] = strconv.FormatInt(int64(secs), 10)
|
|
}
|
|
}
|
|
|
|
tx, err := db.BeginTx(r.Context(), nil)
|
|
if err != nil {
|
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
|
return
|
|
}
|
|
defer tx.Rollback() //nolint:errcheck
|
|
|
|
for key, value := range values {
|
|
if _, err := tx.ExecContext(r.Context(), `
|
|
INSERT INTO settings (key, value, updated_at)
|
|
VALUES ($1, $2, `+nowEpoch+`)
|
|
ON CONFLICT (key) DO UPDATE SET
|
|
value = excluded.value, updated_at = excluded.updated_at`,
|
|
key, value); err != nil {
|
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
|
return
|
|
}
|
|
}
|
|
if err := tx.Commit(); err != nil {
|
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
|
return
|
|
}
|
|
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
}
|
|
|
|
// handleAdminListTeams lists every team on the server, with its size. The
|
|
// ordinary /api/teams answers "what am I in"; this one answers "what exists",
|
|
// which only an administrator may ask.
|
|
func handleAdminListTeams(db *sql.DB) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
rows, err := db.QueryContext(r.Context(), `
|
|
SELECT t.id, t.name, t.created_at,
|
|
(SELECT COUNT(*) FROM team_members m WHERE m.team_id = t.id),
|
|
(SELECT COUNT(*) FROM incidents i
|
|
WHERE i.team_id = t.id AND i.resolved_at IS NULL)
|
|
FROM teams t
|
|
ORDER BY t.name`)
|
|
if err != nil {
|
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
|
return
|
|
}
|
|
defer rows.Close()
|
|
|
|
type adminTeam struct {
|
|
ID int64 `json:"id"`
|
|
Name string `json:"name"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
Members int64 `json:"members"`
|
|
OpenIncidents int64 `json:"open_incidents"`
|
|
}
|
|
teams := []adminTeam{}
|
|
for rows.Next() {
|
|
var t adminTeam
|
|
var created int64
|
|
if err := rows.Scan(&t.ID, &t.Name, &created, &t.Members, &t.OpenIncidents); err != nil {
|
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
|
return
|
|
}
|
|
t.CreatedAt = time.Unix(created, 0).UTC()
|
|
teams = append(teams, t)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
|
return
|
|
}
|
|
respond(w, http.StatusOK, teams)
|
|
}
|
|
}
|
|
|
|
// handleRenameTeam renames a team. An owner's job, and an administrator's when
|
|
// a team has nobody left to do it.
|
|
func handleRenameTeam(db *sql.DB) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
teamID, ok := teamParam(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
if !requireTeamOwner(w, r, teamID) {
|
|
return
|
|
}
|
|
|
|
var req struct {
|
|
Name string `json:"name"`
|
|
}
|
|
if err := decodeJSON(r, &req); err != nil || req.Name == "" {
|
|
respond(w, http.StatusBadRequest, errResp("name is required"))
|
|
return
|
|
}
|
|
|
|
res, err := db.ExecContext(r.Context(),
|
|
"UPDATE teams SET name = $1 WHERE id = $2", req.Name, teamID)
|
|
if err != nil {
|
|
if isUniqueViolation(err) {
|
|
respond(w, http.StatusConflict, errResp("a team with that name already exists"))
|
|
return
|
|
}
|
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
|
return
|
|
}
|
|
if n, _ := res.RowsAffected(); n == 0 {
|
|
respond(w, http.StatusNotFound, errResp("not found"))
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
}
|
|
|
|
// handleSetUserDisabled takes an account out of use, or puts it back.
|
|
//
|
|
// Not a delete: the person's acknowledgements, assignments and timeline entries
|
|
// stay attached to them. Deleting a user nulls those columns, which rewrites
|
|
// what happened during an incident months after the fact.
|
|
func handleSetUserDisabled(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 req struct {
|
|
Disabled *bool `json:"disabled"`
|
|
}
|
|
if err := decodeJSON(r, &req); err != nil || req.Disabled == nil {
|
|
respond(w, http.StatusBadRequest, errResp("disabled is required"))
|
|
return
|
|
}
|
|
|
|
if *req.Disabled {
|
|
caller, _ := userFromContext(r.Context())
|
|
if caller.ID == id {
|
|
respond(w, http.StatusConflict, errResp("cannot disable your own account"))
|
|
return
|
|
}
|
|
last, err := isLastAdmin(r.Context(), db, id)
|
|
if err != nil {
|
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
|
return
|
|
}
|
|
if last {
|
|
respond(w, http.StatusConflict, errResp("cannot disable the last administrator"))
|
|
return
|
|
}
|
|
}
|
|
|
|
var res sql.Result
|
|
if *req.Disabled {
|
|
res, err = db.ExecContext(r.Context(),
|
|
"UPDATE users SET disabled_at = "+nowEpoch+" WHERE id = $1 AND disabled_at IS NULL", id)
|
|
} else {
|
|
res, err = db.ExecContext(r.Context(),
|
|
"UPDATE users SET disabled_at = NULL WHERE id = $1", id)
|
|
}
|
|
if err != nil {
|
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
|
return
|
|
}
|
|
if n, _ := res.RowsAffected(); n == 0 {
|
|
// Either no such user, or already in the state asked for. The
|
|
// second is not a failure, so check which before answering.
|
|
var exists int
|
|
if err := db.QueryRowContext(r.Context(),
|
|
"SELECT 1 FROM users WHERE id = $1", id).Scan(&exists); errors.Is(err, sql.ErrNoRows) {
|
|
respond(w, http.StatusNotFound, errResp("user not found"))
|
|
return
|
|
}
|
|
}
|
|
|
|
// Signing back in is the only way to use a re-enabled account, and a
|
|
// disabled one must not keep a live session.
|
|
if *req.Disabled {
|
|
db.ExecContext(r.Context(), "DELETE FROM sessions WHERE user_id = $1", id) //nolint:errcheck
|
|
}
|
|
|
|
user, err := fetchUser(r.Context(), db, id)
|
|
if err != nil {
|
|
respond(w, http.StatusInternalServerError, errResp("internal error"))
|
|
return
|
|
}
|
|
respond(w, http.StatusOK, user)
|
|
}
|
|
}
|