diff --git a/README.md b/README.md index 3031e9a..2516637 100644 --- a/README.md +++ b/README.md @@ -58,6 +58,26 @@ bin/sqlc ```go data, err := queries.DataAccessLayerQueryHere(ctx, args)``` +### feedback + +Players submit bug reports and rule suggestions from the in-app help dialog; +both land in the `bugs` and `suggestions` tables rather than going straight to +GitHub. Submission (`POST /bugs`, `POST /suggestions`) is public. + +The read/triage endpoints (`GET`/`PATCH`/`DELETE` on `/bugs` and +`/suggestions`) require the `X-Rulette-Admin` header to match the +`RULETTE_ADMIN_PASSWORD` environment variable. When that variable is unset, +those admin endpoints fail closed (always `401`). + +Review the queue one ticket at a time, filing the good ones as GitHub issues +(via the `gh` CLI) and dropping abusive ones: +```sh +RULETTE_ADMIN_PASSWORD=... bin/triage --kind all +``` +Flags: `--base-url` (default `http://localhost:7777`), `--kind` +(`bugs|suggestions|all`), `--repo` (default `grackleclub/rulette`). Requires +`gh` on `PATH`. + ### htmx [htmx](https://htmx.org) is a JavaScript library for lightweight frontends using HTML as the engine of application state[^HATEOAS]. diff --git a/bin/triage b/bin/triage new file mode 100755 index 0000000..38e46b7 --- /dev/null +++ b/bin/triage @@ -0,0 +1,16 @@ +#!/usr/bin/env bash +# Triage submitted bug reports and rule suggestions one by one. +# Sources dev.env (for RULETTE_ADMIN_PASSWORD) when present, then runs the CLI. +# Pass flags through, e.g. bin/triage --kind bugs --base-url https://rulette.grackle.club + +set -euo pipefail + +if [ -f "dev.env" ]; then + source "dev.env" +fi + +if command -v mise >/dev/null 2>&1; then + mise exec -- go run ./cmd/triage "$@" +else + go run ./cmd/triage "$@" +fi diff --git a/cmd/triage/main.go b/cmd/triage/main.go new file mode 100644 index 0000000..dcdec38 --- /dev/null +++ b/cmd/triage/main.go @@ -0,0 +1,333 @@ +// Command triage walks unmoderated bug reports and rule suggestions one by +// one, letting an admin file the good ones as GitHub issues (via the gh CLI) +// or drop abusive ones. It talks to the running rulette server's admin +// endpoints, authenticating with the RULETTE_ADMIN_PASSWORD header. +package main + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "os/exec" + "strings" + "time" + + "github.com/alexflint/go-arg" + "github.com/pterm/pterm" +) + +const adminHeader = "X-Rulette-Admin" + +type args struct { + BaseURL string `arg:"--base-url,env:RULETTE_BASE_URL" default:"http://localhost:7777" help:"rulette server base URL"` + Kind string `arg:"--kind" default:"all" help:"which queue to triage: bugs, suggestions, or all"` + Repo string `arg:"--repo" default:"grackleclub/rulette" help:"GitHub repo for filed issues"` +} + +// bug mirrors a row from GET /bugs. +type bug struct { + ID int32 `json:"id"` + GameURL string `json:"game_url"` + OS string `json:"os"` + Browser string `json:"browser"` + Version string `json:"version"` + Description string `json:"description"` + Created string `json:"created"` +} + +// suggestion mirrors a row from GET /suggestions. +type suggestion struct { + ID int32 `json:"id"` + Front string `json:"front"` + Back string `json:"back"` + Created string `json:"created"` +} + +// patch is the status update body sent to PATCH /bugs/{id} or +// /suggestions/{id}. +type patch struct { + Status string `json:"status"` + IssueURL string `json:"issue_url,omitempty"` + Notes string `json:"notes,omitempty"` +} + +// client holds the config shared by every request. +type client struct { + http *http.Client + baseURL string + password string + repo string +} + +func main() { + var a args + arg.MustParse(&a) + + password := os.Getenv("RULETTE_ADMIN_PASSWORD") + if password == "" { + fatal("RULETTE_ADMIN_PASSWORD is not set") + } + if _, err := exec.LookPath("gh"); err != nil { + fatal("gh CLI not found on PATH: %v", err) + } + + c := &client{ + http: &http.Client{Timeout: 30 * time.Second}, + baseURL: strings.TrimRight(a.BaseURL, "/"), + password: password, + repo: a.Repo, + } + + ctx := context.Background() + switch a.Kind { + case "bugs": + mustRun(c.triageBugs(ctx)) + case "suggestions": + mustRun(c.triageSuggestions(ctx)) + case "all": + mustRun(c.triageBugs(ctx)) + mustRun(c.triageSuggestions(ctx)) + default: + fatal("unknown --kind %q (want bugs, suggestions, or all)", a.Kind) + } +} + +// triageBugs lists new bug reports and walks each through a triage decision. +func (c *client) triageBugs(ctx context.Context) error { + var bugs []bug + if err := c.get(ctx, "/bugs", &bugs); err != nil { + return fmt.Errorf("list bugs: %w", err) + } + if len(bugs) == 0 { + pterm.Info.Println("no new bug reports") + return nil + } + pterm.DefaultHeader.Printf("%d bug report(s)", len(bugs)) + for i, b := range bugs { + pterm.DefaultBox.WithTitle(fmt.Sprintf("bug #%d (%d/%d)", b.ID, i+1, len(bugs))). + Println(bugDetail(b)) + stop, err := c.decide(ctx, "bugs", b.ID, "bug", + "[bug]: "+summary(b.Description), bugDetail(b)) + if err != nil { + return err + } + if stop { + break + } + } + return nil +} + +// triageSuggestions lists new rule suggestions and walks each one. +func (c *client) triageSuggestions(ctx context.Context) error { + var suggestions []suggestion + if err := c.get(ctx, "/suggestions", &suggestions); err != nil { + return fmt.Errorf("list suggestions: %w", err) + } + if len(suggestions) == 0 { + pterm.Info.Println("no new rule suggestions") + return nil + } + pterm.DefaultHeader.Printf("%d rule suggestion(s)", len(suggestions)) + for i, s := range suggestions { + pterm.DefaultBox.WithTitle(fmt.Sprintf("suggestion #%d (%d/%d)", s.ID, i+1, len(suggestions))). + Println(suggestionDetail(s)) + stop, err := c.decide(ctx, "suggestions", s.ID, "rule", + "[rule]: "+s.Front, suggestionDetail(s)) + if err != nil { + return err + } + if stop { + break + } + } + return nil +} + +// decide prompts for one ticket and carries out the choice. It returns +// stop=true when the operator chooses to quit. kind is the URL segment +// ("bugs"/"suggestions"); label, title, and detail seed the GitHub issue. +func (c *client) decide(ctx context.Context, kind string, id int32, label, title, detail string) (bool, error) { + const ( + file = "file GitHub issue" + reject = "reject (keep, mark rejected)" + del = "delete (abusive)" + skip = "skip" + quit = "quit" + ) + choice, err := pterm.DefaultInteractiveSelect. + WithOptions([]string{file, reject, del, skip, quit}).Show() + if err != nil { + return false, fmt.Errorf("prompt: %w", err) + } + switch choice { + case file: + notes, _ := pterm.DefaultInteractiveTextInput.Show("developer notes (optional)") + body := detail + if strings.TrimSpace(notes) != "" { + body += "\n\n---\n_developer notes: " + notes + "_" + } + url, err := ghIssue(ctx, c.repo, title, label, body) + if err != nil { + return false, fmt.Errorf("create issue: %w", err) + } + pterm.Success.Println("filed " + url) + if err := c.patch(ctx, kind, id, patch{Status: "filed", IssueURL: url, Notes: notes}); err != nil { + return false, fmt.Errorf("mark filed: %w", err) + } + case reject: + notes, _ := pterm.DefaultInteractiveTextInput.Show("reason (optional)") + if err := c.patch(ctx, kind, id, patch{Status: "rejected", Notes: notes}); err != nil { + return false, fmt.Errorf("mark rejected: %w", err) + } + pterm.Info.Println("rejected") + case del: + ok, _ := pterm.DefaultInteractiveConfirm.Show("permanently delete this ticket?") + if !ok { + return false, nil + } + if err := c.delete(ctx, kind, id); err != nil { + return false, fmt.Errorf("delete: %w", err) + } + pterm.Warning.Println("deleted") + case skip: + pterm.Info.Println("skipped") + case quit: + return true, nil + } + return false, nil +} + +// ghIssue shells out to the gh CLI to open an issue and returns its URL, +// which gh prints on stdout. +func ghIssue(ctx context.Context, repo, title, label, body string) (string, error) { + cmd := exec.CommandContext(ctx, "gh", "issue", "create", + "--repo", repo, + "--title", title, + "--label", label, + "--body", body, + ) + var out, stderr bytes.Buffer + cmd.Stdout = &out + cmd.Stderr = &stderr + if err := cmd.Run(); err != nil { + return "", fmt.Errorf("gh issue create: %w: %s", err, strings.TrimSpace(stderr.String())) + } + return strings.TrimSpace(out.String()), nil +} + +func bugDetail(b bug) string { + return fmt.Sprintf( + "**Game URL:** %s\n**OS:** %s\n**Browser:** %s\n**Version:** %s\n\n### What happened\n%s", + b.GameURL, b.OS, b.Browser, orNone(b.Version), b.Description, + ) +} + +func suggestionDetail(s suggestion) string { + return fmt.Sprintf("**Front:** %s\n**Back:** %s", s.Front, s.Back) +} + +// summary returns the first line of s, trimmed to a reasonable issue-title +// length. +func summary(s string) string { + if i := strings.IndexByte(s, '\n'); i >= 0 { + s = s[:i] + } + s = strings.TrimSpace(s) + if len(s) > 60 { + s = s[:57] + "..." + } + return s +} + +func orNone(s string) string { + if s == "" { + return "(none)" + } + return s +} + +// get fetches path and decodes the JSON response into out. +func (c *client) get(ctx context.Context, path string, out any) error { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+path, nil) + if err != nil { + return fmt.Errorf("build request: %w", err) + } + req.Header.Set(adminHeader, c.password) + res, err := c.http.Do(req) + if err != nil { + return fmt.Errorf("do request: %w", err) + } + defer res.Body.Close() + if res.StatusCode != http.StatusOK { + return statusErr(res) + } + if err := json.NewDecoder(res.Body).Decode(out); err != nil { + return fmt.Errorf("decode response: %w", err) + } + return nil +} + +// patch updates a ticket's status. +func (c *client) patch(ctx context.Context, kind string, id int32, body patch) error { + buf, err := json.Marshal(body) + if err != nil { + return fmt.Errorf("encode body: %w", err) + } + url := fmt.Sprintf("%s/%s?id=%d", c.baseURL, kind, id) + req, err := http.NewRequestWithContext(ctx, http.MethodPatch, url, bytes.NewReader(buf)) + if err != nil { + return fmt.Errorf("build request: %w", err) + } + req.Header.Set(adminHeader, c.password) + req.Header.Set("Content-Type", "application/json") + res, err := c.http.Do(req) + if err != nil { + return fmt.Errorf("do request: %w", err) + } + defer res.Body.Close() + if res.StatusCode != http.StatusNoContent { + return statusErr(res) + } + return nil +} + +// delete removes a ticket. +func (c *client) delete(ctx context.Context, kind string, id int32) error { + url := fmt.Sprintf("%s/%s?id=%d", c.baseURL, kind, id) + req, err := http.NewRequestWithContext(ctx, http.MethodDelete, url, nil) + if err != nil { + return fmt.Errorf("build request: %w", err) + } + req.Header.Set(adminHeader, c.password) + res, err := c.http.Do(req) + if err != nil { + return fmt.Errorf("do request: %w", err) + } + defer res.Body.Close() + if res.StatusCode != http.StatusNoContent { + return statusErr(res) + } + return nil +} + +// statusErr turns a non-success response into an error carrying the body. +func statusErr(res *http.Response) error { + body, _ := io.ReadAll(io.LimitReader(res.Body, 1024)) + return fmt.Errorf("unexpected status %s: %s", res.Status, strings.TrimSpace(string(body))) +} + +func mustRun(err error) { + if err != nil { + fatal("%v", err) + } +} + +func fatal(format string, a ...any) { + pterm.Error.Printfln(format, a...) + os.Exit(1) +} diff --git a/db/queries/bugs.sql b/db/queries/bugs.sql new file mode 100644 index 0000000..dcd3b21 --- /dev/null +++ b/db/queries/bugs.sql @@ -0,0 +1,24 @@ +-- name: BugCreate :one +INSERT INTO bugs (game_url, os, browser, version, description) +VALUES ($1, $2, $3, $4, $5) +RETURNING id; + +-- name: BugGet :one +SELECT * FROM bugs +WHERE id = $1; + +-- name: BugsNew :many +SELECT * FROM bugs +WHERE status = 'new' +ORDER BY created; + +-- name: BugSetStatus :exec +UPDATE bugs +SET status = $2, + issue_url = $3, + notes = $4 +WHERE id = $1; + +-- name: BugDelete :exec +DELETE FROM bugs +WHERE id = $1; diff --git a/db/queries/suggestions.sql b/db/queries/suggestions.sql new file mode 100644 index 0000000..9670d66 --- /dev/null +++ b/db/queries/suggestions.sql @@ -0,0 +1,24 @@ +-- name: SuggestionCreate :one +INSERT INTO suggestions (front, back) +VALUES ($1, $2) +RETURNING id; + +-- name: SuggestionGet :one +SELECT * FROM suggestions +WHERE id = $1; + +-- name: SuggestionsNew :many +SELECT * FROM suggestions +WHERE status = 'new' +ORDER BY created; + +-- name: SuggestionSetStatus :exec +UPDATE suggestions +SET status = $2, + issue_url = $3, + notes = $4 +WHERE id = $1; + +-- name: SuggestionDelete :exec +DELETE FROM suggestions +WHERE id = $1; diff --git a/db/schema.sql b/db/schema.sql index 5b400b0..d41f7a6 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -355,3 +355,33 @@ CREATE TABLE IF NOT EXISTS event_log ( FOREIGN KEY (point_change_id) REFERENCES point_changes(id) ON DELETE SET NULL ); CREATE INDEX IF NOT EXISTS event_log_game_id_idx ON event_log (game_id, id); + +-- bugs: player-submitted bug reports awaiting moderation. mirrors the fields +-- of the old GitHub bug template. status moves new -> filed (a GitHub issue +-- was opened) or new -> rejected. issue_url and notes are filled in by the +-- triage CLI when a report is filed. +CREATE TABLE IF NOT EXISTS bugs ( + id SERIAL PRIMARY KEY, + game_url TEXT NOT NULL, + os TEXT NOT NULL, + browser TEXT NOT NULL, + version TEXT, + description TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'new', -- new | filed | rejected + issue_url TEXT, -- GitHub issue, set when filed + notes TEXT, -- developer notes, added at triage + created TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- suggestions: player-submitted rule cards awaiting moderation. mirrors the +-- fields of the old GitHub new-rule template. status and the triage columns +-- behave the same as bugs above. +CREATE TABLE IF NOT EXISTS suggestions ( + id SERIAL PRIMARY KEY, + front TEXT NOT NULL, + back TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'new', -- new | filed | rejected + issue_url TEXT, -- GitHub issue, set when filed + notes TEXT, -- developer notes, added at triage + created TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); diff --git a/db/schema_down.sql b/db/schema_down.sql index 865b951..f4e04d1 100644 --- a/db/schema_down.sql +++ b/db/schema_down.sql @@ -12,4 +12,6 @@ DROP TABLE IF EXISTS point_changes CASCADE; DROP TABLE IF EXISTS event_log CASCADE; DROP TABLE IF EXISTS event_types CASCADE; DROP TABLE IF EXISTS game_cache CASCADE; +DROP TABLE IF EXISTS bugs CASCADE; +DROP TABLE IF EXISTS suggestions CASCADE; diff --git a/db/sqlc.yaml b/db/sqlc.yaml index 6defa3e..fbecfae 100644 --- a/db/sqlc.yaml +++ b/db/sqlc.yaml @@ -2,6 +2,7 @@ version: "2" sql: - engine: "postgresql" queries: + - "queries/bugs.sql" - "queries/cache.sql" - "queries/cards.sql" - "queries/event.sql" @@ -12,6 +13,7 @@ sql: - "queries/player.sql" - "queries/point_changes.sql" - "queries/spins.sql" + - "queries/suggestions.sql" schema: "schema.sql" gen: go: diff --git a/db/sqlc/bugs.sql.go b/db/sqlc/bugs.sql.go new file mode 100644 index 0000000..dc7af37 --- /dev/null +++ b/db/sqlc/bugs.sql.go @@ -0,0 +1,134 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: bugs.sql + +package sqlc + +import ( + "context" + + "github.com/jackc/pgx/v5/pgtype" +) + +const bugCreate = `-- name: BugCreate :one +INSERT INTO bugs (game_url, os, browser, version, description) +VALUES ($1, $2, $3, $4, $5) +RETURNING id +` + +type BugCreateParams struct { + GameUrl string `json:"game_url"` + Os string `json:"os"` + Browser string `json:"browser"` + Version pgtype.Text `json:"version"` + Description string `json:"description"` +} + +func (q *Queries) BugCreate(ctx context.Context, arg BugCreateParams) (int32, error) { + row := q.db.QueryRow(ctx, bugCreate, + arg.GameUrl, + arg.Os, + arg.Browser, + arg.Version, + arg.Description, + ) + var id int32 + err := row.Scan(&id) + return id, err +} + +const bugDelete = `-- name: BugDelete :exec +DELETE FROM bugs +WHERE id = $1 +` + +func (q *Queries) BugDelete(ctx context.Context, id int32) error { + _, err := q.db.Exec(ctx, bugDelete, id) + return err +} + +const bugGet = `-- name: BugGet :one +SELECT id, game_url, os, browser, version, description, status, issue_url, notes, created FROM bugs +WHERE id = $1 +` + +func (q *Queries) BugGet(ctx context.Context, id int32) (Bugs, error) { + row := q.db.QueryRow(ctx, bugGet, id) + var i Bugs + err := row.Scan( + &i.ID, + &i.GameUrl, + &i.Os, + &i.Browser, + &i.Version, + &i.Description, + &i.Status, + &i.IssueUrl, + &i.Notes, + &i.Created, + ) + return i, err +} + +const bugSetStatus = `-- name: BugSetStatus :exec +UPDATE bugs +SET status = $2, + issue_url = $3, + notes = $4 +WHERE id = $1 +` + +type BugSetStatusParams struct { + ID int32 `json:"id"` + Status string `json:"status"` + IssueUrl pgtype.Text `json:"issue_url"` + Notes pgtype.Text `json:"notes"` +} + +func (q *Queries) BugSetStatus(ctx context.Context, arg BugSetStatusParams) error { + _, err := q.db.Exec(ctx, bugSetStatus, + arg.ID, + arg.Status, + arg.IssueUrl, + arg.Notes, + ) + return err +} + +const bugsNew = `-- name: BugsNew :many +SELECT id, game_url, os, browser, version, description, status, issue_url, notes, created FROM bugs +WHERE status = 'new' +ORDER BY created +` + +func (q *Queries) BugsNew(ctx context.Context) ([]Bugs, error) { + rows, err := q.db.Query(ctx, bugsNew) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Bugs + for rows.Next() { + var i Bugs + if err := rows.Scan( + &i.ID, + &i.GameUrl, + &i.Os, + &i.Browser, + &i.Version, + &i.Description, + &i.Status, + &i.IssueUrl, + &i.Notes, + &i.Created, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} diff --git a/db/sqlc/models.go b/db/sqlc/models.go index 4c901b3..7a10270 100644 --- a/db/sqlc/models.go +++ b/db/sqlc/models.go @@ -8,6 +8,19 @@ import ( "github.com/jackc/pgx/v5/pgtype" ) +type Bugs struct { + ID int32 `json:"id"` + GameUrl string `json:"game_url"` + Os string `json:"os"` + Browser string `json:"browser"` + Version pgtype.Text `json:"version"` + Description string `json:"description"` + Status string `json:"status"` + IssueUrl pgtype.Text `json:"issue_url"` + Notes pgtype.Text `json:"notes"` + Created pgtype.Timestamp `json:"created"` +} + type CardTypes struct { Name string `json:"name"` Description pgtype.Text `json:"description"` @@ -126,3 +139,13 @@ type Spins struct { CardID pgtype.Int4 `json:"card_id"` Ts pgtype.Timestamp `json:"ts"` } + +type Suggestions struct { + ID int32 `json:"id"` + Front string `json:"front"` + Back string `json:"back"` + Status string `json:"status"` + IssueUrl pgtype.Text `json:"issue_url"` + Notes pgtype.Text `json:"notes"` + Created pgtype.Timestamp `json:"created"` +} diff --git a/db/sqlc/suggestions.sql.go b/db/sqlc/suggestions.sql.go new file mode 100644 index 0000000..2c5af1e --- /dev/null +++ b/db/sqlc/suggestions.sql.go @@ -0,0 +1,119 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: suggestions.sql + +package sqlc + +import ( + "context" + + "github.com/jackc/pgx/v5/pgtype" +) + +const suggestionCreate = `-- name: SuggestionCreate :one +INSERT INTO suggestions (front, back) +VALUES ($1, $2) +RETURNING id +` + +type SuggestionCreateParams struct { + Front string `json:"front"` + Back string `json:"back"` +} + +func (q *Queries) SuggestionCreate(ctx context.Context, arg SuggestionCreateParams) (int32, error) { + row := q.db.QueryRow(ctx, suggestionCreate, arg.Front, arg.Back) + var id int32 + err := row.Scan(&id) + return id, err +} + +const suggestionDelete = `-- name: SuggestionDelete :exec +DELETE FROM suggestions +WHERE id = $1 +` + +func (q *Queries) SuggestionDelete(ctx context.Context, id int32) error { + _, err := q.db.Exec(ctx, suggestionDelete, id) + return err +} + +const suggestionGet = `-- name: SuggestionGet :one +SELECT id, front, back, status, issue_url, notes, created FROM suggestions +WHERE id = $1 +` + +func (q *Queries) SuggestionGet(ctx context.Context, id int32) (Suggestions, error) { + row := q.db.QueryRow(ctx, suggestionGet, id) + var i Suggestions + err := row.Scan( + &i.ID, + &i.Front, + &i.Back, + &i.Status, + &i.IssueUrl, + &i.Notes, + &i.Created, + ) + return i, err +} + +const suggestionSetStatus = `-- name: SuggestionSetStatus :exec +UPDATE suggestions +SET status = $2, + issue_url = $3, + notes = $4 +WHERE id = $1 +` + +type SuggestionSetStatusParams struct { + ID int32 `json:"id"` + Status string `json:"status"` + IssueUrl pgtype.Text `json:"issue_url"` + Notes pgtype.Text `json:"notes"` +} + +func (q *Queries) SuggestionSetStatus(ctx context.Context, arg SuggestionSetStatusParams) error { + _, err := q.db.Exec(ctx, suggestionSetStatus, + arg.ID, + arg.Status, + arg.IssueUrl, + arg.Notes, + ) + return err +} + +const suggestionsNew = `-- name: SuggestionsNew :many +SELECT id, front, back, status, issue_url, notes, created FROM suggestions +WHERE status = 'new' +ORDER BY created +` + +func (q *Queries) SuggestionsNew(ctx context.Context) ([]Suggestions, error) { + rows, err := q.db.Query(ctx, suggestionsNew) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Suggestions + for rows.Next() { + var i Suggestions + if err := rows.Scan( + &i.ID, + &i.Front, + &i.Back, + &i.Status, + &i.IssueUrl, + &i.Notes, + &i.Created, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} diff --git a/feedback.go b/feedback.go new file mode 100644 index 0000000..aaed347 --- /dev/null +++ b/feedback.go @@ -0,0 +1,235 @@ +package main + +import ( + "encoding/json" + "errors" + "net/http" + "strconv" + "strings" + + sqlc "github.com/grackleclub/rulette/db/sqlc" + "github.com/jackc/pgx/v5/pgtype" +) + +// validStatuses are the lifecycle values a bug or suggestion may hold. +// Submissions start at "new"; the triage CLI moves them to "filed" once a +// GitHub issue exists, or "rejected" when dropped. +var validStatuses = map[string]bool{ + "new": true, + "filed": true, + "rejected": true, +} + +// patchBody is the JSON the triage CLI sends to update a ticket's status. +type patchBody struct { + Status string `json:"status"` + IssueURL string `json:"issue_url"` + Notes string `json:"notes"` +} + +// pgText wraps an optional string as a pgtype.Text, storing NULL when empty. +func pgText(s string) pgtype.Text { + return pgtype.Text{String: s, Valid: s != ""} +} + +// bugsHandler serves the /bugs resource. POST is public (submit a report); GET, +// PATCH, and DELETE are admin-only (list new reports, set a report's status, +// remove a report). PATCH and DELETE take the row id from the "id" query +// parameter — the game routes own the /{game_id}/... wildcard, so a /bugs/{id} +// path can't be registered without a router conflict. +func bugsHandler(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodPost: + game := strings.TrimSpace(r.FormValue("game_url")) + os := strings.TrimSpace(r.FormValue("os")) + browser := strings.TrimSpace(r.FormValue("browser")) + desc := strings.TrimSpace(r.FormValue("description")) + if game == "" || os == "" || browser == "" || desc == "" { + http.Error(w, "missing required field", http.StatusBadRequest) + return + } + id, err := queries.BugCreate(r.Context(), sqlc.BugCreateParams{ + GameUrl: game, + Os: os, + Browser: browser, + Version: pgText(strings.TrimSpace(r.FormValue("version"))), + Description: desc, + }) + if err != nil { + log.Error("create bug", "error", err) + http.Error(w, "server error", http.StatusInternalServerError) + return + } + log.Info("bug submitted", "id", id) + w.WriteHeader(http.StatusNoContent) + case http.MethodGet: + if !requireAdmin(w, r) { + return + } + bugs, err := queries.BugsNew(r.Context()) + if err != nil { + log.Error("list bugs", "error", err) + http.Error(w, "server error", http.StatusInternalServerError) + return + } + writeJSON(w, bugs) + case http.MethodPatch: + if !requireAdmin(w, r) { + return + } + id, body, ok := readUpdate(w, r) + if !ok { + return + } + err := queries.BugSetStatus(r.Context(), sqlc.BugSetStatusParams{ + ID: id, + Status: body.Status, + IssueUrl: pgText(body.IssueURL), + Notes: pgText(body.Notes), + }) + if err != nil { + log.Error("update bug", "error", err, "id", id) + http.Error(w, "server error", http.StatusInternalServerError) + return + } + w.WriteHeader(http.StatusNoContent) + case http.MethodDelete: + if !requireAdmin(w, r) { + return + } + id, err := queryID(r) + if err != nil { + http.Error(w, "bad id", http.StatusBadRequest) + return + } + if err := queries.BugDelete(r.Context(), id); err != nil { + log.Error("delete bug", "error", err, "id", id) + http.Error(w, "server error", http.StatusInternalServerError) + return + } + w.WriteHeader(http.StatusNoContent) + default: + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + } +} + +// suggestionsHandler serves the /suggestions resource, mirroring bugsHandler: +// public POST to suggest a rule, admin-only GET/PATCH/DELETE to triage. +func suggestionsHandler(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodPost: + front := strings.TrimSpace(r.FormValue("front")) + back := strings.TrimSpace(r.FormValue("back")) + if front == "" || back == "" { + http.Error(w, "missing required field", http.StatusBadRequest) + return + } + id, err := queries.SuggestionCreate(r.Context(), sqlc.SuggestionCreateParams{ + Front: front, + Back: back, + }) + if err != nil { + log.Error("create suggestion", "error", err) + http.Error(w, "server error", http.StatusInternalServerError) + return + } + log.Info("suggestion submitted", "id", id) + w.WriteHeader(http.StatusNoContent) + case http.MethodGet: + if !requireAdmin(w, r) { + return + } + suggestions, err := queries.SuggestionsNew(r.Context()) + if err != nil { + log.Error("list suggestions", "error", err) + http.Error(w, "server error", http.StatusInternalServerError) + return + } + writeJSON(w, suggestions) + case http.MethodPatch: + if !requireAdmin(w, r) { + return + } + id, body, ok := readUpdate(w, r) + if !ok { + return + } + err := queries.SuggestionSetStatus(r.Context(), sqlc.SuggestionSetStatusParams{ + ID: id, + Status: body.Status, + IssueUrl: pgText(body.IssueURL), + Notes: pgText(body.Notes), + }) + if err != nil { + log.Error("update suggestion", "error", err, "id", id) + http.Error(w, "server error", http.StatusInternalServerError) + return + } + w.WriteHeader(http.StatusNoContent) + case http.MethodDelete: + if !requireAdmin(w, r) { + return + } + id, err := queryID(r) + if err != nil { + http.Error(w, "bad id", http.StatusBadRequest) + return + } + if err := queries.SuggestionDelete(r.Context(), id); err != nil { + log.Error("delete suggestion", "error", err, "id", id) + http.Error(w, "server error", http.StatusInternalServerError) + return + } + w.WriteHeader(http.StatusNoContent) + default: + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + } +} + +// requireAdmin writes a 401 and returns false when the request lacks a valid +// admin password, so a handler can guard a branch with one if. +func requireAdmin(w http.ResponseWriter, r *http.Request) bool { + if !isAdmin(r) { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return false + } + return true +} + +// readUpdate parses the id query parameter and the JSON status body shared by +// the PATCH branches, writing a 400 and returning ok=false on bad input. +func readUpdate(w http.ResponseWriter, r *http.Request) (int32, patchBody, bool) { + id, err := queryID(r) + if err != nil { + http.Error(w, "bad id", http.StatusBadRequest) + return 0, patchBody{}, false + } + var body patchBody + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + http.Error(w, "invalid json", http.StatusBadRequest) + return 0, patchBody{}, false + } + if !validStatuses[body.Status] { + http.Error(w, "invalid status", http.StatusBadRequest) + return 0, patchBody{}, false + } + return id, body, true +} + +// queryID reads the "id" query parameter as a row id. +func queryID(r *http.Request) (int32, error) { + n, err := strconv.ParseInt(r.URL.Query().Get("id"), 10, 32) + if err != nil { + return 0, errors.New("invalid id") + } + return int32(n), nil +} + +// writeJSON encodes v as the JSON response body, logging on failure (the +// header is already sent by then, so the caller can't recover). +func writeJSON(w http.ResponseWriter, v any) { + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(v); err != nil { + log.Error("encode json response", "error", err) + } +} diff --git a/feedback_test.go b/feedback_test.go new file mode 100644 index 0000000..b3f2a48 --- /dev/null +++ b/feedback_test.go @@ -0,0 +1,204 @@ +package main + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "net/url" + "strconv" + "strings" + "testing" + + "github.com/grackleclub/postgres" + sqlc "github.com/grackleclub/rulette/db/sqlc" + "github.com/stretchr/testify/require" +) + +// TestAdminAuth covers the admin gate without a database: isAdmin and the +// adminOnly middleware should fail closed when no password is set and reject +// wrong or missing headers. +func TestAdminAuth(t *testing.T) { + initLogger(nil) + const secret = "hunter2" + + t.Run("fails closed when unset", func(t *testing.T) { + adminPassword = "" + req := httptest.NewRequest(http.MethodGet, "/bugs", nil) + req.Header.Set(adminHeader, "anything") + require.False(t, isAdmin(req)) + }) + + adminPassword = secret + t.Cleanup(func() { adminPassword = "" }) + + cases := []struct { + name string + header string + want bool + }{ + {"correct", secret, true}, + {"wrong", "nope", false}, + {"missing", "", false}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/bugs", nil) + if c.header != "" { + req.Header.Set(adminHeader, c.header) + } + require.Equal(t, c.want, isAdmin(req)) + }) + } + + t.Run("delete without admin is rejected", func(t *testing.T) { + adminPassword = "" + req := httptest.NewRequest(http.MethodDelete, "/bugs?id=1", nil) + w := httptest.NewRecorder() + bugsHandler(w, req) + require.Equal(t, http.StatusUnauthorized, w.Result().StatusCode) + }) +} + +// TestFeedbackValidation checks that submissions missing a required field are +// rejected before any database call, so it needs no database. +func TestFeedbackValidation(t *testing.T) { + initLogger(nil) + t.Run("bug missing fields", func(t *testing.T) { + body := strings.NewReader(url.Values{"game_url": {"x"}}.Encode()) + req := httptest.NewRequest(http.MethodPost, "/bugs", body) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + w := httptest.NewRecorder() + bugsHandler(w, req) + require.Equal(t, http.StatusBadRequest, w.Result().StatusCode) + }) + t.Run("suggestion missing fields", func(t *testing.T) { + body := strings.NewReader(url.Values{"front": {"x"}}.Encode()) + req := httptest.NewRequest(http.MethodPost, "/suggestions", body) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + w := httptest.NewRecorder() + suggestionsHandler(w, req) + require.Equal(t, http.StatusBadRequest, w.Result().StatusCode) + }) +} + +// TestFeedback exercises the full submit -> list -> triage round trip against +// an ephemeral database. +func TestFeedback(t *testing.T) { + initLogger(nil) + ctx := context.Background() + db, teardown, err := postgres.NewTestDB(ctx, testDBOpts(t)) + require.NoError(t, err) + defer teardown() + pool, err := db.Pool(ctx) + require.NoError(t, err) + dbPool = pool + queries = sqlc.New(pool) + _, err = db.Conn.ExecContext(ctx, dbSchema) + require.NoError(t, err) + + const secret = "triage-pw" + adminPassword = secret + t.Cleanup(func() { adminPassword = "" }) + + // route through a mux to mirror production dispatch + mux := http.NewServeMux() + mux.HandleFunc("/bugs", bugsHandler) + mux.HandleFunc("/suggestions", suggestionsHandler) + + t.Run("submit bug", func(t *testing.T) { + form := url.Values{ + "game_url": {"http://localhost/abc123"}, + "os": {"Linux"}, + "browser": {"Firefox"}, + "version": {"v1.2.3"}, + "description": {"the wheel spun forever"}, + } + req := httptest.NewRequest(http.MethodPost, "/bugs", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + w := httptest.NewRecorder() + mux.ServeHTTP(w, req) + require.Equal(t, http.StatusNoContent, w.Result().StatusCode) + }) + + t.Run("list requires admin", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/bugs", nil) + w := httptest.NewRecorder() + mux.ServeHTTP(w, req) + require.Equal(t, http.StatusUnauthorized, w.Result().StatusCode) + }) + + var bugID int32 + t.Run("admin lists new bug", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/bugs", nil) + req.Header.Set(adminHeader, secret) + w := httptest.NewRecorder() + mux.ServeHTTP(w, req) + require.Equal(t, http.StatusOK, w.Result().StatusCode) + var bugs []sqlc.Bugs + require.NoError(t, json.NewDecoder(w.Result().Body).Decode(&bugs)) + require.Len(t, bugs, 1) + require.Equal(t, "the wheel spun forever", bugs[0].Description) + require.Equal(t, "new", bugs[0].Status) + bugID = bugs[0].ID + }) + + t.Run("patch marks filed and drops from new list", func(t *testing.T) { + body, _ := json.Marshal(patchBody{Status: "filed", IssueURL: "http://gh/1", Notes: "dupe of #4"}) + req := httptest.NewRequest(http.MethodPatch, bugPath(bugID), strings.NewReader(string(body))) + req.Header.Set(adminHeader, secret) + w := httptest.NewRecorder() + mux.ServeHTTP(w, req) + require.Equal(t, http.StatusNoContent, w.Result().StatusCode) + + stored, err := queries.BugGet(ctx, bugID) + require.NoError(t, err) + require.Equal(t, "filed", stored.Status) + require.Equal(t, "http://gh/1", stored.IssueUrl.String) + + // no longer surfaced to the triage list + req = httptest.NewRequest(http.MethodGet, "/bugs", nil) + req.Header.Set(adminHeader, secret) + w = httptest.NewRecorder() + mux.ServeHTTP(w, req) + var bugs []sqlc.Bugs + require.NoError(t, json.NewDecoder(w.Result().Body).Decode(&bugs)) + require.Empty(t, bugs) + }) + + t.Run("patch rejects invalid status", func(t *testing.T) { + body, _ := json.Marshal(map[string]string{"status": "bogus"}) + req := httptest.NewRequest(http.MethodPatch, bugPath(bugID), strings.NewReader(string(body))) + req.Header.Set(adminHeader, secret) + w := httptest.NewRecorder() + mux.ServeHTTP(w, req) + require.Equal(t, http.StatusBadRequest, w.Result().StatusCode) + }) + + t.Run("suggestion submit and delete", func(t *testing.T) { + form := url.Values{"front": {"in a whisper"}, "back": {"too loudly"}} + req := httptest.NewRequest(http.MethodPost, "/suggestions", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + w := httptest.NewRecorder() + mux.ServeHTTP(w, req) + require.Equal(t, http.StatusNoContent, w.Result().StatusCode) + + list, err := queries.SuggestionsNew(ctx) + require.NoError(t, err) + require.Len(t, list, 1) + id := list[0].ID + + req = httptest.NewRequest(http.MethodDelete, suggestionPath(id), nil) + req.Header.Set(adminHeader, secret) + w = httptest.NewRecorder() + mux.ServeHTTP(w, req) + require.Equal(t, http.StatusNoContent, w.Result().StatusCode) + + list, err = queries.SuggestionsNew(ctx) + require.NoError(t, err) + require.Empty(t, list) + }) +} + +func bugPath(id int32) string { return "/bugs?id=" + strconv.Itoa(int(id)) } +func suggestionPath(id int32) string { return "/suggestions?id=" + strconv.Itoa(int(id)) } diff --git a/go.mod b/go.mod index ca0ec35..2b640aa 100644 --- a/go.mod +++ b/go.mod @@ -3,9 +3,11 @@ module github.com/grackleclub/rulette go 1.25.0 require ( + github.com/alexflint/go-arg v1.6.1 github.com/grackleclub/log v1.2.0 github.com/grackleclub/postgres v0.0.3 github.com/jackc/pgx/v5 v5.7.5 + github.com/pterm/pterm v0.12.83 github.com/stretchr/testify v1.11.1 go.opentelemetry.io/contrib/bridges/otelslog v0.18.0 go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0 @@ -24,12 +26,18 @@ require ( ) require ( + atomicgo.dev/cursor v0.2.0 // indirect + atomicgo.dev/keyboard v0.2.9 // indirect + atomicgo.dev/schedule v0.1.0 // indirect dario.cat/mergo v1.0.0 // indirect github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 // indirect github.com/Microsoft/go-winio v0.6.2 // indirect + github.com/alexflint/go-scalar v1.2.0 // indirect github.com/cenkalti/backoff/v4 v4.2.1 // indirect github.com/cenkalti/backoff/v5 v5.0.3 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/clipperhouse/uax29/v2 v2.7.0 // indirect + github.com/containerd/console v1.0.5 // indirect github.com/containerd/containerd v1.7.18 // indirect github.com/containerd/log v0.1.0 // indirect github.com/containerd/platforms v0.2.1 // indirect @@ -45,15 +53,18 @@ require ( github.com/go-ole/go-ole v1.2.6 // indirect github.com/gogo/protobuf v1.3.2 // indirect github.com/google/uuid v1.6.0 // indirect + github.com/gookit/color v1.6.0 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/klauspost/compress v1.17.4 // indirect github.com/lib/pq v1.10.9 // indirect + github.com/lithammer/fuzzysearch v1.1.8 // indirect github.com/lmittmann/tint v1.0.5 // indirect github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect github.com/magiconair/properties v1.8.7 // indirect + github.com/mattn/go-runewidth v0.0.20 // indirect github.com/moby/docker-image-spec v1.3.1 // indirect github.com/moby/patternmatcher v0.6.0 // indirect github.com/moby/sys/sequential v0.5.0 // indirect @@ -72,6 +83,7 @@ require ( github.com/testcontainers/testcontainers-go/modules/postgres v0.33.0 // indirect github.com/tklauser/go-sysconf v0.3.12 // indirect github.com/tklauser/numcpus v0.6.1 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect github.com/yusufpapurcu/wmi v1.2.3 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0 // indirect diff --git a/go.sum b/go.sum index 11fb932..b11928d 100644 --- a/go.sum +++ b/go.sum @@ -1,17 +1,44 @@ +atomicgo.dev/assert v0.0.2 h1:FiKeMiZSgRrZsPo9qn/7vmr7mCsh5SZyXY4YGYiYwrg= +atomicgo.dev/assert v0.0.2/go.mod h1:ut4NcI3QDdJtlmAxQULOmA13Gz6e2DWbSAS8RUOmNYQ= +atomicgo.dev/cursor v0.2.0 h1:H6XN5alUJ52FZZUkI7AlJbUc1aW38GWZalpYRPpoPOw= +atomicgo.dev/cursor v0.2.0/go.mod h1:Lr4ZJB3U7DfPPOkbH7/6TOtJ4vFGHlgj1nc+n900IpU= +atomicgo.dev/keyboard v0.2.9 h1:tOsIid3nlPLZ3lwgG8KZMp/SFmr7P0ssEN5JUsm78K8= +atomicgo.dev/keyboard v0.2.9/go.mod h1:BC4w9g00XkxH/f1HXhW2sXmJFOCWbKn9xrOunSFtExQ= +atomicgo.dev/schedule v0.1.0 h1:nTthAbhZS5YZmgYbb2+DH8uQIZcTlIrd4eYr3UQxEjs= +atomicgo.dev/schedule v0.1.0/go.mod h1:xeUa3oAkiuHYh8bKiQBRojqAMq3PXXbJujjb0hw8pEU= dario.cat/mergo v1.0.0 h1:AGCNq9Evsj31mOgNPcLyXc+4PNABt905YmuqPYYpBWk= dario.cat/mergo v1.0.0/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= github.com/AdaLogics/go-fuzz-headers v0.0.0-20230811130428-ced1acdcaa24 h1:bvDV9vkmnHYOMsOr4WLk+Vo07yKIzd94sVoIqshQ4bU= github.com/AdaLogics/go-fuzz-headers v0.0.0-20230811130428-ced1acdcaa24/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8= github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 h1:UQHMgLO+TxOElx5B5HZ4hJQsoJ/PvUvKRhJHDQXO8P8= github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= +github.com/MarvinJWendt/testza v0.1.0/go.mod h1:7AxNvlfeHP7Z/hDQ5JtE3OKYT3XFUeLCDE2DQninSqs= +github.com/MarvinJWendt/testza v0.2.1/go.mod h1:God7bhG8n6uQxwdScay+gjm9/LnO4D3kkcZX4hv9Rp8= +github.com/MarvinJWendt/testza v0.2.8/go.mod h1:nwIcjmr0Zz+Rcwfh3/4UhBp7ePKVhuBExvZqnKYWlII= +github.com/MarvinJWendt/testza v0.2.10/go.mod h1:pd+VWsoGUiFtq+hRKSU1Bktnn+DMCSrDrXDpX2bG66k= +github.com/MarvinJWendt/testza v0.2.12/go.mod h1:JOIegYyV7rX+7VZ9r77L/eH6CfJHHzXjB69adAhzZkI= +github.com/MarvinJWendt/testza v0.3.0/go.mod h1:eFcL4I0idjtIx8P9C6KkAuLgATNKpX4/2oUqKc6bF2c= +github.com/MarvinJWendt/testza v0.4.2/go.mod h1:mSdhXiKH8sg/gQehJ63bINcCKp7RtYewEjXsvsVUPbE= +github.com/MarvinJWendt/testza v0.5.2 h1:53KDo64C1z/h/d/stCYCPY69bt/OSwjq5KpFNwi+zB4= +github.com/MarvinJWendt/testza v0.5.2/go.mod h1:xu53QFE5sCdjtMCKk8YMQ2MnymimEctc4n3EjyIYvEY= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= +github.com/alexflint/go-arg v1.6.1 h1:uZogJ6VDBjcuosydKgvYYRhh9sRCusjOvoOLZopBlnA= +github.com/alexflint/go-arg v1.6.1/go.mod h1:nQ0LFYftLJ6njcaee0sU+G0iS2+2XJQfA8I062D0LGc= +github.com/alexflint/go-scalar v1.2.0 h1:WR7JPKkeNpnYIOfHRa7ivM21aWAdHD0gEWHCx+WQBRw= +github.com/alexflint/go-scalar v1.2.0/go.mod h1:LoFvNMqS1CPrMVltza4LvnGKhaSpc3oyLEBUZVhhS2o= +github.com/atomicgo/cursor v0.0.1/go.mod h1:cBON2QmmrysudxNBFthvMtN32r3jxVRIvzkUiF/RuIk= github.com/cenkalti/backoff/v4 v4.2.1 h1:y4OZtCnogmCPw98Zjyt5a6+QwPLGkiQsYW5oUqylYbM= github.com/cenkalti/backoff/v4 v4.2.1/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= +github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= +github.com/containerd/console v1.0.3/go.mod h1:7LqA/THxQ86k76b8c/EMSiaJ3h1eZkMkXar0TQ1gf3U= +github.com/containerd/console v1.0.5 h1:R0ymNeydRqH2DmakFNdmjR2k0t7UPuiOV/N/27/qqsc= +github.com/containerd/console v1.0.5/go.mod h1:YynlIjWYF8myEu6sdkwKIvGQq+cOckRm6So2avqoYAk= github.com/containerd/containerd v1.7.18 h1:jqjZTQNfXGoEaZdW1WwPU0RqSn1Bm2Ay/KJPUuO8nao= github.com/containerd/containerd v1.7.18/go.mod h1:IYEk9/IO6wAPUz2bCMVUbsfXjzw5UNP5fLz4PsUygQ4= github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I= @@ -53,6 +80,12 @@ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gookit/assert v0.1.1 h1:lh3GcawXe/p+cU7ESTZ5Ui3Sm/x8JWpIis4/1aF0mY0= +github.com/gookit/assert v0.1.1/go.mod h1:jS5bmIVQZTIwk42uXl4lyj4iaaxx32tqH16CFj0VX2E= +github.com/gookit/color v1.4.2/go.mod h1:fqRyamkC1W8uxl+lxCQxOT09l/vYfZ+QeiX3rKQHCoQ= +github.com/gookit/color v1.5.0/go.mod h1:43aQb+Zerm/BWh2GnrgOQm7ffz7tvQXEKV6BFMl7wAo= +github.com/gookit/color v1.6.0 h1:JjJXBTk1ETNyqyilJhkTXJYYigHG24TM9Xa2M1xAhRA= +github.com/gookit/color v1.6.0/go.mod h1:9ACFc7/1IpHGBW8RwuDm/0YEnhg3dwwXpoMsmtyHfjs= github.com/grackleclub/log v1.2.0 h1:dh5a++/3mf7YZ9XxyG3CuFpF2AYYgfO9ZdaQnkRsqzU= github.com/grackleclub/log v1.2.0/go.mod h1:duXD7RWceLMX8RUxOpQC/uKb3CYZnAuoT87SMyTn+tI= github.com/grackleclub/postgres v0.0.3 h1:p6dqPIHpWXXSPFX0VKCpBTx8MXpMYod293hnXukiTOA= @@ -71,12 +104,22 @@ github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/klauspost/compress v1.17.4 h1:Ej5ixsIri7BrIjBkRZLTo6ghwrEtHFk7ijlczPW4fZ4= github.com/klauspost/compress v1.17.4/go.mod h1:/dCuZOvVtNoHsyb+cuJD3itjs3NbnF6KH9zAO4BDxPM= +github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= +github.com/klauspost/cpuid/v2 v2.0.10/go.mod h1:g2LTdtYhdyuGPqyWyv7qRAmj1WBqxuObKfj5c0PQa7c= +github.com/klauspost/cpuid/v2 v2.0.12/go.mod h1:g2LTdtYhdyuGPqyWyv7qRAmj1WBqxuObKfj5c0PQa7c= +github.com/klauspost/cpuid/v2 v2.2.3 h1:sxCkb+qR91z4vsqw4vGGZlDgPz3G7gjaLyK3V8y70BU= +github.com/klauspost/cpuid/v2 v2.2.3/go.mod h1:RVVoqg1df56z8g3pUjL/3lE5UfnlrJX8tyFgg4nqhuY= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= +github.com/lithammer/fuzzysearch v1.1.8 h1:/HIuJnjHuXS8bKaiTMeeDlW2/AyIWk2brx1V8LFgLN4= +github.com/lithammer/fuzzysearch v1.1.8/go.mod h1:IdqeyBClc3FFqSzYq/MXESsS4S0FsZ5ajtkr5xPLts4= github.com/lmittmann/tint v1.0.5 h1:NQclAutOfYsqs2F1Lenue6OoWCajs5wJcP3DfWVpePw= github.com/lmittmann/tint v1.0.5/go.mod h1:HIS3gSy7qNwGCj+5oRjAutErFBl4BzdQP6cJZ0NfMwE= github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4= @@ -85,6 +128,9 @@ github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0V github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-runewidth v0.0.13/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/mattn/go-runewidth v0.0.20 h1:WcT52H91ZUAwy8+HUkdM3THM6gXqXuLJi9O3rjcQQaQ= +github.com/mattn/go-runewidth v0.0.20/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= github.com/moby/patternmatcher v0.6.0 h1:GmP9lR19aU5GqSSFko+5pRqHi+Ohk1O69aFiKkVGiPk= @@ -107,8 +153,20 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c h1:ncq/mPwQF4JjgDlrVEn3C11VoGHZN7m8qihwgMEtzYw= github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= +github.com/pterm/pterm v0.12.27/go.mod h1:PhQ89w4i95rhgE+xedAoqous6K9X+r6aSOI2eFF7DZI= +github.com/pterm/pterm v0.12.29/go.mod h1:WI3qxgvoQFFGKGjGnJR849gU0TsEOvKn5Q8LlY1U7lg= +github.com/pterm/pterm v0.12.30/go.mod h1:MOqLIyMOgmTDz9yorcYbcw+HsgoZo3BQfg2wtl3HEFE= +github.com/pterm/pterm v0.12.31/go.mod h1:32ZAWZVXD7ZfG0s8qqHXePte42kdz8ECtRyEejaWgXU= +github.com/pterm/pterm v0.12.33/go.mod h1:x+h2uL+n7CP/rel9+bImHD5lF3nM9vJj80k9ybiiTTE= +github.com/pterm/pterm v0.12.36/go.mod h1:NjiL09hFhT/vWjQHSj1athJpx6H8cjpHXNAK5bUw8T8= +github.com/pterm/pterm v0.12.40/go.mod h1:ffwPLwlbXxP+rxT0GsgDTzS3y3rmpAO1NMjUkGTYf8s= +github.com/pterm/pterm v0.12.83 h1:ie+YmGmA727VuhxBlyGr74Ks+7McV6kT99IB8EU80aA= +github.com/pterm/pterm v0.12.83/go.mod h1:xlgc6bFWyJIMtmLJvGim+L7jhSReilOlOnodeIYe4Tk= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/sergi/go-diff v1.2.0 h1:XU+rvMAioB0UC3q1MFrIQy4Vo5/4VsRDQQXHsEya6xQ= +github.com/sergi/go-diff v1.2.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM= github.com/shirou/gopsutil/v3 v3.23.12 h1:z90NtUkp3bMtmICZKpC4+WaknU1eXtp5vtbQ11DgpE4= github.com/shirou/gopsutil/v3 v3.23.12/go.mod h1:1FrWgea594Jp7qmjHUUPlJDTPgcsb9mGnXDxavtikzM= github.com/shoenig/go-m1cpu v0.1.6 h1:nxdKQNcEB6vzgA2E2bvzKIYRuNj7XNJ4S/aRSwKzFtM= @@ -120,7 +178,10 @@ github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVs github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= @@ -135,8 +196,12 @@ github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFA github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI= github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk= github.com/tklauser/numcpus v0.6.1/go.mod h1:1XfjsgE2zo8GVw7POkMbHENHzVg3GzmoZ9fESEdAacY= +github.com/xo/terminfo v0.0.0-20210125001918-ca9a967f8778/go.mod h1:2MuV+tbUrU1zIOPMxZ5EncGwgmMJsa+9ucAQZXxsObs= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= github.com/yusufpapurcu/wmi v1.2.3 h1:E1ctvB7uKFMOJw3fdOW32DwGE9I7t++CRUEMKvFoFiw= github.com/yusufpapurcu/wmi v1.2.3/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= @@ -176,39 +241,67 @@ go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= +golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E= +golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE= golang.org/x/image v0.39.0 h1:skVYidAEVKgn8lZ602XO75asgXBgLj9G/FE3RbuPFww= golang.org/x/image v0.39.0/go.mod h1:sIbmppfU+xFLPIG0FoVUTvyBMmgng1/XAMhQ2ft0hpA= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0= golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20211013075003-97ac67df715c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220319134239-a9b59b0215f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210220032956-6a3ed077a48d/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210615171337-6886f2dfbf5b/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU= golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= @@ -217,6 +310,8 @@ golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGm golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= @@ -232,9 +327,13 @@ google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07 google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gotest.tools/v3 v3.5.1 h1:EENdUnS3pdur5nybKYIh2Vfgc8IUNBjxDPSjtiJcOzU= diff --git a/main.go b/main.go index 51ecd8d..e10738d 100644 --- a/main.go +++ b/main.go @@ -118,6 +118,9 @@ func main() { mux.Handle("/{game_id}/qr", logMW(rateMW(http.HandlerFunc(qrHandler)))) mux.Handle("/{game_id}/data/{topic}", logMW(rateMW(http.HandlerFunc(dataHandler)))) mux.Handle("/{game_id}/action/{action}", logMW(rateMW(http.HandlerFunc(actionHandler)))) + // feedback.go: public POST to submit, admin-only GET/PATCH/DELETE to triage + mux.Handle("/bugs", logMW(rateMW(http.HandlerFunc(bugsHandler)))) + mux.Handle("/suggestions", logMW(rateMW(http.HandlerFunc(suggestionsHandler)))) // RULETTE_PG_URL is a postgres connection string, e.g.: // postgres://user@host/rulette or postgres://user:pass@host:5432/db?sslmode=require @@ -179,6 +182,12 @@ func main() { log.Info("metrics initialized") } go cacheJanitor(ctx, &cache) + // RULETTE_ADMIN_PASSWORD guards the bug/suggestion triage endpoints. When + // unset, those admin endpoints fail closed (see middleware.go). + adminPassword = os.Getenv("RULETTE_ADMIN_PASSWORD") + if adminPassword == "" { + log.Warn("RULETTE_ADMIN_PASSWORD unset; bug/suggestion admin endpoints disabled") + } port := os.Getenv("RULETTE_PORT") if port == "" { port = os.Getenv("PORT") diff --git a/middleware.go b/middleware.go index edf27df..a50afb0 100644 --- a/middleware.go +++ b/middleware.go @@ -1,6 +1,7 @@ package main import ( + "crypto/subtle" "net" "net/http" "sync" @@ -9,6 +10,25 @@ import ( "golang.org/x/time/rate" ) +// adminHeader carries the admin password on requests to the moderation +// endpoints (GET/PATCH/DELETE on /bugs and /suggestions). +const adminHeader = "X-Rulette-Admin" + +// adminPassword is the expected value of adminHeader, read once from +// RULETTE_ADMIN_PASSWORD at startup. When empty, admin endpoints fail closed. +var adminPassword string + +// isAdmin reports whether the request carries the correct admin password. It +// fails closed when no password is configured, and uses a constant-time +// compare so a wrong guess leaks no timing signal. +func isAdmin(r *http.Request) bool { + if adminPassword == "" { + return false + } + got := r.Header.Get(adminHeader) + return subtle.ConstantTimeCompare([]byte(got), []byte(adminPassword)) == 1 +} + // logMW logs every incoming request. func logMW(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { diff --git a/static/css/style.css b/static/css/style.css index 3c8a785..ead32f2 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -752,6 +752,33 @@ dialog fieldset { background: var(--color-backdrop); } +/* feedback dialogs (suggest a rule / report a bug) */ +.feedback-field { + display: flex; + flex-direction: column; + gap: .25em; + text-align: left; +} + +.feedback-field span { + font-size: .85em; +} + +.feedback-field input, +.feedback-field select, +.feedback-field textarea { + width: 100%; +} + +.feedback-guidelines { + font-size: .85em; + opacity: .8; +} + +.feedback-thanks { + text-align: center; +} + /* table bar (inside header card) */ .table-bar { position: relative; diff --git a/static/html/tmpl.footer.html b/static/html/tmpl.footer.html index 69510fe..e4376e5 100644 --- a/static/html/tmpl.footer.html +++ b/static/html/tmpl.footer.html @@ -30,8 +30,8 @@