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) } }