package api import ( "context" "database/sql" "errors" "net/http" "strconv" "time" "github.com/go-chi/chi/v5" "git.ryuvia.com/niklas/terdut-server/internal/models" "git.ryuvia.com/niklas/terdut-server/internal/notify" ) // maxChannelsPerUser bounds how many channels one account can hold. Nobody needs // more than a handful, and every enabled one is a delivery per page. const maxChannelsPerUser = 10 // loadChannels reads a user's channels. withAddress is false for anyone but the // owner: an address can be a secret (an ntfy topic lets anyone holding it // publish to it), and an administrator has no need of it to run the install. func loadChannels(ctx context.Context, q querier, userID int64, withAddress bool) ([]models.NotificationChannel, error) { rows, err := q.QueryContext(ctx, ` SELECT id, type, address, enabled, created_at FROM notification_channels WHERE user_id = $1 ORDER BY id`, userID) if err != nil { return nil, err } defer rows.Close() out := []models.NotificationChannel{} for rows.Next() { var c models.NotificationChannel var ts int64 if err := rows.Scan(&c.ID, &c.Type, &c.Address, &c.Enabled, &ts); err != nil { return nil, err } c.CreatedAt = time.Unix(ts, 0).UTC() if !withAddress { c.Address = "" } out = append(out, c) } return out, rows.Err() } // hasReachableChannel reports whether a page to this user would go anywhere: an // enabled channel of a type this server can deliver. It is the one definition of // "pageable" the team and escalation pages share with the notifier, so they // cannot promise a page that the notifier would skip. func hasReachableChannel(ctx context.Context, q querier, providers *notify.Registry, userID int64) (bool, error) { rows, err := q.QueryContext(ctx, "SELECT type FROM notification_channels WHERE user_id = $1 AND enabled", userID) if err != nil { return false, err } defer rows.Close() for rows.Next() { var typ string if err := rows.Scan(&typ); err != nil { return false, err } if providers.Has(typ) { return true, nil } } return false, rows.Err() } // validateTarget checks a fallback target. Both halves empty is "none", which is // fine; one half alone is a mistake; and the type must be a provider this server // has, since a fallback nobody can deliver would look configured and page // nobody. func validateTarget(providers *notify.Registry, t notify.Target) (notify.Target, error) { if t.IsZero() { return notify.Target{}, nil } if t.Type == "" || t.Address == "" { return notify.Target{}, errors.New("type and address go together: set both, or neither") } p, ok := providers.Get(t.Type) if !ok { return notify.Target{}, errors.New("this server has no " + strconv.Quote(t.Type) + " provider") } address, err := p.Validate(t.Address) if err != nil { return notify.Target{}, err } return notify.Target{Type: t.Type, Address: address}, nil } // providerTypes lists the registered provider types, as a query parameter for // "channels this server can deliver". Never nil: a nil slice is NULL to Postgres, // and `= ANY(NULL)` is not false. func providerTypes(providers *notify.Registry) []string { types := []string{} for _, p := range providers.List() { types = append(types, p.Type()) } return types } // providerView is what the UI needs to offer a channel type. type providerView struct { Type string `json:"type"` Label string `json:"label"` AddressLabel string `json:"address_label"` AddressHint string `json:"address_hint"` } // handleListProviders says which kinds of channel this server can deliver, so // the UI offers only those. Readable by anyone signed in: it names no secret. func handleListProviders(providers *notify.Registry) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { out := []providerView{} for _, p := range providers.List() { out = append(out, providerView{ Type: p.Type(), Label: p.Label(), AddressLabel: p.AddressLabel(), AddressHint: p.AddressHint(), }) } respond(w, http.StatusOK, out) } } // channelOwner parses {id} and checks the caller may act on that user's // channels: themselves, or an administrator. func channelOwner(w http.ResponseWriter, r *http.Request) (int64, bool) { id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64) if err != nil { respond(w, http.StatusBadRequest, errResp("invalid user id")) return 0, false } if !requireSelfOrAdmin(w, r, id) { return 0, false } return id, true } func isOwner(r *http.Request, userID int64) bool { caller, ok := userFromContext(r.Context()) return ok && caller.ID == userID } // handleListChannels returns a user's channels. The owner sees the addresses; an // administrator sees the types and whether each is on. func handleListChannels(db *sql.DB) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { id, ok := channelOwner(w, r) if !ok { return } channels, err := loadChannels(r.Context(), db, id, isOwner(r, id)) if err != nil { serverError(w, r, err) return } respond(w, http.StatusOK, channels) } } // handleCreateChannel adds a channel to a user. The same address twice on one // account is a 409 rather than two pages for one phone. func handleCreateChannel(db *sql.DB, providers *notify.Registry) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { id, ok := channelOwner(w, r) if !ok { return } var req struct { Type string `json:"type"` Address string `json:"address"` } if err := decodeJSON(r, &req); err != nil { respond(w, http.StatusBadRequest, errResp("invalid request body")) return } p, ok := providers.Get(req.Type) if !ok { respond(w, http.StatusBadRequest, errResp("this server has no "+strconv.Quote(req.Type)+" provider")) return } address, err := p.Validate(req.Address) if err != nil { respond(w, http.StatusBadRequest, errResp(err.Error())) return } var count int if err := db.QueryRowContext(r.Context(), "SELECT COUNT(*) FROM notification_channels WHERE user_id = $1", id).Scan(&count); err != nil { serverError(w, r, err) return } if count >= maxChannelsPerUser { respond(w, http.StatusConflict, errResp("an account can have at most "+strconv.Itoa(maxChannelsPerUser)+" notification channels")) return } var channelID int64 err = db.QueryRowContext(r.Context(), ` INSERT INTO notification_channels (user_id, type, address) VALUES ($1, $2, $3) RETURNING id`, id, p.Type(), address).Scan(&channelID) if err != nil { switch { case isUniqueViolation(err): respond(w, http.StatusConflict, errResp("that channel already exists")) case isForeignKeyViolation(err): respond(w, http.StatusNotFound, errResp("user not found")) default: serverError(w, r, err) } return } respondChannel(w, r, db, id, channelID, http.StatusCreated) } } // handleUpdateChannel turns a channel on or off, or changes its address. Off is // the mute button for one channel: it stays in the list, and pages skip it. func handleUpdateChannel(db *sql.DB, providers *notify.Registry) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { id, ok := channelOwner(w, r) if !ok { return } channelID, err := strconv.ParseInt(chi.URLParam(r, "channelID"), 10, 64) if err != nil { respond(w, http.StatusBadRequest, errResp("invalid channel id")) return } var req struct { Enabled *bool `json:"enabled"` Address *string `json:"address"` } if err := decodeJSON(r, &req); err != nil || (req.Enabled == nil && req.Address == nil) { respond(w, http.StatusBadRequest, errResp("enabled or address is required")) return } var typ string if err := db.QueryRowContext(r.Context(), "SELECT type FROM notification_channels WHERE id = $1 AND user_id = $2", channelID, id).Scan(&typ); errors.Is(err, sql.ErrNoRows) { respond(w, http.StatusNotFound, errResp("channel not found")) return } else if err != nil { serverError(w, r, err) return } if req.Address != nil { p, ok := providers.Get(typ) if !ok { respond(w, http.StatusConflict, errResp("this server no longer has a "+strconv.Quote(typ)+" provider, so its address cannot be checked")) return } address, err := p.Validate(*req.Address) if err != nil { respond(w, http.StatusBadRequest, errResp(err.Error())) return } if _, err := db.ExecContext(r.Context(), "UPDATE notification_channels SET address = $1 WHERE id = $2", address, channelID); err != nil { if isUniqueViolation(err) { respond(w, http.StatusConflict, errResp("that channel already exists")) return } serverError(w, r, err) return } } if req.Enabled != nil { if _, err := db.ExecContext(r.Context(), "UPDATE notification_channels SET enabled = $1 WHERE id = $2", *req.Enabled, channelID); err != nil { serverError(w, r, err) return } } respondChannel(w, r, db, id, channelID, http.StatusOK) } } // handleDeleteChannel removes a channel. Notifications already queued for it // keep their address snapshot and are still delivered; later reminders skip it. func handleDeleteChannel(db *sql.DB) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { id, ok := channelOwner(w, r) if !ok { return } channelID, err := strconv.ParseInt(chi.URLParam(r, "channelID"), 10, 64) if err != nil { respond(w, http.StatusBadRequest, errResp("invalid channel id")) return } res, err := db.ExecContext(r.Context(), "DELETE FROM notification_channels WHERE id = $1 AND user_id = $2", channelID, id) if err != nil { serverError(w, r, err) return } if n, _ := res.RowsAffected(); n == 0 { respond(w, http.StatusNotFound, errResp("channel not found")) return } w.WriteHeader(http.StatusNoContent) } } // respondChannel writes one channel back, with the address only to its owner. func respondChannel(w http.ResponseWriter, r *http.Request, db *sql.DB, userID, channelID int64, status int) { channels, err := loadChannels(r.Context(), db, userID, isOwner(r, userID)) if err != nil { serverError(w, r, err) return } for _, c := range channels { if c.ID == channelID { respond(w, status, c) return } } respond(w, http.StatusNotFound, errResp("channel not found")) } // handleTestChannel sends one test message through a channel. // // The point of the first-run checklist's notification step is not that an // address has been typed but that a phone buzzes, and only the person holding it // can tell whether it did. Sent directly rather than through the outbox: the // outbox row requires an incident, and this deliberately belongs to no incident. // // Owner only, not an administrator: a test is a page, and nobody should be woken // by somebody else's click. func handleTestChannel(db *sql.DB, cfg NotifyConfig) 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 !isOwner(r, id) { respond(w, http.StatusForbidden, errResp("a test can only be sent to your own channel")) return } channelID, err := strconv.ParseInt(chi.URLParam(r, "channelID"), 10, 64) if err != nil { respond(w, http.StatusBadRequest, errResp("invalid channel id")) return } var typ, address string if err := db.QueryRowContext(r.Context(), "SELECT type, address FROM notification_channels WHERE id = $1 AND user_id = $2", channelID, id).Scan(&typ, &address); errors.Is(err, sql.ErrNoRows) { respond(w, http.StatusNotFound, errResp("channel not found")) return } else if err != nil { serverError(w, r, err) return } p, ok := cfg.Providers.Get(typ) if !ok { respond(w, http.StatusServiceUnavailable, errResp("this server has no "+strconv.Quote(typ)+" provider configured, so it can send nothing there")) return } if err := p.Send(r.Context(), notify.Message{ Address: address, Kind: notify.KindTest, Title: "terdut test", Body: "If this arrived, your notifications work.", }); err != nil { // The failure is the useful part here: a wrong address, a credential // the provider rejects, or a provider that is down all look the same // from the phone, which is silence. respond(w, http.StatusBadGateway, errResp(p.Label()+" rejected the test: "+err.Error())) return } w.WriteHeader(http.StatusNoContent) } }