diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index df6b503..097d190 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -3,9 +3,6 @@ name: CI on: workflow_call: pull_request: - push: - branches: - - main permissions: contents: read diff --git a/README.md b/README.md index 73b8fd9..edb4a1c 100644 --- a/README.md +++ b/README.md @@ -1,60 +1,14 @@ # Augur -## Overview - Discord bot for requesting movies and TV shows through [Seerr](https://github.com/seerr-team/seerr). -## Quick start - -You need a Discord server where you can install an app, a reachable Seerr instance, Docker with Compose, and a Seerr API -key. Create the key in Seerr's administration/settings area; the exact menu label can vary between Seerr releases. - -Clone or download this repository, then open its directory. With Git: - -```sh -git clone https://github.com/mayvqt/Augur.git -cd Augur -``` - -1. In the [Discord Developer Portal](https://discord.com/developers/applications), create an application and add a bot. - Install it with the `bot` and `applications.commands` scopes. Grant the bot View Channel, Send Messages, Embed Links, - and Manage Messages; administrators also need Manage Server to configure approval messages. -2. Copy the example environment file and fill in the Discord token, Seerr API key, Seerr URLs, and any optional settings: - - ```sh - cp .env.example .env - $EDITOR .env - ``` - -3. Start Augur: - - ```sh - docker compose up -d - ``` - -4. Check startup with `docker compose logs -f augur`. In Discord, run `/link`, open the settings page, and save the - Discord ID shown by the bot in your Seerr notification settings. Then use `/request query:` to search and - submit a request. Server administrators can use `/approvals enable channel:#approvals` for approval cards. - -`AUGUR_SEERR_BASE_URL` must be reachable from the Augur container. The sample Seerr URL is intentionally a host -placeholder: this Compose file starts Augur only, so `http://seerr:5055` works only when a container named `seerr` is on -the same Docker network. For a separate Seerr installation, use its LAN hostname or IP and port instead. The public URL -is the browser-facing address that Augur sends to users for account linking. - -Approval cards cover all Seerr request sources, notify linked requesters of the decision, and are removed two minutes -after approval or decline. State is stored in `./data`. - -See [commands and approval permissions](docs/features.md) before enabling approval cards, and -[upgrades and backups](docs/releases.md#upgrading-and-rolling-back) before updating an existing installation. - ## Documentation -- [Configuration](docs/configuration.md) -- [Features](docs/features.md) -- [Unraid](docs/unraid.md) -- [Releases](docs/releases.md) -- [Security](SECURITY.md) -- [Development](docs/development.md) +- Get started: [Setup](docs/setup.md) and [Configuration](docs/configuration.md) +- Run Augur: [Operations](docs/development/operations.md) and [Unraid](docs/unraid.md) +- Use the bot: [Capabilities](docs/capabilities.md) and [Commands and approvals](docs/features.md) +- Contribute: [Development](docs/development/README.md) and [Security](SECURITY.md) +- Follow releases: [Release notes](docs/releases.md) - [License](LICENSE) - [Issues](https://github.com/mayvqt/Augur/issues) diff --git a/docs/capabilities.md b/docs/capabilities.md new file mode 100644 index 0000000..35dc3ab --- /dev/null +++ b/docs/capabilities.md @@ -0,0 +1,14 @@ +# Capabilities + +Augur lets Discord members search Seerr, preview a movie or show, choose TV +seasons, submit a request, and review their recent requests without leaving +Discord. Members can opt in or out of direct messages for approvals, declines, +and availability. + +Server administrators can send pending Seerr requests to a Discord channel as +approval cards. Those cards also cover requests created outside Discord. Augur +polls Seerr for request decisions and availability, remembers delivery state +across restarts, and can expose private health, readiness, and metrics endpoints. + +For exact commands and permissions, see [Commands and approvals](features.md). +For deployment settings, see [Configuration](configuration.md). diff --git a/docs/development.md b/docs/development.md index 4674bf1..f09fccf 100644 --- a/docs/development.md +++ b/docs/development.md @@ -1,26 +1,11 @@ # Development -Use the Go version declared in [`go.mod`](../go.mod). Run automated checks from -the repository root: - -```sh -gofmt -l ./cmd ./internal -go mod tidy -diff -go test ./... -go vet ./... -go test -race ./... -CGO_ENABLED=0 go build ./cmd/augur -``` - -The formatting command should print no paths. Tests use temporary SQLite files -and fake clients/transports; they do not need live Discord or Seerr credentials. -[CI](../.github/workflows/ci.yml) also runs static analysis, dependency vulnerability -checks, and a container build with entrypoint smoke tests. Image publishing reuses -these same gates. CI pins action commits and analysis tool versions; update the -pins deliberately and validate their checks before release. - -For manual integration testing, follow the [setup instructions](../README.md#quick-start) -with a dedicated Discord application/server, Seerr instance, and isolated data. -`docker compose up --build` starts a live bot, reads local configuration/secrets, -and mounts `./data`; it is not an automated test. Stop the test deployment with -`docker compose down` when finished. +Start with the [development task index](development/README.md). It links to the +code map, architecture, storage, validation, operations, and documentation +guides. + +For a manual integration test, follow the [setup guide](setup.md) +with a dedicated Discord application and server, a test Seerr instance, and an +isolated data directory. `docker compose up --build` starts a real bot and uses +the secrets in your local environment; stop it with `docker compose down` when +you finish. diff --git a/docs/development/README.md b/docs/development/README.md new file mode 100644 index 0000000..7f3ccef --- /dev/null +++ b/docs/development/README.md @@ -0,0 +1,13 @@ +# Development task index + +Pick the page that matches the work: + +- [Codebase map](codebase-map.md) — packages, entry points, and ownership. +- [Architecture](architecture.md) — Discord, Seerr, background work, and trust boundaries. +- [Data](data.md) — SQLite tables, migrations, identifiers, and retention. +- [Validation](validation.md) — focused checks, manual interaction checks, and CI. +- [Operations](operations.md) — deployment, backups, health, releases, and rollback. +- [Documentation conventions](documentation-conventions.md) — where public information belongs. + +The repository [README](../../README.md) indexes user and operator guides. +Security-sensitive work also needs [Security](../../SECURITY.md). diff --git a/docs/development/architecture.md b/docs/development/architecture.md new file mode 100644 index 0000000..fbc6c2d --- /dev/null +++ b/docs/development/architecture.md @@ -0,0 +1,36 @@ +# Architecture + +`cmd/augur` loads configuration and creates an `app.Runner`. The runner owns +the Discord connection, Seerr client, SQLite store, optional health server, and +background monitor. Discord handlers ask the runner to perform application +operations; protocol details stay in `internal/discordbot` and `internal/seer`, +and durable state stays in `internal/storage`. + +## Sources of truth + +- Discord owns server membership, channel permissions, interaction identity, + messages, and component events. +- Seerr owns linked users, request permissions, quotas, request decisions, and + media availability. +- SQLite owns Augur's subscriptions, delivery decisions, approval-channel + settings, rendered-message references, and notification preferences. +- `config.json` supplies defaults; environment variables override supported + fields at startup. [Configuration](../configuration.md) is the public contract. + +Commands and component IDs are public interaction contracts. Seerr endpoints and +Discord payloads are external contracts: check their current official +documentation, then cover changes with fake clients or transports before using a +dedicated live test environment. + +## Trust and concurrency + +The Discord token, Seerr API key, user IDs, guild/channel IDs, request details, +and database are sensitive. Approval actions use the configured Seerr API key, +so the Manage Server or Administrator permission check is a security boundary. +Account-name matching must never replace explicit Seerr linking. + +The runner serializes lifecycle changes and approval reconciliation separately. +Background polling, Discord callbacks, and shutdown can overlap. Keep operations +idempotent, make state changes durable before sending notifications where +practical, bound retries and waits, and honor context cancellation. SQLite uses +one connection and WAL mode; do not add parallel database owners. diff --git a/docs/development/codebase-map.md b/docs/development/codebase-map.md new file mode 100644 index 0000000..a33fee4 --- /dev/null +++ b/docs/development/codebase-map.md @@ -0,0 +1,17 @@ +# Codebase map + +| Path | What lives here | +| --- | --- | +| `cmd/augur` | Process entry point, signal handling, configuration loading, and logging setup. | +| `internal/app` | Application lifecycle, request/approval coordination, polling, health endpoints, and metrics. | +| `internal/config` | JSON and environment configuration, defaults, normalization, and validation. | +| `internal/discordbot` | Discord session lifecycle, slash commands, buttons, previews, approval cards, formatting, and delivery cache. | +| `internal/seer` | Seerr HTTP client, account links, requests, status, and user lookup. | +| `internal/storage` | SQLite schema, migrations, subscriptions, approvals, notification preferences, and scans. | +| `internal/safelog` | Log redaction and safe error output. | +| `Dockerfile`, `docker-entrypoint.sh`, `docker-compose.yml` | Container build, startup, ownership, and example deployment. | +| `unraid` | Unraid application template. | +| `.github/workflows` | CI, dependency review, image publishing, and release announcements. | + +Tests sit beside the packages they cover. Update this map when a package takes +on a new responsibility or a top-level area is added. diff --git a/docs/development/data.md b/docs/development/data.md new file mode 100644 index 0000000..ab70fde --- /dev/null +++ b/docs/development/data.md @@ -0,0 +1,27 @@ +# Data and migrations + +`internal/storage/store.go` is the schema and migration source of truth. The +`schema_migrations` ledger records applied versions. Current tables store: + +- request subscriptions and availability completion; +- approval settings per Discord server; +- approval-message IDs and decisions; +- notification preferences per Discord user; and +- decision-notification deduplication. + +Discord, guild, channel, message, Seerr request, and media IDs are identifiers, +not proof of authorization. Re-check permissions at the action boundary. The +database may reveal account relationships and request history, so treat it and +its backups as private. + +Migrations run in transactions and move forward only. Add a new numbered +migration; never edit or delete a version that may already be applied. Prefer an +expand/contract sequence when a rolling transition needs old and new code to +coexist. Startup rejects a migration version newer than the binary understands +instead of risking changes to a future schema. + +Each schema change needs tests for a fresh database, upgrade from every affected +supported version, preservation of existing rows, repeat startup, and failure +behavior. Keep SQL and migration compatibility in `internal/storage`. Restore +the full data directory for rollback; an older binary may not understand a newer +schema. diff --git a/docs/development/documentation-conventions.md b/docs/development/documentation-conventions.md new file mode 100644 index 0000000..8f0357c --- /dev/null +++ b/docs/development/documentation-conventions.md @@ -0,0 +1,27 @@ +# Documentation conventions + +Write for the person using the page. Keep installation steps in +[Setup](../setup.md), settings in [Configuration](../configuration.md), command help in [Features](../features.md), shipped scope in +[Capabilities](../capabilities.md), release changes in [Releases](../releases.md), +and contributor details in this directory. Link instead of repeating a procedure. +Do not add empty roadmap or known-issues pages; add one only when there is accepted +future work or a reproduced issue worth publishing. + +For a risky integration change, keep the agreed design stable while work is in +progress and record test evidence separately. List the external operations, +check Discord and Seerr behavior against their official documentation, cover it +with deterministic fakes, and make any live test clearly opt-in. + +## Documentation impact map + +| If you change... | Also update... | +| --- | --- | +| A command, message, approval, or notification | [Features](../features.md), [Capabilities](../capabilities.md) when scope changes, and the README if navigation changes | +| Configuration or setup | [Setup](../setup.md), [Configuration](../configuration.md), Unraid docs when affected, and their nearest index | +| Package ownership or architecture | [Codebase map](codebase-map.md), [Architecture](architecture.md), and this index | +| Schema, identifiers, or retained state | [Data](data.md), [Operations](operations.md) when upgrades change, and this index | +| Tests, tools, interaction states, or CI | [Validation](validation.md) and this index | +| Credentials, permissions, or disclosure | [Security](../../SECURITY.md), the relevant architecture/data/operations page, and the nearest index | +| Deployment, backup, health, rollback, or release | [Operations](operations.md), [Releases](../releases.md), and public setup/configuration pages that changed | + +Add new documentation domains here and to the nearest index. diff --git a/docs/development/operations.md b/docs/development/operations.md new file mode 100644 index 0000000..0b9dc95 --- /dev/null +++ b/docs/development/operations.md @@ -0,0 +1,34 @@ +# Operations + +Run Augur with the smallest practical permissions: one writable `/data` +mount, outbound access to Discord and Seerr, and no public inbound port unless +the optional health server is deliberately exposed to a private monitoring +network. Keep the Discord token, Seerr API key, `.env`, `config.json`, data +directory, and diagnostics private. Redact IDs and request details when they are +not needed to diagnose a problem. + +## Back up, upgrade, and roll back + +1. Record the current immutable image tag or digest and read the release notes. +2. Stop Augur, then copy the entire data directory and deployment configuration + to restricted off-host storage. +3. Restore that backup into a separate test location and confirm the bot starts, + retains approval settings and subscriptions, and can read the database. +4. Deploy a published version tag or digest, then check logs, a harmless command, + a synthetic request, and any configured approval channel. + +`latest` moves and is not a rollback reference. Migrations run at startup and +are forward-only. To roll back across a schema change, stop Augur, restore the +matching pre-upgrade data, and start the previous immutable image. Local state +created after the backup is lost; requests already sent to Seerr are not undone. + +When enabled, `/healthz` reports that the health process is running, `/readyz` +checks readiness and SQLite access, and `/metrics` exposes counters. These +endpoints are unauthenticated, so bind them privately. A release is complete only +after the exact revision passes CI, the published artifact is tied to that +revision, and post-deploy health and Discord/Seerr checks succeed. + +The release sequence is: update [release notes](../releases.md), run the final CI +gate, create an annotated `vX.Y.Z` tag, publish the matching GitHub release, +verify the versioned image, and announce any operator action. Repairs that change +Discord messages, Seerr requests, or SQLite state must be explicit and opt-in. diff --git a/docs/development/validation.md b/docs/development/validation.md new file mode 100644 index 0000000..8a5c24e --- /dev/null +++ b/docs/development/validation.md @@ -0,0 +1,33 @@ +# Validation + +Start with the narrowest useful check: + +| Change | Focused check | +| --- | --- | +| Application coordination or health | `go test ./internal/app` | +| Discord commands, cards, or formatting | `go test ./internal/discordbot` | +| Seerr protocol | `go test ./internal/seer` | +| SQLite or migrations | `go test ./internal/storage` | +| Configuration or redaction | `go test ./internal/config ./internal/safelog` | +| CLI startup | `go test ./cmd/augur` | +| Release binary | `CGO_ENABLED=0 go build -trimpath -o /tmp/augur ./cmd/augur` | + +Run `gofmt -w` on touched Go files. For performance work, include a +representative before/after benchmark or trace and a regression limit. + +Discord changes also need a manual pass in a dedicated server with synthetic +requests. Check command discovery, keyboard use in Discord clients, ephemeral and +public responses, missing links, empty search/results, API errors, expired +components, permission denial, DM-disabled users, and shutdown during polling. +Check narrow and desktop Discord layouts when card content changes. Live Discord +or Seerr tests are opt-in and must never use production tokens or data. + +Use the Go version in `go.mod`. Do not install tools or download dependencies +without approval. Reuse a module cache only when `go.sum`, the Go toolchain, +OS/architecture, and installed module tree match the revision; otherwise use a +clean locked environment or stop. + +The final gate is the complete `CI` workflow in `.github/workflows/ci.yml` on +the exact revision. It checks formatting, `go mod tidy -diff`, whitespace, +tests, vet, the race detector, pinned Staticcheck and govulncheck versions, the +release build, Docker build, entrypoint behavior, and runtime ownership. diff --git a/docs/releases.md b/docs/releases.md index ac28268..73dff39 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -7,34 +7,9 @@ tag is published. Pin production deployments to a published semver tag; use `lat - Standardize project documentation and harden release automation. +- Refuse to open database state created by a newer Augur version. - Reject zero-padded root user/group IDs in container configuration. - Clarify setup, account linking, approval permissions, and backup procedures. -## Release procedure - -Release checklist: - -1. Preflight: run the full checks in [`development.md`](development.md), review migration notes, and build and - smoke-test the image. -2. Tag: create an annotated semver tag, for example `git tag -a v1.2.3 -m "v1.2.3"`. -3. Publish: push the tag and create the matching GitHub release, then verify the published container image. -4. Announce the release with upgrade notes and supported changes. - -## Upgrading and rolling back - -1. Read the release notes and record the image tag you currently use. Choose an - image tag listed on the [published release](https://github.com/mayvqt/Augur/releases); - Git tags include a `v` prefix, while versioned container tags omit it. -2. Stop Augur with `docker compose stop augur`, then back up the entire `data/` - directory and your deployment configuration. Keep the backup private: it - contains account IDs, request state, and possibly credentials. -3. Set `image:` in `docker-compose.yml` to the chosen container tag, run - `docker compose pull augur`, then `docker compose up -d augur`. -4. Check `docker compose logs --tail=100 augur`, then verify `/request` and any - approval channel you use. - -Database migrations run on startup. Do not edit or delete applied migrations. -An older image may not support a newer database schema. To roll back, stop Augur, -restore the matching pre-upgrade data backup when required by the release notes, -and start the previous image tag. Restoring a backup loses local changes made -since that backup; Seerr requests already submitted are not undone. +Release and upgrade procedures live in [Operations](development/operations.md). +This page is only for changes that shipped or are ready for the next release. diff --git a/docs/setup.md b/docs/setup.md new file mode 100644 index 0000000..500dcec --- /dev/null +++ b/docs/setup.md @@ -0,0 +1,44 @@ +# Setup + +You need a Discord server where you can install an app, a reachable Seerr +instance, Docker with Compose, and a Seerr API key. + +1. Create an application in the + [Discord Developer Portal](https://discord.com/developers/applications) and + add a bot. Install it with the `bot` and `applications.commands` scopes. + Grant View Channel, Send Messages, Embed Links, and Manage Messages. +2. Clone the repository and open it: + + ```sh + git clone https://github.com/mayvqt/Augur.git + cd Augur + ``` + +3. Copy the example environment file and add the Discord token, Seerr API key, + internal Seerr URL, and public Seerr URL: + + ```sh + cp .env.example .env + $EDITOR .env + ``` + +4. Start Augur and check its logs: + + ```sh + docker compose up -d + docker compose logs -f augur + ``` + +5. Run `/link` in Discord. Open the Seerr settings link and save the Discord ID + shown by the bot in your Seerr notification settings. Then try + `/request query:<title>`. + +`AUGUR_SEERR_BASE_URL` must be reachable from inside the Augur container. This +Compose file starts Augur only, so `http://seerr:5055` works only when a +container named `seerr` shares its Docker network. Otherwise, use the Seerr +host's LAN name or address. `AUGUR_SEERR_PUBLIC_URL` is the browser-facing URL +sent to users. + +State is stored in `./data`. Before enabling approval cards, read the +[permissions and behavior](features.md#approval-cards). Before an upgrade, follow +the [backup and rollback guide](development/operations.md). diff --git a/internal/storage/store.go b/internal/storage/store.go index d7a03c1..573a90d 100644 --- a/internal/storage/store.go +++ b/internal/storage/store.go @@ -55,6 +55,8 @@ type NotificationPreferences struct { const subscriptionColumnList = "request_id, discord_id, title, media_type, overview, poster_path, release_year, language, rating, created_at, completed_at" +const currentMigrationVersion = 5 + func Open(path string) (*Store, error) { dbPath, err := storagePath(path) if err != nil { @@ -448,6 +450,13 @@ func (s *Store) initialize(ctx context.Context) error { if _, err := s.db.ExecContext(ctx, `CREATE TABLE IF NOT EXISTS schema_migrations (version INTEGER PRIMARY KEY, applied_at TEXT NOT NULL)`); err != nil { return fmt.Errorf("create schema ledger: %w", err) } + var latestVersion sql.NullInt64 + if err := s.db.QueryRowContext(ctx, `SELECT max(version) FROM schema_migrations`).Scan(&latestVersion); err != nil { + return fmt.Errorf("inspect schema version: %w", err) + } + if latestVersion.Valid && latestVersion.Int64 > currentMigrationVersion { + return fmt.Errorf("storage schema version %d is newer than supported version %d", latestVersion.Int64, currentMigrationVersion) + } var migrationCount int if err := s.db.QueryRowContext(ctx, `SELECT count(*) FROM schema_migrations`).Scan(&migrationCount); err != nil { return fmt.Errorf("inspect schema ledger: %w", err) @@ -463,7 +472,7 @@ func (s *Store) initialize(ctx context.Context) error { } } } - for version := 1; version <= 5; version++ { + for version := 1; version <= currentMigrationVersion; version++ { var applied int if err := s.db.QueryRowContext(ctx, `SELECT count(*) FROM schema_migrations WHERE version = ?`, version).Scan(&applied); err != nil { return fmt.Errorf("inspect migration %d: %w", version, err) diff --git a/internal/storage/store_test.go b/internal/storage/store_test.go index bf4d12f..22d83c0 100644 --- a/internal/storage/store_test.go +++ b/internal/storage/store_test.go @@ -6,6 +6,7 @@ import ( "os" "path/filepath" "runtime" + "strings" "testing" "time" @@ -34,16 +35,45 @@ func TestFreshDatabaseRecordsOrderedMigrations(t *testing.T) { if err := rows.Err(); err != nil { t.Fatal(err) } - if got := len(versions); got != 5 { - t.Fatalf("migration versions = %v, want 1..5", versions) + if got := len(versions); got != currentMigrationVersion { + t.Fatalf("migration versions = %v, want 1..%d", versions, currentMigrationVersion) } for i, version := range versions { if version != i+1 { - t.Fatalf("migration versions = %v, want 1..5", versions) + t.Fatalf("migration versions = %v, want 1..%d", versions, currentMigrationVersion) } } } +func TestOpenRejectsNewerMigrationVersion(t *testing.T) { + path := filepath.Join(t.TempDir(), "state.db") + db, err := sql.Open("sqlite", path) + if err != nil { + t.Fatal(err) + } + if _, err := db.Exec(` + CREATE TABLE schema_migrations (version INTEGER PRIMARY KEY, applied_at TEXT NOT NULL); + INSERT INTO schema_migrations(version, applied_at) VALUES (?, '2026-01-01T00:00:00Z') + `, currentMigrationVersion+1); err != nil { + db.Close() + t.Fatal(err) + } + if err := db.Close(); err != nil { + t.Fatal(err) + } + + _, err = Open(path) + if err == nil { + t.Fatal("Open accepted a database with a newer migration version") + } + if !strings.Contains(err.Error(), "newer than supported") { + t.Fatalf("Open error = %q, want a clear unsupported-version error", err) + } + if strings.Contains(err.Error(), path) { + t.Fatalf("Open error exposed database path: %q", err) + } +} + func TestExistingMainDatabaseUpgradesWithoutLosingRows(t *testing.T) { path := filepath.Join(t.TempDir(), "state.db") db, err := sql.Open("sqlite", path) @@ -100,8 +130,8 @@ func TestExistingMainDatabaseUpgradesWithoutLosingRows(t *testing.T) { if err := store.db.QueryRow(`SELECT count(*) FROM schema_migrations`).Scan(&versions); err != nil { t.Fatal(err) } - if versions != 5 { - t.Fatalf("migration count = %d, want 5", versions) + if versions != currentMigrationVersion { + t.Fatalf("migration count = %d, want %d", versions, currentMigrationVersion) } }