From 5ed4f697f9f41f1c86822707aaeb0cb0f298ebb5 Mon Sep 17 00:00:00 2001
From: turk
Date: Sat, 13 Jun 2026 14:03:15 -0600
Subject: [PATCH] first draft: feedback to db
---
README.md | 20 +++
bin/triage | 16 ++
cmd/triage/main.go | 333 +++++++++++++++++++++++++++++++++++
db/queries/bugs.sql | 24 +++
db/queries/suggestions.sql | 24 +++
db/schema.sql | 30 ++++
db/schema_down.sql | 2 +
db/sqlc.yaml | 2 +
db/sqlc/bugs.sql.go | 134 ++++++++++++++
db/sqlc/models.go | 23 +++
db/sqlc/suggestions.sql.go | 119 +++++++++++++
feedback.go | 235 ++++++++++++++++++++++++
feedback_test.go | 204 +++++++++++++++++++++
go.mod | 12 ++
go.sum | 99 +++++++++++
main.go | 9 +
middleware.go | 20 +++
static/css/style.css | 27 +++
static/html/tmpl.footer.html | 73 +++++++-
static/js/feedback.js | 36 ++++
20 files changed, 1439 insertions(+), 3 deletions(-)
create mode 100755 bin/triage
create mode 100644 cmd/triage/main.go
create mode 100644 db/queries/bugs.sql
create mode 100644 db/queries/suggestions.sql
create mode 100644 db/sqlc/bugs.sql.go
create mode 100644 db/sqlc/suggestions.sql.go
create mode 100644 feedback.go
create mode 100644 feedback_test.go
create mode 100644 static/js/feedback.js
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 @@ credit
This project is completely unaffiliated with Dropout. Be cool, Sam!
+
+
+
+
{{ if inGame . }}