Files
terdut-server/internal/api/signup.go
T
Niklas Ye b39aac36b7
CI / chart (pull_request) Successful in 1s
CI / security (pull_request) Successful in 13s
CI / test (pull_request) Successful in 2m28s
Add the sign-up page and the first-run checklist
Second half of #7. The API could create accounts from invite links since
the last change; this is the part somebody can actually use.

/signup is the one route that works without a session. It asks the server
what it may offer before showing anything: an invite link that is good
names the team it leads to, a link that is not says so before somebody
picks a password rather than after, and an invite-only server with no
link says that instead of presenting a form it will refuse. The login
card only offers "create one" when sign-up is open, so the door nobody
can walk through is not advertised.

Signing up signs you in and lands on the queue, because the alternative
is a form saying "now go and log in" about the credential just chosen.

The checklist is the other half. Four things have to be true before an
alert reaches a phone -- a notification topic, somebody on the rota, an
alert source, and an alert that has actually arrived -- and on a fresh
install none of them are. It sits above the queue until they are.

It is computed from the data rather than from stored progress: a topic is
set or it is not, an integration exists or it does not. That means it
cannot claim a step is done when it is not, and it comes back by itself
if somebody deletes their integration a month later. The only stored
state is the dismissal, which is per user and not per browser --
finishing on a laptop should not leave the phone nagging.

The topic step is the only one the checklist can finish itself, and the
only proof that counts is a phone buzzing, so there is a test push.
POST /api/me/notify/test publishes directly rather than through the
outbox, which requires an incident this deliberately does not have. Its
failure is the useful part: a wrong topic, a rejected token and an ntfy
that is down all look identical from the phone, which is silence, so the
error comes back to the browser instead.

Verified against a live server with a real ntfy stand-in, the whole path:
an owner mints an invite, the sign-up page reports it valid and names the
team, the invitee signs up and is signed in as a member of that team, the
checklist's four questions answer correctly on a fresh install, a test
push is refused with no topic and delivered with one -- "PAGED
terdut-owner | terdut test" -- and the dismissal survives a reload.

Claude-Session: https://claude.ai/code/session_01RHPj4ggeFdEjKKfm4SHbD7
2026-09-21 10:47:15 +02:00

490 lines
16 KiB
Go

package api
import (
"context"
"database/sql"
"errors"
"net/http"
"strconv"
"strings"
"time"
"git.ryuvia.com/niklas/terdut-server/internal/models"
"github.com/go-chi/chi/v5"
)
// SettingSignupMode says who may create an account. It lives in the settings
// table with the other behaviour settings, so an administrator changes it in
// the admin page rather than in a chart.
//
// Two modes, not three. A domain-restricted mode was considered and dropped:
// with no email in this server there is nothing to verify an address against,
// so it would check the domain of a string somebody typed — a speed bump
// dressed as a control.
const (
SettingSignupMode = "signup_mode"
SignupInviteOnly = "invite_only"
SignupOpen = "open"
)
// defaultSignupMode is invite-only. An install that gets a public hostname
// before anybody has thought about sign-up should not be collecting accounts
// from the internet by default.
const defaultSignupMode = SignupInviteOnly
// inviteTTL is how long a new invite link lives. Long enough to send it and be
// read tomorrow, short enough that a link in an old chat log stops working.
const inviteTTL = 7 * 24 * time.Hour
// signupMode reads the current mode, falling back to invite-only for a missing
// or unrecognised value: the failure mode of a typo in this setting should be
// the closed door, not the open one.
func signupMode(ctx context.Context, db *sql.DB) string {
var raw string
if err := db.QueryRowContext(ctx,
"SELECT value FROM settings WHERE key = $1", SettingSignupMode).Scan(&raw); err != nil {
return defaultSignupMode
}
if raw != SignupOpen && raw != SignupInviteOnly {
return defaultSignupMode
}
return raw
}
// handleSignupInfo tells the sign-up page what it may offer, without requiring
// a session: whether open sign-up is on, and whether the invite in the URL is
// any good. A bad invite is better reported before somebody picks a password.
func handleSignupInfo(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
out := map[string]any{"mode": signupMode(r.Context(), db)}
if token := r.URL.Query().Get("invite"); token != "" {
inv, err := loadInvite(r.Context(), db, token)
switch {
case err == nil:
out["invite_valid"] = true
out["invite_team"] = inv.teamName
default:
// Deliberately 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.
out["invite_valid"] = false
}
}
respond(w, http.StatusOK, out)
}
}
type invite struct {
id int64
teamID int64
teamName string
role string
}
// loadInvite resolves a raw token to a usable invite, or an error. Usable means
// it exists, has not been revoked, has not expired and has uses left.
func loadInvite(ctx context.Context, q querier, token string) (invite, error) {
var inv invite
err := q.QueryRowContext(ctx, `
SELECT i.id, i.team_id, t.name, i.role
FROM invites i
JOIN teams t ON t.id = i.team_id
WHERE i.token_hash = $1
AND i.revoked_at IS NULL
AND i.expires_at > `+nowEpoch+`
AND i.uses < i.max_uses`, hashToken(token)).
Scan(&inv.id, &inv.teamID, &inv.teamName, &inv.role)
if errors.Is(err, sql.ErrNoRows) {
return invite{}, errInviteUnusable
}
return inv, err
}
var errInviteUnusable = errors.New("invite is not usable")
// handleSignup creates an account, and puts it somewhere.
//
// Rate-limited on the same limiter as login, by address: sign-up is the other
// unauthenticated endpoint that writes, and an open install without this is a
// way to fill somebody's user table.
func handleSignup(db *sql.DB, limiter *loginLimiter, publicURL string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
addr := clientAddr(r)
if limiter.blocked("signup:"+addr, maxSignupsPerAddr) {
respond(w, http.StatusTooManyRequests, errResp("too many sign-ups from this address"))
return
}
var req struct {
Username string `json:"username"`
Email string `json:"email"`
Password string `json:"password"`
Invite string `json:"invite"`
TeamName string `json:"team_name"`
}
if err := decodeJSON(r, &req); err != nil {
respond(w, http.StatusBadRequest, errResp("invalid request body"))
return
}
req.Username = strings.TrimSpace(req.Username)
req.Email = strings.TrimSpace(req.Email)
req.TeamName = strings.TrimSpace(req.TeamName)
if req.Username == "" || req.Email == "" {
respond(w, http.StatusBadRequest, errResp("username and email are required"))
return
}
if msg := validatePassword(req.Password); msg != "" {
respond(w, http.StatusBadRequest, errResp(msg))
return
}
mode := signupMode(r.Context(), db)
var inv invite
hasInvite := false
if req.Invite != "" {
var err error
inv, err = loadInvite(r.Context(), db, req.Invite)
if err != nil {
limiter.fail("signup:" + addr)
respond(w, http.StatusForbidden, errResp("this invite link is not usable"))
return
}
hasInvite = true
}
if !hasInvite && mode != SignupOpen {
// No invite and the door is shut. Not 404: the endpoint exists and
// saying so is how somebody knows to ask for a link.
respond(w, http.StatusForbidden,
errResp("sign-up is invite-only on this server"))
return
}
if !hasInvite && req.TeamName == "" {
// Open sign-up with no team would create an account that sees an
// empty queue and can be paged by nobody.
respond(w, http.StatusBadRequest, errResp("team_name is required"))
return
}
hash, err := hashPassword(req.Password)
if err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
tx, err := db.BeginTx(r.Context(), nil)
if err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
defer tx.Rollback() //nolint:errcheck
var userID int64
var invitedVia *int64
if hasInvite {
invitedVia = &inv.id
}
if err := tx.QueryRowContext(r.Context(), `
INSERT INTO users (username, email, password_hash, invited_via)
VALUES ($1, $2, $3, $4) RETURNING id`,
req.Username, req.Email, hash, invitedVia).Scan(&userID); err != nil {
if isUniqueViolation(err) {
respond(w, http.StatusConflict, errResp("username or email already exists"))
return
}
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
teamID, role := inv.teamID, inv.role
if !hasInvite {
// Open sign-up makes a team, and its creator owns it.
if err := tx.QueryRowContext(r.Context(),
"INSERT INTO teams (name) VALUES ($1) RETURNING id", req.TeamName).Scan(&teamID); 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
}
role = models.RoleOwner
}
if _, err := tx.ExecContext(r.Context(),
"INSERT INTO team_members (team_id, user_id, role) VALUES ($1, $2, $3)",
teamID, userID, role); err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
if hasInvite {
// Counted inside the transaction, so two people redeeming the last
// use of a link at once cannot both get in.
res, err := tx.ExecContext(r.Context(),
"UPDATE invites SET uses = uses + 1 WHERE id = $1 AND uses < max_uses", inv.id)
if err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
if n, _ := res.RowsAffected(); n == 0 {
respond(w, http.StatusForbidden, errResp("this invite link is not usable"))
return
}
}
if err := tx.Commit(); err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
// Signed in immediately: the alternative is a form that says "now go
// and log in", which is the same credential typed twice.
if err := startSession(w, r, db, userID, publicURL); err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
user, _ := fetchUser(r.Context(), db, userID)
respond(w, http.StatusCreated, meResponse{User: user, HasPassword: true})
}
}
// maxSignupsPerAddr is looser than the login limit: several people joining from
// one office share an address, and the thing being limited is account creation
// rather than password guessing.
const maxSignupsPerAddr = 10
// ---------------------------------------------------------------------------
// Invites
// ---------------------------------------------------------------------------
type inviteJSON struct {
ID int64 `json:"id"`
TeamID int64 `json:"team_id"`
Role string `json:"role"`
CreatedAt time.Time `json:"created_at"`
ExpiresAt time.Time `json:"expires_at"`
MaxUses int64 `json:"max_uses"`
Uses int64 `json:"uses"`
Revoked bool `json:"revoked"`
// URL is the whole link, returned once when the invite is created. Like an
// integration key, only its hash is stored.
URL string `json:"url,omitempty"`
}
func handleListInvites(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
}
rows, err := db.QueryContext(r.Context(), `
SELECT id, team_id, role, created_at, expires_at, max_uses, uses, revoked_at
FROM invites
WHERE team_id = $1
ORDER BY id DESC`, teamID)
if err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
defer rows.Close()
out := []inviteJSON{}
for rows.Next() {
var i inviteJSON
var created, expires int64
var revoked *int64
if err := rows.Scan(&i.ID, &i.TeamID, &i.Role, &created, &expires,
&i.MaxUses, &i.Uses, &revoked); err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
i.CreatedAt = time.Unix(created, 0).UTC()
i.ExpiresAt = time.Unix(expires, 0).UTC()
i.Revoked = revoked != nil
out = append(out, i)
}
if err := rows.Err(); err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
respond(w, http.StatusOK, out)
}
}
// handleCreateInvite mints a link into this team. Owner-only, like the rest of
// a team's configuration: deciding who joins is configuring the team.
func handleCreateInvite(db *sql.DB, publicURL string) 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 {
Role string `json:"role"`
MaxUses int64 `json:"max_uses"`
}
if err := decodeJSON(r, &req); err != nil {
respond(w, http.StatusBadRequest, errResp("invalid request body"))
return
}
if req.Role == "" {
req.Role = models.RoleMember
}
if req.Role != models.RoleOwner && req.Role != models.RoleMember {
respond(w, http.StatusBadRequest, errResp("role must be owner or member"))
return
}
if req.MaxUses == 0 {
req.MaxUses = 1
}
if req.MaxUses < 1 || req.MaxUses > 100 {
respond(w, http.StatusBadRequest, errResp("max_uses must be between 1 and 100"))
return
}
raw, hash, err := randomToken()
if err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
caller, _ := userFromContext(r.Context())
expires := time.Now().Add(inviteTTL)
var out inviteJSON
var created, expiresAt int64
if err := db.QueryRowContext(r.Context(), `
INSERT INTO invites (token_hash, team_id, role, created_by, expires_at, max_uses)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING id, team_id, role, created_at, expires_at, max_uses, uses`,
hash, teamID, req.Role, caller.ID, expires.Unix(), req.MaxUses).
Scan(&out.ID, &out.TeamID, &out.Role, &created, &expiresAt, &out.MaxUses, &out.Uses); err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
out.CreatedAt = time.Unix(created, 0).UTC()
out.ExpiresAt = time.Unix(expiresAt, 0).UTC()
out.URL = strings.TrimSuffix(publicURL, "/") + "/signup?invite=" + raw
respond(w, http.StatusCreated, out)
}
}
// handleRevokeInvite stops a link working without waiting for it to expire.
func handleRevokeInvite(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
}
id, err := strconv.ParseInt(chi.URLParam(r, "inviteID"), 10, 64)
if err != nil {
respond(w, http.StatusBadRequest, errResp("invalid invite id"))
return
}
res, err := db.ExecContext(r.Context(),
"UPDATE invites SET revoked_at = "+nowEpoch+
" WHERE id = $1 AND team_id = $2 AND revoked_at IS NULL", id, teamID)
if err != nil {
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)
}
}
// ---------------------------------------------------------------------------
// Onboarding
// ---------------------------------------------------------------------------
// handleTestNotification publishes one push to the caller's own topic.
//
// The point of the first-run checklist's notification step is not that a topic
// string has been typed but that a phone buzzes, and only the person holding it
// can tell whether it did. Published directly rather than through the outbox:
// the outbox row requires an incident, and this deliberately belongs to no
// incident.
func handleTestNotification(cfg NotifyConfig, db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if cfg.BaseURL == "" {
respond(w, http.StatusServiceUnavailable,
errResp("this server has no ntfy configured, so it can send nothing"))
return
}
caller, _ := userFromContext(r.Context())
var topic *string
if err := db.QueryRowContext(r.Context(),
"SELECT ntfy_topic FROM users WHERE id = $1", caller.ID).Scan(&topic); err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
if topic == nil || *topic == "" {
respond(w, http.StatusBadRequest, errResp("set a notification topic first"))
return
}
if err := publish(r.Context(), cfg, ntfyMessage{
Topic: *topic,
Title: "terdut test",
Message: "If this arrived, your notifications work.",
Tags: []string{"white_check_mark"},
}); err != nil {
// The failure is the useful part here: a wrong topic, a token the
// ntfy server rejects, or an ntfy that is down all look the same
// from the phone, which is silence.
respond(w, http.StatusBadGateway, errResp("ntfy rejected the test: "+err.Error()))
return
}
w.WriteHeader(http.StatusNoContent)
}
}
// handleDismissOnboarding hides the first-run checklist, or brings it back.
// Stored per user rather than in the browser: somebody who finishes setting up
// on a laptop should not be nagged again on their phone.
func handleDismissOnboarding(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req struct {
Dismissed *bool `json:"dismissed"`
}
if err := decodeJSON(r, &req); err != nil || req.Dismissed == nil {
respond(w, http.StatusBadRequest, errResp("dismissed is required"))
return
}
caller, _ := userFromContext(r.Context())
var err error
if *req.Dismissed {
_, err = db.ExecContext(r.Context(),
"UPDATE users SET onboarding_dismissed_at = "+nowEpoch+" WHERE id = $1", caller.ID)
} else {
_, err = db.ExecContext(r.Context(),
"UPDATE users SET onboarding_dismissed_at = NULL WHERE id = $1", caller.ID)
}
if err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
w.WriteHeader(http.StatusNoContent)
}
}