diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..e74498b --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,117 @@ +# Working in shieldeddotdev + +`shieldeddotdev` is a small, single-binary Go web service for creating and serving dynamic README badges. It uses MySQL for persisted users and badges, server-rendered `templ` pages, and a TypeScript browser application bundled into a static ES module. + +Read [ARCHITECTURE.md](ARCHITECTURE.md) before changing routing, persistence, authentication, or the public badge/API contracts. It documents the behavior implemented by the current code, including noteworthy constraints. + +## Repository map + +| Path | Responsibility | +| --- | --- | +| `cmd/shielded/` | Executable entry point, configuration flags, host-based routing, local/production serving, and build metadata. | +| `*.go` at the repository root | HTTP handlers, authentication, SVG badge rendering, color normalization, and embedded static assets. | +| `model/` | MySQL-backed `UserMapper` and `ShieldMapper`; SQL is intentionally kept here rather than in a separate repository layer. | +| `pages/*.templ` | Authoritative `templ` page definitions and shared page components. | +| `pages/*_templ.go` | Generated Go from `templ`; do not hand-edit. | +| `schema/` | MariaDB/MySQL baseline schema and forward-only, numbered SQL migrations. | +| `ts/` | TypeScript source for the home API examples and authenticated dashboard. | +| `scss/` | Authoritative Sass stylesheets. | +| `static/` | Browser assets. `static/main.js` and `static/style/style.css` are generated from `ts/` and `scss/`; other static assets are source assets. | +| `Makefile` | Canonical build, generation, lint, local-debug, clean, and release targets. | +| `Caddyfile.local`, `run-local.sh` | Optional local HTTPS proxy and lifecycle script, including a disposable MySQL container. | + +## Development setup + +The module declares Go `1.26.2`; use a compatible installed Go toolchain. The frontend tools are development dependencies in `package.json`. + +```sh +npm install +go generate ./... +make build +``` + +`make build` already runs generation and makes the generated CSS and JavaScript prerequisites. It creates a `shielded` executable with build metadata embedded through linker flags. + +For the configured local mode: + +```sh +make debug +``` + +This cleans generated assets and binaries, rebuilds with the `debug` build tag, changes the three configured host names to `local.shielded.dev`, `api.local.shielded.dev`, and `img.local.shielded.dev`, then runs `shielded-debug` on `:8686`. The server still needs a reachable MySQL instance; the default DSN is shown by `./shielded -help`. + +`Caddyfile.local` is an optional local HTTPS front end for `make debug`. It maps the canonical production hosts and the local-debug hosts to the one backend while rewriting the upstream `Host` header to the host expected by the Go router. Follow its `/etc/hosts` and local-CA comments before using it. + +After the Caddy host entries and local CA are configured, `./run-local.sh` removes any prior `shieldeddotdev-local-mysql` container, starts a fresh `mysql:8.4` container, applies every numbered `schema/*.sql` migration in order, ensures the debug user exists, builds the debug executable, starts it in the background, and runs Caddy in the foreground. Ctrl-C stops Caddy, the Go process, and the container. + +Useful focused commands: + +```sh +go generate ./... # regenerate templ Go files +npx sass scss:static/style # regenerate static/style/style.css +npx rollup --config rollup.config.mjs # regenerate static/main.js +make lint # runs TSLint with --fix +go test ./... # compile/test all Go packages +``` + +`make clean` removes binaries, generated frontend assets, release artifacts, and emitted JavaScript below `ts/`; do not run it merely to inspect a dirty worktree. + +## Change rules + +### Generated files and assets + +- Edit `.templ` files, never `pages/*_templ.go`. Regenerate with `go generate ./...` and include the matching generated files when a template change changes them. +- Edit `ts/`, never `static/main.js`. Regenerate through Rollup and include `static/main.js` when its source changes. +- Edit `scss/`, never `static/style/style.css`. Regenerate through Sass and include the output when styles change. +- The release binary embeds `static/` (`static.go`). A frontend or stylesheet change is therefore part of the binary release. + +### Review feedback + +- When addressing or declining a Copilot review comment, reply in its GitHub thread with the implemented change or the reason it does not apply. + +### Go + +- Run `gofmt` on each changed Go file and preserve the existing package split: root package `shieldeddotdev` contains HTTP/application behavior, while `model` contains database access. +- Keep route setup in `cmd/shielded/main.go`; keep request parsing/validation and HTTP responses in the applicable handler. +- Use parameterized SQL through `database/sql`, as the mappers do. Shield reads and writes must preserve the ownership checks used by the dashboard handlers. +- Do not change public JSON field names or badge URL shapes casually. The dashboard, embedded Markdown, and external clients depend on the exported Go/TypeScript field names and host-specific routes. +- Preserve the `NormalizeColor` path for the public update API and static badge route when adding accepted color input. It resolves named badge colors and validates 3- or 6-digit hexadecimal colors. +- User-level API tokens are issued with `crypto/rand`, prefix values with `sdu_`, store only a one-way hash, return the plaintext only at creation, and scope dashboard reads/deletes by the authenticated user. The API host receives both user and per-shield values as `Authorization: token `; a non-empty form field `shield_key` selects a user-token update/create and is required for `sdu_` values, while a blank or absent value requires a per-shield token. Valid user-token authentication records `date_last_used`. + +### Database schema and migrations + +- `schema/000_BASELINE.sql` is the dump-derived baseline for a new database. It contains `DROP TABLE` statements, so do not apply it to a database that must retain data. +- Add every schema change as a new forward-only SQL file in `schema/`, using the next zero-padded numeric prefix in application order (for example, `001_add_shield_index.sql`). Never renumber, edit, or reorder an existing migration once it may have been applied. +- Give every new database column a `COMMENT` that states its purpose. +- Keep migration SQL compatible with the baseline's MariaDB/MySQL dialect and preserve the `users`/`shields` foreign-key relationship unless the corresponding application behavior changes together. +- The Go binary does not run migrations. `run-local.sh` is a local-only clean-database runner that applies every numbered migration; production deployments must use an equivalent controlled migration process. + +### TypeScript + +- The authenticated dashboard is a Preact SPA mounted from `ts/Dashboard.tsx`. Keep shield forms and user-token management declarative components with local state; do not reintroduce manual DOM attach/detach or controller-event lifecycles for dashboard behavior. +- Requests go through `ts/api/request.ts`. It supplies the leading slash and `withCredentials`; use it rather than duplicating XMLHttpRequest handling. +- Keep browser/API types aligned with the JSON emitted from Go. The current API uses Go-style exported field names such as `ShieldID`, `PublicID`, `Title`, and `Secret`. +- The configured TypeScript compiler is strict and rejects unused locals/parameters. Preact JSX uses the automatic runtime configured in `tsconfig.json`; maintain tab indentation and run `make lint` after TypeScript changes. + +## Verification expectations + +For documentation-only changes, verify links and inspect `git diff --check`. For code changes, run the narrowest relevant generator plus: + +```sh +go test ./... +make lint +``` + +When a change affects routing, authentication, database writes, or generated assets, also build with `make build` (or `make debug` when testing locally). Do not claim a browser, OAuth, or MySQL integration test passed unless it was actually exercised against the required external service. + +## Configuration and operational boundaries + +- Runtime flags: `-dsn`, `-client-id`, `-client-secret`, `-run-local`, `-local-addr`, and `-log-source`. +- Production uses CertMagic to obtain/serve HTTPS for `shielded.dev`, `www.shielded.dev`, `api.shielded.dev`, and `img.shielded.dev`. Local mode serves HTTP directly and supplies a debug user instead of GitHub OAuth. +- `schema/000_BASELINE.sql` is the current source of truth for provisioning a new MariaDB/MySQL database. Future database changes are ordered migrations in that same directory; the application does not run them automatically. +- Authentication signing keys are generated in process at startup. Restarting the service invalidates existing auth cookies. Treat the GitHub OAuth client secret, database DSN, and badge secrets as sensitive. +- Do not add credentials, production DSNs, generated release archives, or `node_modules/` to version control. + +## Current worktree hygiene + +Preserve unrelated user changes. In particular, inspect `git status --short` before generating assets because frontend output may already be untracked or modified. Do not overwrite those changes without explicit direction. diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 0000000..98e9eb3 --- /dev/null +++ b/ARCHITECTURE.md @@ -0,0 +1,247 @@ +# Architecture + +## Purpose + +Shielded.dev hosts dynamic README badges ("shields"). A signed-in owner creates a shield in a dashboard, receives a stable public badge URL and a separate update secret, and can update the displayed badge values through the dashboard or the API. Badge consumers embed the public SVG URL in a README. + +The repository builds one Go executable. The executable owns HTTP serving, OAuth, persistence, badge SVG rendering, pages, and production static assets; there is no separate API or image service process. + +## System shape + +```mermaid +flowchart LR + Browser["Browser / README renderer"] + Root["shielded.dev\nPages, dashboard APIs, OAuth"] + API["api.shielded.dev\nShield-secret and user-token updates"] + Image["img.shielded.dev\nSVG badge endpoints"] + App["One Go process\nGorilla Mux + handlers"] + DB[("MySQL\nusers, shields, user_api_tokens")] + GitHub["GitHub OAuth"] + + Browser --> Root + Browser --> API + Browser --> Image + Root --> App + API --> App + Image --> App + App <--> DB + App <--> GitHub +``` + +Host names, rather than listening ports or services, separate the three external roles. `cmd/shielded/main.go` creates one Gorilla Mux router and mounts subrouters by `Host`. + +## Runtime and configuration + +`cmd/shielded/main.go` is the composition root. + +1. Parses flags and configures structured text logging. +2. Opens MySQL using `-dsn` (default: `admin:password@tcp(127.0.0.1:3306)/shielded?parseTime=true`). +3. Constructs `UserMapper` and `ShieldMapper`. +4. Creates host-aware routers and all handlers. +5. Generates a new UUID-based HMAC key for `JwtAuth` for this process lifetime. +6. Starts either direct local HTTP or CertMagic-managed HTTPS. + +Default production host values are compiled into `cmd/shielded/build.go`: + +| Role | Host | Local-debug host | +| --- | --- | --- | +| Main site/dashboard | `shielded.dev` | `local.shielded.dev` | +| Programmatic update API | `api.shielded.dev` | `api.local.shielded.dev` | +| Public SVG badges | `img.shielded.dev` | `img.local.shielded.dev` | + +`make debug` uses linker flags to substitute the local-debug values and starts on `:8686`. In normal mode (`-run-local=false`) CertMagic terminates HTTPS for all four names including `www.shielded.dev`; `www` redirects permanently to the root host. Static files are read from disk in local mode and from the embedded filesystem in normal builds. + +For local HTTPS, `Caddyfile.local` accepts both the three canonical names and the three local-debug names. It reverse-proxies each request to `127.0.0.1:8686`, sets the upstream `Host` header to the local-debug name the Go router expects, and uses Caddy's internal CA. Its comments list the required `/etc/hosts` entries and startup order. + +`run-local.sh` runs an isolated `mysql:8.4` container named `shieldeddotdev-local-mysql` on `127.0.0.1:3306`, with the default local DSN credentials and no data volume. It removes any prior instance before startup, waits for MySQL, applies every `schema/[0-9][0-9][0-9]_*.sql` file in lexical/numeric order, ensures the fixed debug user exists, then builds the debug-tag executable and runs Caddy. Its signal/exit trap terminates the Go process and force-removes the MySQL container; the next run is also a clean database. + +## HTTP surface + +### Main host: `shielded.dev` + +| Route | Method(s) | Handler / behavior | +| --- | --- | --- | +| `/`, `/index.html` | GET, HEAD | Renders `pages.IndexPage`. | +| `/dashboard` | GET, HEAD | Renders `pages.DashboardPage`; the browser app redirects unauthenticated visitors after checking `/api/authed`. | +| `/privacy.html` | GET, HEAD | Renders `pages.PrivacyPage`. | +| `/github/login` | no method restriction | Starts GitHub OAuth in production; signs in the debug user in local mode. | +| `/github/callback` | no method restriction; production only | Validates OAuth state, exchanges the code, fetches the GitHub user, saves it, signs a JWT cookie, and redirects to the dashboard. | +| `/api/authed` | no method restriction | Returns the authenticated user ID as a JSON number, or `403`. | +| `/api/shields` | GET | Returns the current user's shields as JSON. | +| `/api/shields` | POST | Creates a new shield for the current user and returns it as JSON with `201 Created`. | +| `/api/shield/{id}` | PUT | Updates an existing shield only after looking it up by both authenticated owner ID and shield ID. | +| `/api/shield/{id}` | DELETE | Deletes an owned shield and returns `204 No Content`. | +| `/api/user/tokens` | GET | Returns the current user's API-token metadata. Plaintext token values and hashes are never returned. | +| `/api/user/tokens` | POST | Creates a user-level API token from a required description. Returns its metadata and plaintext token once with `201 Created`. | +| `/api/user/tokens/{id}` | DELETE | Revokes an owned user-level API token and returns `204 No Content`. | +| `/env` | no method restriction | Returns the configured root/API/image hosts as JSON for browser code. | +| all remaining paths | local: no method restriction; production: no method restriction | Serves the static asset filesystem. | + +### Update API host: `api.shielded.dev` + +| Route | Current handler behavior | +| --- | --- | +| `/` | `ApiHandler.HandlePOST` is mounted without a router method restriction. All clients use `Authorization: token `. A non-empty form field `shield_key` selects a user-token request: it is required for `sdu_` tokens, while blank or absent values require a per-shield token. The endpoint accepts optional `title`, `text`, and `color`, normalizes/validates color, and returns `{"ShieldURL":"https://img-host/s/public-id","ShieldKey":"shield-key"}`. A user-token request scopes the shield key to the authenticated user; a missing shield is created with the dashboard defaults—name `New Shield`, title `New`, text `Shield`, and color `00AA55`—plus a generated per-shield token, then returns `201 Created`. Existing shields return `200 OK`. Invalid user tokens produce `401`; invalid shield keys and fields produce `400`. | + +The endpoint is deliberately field-partial: omitted or empty form values leave the stored field unchanged. It does not update `Name`. + +### Image host: `img.shielded.dev` + +| Route | Behavior | +| --- | --- | +| `/s/{pid}` | Loads the shield by public ID (`[a-z0-9]{3,128}`) and renders an SVG with `go-badge`. It sets `Cache-Control: no-cache`, so a stable public URL reflects updates promptly. | +| `/s` | Renders an ephemeral SVG from query `title`, `text`, and `color`; default color is `green`. It validates/normalizes color and sends a 30-day cache policy. Nothing is persisted. | + +Both endpoints return `image/svg+xml`. The public ID is the lookup key exposed in Markdown; the shield secret is the write credential and never appears in a badge URL. + +## Authentication and authorization + +Production login uses GitHub OAuth with scope `user:email`. + +1. `/github/login` generates a UUID, puts it in the `gh-auth-state` cookie with a 15-minute expiry, and redirects to GitHub. +2. `/github/callback` compares the returned `state` to that cookie, exchanges `code` for an OAuth token, and fetches the authenticated GitHub profile. +3. `UserMapper.Save` inserts or updates the GitHub user ID, login, and email in MySQL. +4. `JwtAuth.Authorize` issues an HS256 JWT whose registered ID claim is the numeric GitHub user ID. It is stored in the `auth` cookie for 36 hours with `Secure` and `HttpOnly` flags. +5. Dashboard API handlers call `JwtAuth.GetAuth`; their data access is scoped by user ID, not just shield ID. + +In local mode, `DebugAuthHandler` bypasses GitHub and signs in a fixed `{UserID: 1, Login: "debug"}` user. No database user row is written by that debug-login path. + +The JWT HMAC key is a new random UUID every process start and is not loaded from configuration or persisted. Existing `auth` cookies become invalid after a restart; this is current behavior, not a durable session design. + +## Persistence model + +There are three code-owned mappers in `model/`. `schema/000_BASELINE.sql` is the dump-derived baseline schema for a fresh MariaDB/MySQL database. `schema/001_user_api_tokens.sql` adds user-level token storage, and `schema/002_shield_key.sql` adds an optional `shield_key`. Future schema changes belong in new, forward-only SQL files in `schema/`, numbered in application order. The application has no production migration runner; the local runner starts from a clean database and reapplies every migration. + +```mermaid +erDiagram + USERS ||--o{ SHIELDS : owns + USERS ||--o{ USER_API_TOKENS : owns + USERS { + int_unsigned user_id PK + varchar_255 login + varchar_255 email + timestamp created + } + SHIELDS { + int_unsigned shield_id PK + varchar_128 public_id UK + varchar_64 shield_key "optional, user-scoped UK" + int_unsigned user_id FK + varchar_255 name + varchar_255 title + varchar_255 text + varchar_6 color + varchar_255 secret UK + timestamp stamp_created + timestamp stamp_updated + } + USER_API_TOKENS { + int_unsigned api_token_id PK + int_unsigned user_id FK + varchar_255 description + binary_32 token_hash UK + timestamp date_created + timestamp date_last_used + } +``` + +`ShieldMapper.Save` inserts a shield when `ShieldID` is zero and otherwise updates it. Creation generates both IDs: + +- `PublicID` starts at three characters from a lowercase, ambiguity-reduced alphabet. `PublicIDGenerator` serializes generation within a process and increases the generated length on a collision found in MySQL. +- `Secret` is generated by the dashboard handler as 40 characters from the same alphabet. It authenticates the programmatic update API. + +The mapper uses transactions for shield inserts, updates, and deletes. Reads are direct `QueryRow`/`Query` calls. `UserMapper.Save` uses MySQL `INSERT ... ON DUPLICATE KEY UPDATE`. The baseline has primary keys on `users.user_id` and `shields.shield_id`, unique keys on `shields.public_id` and `shields.secret`, an index on `shields.user_id`, and a cascading foreign key from shields to users. + +`shield_key` stores the optional `ShieldKey`. When set, it must be 3-64 lowercase letters, digits, or hyphens; the dashboard rejects duplicate keys and the `002` migration makes its `(user_id, shield_key)` pair unique. The API host receives it as a `shield_key` form field for user-token requests, without exposing the internal numeric `shield_id` or changing the per-shield token API. + +`000_BASELINE.sql` contains `DROP TABLE IF EXISTS` before its table definitions and is suitable only for initializing or deliberately replacing a database. It is not a safe upgrade migration. Subsequent migrations must use the next numeric prefix (for example, `001_add_example.sql`) and must not be renamed, reordered, or changed after application. + +### User-level API tokens + +`user_api_tokens` holds multiple independently revocable tokens per user. Plaintext values start with `sdu_`; their full value, including the prefix, is hashed for storage. A token has a non-empty description, a database-generated `date_created` timestamp, and a nullable `date_last_used` timestamp; `null` means it has not been used. The `001` migration records actual point-in-time fields as MySQL `TIMESTAMP` columns. + +The dashboard creates a 32-byte `crypto/rand` secret, encodes it as an `sdu_`-prefixed base64url token, and stores only its SHA-256 hash. The plaintext token is returned to the authenticated owner exactly once from `POST /api/user/tokens`; list responses expose only metadata. Deletion revokes a token by deleting its row. The API host receives it as `Authorization: token ` with a `shield_key` form field, hashes the supplied value, scopes the requested shield key to the token's user, and updates `date_last_used` immediately after valid token authentication. + +## Request flows + +### Dashboard creation and editing + +```mermaid +sequenceDiagram + participant B as Browser dashboard + participant W as Main-host handlers + participant D as MySQL + participant I as Image host + + B->>W: GET /api/authed + W-->>B: user ID or 403 + B->>W: GET /api/shields + W->>D: list by user_id + D-->>W: shields (including Secret) + W-->>B: JSON + B->>W: POST /api/shields + W->>D: insert with generated PublicID and Secret + W-->>B: 201 shield JSON + B->>W: PUT /api/shield/{id} + W->>D: read by user_id + shield_id, then update + W-->>B: updated shield JSON + B->>I: GET /s/{PublicID}?break=timestamp + I->>D: read by public_id + I-->>B: SVG +``` + +The authenticated dashboard is a Preact SPA. The shield list and user-token list are component state, while each shield form owns its local draft state. A shield form listens for input events across its controls, cancels any pending save after every change, and schedules a dashboard `PUT` 500 ms after the most recent valid input. This preserves the mounted input and focus during editing. Creation and deletion update the corresponding list immediately through their API calls. + +Each form exposes a badge preview, an optional shield key, a copyable Markdown URL, the copy/reveal-able update secret, and generated API examples. The preview adds a timestamp query value, although the public image handler itself ignores it; the browser uses it to bypass its image cache while editing. + +The dashboard is a single-page application. Its `#/user` client-side view contains the API-token section; it requires a description at creation, displays the raw value once with a copy button, and lists each active token's description, created timestamp, last-used timestamp (or `Never`), and revoke action. + +### External update and README rendering + +1. An external client posts form fields to `https://api.shielded.dev/` with `Authorization: token `. Per-shield values omit `shield_key` and update one shield; `sdu_` user-token values include `shield_key=` and can update or create a shield owned by that user. +2. `ApiHandler` uses a non-empty `shield_key` to select user-token handling, identifies the token from its `sdu_` prefix, finds it by SHA-256 hash, then finds the owned shield by `(user_id, shield_key)`. A user-token request creates the shield when that pair does not exist. Requests with a blank or absent `shield_key` find one shield by secret. +3. The handler validates its known form field names, records `date_last_used` immediately after valid user-token authentication, normalizes a supplied color, saves the row, and returns the stable public image URL. +4. A README renderer requests `https://img.shielded.dev/s/`. +5. `ShieldHandler` loads the shield by `public_id` and writes an SVG using its title, text, and color. + +Because badge content is rendered from the database on every public image request and sent with `Cache-Control: no-cache`, there is no separate cache invalidation or asynchronous rendering pipeline. + +## Server-rendered pages and frontend + +`pages/*.templ` define the pages and shared `Head`, header, footer, and static-badge components. The `templ` generator writes `pages/*_templ.go`, which the Go server renders via `templ.Handler`. + +The static frontend has two entry functions exported from `ts/main.ts`: + +- `Home` calls `/env` and mounts API usage examples on the home page. +- `Dashboard` checks `/api/authed`, retrieves `/env`, and mounts the authenticated Preact dashboard, including its `#/user` API-token view. + +The authenticated frontend uses Preact with a small hash-based route switch and local component state; there is no separate state store library or server-side JSON API versioning. Browser requests use `XMLHttpRequest` in `ts/api/request.ts` with credentials enabled; JSON request bodies are sent by the dashboard handlers even though they do not explicitly set a `Content-Type` header. + +Sass source in `scss/` produces `static/style/style.css`. TypeScript is compiled and bundled by Rollup into the ES module `static/main.js`. The pages load that module directly with inline `type="module"` imports. + +## Build and delivery + +The `Makefile` defines the build pipeline: + +1. `go generate ./...` runs `go tool templ generate -path ../../pages` from the executable package. +2. Sass compiles `scss/` into `static/style/style.css`. +3. Rollup bundles `ts/main.ts` and dependencies into `static/main.js`. +4. `go build ./cmd/shielded` builds the executable and injects the build user, timestamp, git revision/tag, and dirty marker. + +Normal builds use `static.go` to embed the entire `static/` directory into the executable. Builds with the `debug` tag use `staticdebug.go`, which instead points `StaticAssets` to the on-disk `static` directory. + +`make release` (the `release` directory target) cross-builds Linux amd64, macOS amd64/arm64, and Windows amd64 binaries, then writes ZIP files under `dist/`. No CI workflow, container definition, deployment manifest, or database migration runner is present in this repository. + +## Important current constraints + +These are descriptions of behavior visible in the code, useful when planning changes: + +- Automated coverage is currently limited to validation-focused Go tests, and there is no production migration runner. The committed baseline schema is `schema/000_BASELINE.sql`; `run-local.sh` reapplies every ordered migration to a clean local database, while deployment operations must apply them in production. +- The dashboard API serializes the full `Shield` struct, including the update `Secret`, to the owning authenticated browser. +- User-level API-token hashes are never sent to the browser; plaintext values are returned only by token creation and are not persisted by the browser state. `sdu_` user tokens can update or create a shield identified by form field `shield_key` and update their `last_used` timestamp. +- `DashboardShieldApiIndexHandler` and `DashboardShieldApiHandler` accept raw JSON fields without server-side color or content validation; the API-host handler and static badge route do validate colors. +- The programmatic update endpoint's route has no explicit HTTP-method constraint even though its handler is named `HandlePOST`. +- Public IDs use `math/rand` seeded with time. Per-shield update secrets and user-level API tokens use `crypto/rand`; user-token values are stored as SHA-256 hashes. +- The implementation does not set `SameSite` on the OAuth state or auth cookies, and the OAuth state cookie is not explicitly marked `HttpOnly` or `Secure`. + +Treat changes to any of these behaviors as compatibility/security work: update the affected server handler, browser client, public documentation, and verification coverage together. diff --git a/Caddyfile.local b/Caddyfile.local new file mode 100644 index 0000000..566b90e --- /dev/null +++ b/Caddyfile.local @@ -0,0 +1,55 @@ +# Local HTTPS front end for `make debug`. +# +# The Go process listens on 127.0.0.1:8686 and routes by Host. It expects these +# development hosts, which are injected by the Makefile's debug target: +# local.shielded.dev +# api.local.shielded.dev +# img.local.shielded.dev +# +# The canonical production names and local. aliases are also accepted. +# Caddy sets the upstream Host to the development host in every case, allowing +# the Go router to select the correct subrouter. +# +# Add these names to /etc/hosts before starting Caddy: +# 127.0.0.1 shielded.dev api.shielded.dev img.shielded.dev +# 127.0.0.1 local.shielded.dev api.local.shielded.dev img.local.shielded.dev +# 127.0.0.1 local.api.shielded.dev local.img.shielded.dev +# +# Start the backend with `make debug`, then run: +# caddy run --config Caddyfile.local +# +# Caddy's internal CA issues the local TLS certificates. Trust its root CA when +# your browser or operating system does not already trust it. + +(root_app) { + tls internal + reverse_proxy 127.0.0.1:8686 { + header_up Host local.shielded.dev + } +} + +(api_app) { + tls internal + reverse_proxy 127.0.0.1:8686 { + header_up Host api.local.shielded.dev + } +} + +(image_app) { + tls internal + reverse_proxy 127.0.0.1:8686 { + header_up Host img.local.shielded.dev + } +} + +shielded.dev, local.shielded.dev { + import root_app +} + +api.shielded.dev, api.local.shielded.dev, local.api.shielded.dev { + import api_app +} + +img.shielded.dev, img.local.shielded.dev, local.img.shielded.dev { + import image_app +} diff --git a/apihandler.go b/apihandler.go index d45c8a8..0b1de01 100644 --- a/apihandler.go +++ b/apihandler.go @@ -2,6 +2,7 @@ package shieldeddotdev import ( "encoding/json" + "errors" "log/slog" "net/http" "strings" @@ -9,41 +10,145 @@ import ( "github.com/ShieldedDotDev/shieldeddotdev/model" ) +var ( + errInvalidShieldColor = errors.New("invalid shield color") +) + type ApiHandler struct { sm *model.ShieldMapper + tm *model.UserAPITokenMapper imgHost string } -func NewApiHandler(sm *model.ShieldMapper, imgHost string) *ApiHandler { - return &ApiHandler{sm, imgHost} +func NewApiHandler(sm *model.ShieldMapper, tm *model.UserAPITokenMapper, imgHost string) *ApiHandler { + return &ApiHandler{sm: sm, tm: tm, imgHost: imgHost} } func (ah *ApiHandler) HandlePOST(w http.ResponseWriter, r *http.Request) { - auth := r.Header.Get("Authorization") - authParts := strings.SplitN(auth, " ", 2) - if len(authParts) != 2 || authParts[0] != "token" { - http.Error(w, "missing secret", http.StatusBadRequest) + if err := r.ParseForm(); err != nil { + slog.Error("error parsing form", slog.Any("error", err)) + http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) + return + } + + for k := range r.Form { + if k != "shield_key" && k != "title" && k != "text" && k != "color" { + http.Error(w, "invalid field: "+k, http.StatusBadRequest) + return + } + } + + shieldKey := r.FormValue("shield_key") + if shieldKey != "" { + ah.handleUserTokenPOST(w, r, shieldKey) + return + } + + ah.handleShieldTokenPOST(w, r) +} + +func (ah *ApiHandler) handleUserTokenPOST(w http.ResponseWriter, r *http.Request, shieldKey string) { + if !validShieldKey(shieldKey) { + http.Error(w, "shield_key must be 3-64 lowercase letters, digits, or hyphens", http.StatusBadRequest) + return + } + + token, ok := apiRequestToken(w, r) + if !ok { + return + } + if !model.IsUserAPIToken(token) { + http.Error(w, "shield_key requires a user token", http.StatusBadRequest) + return + } + + userToken, err := ah.tm.GetFromToken(token) + if err != nil { + slog.Error("error fetching user API token", slog.Any("error", err)) + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + return + } + if userToken == nil { + http.Error(w, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized) + return + } + if err := ah.tm.MarkUsed(userToken.APITokenID); err != nil { + slog.Error("error recording user API token use", slog.Any("error", err), slog.Int64("api_token_id", userToken.APITokenID)) + } + + shield, err := ah.sm.GetFromUserIDAndShieldKey(userToken.UserID, shieldKey) + if err != nil { + slog.Error("error fetching shield from user API token", slog.Any("error", err), slog.Int64("user_id", userToken.UserID)) + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + return + } + if shield == nil { + secret, err := secureStringWithCharset(40, "abcdefghjkmnpqrstuvwxyz23456789") + if err != nil { + slog.Error("error generating shield secret", slog.Any("error", err)) + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + return + } + shield = &model.Shield{ + UserID: userToken.UserID, + ShieldKey: shieldKey, + Name: "New Shield", + Title: "New", + Text: "Shield", + Color: "00AA55", + Secret: secret, + } + } + + created, err := ah.saveShieldFromForm(r, shield) + if err != nil { + ah.handleSaveShieldError(w, err) + return + } + ah.writeShieldResponse(w, shield, created) +} + +func (ah *ApiHandler) handleShieldTokenPOST(w http.ResponseWriter, r *http.Request) { + token, ok := apiRequestToken(w, r) + if !ok { + return + } + if model.IsUserAPIToken(token) { + http.Error(w, "user token requires a shield_key", http.StatusBadRequest) return } - shield, err := ah.sm.GetFromSecret(authParts[1]) + shield, err := ah.sm.GetFromSecret(token) if err != nil { slog.Error("error fetching shield from secret", slog.Any("error", err)) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) return } if shield == nil { - slog.Info("shield not found", slog.String("secret", authParts[1])) + slog.Info("shield not found") http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound) return } - err = r.ParseForm() + created, err := ah.saveShieldFromForm(r, shield) if err != nil { - slog.Error("error parsing form", slog.Any("error", err)) - http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) + ah.handleSaveShieldError(w, err) return } + ah.writeShieldResponse(w, shield, created) +} + +func apiRequestToken(w http.ResponseWriter, r *http.Request) (string, bool) { + authParts := strings.SplitN(r.Header.Get("Authorization"), " ", 2) + if len(authParts) != 2 || authParts[0] != "token" { + http.Error(w, "missing secret", http.StatusBadRequest) + return "", false + } + return authParts[1], true +} + +func (ah *ApiHandler) saveShieldFromForm(r *http.Request, shield *model.Shield) (bool, error) { + created := shield.ShieldID == 0 if title := r.FormValue("title"); title != "" { shield.Title = title @@ -54,29 +159,39 @@ func (ah *ApiHandler) HandlePOST(w http.ResponseWriter, r *http.Request) { if color := r.FormValue("color"); color != "" { color, err := NormalizeColor(color) if err != nil { - slog.Error("error normalizing color", slog.Any("error", err)) - http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) - return + return created, errInvalidShieldColor } shield.Color = color } - - for k := range r.Form { - if k != "title" && k != "text" && k != "color" { - http.Error(w, "invalid field: "+k, http.StatusBadRequest) - return - } + err := ah.sm.Save(shield) + if err != nil { + return created, err } - err = ah.sm.Save(shield) - if err != nil { - slog.Error("error saving shield", slog.Any("error", err)) - http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + return created, nil +} + +func (ah *ApiHandler) handleSaveShieldError(w http.ResponseWriter, err error) { + if errors.Is(err, errInvalidShieldColor) { + http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) + return + } + if shieldKeyInUseError(err) { + http.Error(w, "shield key is already in use", http.StatusConflict) return } + slog.Error("error saving shield", slog.Any("error", err)) + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) +} + +func (ah *ApiHandler) writeShieldResponse(w http.ResponseWriter, shield *model.Shield, created bool) { w.Header().Set("Content-Type", "application/json") + if created { + w.WriteHeader(http.StatusCreated) + } json.NewEncoder(w).Encode(map[string]string{ "ShieldURL": "https://" + ah.imgHost + "/s/" + shield.PublicID, + "ShieldKey": shield.ShieldKey, }) } diff --git a/apihandler_test.go b/apihandler_test.go new file mode 100644 index 0000000..5447832 --- /dev/null +++ b/apihandler_test.go @@ -0,0 +1,35 @@ +package shieldeddotdev + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestApiHandlerRejectsIDFormField(t *testing.T) { + req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader("id=production-status")) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + response := httptest.NewRecorder() + + (&ApiHandler{}).HandlePOST(response, req) + + if response.Code != http.StatusBadRequest { + t.Errorf("status = %d, want %d", response.Code, http.StatusBadRequest) + } +} + +func TestApiHandlerValidatesShieldKeyBeforeAuthentication(t *testing.T) { + req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader("shield_key=ab")) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + response := httptest.NewRecorder() + + (&ApiHandler{}).HandlePOST(response, req) + + if response.Code != http.StatusBadRequest { + t.Errorf("status = %d, want %d", response.Code, http.StatusBadRequest) + } + if got, want := response.Body.String(), "shield_key must be 3-64 lowercase letters, digits, or hyphens\n"; got != want { + t.Errorf("body = %q, want %q", got, want) + } +} diff --git a/authhandlers.go b/authhandlers.go index cf30ce7..96d9a90 100644 --- a/authhandlers.go +++ b/authhandlers.go @@ -35,7 +35,7 @@ func (ah *DebugAuthHandler) LoginHandler(w http.ResponseWriter, r *http.Request) return } - http.Redirect(w, r, "/dashboard.html", http.StatusTemporaryRedirect) + http.Redirect(w, r, "/dashboard", http.StatusTemporaryRedirect) } type GitHubAuthHandler struct { @@ -134,7 +134,7 @@ func (ah *GitHubAuthHandler) CallbackHandler(w http.ResponseWriter, r *http.Requ return } - http.Redirect(w, r, "/dashboard.html", http.StatusTemporaryRedirect) + http.Redirect(w, r, "/dashboard", http.StatusTemporaryRedirect) } type JwtAuth struct { diff --git a/cmd/shielded/main.go b/cmd/shielded/main.go index c79d092..13335fe 100644 --- a/cmd/shielded/main.go +++ b/cmd/shielded/main.go @@ -1,4 +1,4 @@ -//go:generate go tool templ generate ../../pages +//go:generate go tool templ generate -path ../../pages package main @@ -59,13 +59,14 @@ func main() { um := model.NewUserMapper(db) sm := model.NewShieldMapper(db) + tm := model.NewUserAPITokenMapper(db) ro := mux.NewRouter() ro.PathPrefix("/").Host("www." + rootHost).Handler( http.RedirectHandler("https://"+rootHost, http.StatusPermanentRedirect)) ao := ro.Host(apiHost).Subrouter() - apih := shieldeddotdev.NewApiHandler(sm, imgHost) + apih := shieldeddotdev.NewApiHandler(sm, tm, imgHost) ao.HandleFunc("/", apih.HandlePOST) io := ro.Host(imgHost).Subrouter() @@ -81,7 +82,7 @@ func main() { wo := ro.Host(rootHost).Subrouter() wo.Handle("/", templ.Handler(pages.IndexPage(hosts))).Methods(http.MethodGet, http.MethodHead) wo.Handle("/index.html", templ.Handler(pages.IndexPage(hosts))).Methods(http.MethodGet, http.MethodHead) - wo.Handle("/dashboard.html", templ.Handler(pages.DashboardPage(hosts))).Methods(http.MethodGet, http.MethodHead) + wo.Handle("/dashboard", templ.Handler(pages.DashboardPage(hosts))).Methods(http.MethodGet, http.MethodHead) wo.Handle("/privacy.html", templ.Handler(pages.PrivacyPage(hosts))).Methods(http.MethodGet, http.MethodHead) uuu, err := uuid.NewV4() @@ -108,6 +109,13 @@ func main() { wo.HandleFunc("/api/shield/{id:[0-9]+}", sah.HandlePUT).Methods("PUT") wo.HandleFunc("/api/shield/{id:[0-9]+}", sah.HandleDELETE).Methods("DELETE") + tih := shieldeddotdev.NewDashboardUserAPITokenIndexHandler(tm, jwta) + wo.HandleFunc("/api/user/tokens", tih.HandleGET).Methods("GET") + wo.HandleFunc("/api/user/tokens", tih.HandlePOST).Methods("POST") + + th := shieldeddotdev.NewDashboardUserAPITokenHandler(tm, jwta) + wo.HandleFunc("/api/user/tokens/{id:[0-9]+}", th.HandleDELETE).Methods("DELETE") + wo.HandleFunc("/env", func(w http.ResponseWriter, r *http.Request) { json.NewEncoder(w).Encode(map[string]any{ "RootHost": rootHost, diff --git a/dashboardapihandlers.go b/dashboardapihandlers.go index 4f4a6fc..6678eaa 100644 --- a/dashboardapihandlers.go +++ b/dashboardapihandlers.go @@ -1,18 +1,46 @@ package shieldeddotdev import ( + cryptorand "crypto/rand" "encoding/json" + "errors" "fmt" "log/slog" - "math/rand" + "math/big" "net/http" + "regexp" "strconv" - "time" + "strings" "github.com/ShieldedDotDev/shieldeddotdev/model" + "github.com/go-sql-driver/mysql" "github.com/gorilla/mux" ) +var shieldKeyPattern = regexp.MustCompile(`^[a-z0-9-]{3,64}$`) + +func validShieldKey(shieldKey string) bool { + return shieldKey == "" || shieldKeyPattern.MatchString(shieldKey) +} + +func shieldKeyAvailable(sm *model.ShieldMapper, userID, shieldID int64, shieldKey string) (bool, error) { + if shieldKey == "" { + return true, nil + } + + shield, err := sm.GetFromUserIDAndShieldKey(userID, shieldKey) + if err != nil { + return false, err + } + + return shield == nil || shield.ShieldID == shieldID, nil +} + +func shieldKeyInUseError(err error) bool { + var mysqlErr *mysql.MySQLError + return errors.As(err, &mysqlErr) && mysqlErr.Number == 1062 && strings.Contains(mysqlErr.Message, "unq_shields_shield_key") +} + type DashboardShieldApiIndexHandler struct { sm *model.ShieldMapper jwtAuth *JwtAuth @@ -63,13 +91,33 @@ func (sh *DashboardShieldApiIndexHandler) HandlePOST(w http.ResponseWriter, r *h http.Error(w, "failed to parse request body", http.StatusBadRequest) return } + if !validShieldKey(postShield.ShieldKey) { + http.Error(w, "shield key must be 3-64 lowercase letters, digits, or hyphens", http.StatusBadRequest) + return + } + available, err := shieldKeyAvailable(sh.sm, *id, 0, postShield.ShieldKey) + if err != nil { + slog.Error("error checking shield key", slog.Any("error", err), slog.Any("id", *id)) + http.Error(w, "database error", http.StatusInternalServerError) + return + } + if !available { + http.Error(w, "shield key is already in use", http.StatusConflict) + return + } - uu := stringWithCharset(40, "abcdefghjkmnpqrstuvwxyz23456789") + uu, err := secureStringWithCharset(40, "abcdefghjkmnpqrstuvwxyz23456789") + if err != nil { + slog.Error("error generating shield secret", slog.Any("error", err)) + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + return + } cleanShield := &model.Shield{ UserID: *id, - Name: postShield.Name, + ShieldKey: postShield.ShieldKey, + Name: postShield.Name, Title: postShield.Title, Text: postShield.Text, @@ -79,6 +127,10 @@ func (sh *DashboardShieldApiIndexHandler) HandlePOST(w http.ResponseWriter, r *h } err = sh.sm.Save(cleanShield) + if shieldKeyInUseError(err) { + http.Error(w, "shield key is already in use", http.StatusConflict) + return + } if err != nil { slog.Error("error saving shield", slog.Any("error", err)) http.Error(w, "database error", http.StatusInternalServerError) @@ -93,14 +145,17 @@ func (sh *DashboardShieldApiIndexHandler) HandlePOST(w http.ResponseWriter, r *h x.Encode(cleanShield) } -var seededRand *rand.Rand = rand.New(rand.NewSource(time.Now().UnixNano())) - -func stringWithCharset(length int, charset string) string { +func secureStringWithCharset(length int, charset string) (string, error) { b := make([]byte, length) + limit := big.NewInt(int64(len(charset))) for i := range b { - b[i] = charset[seededRand.Intn(len(charset))] + index, err := cryptorand.Int(cryptorand.Reader, limit) + if err != nil { + return "", err + } + b[i] = charset[index.Int64()] } - return string(b) + return string(b), nil } type DashboardShieldApiHandler struct { @@ -162,7 +217,22 @@ func (dh *DashboardShieldApiHandler) HandlePUT(w http.ResponseWriter, r *http.Re http.Error(w, "failed to parse request body", http.StatusBadRequest) return } + if !validShieldKey(putShield.ShieldKey) { + http.Error(w, "shield key must be 3-64 lowercase letters, digits, or hyphens", http.StatusBadRequest) + return + } + available, err := shieldKeyAvailable(dh.sm, shield.UserID, shield.ShieldID, putShield.ShieldKey) + if err != nil { + slog.Error("error checking shield key", slog.Any("error", err), slog.Any("id", shield.UserID)) + http.Error(w, "database error", http.StatusInternalServerError) + return + } + if !available { + http.Error(w, "shield key is already in use", http.StatusConflict) + return + } + shield.ShieldKey = putShield.ShieldKey shield.Name = putShield.Name shield.Title = putShield.Title @@ -170,6 +240,10 @@ func (dh *DashboardShieldApiHandler) HandlePUT(w http.ResponseWriter, r *http.Re shield.Color = putShield.Color err = dh.sm.Save(shield) + if shieldKeyInUseError(err) { + http.Error(w, "shield key is already in use", http.StatusConflict) + return + } if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return diff --git a/dashboardapihandlers_test.go b/dashboardapihandlers_test.go new file mode 100644 index 0000000..7a9ae56 --- /dev/null +++ b/dashboardapihandlers_test.go @@ -0,0 +1,51 @@ +package shieldeddotdev + +import ( + "testing" + + "github.com/go-sql-driver/mysql" +) + +func TestValidShieldKey(t *testing.T) { + tests := []struct { + name string + shieldKey string + valid bool + }{ + {name: "empty is optional", shieldKey: "", valid: true}, + {name: "minimum length", shieldKey: "abc", valid: true}, + {name: "maximum length", shieldKey: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", valid: true}, + {name: "too short", shieldKey: "ab", valid: false}, + {name: "too long", shieldKey: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", valid: false}, + {name: "uppercase", shieldKey: "Release-1", valid: false}, + {name: "underscore", shieldKey: "release_1", valid: false}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if got := validShieldKey(test.shieldKey); got != test.valid { + t.Errorf("validShieldKey(%q) = %t, want %t", test.shieldKey, got, test.valid) + } + }) + } +} + +func TestShieldKeyInUseError(t *testing.T) { + tests := []struct { + name string + err error + want bool + }{ + {name: "shield key duplicate", err: &mysql.MySQLError{Number: 1062, Message: "Duplicate entry 'example' for key 'unq_shields_shield_key'"}, want: true}, + {name: "other duplicate", err: &mysql.MySQLError{Number: 1062, Message: "Duplicate entry 'example' for key 'secret'"}, want: false}, + {name: "other MySQL error", err: &mysql.MySQLError{Number: 1452}, want: false}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if got := shieldKeyInUseError(test.err); got != test.want { + t.Errorf("shieldKeyInUseError(%v) = %t, want %t", test.err, got, test.want) + } + }) + } +} diff --git a/dashboardapitokenhandlers.go b/dashboardapitokenhandlers.go new file mode 100644 index 0000000..c657884 --- /dev/null +++ b/dashboardapitokenhandlers.go @@ -0,0 +1,129 @@ +package shieldeddotdev + +import ( + "encoding/json" + "errors" + "log/slog" + "net/http" + "strconv" + + "github.com/ShieldedDotDev/shieldeddotdev/model" + "github.com/gorilla/mux" +) + +type DashboardUserAPITokenIndexHandler struct { + tm *model.UserAPITokenMapper + jwtAuth *JwtAuth +} + +func NewDashboardUserAPITokenIndexHandler(tm *model.UserAPITokenMapper, jwtAuth *JwtAuth) *DashboardUserAPITokenIndexHandler { + return &DashboardUserAPITokenIndexHandler{ + tm: tm, + jwtAuth: jwtAuth, + } +} + +func (th *DashboardUserAPITokenIndexHandler) HandleGET(w http.ResponseWriter, r *http.Request) { + userID := th.jwtAuth.GetAuth(r) + if userID == nil { + http.Error(w, http.StatusText(http.StatusForbidden), http.StatusForbidden) + return + } + + tokens, err := th.tm.GetFromUserID(*userID) + if err != nil { + slog.Error("error fetching API tokens", slog.Any("error", err), slog.Int64("user_id", *userID)) + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(tokens); err != nil { + slog.Error("error encoding API tokens", slog.Any("error", err)) + } +} + +func (th *DashboardUserAPITokenIndexHandler) HandlePOST(w http.ResponseWriter, r *http.Request) { + userID := th.jwtAuth.GetAuth(r) + if userID == nil { + http.Error(w, http.StatusText(http.StatusForbidden), http.StatusForbidden) + return + } + + request := struct { + Description string + }{} + if err := json.NewDecoder(r.Body).Decode(&request); err != nil { + http.Error(w, "failed to parse request body", http.StatusBadRequest) + return + } + + token, plainToken, err := th.tm.Create(*userID, request.Description) + if errors.Is(err, model.ErrUserAPITokenDescriptionRequired) || errors.Is(err, model.ErrUserAPITokenDescriptionTooLong) { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + if err != nil { + slog.Error("error creating API token", slog.Any("error", err), slog.Int64("user_id", *userID)) + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + return + } + + w.Header().Set("Location", "/api/user/tokens/"+strconv.FormatInt(token.APITokenID, 10)) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + if err := json.NewEncoder(w).Encode(struct { + *model.UserAPIToken + Token string + }{ + UserAPIToken: token, + Token: plainToken, + }); err != nil { + slog.Error("error encoding created API token", slog.Any("error", err)) + } +} + +type DashboardUserAPITokenHandler struct { + tm *model.UserAPITokenMapper + jwtAuth *JwtAuth +} + +func NewDashboardUserAPITokenHandler(tm *model.UserAPITokenMapper, jwtAuth *JwtAuth) *DashboardUserAPITokenHandler { + return &DashboardUserAPITokenHandler{ + tm: tm, + jwtAuth: jwtAuth, + } +} + +func (th *DashboardUserAPITokenHandler) HandleDELETE(w http.ResponseWriter, r *http.Request) { + userID := th.jwtAuth.GetAuth(r) + if userID == nil { + http.Error(w, http.StatusText(http.StatusForbidden), http.StatusForbidden) + return + } + + apiTokenID, err := strconv.ParseInt(mux.Vars(r)["id"], 10, 64) + if err != nil || apiTokenID < 1 { + http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) + return + } + + token, err := th.tm.GetFromUserIDAndID(*userID, apiTokenID) + if err != nil { + slog.Error("error fetching API token for deletion", slog.Any("error", err), slog.Int64("user_id", *userID), slog.Int64("api_token_id", apiTokenID)) + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + return + } + if token == nil { + http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound) + return + } + + if err := th.tm.DeleteFromUserIDAndID(*userID, apiTokenID); err != nil { + slog.Error("error deleting API token", slog.Any("error", err), slog.Int64("user_id", *userID), slog.Int64("api_token_id", apiTokenID)) + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusNoContent) +} diff --git a/model/shields.go b/model/shields.go index 7e7662b..bcfd9ed 100644 --- a/model/shields.go +++ b/model/shields.go @@ -9,9 +9,10 @@ import ( ) type Shield struct { - ShieldID int64 - PublicID string - UserID int64 + ShieldID int64 + PublicID string + ShieldKey string + UserID int64 Name string @@ -50,8 +51,8 @@ func (sm *ShieldMapper) New(userID int64) (*Shield, error) { func (sm *ShieldMapper) GetFromID(id int64) (*Shield, error) { sh := &Shield{} - row := sm.db.QueryRow("SELECT s.shield_id, s.public_id, s.user_id, s.name, s.title, s.text, s.color, s.secret, s.stamp_created, s.stamp_updated FROM shields AS s WHERE s.shield_id = ?", id) - switch err := row.Scan(&sh.ShieldID, &sh.PublicID, &sh.UserID, &sh.Name, &sh.Title, &sh.Text, &sh.Color, &sh.Secret, &sh.Created, &sh.Updated); err { + row := sm.db.QueryRow("SELECT s.shield_id, s.public_id, COALESCE(s.shield_key, '') AS shield_key, s.user_id, s.name, s.title, s.text, s.color, s.secret, s.stamp_created, s.stamp_updated FROM shields AS s WHERE s.shield_id = ?", id) + switch err := row.Scan(&sh.ShieldID, &sh.PublicID, &sh.ShieldKey, &sh.UserID, &sh.Name, &sh.Title, &sh.Text, &sh.Color, &sh.Secret, &sh.Created, &sh.Updated); err { case sql.ErrNoRows: return nil, nil case nil: @@ -64,8 +65,8 @@ func (sm *ShieldMapper) GetFromID(id int64) (*Shield, error) { func (sm *ShieldMapper) GetFromPublicID(publicID string) (*Shield, error) { sh := &Shield{} - row := sm.db.QueryRow("SELECT s.shield_id, s.public_id, s.user_id, s.name, s.title, s.text, s.color, s.secret, s.stamp_created, s.stamp_updated FROM shields AS s WHERE s.public_id = ?", publicID) - switch err := row.Scan(&sh.ShieldID, &sh.PublicID, &sh.UserID, &sh.Name, &sh.Title, &sh.Text, &sh.Color, &sh.Secret, &sh.Created, &sh.Updated); err { + row := sm.db.QueryRow("SELECT s.shield_id, s.public_id, COALESCE(s.shield_key, '') AS shield_key, s.user_id, s.name, s.title, s.text, s.color, s.secret, s.stamp_created, s.stamp_updated FROM shields AS s WHERE s.public_id = ?", publicID) + switch err := row.Scan(&sh.ShieldID, &sh.PublicID, &sh.ShieldKey, &sh.UserID, &sh.Name, &sh.Title, &sh.Text, &sh.Color, &sh.Secret, &sh.Created, &sh.Updated); err { case sql.ErrNoRows: return nil, nil case nil: @@ -78,8 +79,22 @@ func (sm *ShieldMapper) GetFromPublicID(publicID string) (*Shield, error) { func (sm *ShieldMapper) GetFromUserIDAndID(userID, id int64) (*Shield, error) { sh := &Shield{} - row := sm.db.QueryRow("SELECT s.shield_id, s.public_id, s.user_id, s.name, s.title, s.text, s.color, s.secret, s.stamp_created, s.stamp_updated FROM shields AS s WHERE s.user_id = ? AND s.shield_id = ?", userID, id) - switch err := row.Scan(&sh.ShieldID, &sh.PublicID, &sh.UserID, &sh.Name, &sh.Title, &sh.Text, &sh.Color, &sh.Secret, &sh.Created, &sh.Updated); err { + row := sm.db.QueryRow("SELECT s.shield_id, s.public_id, COALESCE(s.shield_key, '') AS shield_key, s.user_id, s.name, s.title, s.text, s.color, s.secret, s.stamp_created, s.stamp_updated FROM shields AS s WHERE s.user_id = ? AND s.shield_id = ?", userID, id) + switch err := row.Scan(&sh.ShieldID, &sh.PublicID, &sh.ShieldKey, &sh.UserID, &sh.Name, &sh.Title, &sh.Text, &sh.Color, &sh.Secret, &sh.Created, &sh.Updated); err { + case sql.ErrNoRows: + return nil, nil + case nil: + return sh, nil + default: + return nil, err + } +} + +func (sm *ShieldMapper) GetFromUserIDAndShieldKey(userID int64, shieldKey string) (*Shield, error) { + sh := &Shield{} + + row := sm.db.QueryRow("SELECT s.shield_id, s.public_id, COALESCE(s.shield_key, '') AS shield_key, s.user_id, s.name, s.title, s.text, s.color, s.secret, s.stamp_created, s.stamp_updated FROM shields AS s WHERE s.user_id = ? AND s.shield_key = ?", userID, shieldKey) + switch err := row.Scan(&sh.ShieldID, &sh.PublicID, &sh.ShieldKey, &sh.UserID, &sh.Name, &sh.Title, &sh.Text, &sh.Color, &sh.Secret, &sh.Created, &sh.Updated); err { case sql.ErrNoRows: return nil, nil case nil: @@ -92,8 +107,8 @@ func (sm *ShieldMapper) GetFromUserIDAndID(userID, id int64) (*Shield, error) { func (sm *ShieldMapper) GetFromSecret(secret string) (*Shield, error) { sh := &Shield{} - row := sm.db.QueryRow("SELECT s.shield_id, s.public_id, s.user_id, s.name, s.title, s.text, s.color, s.secret, s.stamp_created, s.stamp_updated FROM shields AS s WHERE s.secret = ?", secret) - switch err := row.Scan(&sh.ShieldID, &sh.PublicID, &sh.UserID, &sh.Name, &sh.Title, &sh.Text, &sh.Color, &sh.Secret, &sh.Created, &sh.Updated); err { + row := sm.db.QueryRow("SELECT s.shield_id, s.public_id, COALESCE(s.shield_key, '') AS shield_key, s.user_id, s.name, s.title, s.text, s.color, s.secret, s.stamp_created, s.stamp_updated FROM shields AS s WHERE s.secret = ?", secret) + switch err := row.Scan(&sh.ShieldID, &sh.PublicID, &sh.ShieldKey, &sh.UserID, &sh.Name, &sh.Title, &sh.Text, &sh.Color, &sh.Secret, &sh.Created, &sh.Updated); err { case sql.ErrNoRows: return nil, nil case nil: @@ -106,7 +121,7 @@ func (sm *ShieldMapper) GetFromSecret(secret string) (*Shield, error) { func (sm *ShieldMapper) GetFromUserID(userID int64) ([]*Shield, error) { out := []*Shield{} - rows, err := sm.db.Query("SELECT s.shield_id, s.public_id, s.user_id, s.name, s.title, s.text, s.color, s.secret, s.stamp_created, s.stamp_updated FROM shields AS s WHERE s.user_id = ?", userID) + rows, err := sm.db.Query("SELECT s.shield_id, s.public_id, COALESCE(s.shield_key, '') AS shield_key, s.user_id, s.name, s.title, s.text, s.color, s.secret, s.stamp_created, s.stamp_updated FROM shields AS s WHERE s.user_id = ?", userID) if err != nil { return out, err } @@ -114,7 +129,7 @@ func (sm *ShieldMapper) GetFromUserID(userID int64) ([]*Shield, error) { for rows.Next() { sh := &Shield{} - err := rows.Scan(&sh.ShieldID, &sh.PublicID, &sh.UserID, &sh.Name, &sh.Title, &sh.Text, &sh.Color, &sh.Secret, &sh.Created, &sh.Updated) + err := rows.Scan(&sh.ShieldID, &sh.PublicID, &sh.ShieldKey, &sh.UserID, &sh.Name, &sh.Title, &sh.Text, &sh.Color, &sh.Secret, &sh.Created, &sh.Updated) if err != nil { return out, err } @@ -141,8 +156,8 @@ func (sm *ShieldMapper) Save(n *Shield) error { }() if n.ShieldID > 0 { - _, err = tx.Exec("UPDATE shields SET user_id=?, name=?, title=?, text=?, color=?, secret=? WHERE shield_id = ?", - n.UserID, n.Name, n.Title, n.Text, n.Color, n.Secret, n.ShieldID) + _, err = tx.Exec("UPDATE shields SET user_id=?, shield_key=?, name=?, title=?, text=?, color=?, secret=? WHERE shield_id = ?", + n.UserID, nullableString(n.ShieldKey), n.Name, n.Title, n.Text, n.Color, n.Secret, n.ShieldID) if err != nil { return err } @@ -152,8 +167,8 @@ func (sm *ShieldMapper) Save(n *Shield) error { return err } n.PublicID = p - res, err := tx.Exec("INSERT INTO shields (public_id, user_id, name, title, text, color, secret) VALUES (?,?,?,?,?,?,?)", - n.PublicID, n.UserID, n.Name, n.Title, n.Text, n.Color, n.Secret) + res, err := tx.Exec("INSERT INTO shields (public_id, shield_key, user_id, name, title, text, color, secret) VALUES (?,?,?,?,?,?,?,?)", + n.PublicID, nullableString(n.ShieldKey), n.UserID, n.Name, n.Title, n.Text, n.Color, n.Secret) if err != nil { return err } @@ -171,6 +186,14 @@ func (sm *ShieldMapper) Save(n *Shield) error { return nil } +func nullableString(value string) *string { + if value == "" { + return nil + } + + return &value +} + func (sm *ShieldMapper) Delete(n *Shield) error { success := false tx, err := sm.db.Begin() diff --git a/model/user_api_tokens.go b/model/user_api_tokens.go new file mode 100644 index 0000000..7bbe5bc --- /dev/null +++ b/model/user_api_tokens.go @@ -0,0 +1,180 @@ +package model + +import ( + "crypto/rand" + "crypto/sha256" + "database/sql" + "encoding/base64" + "errors" + "strings" + "time" + "unicode/utf8" +) + +const ( + MaxUserAPITokenDescriptionLength = 255 + UserAPITokenPrefix = "sdu_" +) + +var ( + ErrUserAPITokenDescriptionRequired = errors.New("API token description is required") + ErrUserAPITokenDescriptionTooLong = errors.New("API token description is too long") +) + +// UserAPIToken is the metadata shown to its owner. Token hashes and plaintext +// token values are deliberately excluded from this type. +type UserAPIToken struct { + APITokenID int64 + UserID int64 + Description string + Created time.Time + LastUsed *time.Time +} + +type UserAPITokenMapper struct { + db *sql.DB +} + +func NewUserAPITokenMapper(db *sql.DB) *UserAPITokenMapper { + return &UserAPITokenMapper{db: db} +} + +func (tm *UserAPITokenMapper) GetFromUserID(userID int64) ([]*UserAPIToken, error) { + rows, err := tm.db.Query(`SELECT api_token_id, user_id, description, date_created, date_last_used FROM user_api_tokens WHERE user_id = ? ORDER BY date_created DESC, api_token_id DESC`, userID) + if err != nil { + return nil, err + } + defer rows.Close() + + tokens := []*UserAPIToken{} + for rows.Next() { + token, err := scanUserAPIToken(rows) + if err != nil { + return nil, err + } + tokens = append(tokens, token) + } + if err := rows.Err(); err != nil { + return nil, err + } + + return tokens, nil +} + +func (tm *UserAPITokenMapper) GetFromUserIDAndID(userID, apiTokenID int64) (*UserAPIToken, error) { + row := tm.db.QueryRow(`SELECT api_token_id, user_id, description, date_created, date_last_used FROM user_api_tokens WHERE user_id = ? AND api_token_id = ?`, userID, apiTokenID) + token, err := scanUserAPIToken(row) + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, err + } + + return token, nil +} + +// Create returns the public metadata and the plaintext user token. The +// plaintext token is never stored and must be shown to the owner only now. +func (tm *UserAPITokenMapper) Create(userID int64, description string) (*UserAPIToken, string, error) { + description, err := normalizeUserAPITokenDescription(description) + if err != nil { + return nil, "", err + } + + plainToken, tokenHash, err := newUserAPITokenSecret() + if err != nil { + return nil, "", err + } + + result, err := tm.db.Exec(`INSERT INTO user_api_tokens (user_id, description, token_hash) VALUES (?, ?, ?)`, userID, description, tokenHash[:]) + if err != nil { + return nil, "", err + } + + apiTokenID, err := result.LastInsertId() + if err != nil { + return nil, "", err + } + + token, err := tm.GetFromUserIDAndID(userID, apiTokenID) + if err != nil { + return nil, "", err + } + if token == nil { + return nil, "", errors.New("created API token was not found") + } + + return token, plainToken, nil +} + +func (tm *UserAPITokenMapper) DeleteFromUserIDAndID(userID, apiTokenID int64) error { + _, err := tm.db.Exec(`DELETE FROM user_api_tokens WHERE user_id = ? AND api_token_id = ?`, userID, apiTokenID) + return err +} + +// GetFromToken finds a token from its plaintext value without exposing +// its stored hash to callers. +func (tm *UserAPITokenMapper) GetFromToken(plainToken string) (*UserAPIToken, error) { + tokenHash := sha256.Sum256([]byte(plainToken)) + row := tm.db.QueryRow(`SELECT api_token_id, user_id, description, date_created, date_last_used FROM user_api_tokens WHERE token_hash = ?`, tokenHash[:]) + token, err := scanUserAPIToken(row) + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, err + } + + return token, nil +} + +func (tm *UserAPITokenMapper) MarkUsed(apiTokenID int64) error { + _, err := tm.db.Exec(`UPDATE user_api_tokens SET date_last_used = CURRENT_TIMESTAMP() WHERE api_token_id = ?`, apiTokenID) + return err +} + +type rowScanner interface { + Scan(dest ...any) error +} + +func scanUserAPIToken(row rowScanner) (*UserAPIToken, error) { + token := &UserAPIToken{} + lastUsed := sql.NullTime{} + err := row.Scan(&token.APITokenID, &token.UserID, &token.Description, &token.Created, &lastUsed) + if err != nil { + return nil, err + } + if lastUsed.Valid { + value := lastUsed.Time + token.LastUsed = &value + } + + return token, nil +} + +func normalizeUserAPITokenDescription(description string) (string, error) { + description = strings.TrimSpace(description) + if description == "" { + return "", ErrUserAPITokenDescriptionRequired + } + if utf8.RuneCountInString(description) > MaxUserAPITokenDescriptionLength { + return "", ErrUserAPITokenDescriptionTooLong + } + + return description, nil +} + +func newUserAPITokenSecret() (string, [sha256.Size]byte, error) { + secret := make([]byte, 32) + if _, err := rand.Read(secret); err != nil { + return "", [sha256.Size]byte{}, err + } + + plainToken := UserAPITokenPrefix + base64.RawURLEncoding.EncodeToString(secret) + return plainToken, sha256.Sum256([]byte(plainToken)), nil +} + +func IsUserAPIToken(plainToken string) bool { + return strings.HasPrefix(plainToken, UserAPITokenPrefix) +} diff --git a/model/user_api_tokens_test.go b/model/user_api_tokens_test.go new file mode 100644 index 0000000..4003522 --- /dev/null +++ b/model/user_api_tokens_test.go @@ -0,0 +1,30 @@ +package model + +import ( + "crypto/sha256" + "strings" + "testing" +) + +func TestNewUserAPITokenSecret(t *testing.T) { + plainToken, tokenHash, err := newUserAPITokenSecret() + if err != nil { + t.Fatal(err) + } + + if !strings.HasPrefix(plainToken, UserAPITokenPrefix) { + t.Fatalf("token %q does not have prefix %q", plainToken, UserAPITokenPrefix) + } + if tokenHash != sha256.Sum256([]byte(plainToken)) { + t.Fatal("token hash does not match plaintext token") + } +} + +func TestIsUserAPIToken(t *testing.T) { + if !IsUserAPIToken("sdu_current") { + t.Fatal("user API token was not recognized") + } + if IsUserAPIToken("shield-secret") { + t.Fatal("per-shield token was recognized as a user API token") + } +} diff --git a/package-lock.json b/package-lock.json index 01c12cf..ffd9e7b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6,6 +6,9 @@ "": { "name": "shieldeddotdev", "license": "MIT", + "dependencies": { + "preact": "^10.29.7" + }, "devDependencies": { "@rollup/plugin-commonjs": "^29.0.3", "@rollup/plugin-node-resolve": "^16.0.3", @@ -1569,6 +1572,24 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/preact": { + "version": "10.29.7", + "resolved": "https://registry.npmjs.org/preact/-/preact-10.29.7.tgz", + "integrity": "sha512-DCHYrK/B10yUD3ZjLfhZ3WIE/9Vf9VFUODcRE2dRomTYDpJk6z6L9wecSfhfE6M9ZTHUdyQkoC46arIDhEV84Q==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/preact" + }, + "peerDependencies": { + "preact-render-to-string": ">=5" + }, + "peerDependenciesMeta": { + "preact-render-to-string": { + "optional": true + } + } + }, "node_modules/readdirp": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz", @@ -2908,6 +2929,12 @@ "dev": true, "optional": true }, + "preact": { + "version": "10.29.7", + "resolved": "https://registry.npmjs.org/preact/-/preact-10.29.7.tgz", + "integrity": "sha512-DCHYrK/B10yUD3ZjLfhZ3WIE/9Vf9VFUODcRE2dRomTYDpJk6z6L9wecSfhfE6M9ZTHUdyQkoC46arIDhEV84Q==", + "requires": {} + }, "readdirp": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz", diff --git a/package.json b/package.json index 32b2809..dbf4a42 100644 --- a/package.json +++ b/package.json @@ -21,5 +21,8 @@ "sass": "^1.101.0", "tslint": "^6.1.3", "typescript": "^5.9.3" + }, + "dependencies": { + "preact": "^10.29.7" } } diff --git a/pages/common_templ.go b/pages/common_templ.go index f9e3ab2..5c71b7e 100644 --- a/pages/common_templ.go +++ b/pages/common_templ.go @@ -1,6 +1,6 @@ // Code generated by templ - DO NOT EDIT. -// templ: version: v0.3.1001 +// templ: version: v0.3.1020 package pages //lint:file-ignore SA4006 This context is only used if a nested component is present. @@ -62,7 +62,7 @@ func Head(hosts Hosts) templ.Component { var templ_7745c5c3_Var2 templ.SafeURL templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinURLErrs(dnsPrefetchURL(hosts.ApiHost)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `pages/common.templ`, Line: 31, Col: 62} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `common.templ`, Line: 31, Col: 62} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2)) if templ_7745c5c3_Err != nil { @@ -75,7 +75,7 @@ func Head(hosts Hosts) templ.Component { var templ_7745c5c3_Var3 templ.SafeURL templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinURLErrs(dnsPrefetchURL(hosts.ImgHost)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `pages/common.templ`, Line: 32, Col: 62} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `common.templ`, Line: 32, Col: 62} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3)) if templ_7745c5c3_Err != nil { @@ -173,11 +173,11 @@ func Shield(hosts Hosts, title, text, color string) templ.Component { return templ_7745c5c3_Err } var templ_7745c5c3_Var7 string - templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(shieldURL(hosts.ImgHost, title, text, color)) + templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.ResolveAttributeValue(shieldURL(hosts.ImgHost, title, text, color)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `pages/common.templ`, Line: 55, Col: 56} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `common.templ`, Line: 55, Col: 56} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var7) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -186,11 +186,11 @@ func Shield(hosts Hosts, title, text, color string) templ.Component { return templ_7745c5c3_Err } var templ_7745c5c3_Var8 string - templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(shieldAlt(title, text)) + templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.ResolveAttributeValue(shieldAlt(title, text)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `pages/common.templ`, Line: 55, Col: 87} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `common.templ`, Line: 55, Col: 87} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var8) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } diff --git a/pages/dashboard.templ b/pages/dashboard.templ index 81a3add..f184982 100644 --- a/pages/dashboard.templ +++ b/pages/dashboard.templ @@ -9,17 +9,15 @@ templ DashboardPage(hosts Hosts) { @Header()
-
-
-

Dashboard

-
+
+
@Footer() diff --git a/pages/dashboard_templ.go b/pages/dashboard_templ.go index 72d21e7..30e0095 100644 --- a/pages/dashboard_templ.go +++ b/pages/dashboard_templ.go @@ -1,6 +1,6 @@ // Code generated by templ - DO NOT EDIT. -// templ: version: v0.3.1001 +// templ: version: v0.3.1020 package pages //lint:file-ignore SA4006 This context is only used if a nested component is present. @@ -45,7 +45,7 @@ func DashboardPage(hosts Hosts) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "

Dashboard

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } diff --git a/pages/index_templ.go b/pages/index_templ.go index e6d977d..4ad8920 100644 --- a/pages/index_templ.go +++ b/pages/index_templ.go @@ -1,6 +1,6 @@ // Code generated by templ - DO NOT EDIT. -// templ: version: v0.3.1001 +// templ: version: v0.3.1020 package pages //lint:file-ignore SA4006 This context is only used if a nested component is present. diff --git a/pages/privacy_templ.go b/pages/privacy_templ.go index 43725f5..ec5dcf9 100644 --- a/pages/privacy_templ.go +++ b/pages/privacy_templ.go @@ -1,6 +1,6 @@ // Code generated by templ - DO NOT EDIT. -// templ: version: v0.3.1001 +// templ: version: v0.3.1020 package pages //lint:file-ignore SA4006 This context is only used if a nested component is present. diff --git a/run-local.sh b/run-local.sh new file mode 100755 index 0000000..c476f44 --- /dev/null +++ b/run-local.sh @@ -0,0 +1,102 @@ +#!/usr/bin/env bash + +# Build and run the debug server behind Caddy. The local MySQL container is +# started first and every schema migration runs against a clean database before +# the Go process. +# Caddy remains in the foreground; Ctrl-C stops and reaps every local process. + +set -euo pipefail + +repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +mysql_container="shieldeddotdev-local-mysql" +server_pid="" +mysql_started=false + +mysql() { + docker exec --interactive "$mysql_container" mysql --protocol=tcp -h 127.0.0.1 -uadmin -ppassword shielded "$@" +} + +cleanup() { + status=$? + trap - EXIT INT TERM + + if [[ -n "$server_pid" ]] && kill -0 "$server_pid" 2>/dev/null; then + kill -TERM "$server_pid" 2>/dev/null || true + wait "$server_pid" 2>/dev/null || true + fi + if [[ "$mysql_started" == true ]]; then + docker rm --force "$mysql_container" >/dev/null 2>&1 || true + fi + + exit "$status" +} + +trap cleanup EXIT +trap 'exit 130' INT +trap 'exit 143' TERM + +if ! command -v caddy >/dev/null 2>&1; then + echo "Caddy is required. Install it, then run this script again." >&2 + exit 1 +fi +if ! command -v docker >/dev/null 2>&1; then + echo "Docker is required. Install or start Docker, then run this script again." >&2 + exit 1 +fi +if ! docker info >/dev/null 2>&1; then + echo "The Docker daemon is not available. Start Docker, then run this script again." >&2 + exit 1 +fi + +cd "$repo_dir" + +# A local run always starts with a new container. This also clears an abandoned +# container left behind by an interrupted earlier run. +docker rm --force "$mysql_container" >/dev/null 2>&1 || true +docker run --detach \ + --name "$mysql_container" \ + --publish 127.0.0.1:3306:3306 \ + --env MYSQL_DATABASE=shielded \ + --env MYSQL_USER=admin \ + --env MYSQL_PASSWORD=password \ + --env MYSQL_ROOT_PASSWORD=local-root-password \ + --health-cmd='mysqladmin ping -h localhost -uadmin -ppassword' \ + --health-interval=2s \ + --health-timeout=5s \ + --health-retries=30 \ + --health-start-period=10s \ + mysql:8.4 >/dev/null +mysql_started=true + +for ((attempt = 1; attempt <= 60; attempt++)); do + if docker exec "$mysql_container" mysqladmin --protocol=tcp -h 127.0.0.1 -uadmin -ppassword ping --silent >/dev/null 2>&1; then + break + fi + if ((attempt == 60)); then + echo "MySQL did not become ready. Inspect it with: docker logs $mysql_container" >&2 + exit 1 + fi + sleep 1 +done + +while IFS= read -r migration; do + migration_name="$(basename "$migration")" + echo "Applying migration: $migration_name" + mysql < "$migration" +done < <(find "$repo_dir/schema" -maxdepth 1 -type f -name '[0-9][0-9][0-9]_*.sql' -print | LC_ALL=C sort) + +# DebugAuthHandler authenticates this fixed local user. Ensure the foreign-key +# parent exists before the dashboard creates shields or user-level API tokens. +mysql -e "INSERT INTO users (user_id, login, email) VALUES (1, 'debug', 'fake@example.com') ON DUPLICATE KEY UPDATE login = VALUES(login), email = VALUES(email)" + +make \ + BIN=shielded-debug \ + BUILDTAGS=debug \ + LDADDIT="-X main.rootHost=local.shielded.dev -X main.apiHost=api.local.shielded.dev -X main.imgHost=img.local.shielded.dev" \ + build + +./shielded-debug -run-local=true & +server_pid=$! + +echo "Shielded debug server started (pid $server_pid). Starting Caddy; press Ctrl-C to stop Caddy, the server, and MySQL." +caddy run --config "$repo_dir/Caddyfile.local" --adapter caddyfile diff --git a/schema/000_BASELINE.sql b/schema/000_BASELINE.sql new file mode 100644 index 0000000..6851c55 --- /dev/null +++ b/schema/000_BASELINE.sql @@ -0,0 +1,54 @@ +/* + Navicat Premium Dump SQL + + Source Server : Shielded.dev + Source Server Type : MariaDB + Source Server Version : 100338 (10.3.38-MariaDB-0+deb10u1) + Source Host : localhost:3306 + Source Schema : shielded + + Target Server Type : MariaDB + Target Server Version : 100338 (10.3.38-MariaDB-0+deb10u1) + File Encoding : 65001 + + Date: 24/07/2026 10:25:40 +*/ + +SET NAMES utf8mb4; +SET FOREIGN_KEY_CHECKS = 0; + +-- ---------------------------- +-- Table structure for shields +-- ---------------------------- +DROP TABLE IF EXISTS `shields`; +CREATE TABLE `shields` ( + `shield_id` int(10) unsigned NOT NULL AUTO_INCREMENT, + `public_id` varchar(128) NOT NULL, + `user_id` int(10) unsigned NOT NULL, + `name` varchar(255) NOT NULL, + `title` varchar(255) NOT NULL, + `text` varchar(255) NOT NULL, + `color` varchar(6) NOT NULL, + `secret` varchar(255) NOT NULL, + `stamp_created` timestamp NOT NULL DEFAULT current_timestamp(), + `stamp_updated` timestamp NOT NULL DEFAULT current_timestamp() ON UPDATE current_timestamp(), + PRIMARY KEY (`shield_id`) USING BTREE, + UNIQUE KEY `secret` (`secret`), + UNIQUE KEY `unqz_public_id` (`public_id`) USING BTREE, + KEY `user_id` (`user_id`), + CONSTRAINT `shields_ibfk_1` FOREIGN KEY (`user_id`) REFERENCES `users` (`user_id`) ON DELETE CASCADE ON UPDATE CASCADE +) ENGINE=InnoDB AUTO_INCREMENT=107 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; + +-- ---------------------------- +-- Table structure for users +-- ---------------------------- +DROP TABLE IF EXISTS `users`; +CREATE TABLE `users` ( + `user_id` int(10) unsigned NOT NULL, + `login` varchar(255) DEFAULT NULL, + `email` varchar(255) DEFAULT NULL, + `created` timestamp NULL DEFAULT current_timestamp(), + PRIMARY KEY (`user_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; + +SET FOREIGN_KEY_CHECKS = 1; diff --git a/schema/001_user_api_tokens.sql b/schema/001_user_api_tokens.sql new file mode 100644 index 0000000..d803ba8 --- /dev/null +++ b/schema/001_user_api_tokens.sql @@ -0,0 +1,12 @@ +CREATE TABLE `user_api_tokens` ( + `api_token_id` int(10) unsigned NOT NULL AUTO_INCREMENT COMMENT 'Unique identifier for a user API token', + `user_id` int(10) unsigned NOT NULL COMMENT 'Owner user identifier', + `description` varchar(255) NOT NULL COMMENT 'User-provided token description', + `token_hash` binary(32) NOT NULL COMMENT 'SHA-256 hash of the API token', + `date_created` timestamp NOT NULL DEFAULT current_timestamp() COMMENT 'Time the API token was created', + `date_last_used` timestamp NULL DEFAULT NULL COMMENT 'Time the API token was last used', + PRIMARY KEY (`api_token_id`), + UNIQUE KEY `unq_user_api_tokens_token_hash` (`token_hash`), + KEY `idx_user_api_tokens_user_id` (`user_id`), + CONSTRAINT `user_api_tokens_ibfk_1` FOREIGN KEY (`user_id`) REFERENCES `users` (`user_id`) ON DELETE CASCADE ON UPDATE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; diff --git a/schema/002_shield_key.sql b/schema/002_shield_key.sql new file mode 100644 index 0000000..6f2a8c9 --- /dev/null +++ b/schema/002_shield_key.sql @@ -0,0 +1,3 @@ +ALTER TABLE `shields` + ADD COLUMN `shield_key` varchar(64) DEFAULT NULL COMMENT 'Optional user-defined key for API lookup' AFTER `public_id`, + ADD UNIQUE KEY `unq_shields_shield_key` (`user_id`, `shield_key`); diff --git a/scss/_dashboard.scss b/scss/_dashboard.scss index 3e4831c..66d3287 100644 --- a/scss/_dashboard.scss +++ b/scss/_dashboard.scss @@ -3,9 +3,10 @@ @use "shared"; %dashboard { + > .add-button { display: block; - margin: 0 auto; + margin: 0 auto 1em; transform: scale(1.2); @@ -20,6 +21,62 @@ margin-bottom: 2em; } + .api-tokens--controller { + @extend %bordered-box; + + margin-top: 2rem; + padding: 1rem; + + > h3 { + margin-bottom: 0.5rem; + } + + > p, + > form, + > div { + margin-top: 0.75rem; + } + + form { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + align-items: center; + + label { + font-weight: 600; + } + + input { + flex: 1 1 16rem; + } + } + + table { + width: 100%; + margin-top: 0.5rem; + + th, + td { + padding: 0.5rem; + border-bottom: 1px solid variables.$border; + } + + th { + font-weight: 600; + } + } + + > .created-api-token { + display: flex; + gap: 0.5rem; + + input { + flex: 1; + } + } + } + .shield--controller { display: grid; @@ -156,6 +213,12 @@ @extend %input-container; } + .input-error { + color: variables.$danger; + font-size: 0.85em; + margin-top: 0.35em; + } + .name-input { @extend %padded-container; } @@ -218,3 +281,35 @@ } } } + +#dashboard .dashboard-navigation { + display: flex; + gap: 0.25rem; + margin-bottom: 1.5rem; + border-bottom: 1px solid variables.$border; + + > a { + display: block; + padding: 0.5rem 0.9rem; + border: 1px solid variables.$border; + border-bottom: 0; + border-radius: 6px 6px 0 0; + background: variables.$control; + color: variables.$textColor; + font-weight: 600; + text-decoration: none; + + &:hover, + &:focus-visible { + background: variables.$hover; + } + + &[aria-current="page"] { + position: relative; + bottom: -1px; + padding-bottom: calc(0.5rem + 1px); + background: white; + color: variables.$hot; + } + } +} diff --git a/static/main.js b/static/main.js new file mode 100644 index 0000000..edb92ee --- /dev/null +++ b/static/main.js @@ -0,0 +1,475 @@ +var n,l$1,u$2,i$1,r$1,o$1,e$1,f$2,c$1,a$1,s$1,h$1,p$1,v$1,d$1={},w$1=[],_=/acit|ex(?:s|g|n|p|$)|rph|grid|ows|mnc|ntw|ine[ch]|zoo|^ord|itera/i,g$1=Array.isArray;function m$1(n,l){for(var u in l)n[u]=l[u];return n}function b(n){n&&n.parentNode&&n.parentNode.removeChild(n);}function k$1(l,u,t){var i,r,o,e={};for(o in u)"key"==o?i=u[o]:"ref"==o?r=u[o]:e[o]=u[o];if(arguments.length>2&&(e.children=arguments.length>3?n.call(arguments,2):t),"function"==typeof l&&null!=l.defaultProps)for(o in l.defaultProps) void 0===e[o]&&(e[o]=l.defaultProps[o]);return x(l,e,i,r,null)}function x(n,t,i,r,o){var e={type:n,props:t,key:i,ref:r,__k:null,__:null,__b:0,__e:null,__c:null,constructor:void 0,__v:null==o?++u$2:o,__i:-1,__u:0};return null==o&&null!=l$1.vnode&&l$1.vnode(e),e}function S(n){return n.children}function C$1(n,l){this.props=n,this.context=l;}function $(n,l){if(null==l)return n.__?$(n.__,n.__i+1):null;for(var u;ll&&i$1.sort(e$1),n=i$1.shift(),l=i$1.length,I(n);}finally{i$1.length=H.__r=0;}}function L(n,l,u,t,i,r,o,e,f,c,a){var s,h,p,v,y,_,g,m=t&&t.__k||w$1,b=l.length;for(f=T$1(u,l,m,f,b),s=0;s0?o=n.__k[r]=x(o.type,o.props,o.key,o.ref?o.ref:null,o.__v):n.__k[r]=o,f=r+h,o.__=n,o.__b=n.__b+1,e=null,-1!=(c=o.__i=O(o,u,f,s))&&(s--,(e=u[c])&&(e.__u|=2)),null==e||null==e.__v?(-1==c&&(i>a?h--:if?h--:h++,o.__u|=4))):n.__k[r]=null;if(s)for(r=0;r(a?1:0))for(i=u-1,r=u+1;i>=0||r=0?i--:r++])&&0==(2&c.__u)&&e==c.key&&f==c.type)return o;return -1}function z$1(n,l,u){"-"==l[0]?n.setProperty(l,null==u?"":u):n[l]=null==u?"":"number"!=typeof u||_.test(l)?u:u+"px";}function N(n,l,u,t,i){var r,o;n:if("style"==l)if("string"==typeof u)n.style.cssText=u;else {if("string"==typeof t&&(n.style.cssText=t=""),t)for(l in t)u&&l in u||z$1(n.style,l,"");if(u)for(l in u)t&&u[l]==t[l]||z$1(n.style,l,u[l]);}else if("o"==l[0]&&"n"==l[1])r=l!=(l=l.replace(s$1,"$1")),o=l.toLowerCase(),l=o in n||"onFocusOut"==l||"onFocusIn"==l?o.slice(2):l.slice(2),n.l||(n.l={}),n.l[l+r]=u,u?t?u[a$1]=t[a$1]:(u[a$1]=h$1,n.addEventListener(l,r?v$1:p$1,r)):n.removeEventListener(l,r?v$1:p$1,r);else {if("http://www.w3.org/2000/svg"==i)l=l.replace(/xlink(H|:h)/,"h").replace(/sName$/,"s");else if("width"!=l&&"height"!=l&&"href"!=l&&"list"!=l&&"form"!=l&&"tabIndex"!=l&&"download"!=l&&"rowSpan"!=l&&"colSpan"!=l&&"role"!=l&&"popover"!=l&&l in n)try{n[l]=null==u?"":u;break n}catch(n){}"function"==typeof u||(null==u||false===u&&"-"!=l[4]?n.removeAttribute(l):n.setAttribute(l,"popover"==l&&1==u?"":u));}}function V(n){return function(u){if(this.l){var t=this.l[u.type+n];if(null==u[c$1])u[c$1]=h$1++;else if(u[c$1]0?n:g$1(n)?n.map(E):void 0!==n.constructor?null:m$1({},n)}function G(u,t,i,r,o,e,f,c,a){var s,h,p,v,y,w,_,m=i.props||d$1,k=t.props,x=t.type;if("svg"==x?o="http://www.w3.org/2000/svg":"math"==x?o="http://www.w3.org/1998/Math/MathML":o||(o="http://www.w3.org/1999/xhtml"),null!=e)for(s=0;s=u.__.length&&u.__.push({}),u.__[n]}function d(n){return o=1,y(D,n)}function y(n,u,i){var o=s(t++,2);if(o.t=n,!o.__c&&(o.__=[D(void 0,u),function(n){var t=o.__N?o.__N[0]:o.__[0],r=o.t(t,n);t!==r&&(o.__N=[r,o.__[1]],o.__c.setState({}));}],o.__c=r,!r.__f)){var f=function(n,t,r){if(!o.__c.__H)return true;var u=false,i=o.__c.props!==n;if(o.__c.__H.__.some(function(n){if(n.__N){u=true;var t=n.__[0];n.__=n.__N,n.__N=void 0,t!==n.__[0]&&(i=true);}}),c){var f=c.call(this,n,t,r);return u?f||i:f}return !u||i};r.__f=true;var c=r.shouldComponentUpdate,e=r.componentWillUpdate;r.componentWillUpdate=function(n,t,r){if(this.__e){var u=c;c=void 0,f(n,t,r),c=u;}e&&e.call(this,n,t,r);},r.shouldComponentUpdate=f;}return o.__N||o.__}function h(n,u){var i=s(t++,3);!c.__s&&C(i.__H,u)&&(i.__=n,i.u=u,r.__H.__h.push(i));}function A(n){return o=5,T(function(){return {current:n}},[])}function T(n,r){var u=s(t++,7);return C(u.__H,r)&&(u.__=n(),u.__H=r,u.__h=n),u.__}function g(){var n=s(t++,11);if(!n.__){for(var u=r.__v;null!==u&&!u.__m&&null!==u.__;)u=u.__;var i=u.__m||(u.__m=[0,0]);n.__="P"+i[0]+"-"+i[1]++;}return n.__}function j(){for(var n;n=f.shift();){var t=n.__H;if(n.__P&&t)try{t.__h.some(z),t.__h.some(B),t.__h=[];}catch(r){t.__h=[],c.__e(r,n.__v);}}}c.__b=function(n){r=null,e&&e(n);},c.__=function(n,t){n&&t.__k&&t.__k.__m&&(n.__m=t.__k.__m),p&&p(n,t);},c.__r=function(n){a&&a(n),t=0;var i=(r=n.__c).__H;i&&(u===r?(i.__h=[],r.__h=[],i.__.some(function(n){n.__N&&(n.__=n.__N),n.u=n.__N=void 0;})):(i.__h.some(z),i.__h.some(B),i.__h=[],t=0)),u=r;},c.diffed=function(n){v&&v(n);var t=n.__c;t&&t.__H&&(t.__H.__h.length&&(1!==f.push(t)&&i===c.requestAnimationFrame||((i=c.requestAnimationFrame)||w)(j)),t.__H.__.some(function(n){n.u&&(n.__H=n.u,n.u=void 0);})),u=r=null;},c.__c=function(n,t){t.some(function(n){try{n.__h.some(z),n.__h=n.__h.filter(function(n){return !n.__||B(n)});}catch(r){t.some(function(n){n.__h&&(n.__h=[]);}),t=[],c.__e(r,n.__v);}}),l&&l(n,t);},c.unmount=function(n){m&&m(n);var t,r=n.__c;r&&r.__H&&(r.__H.__.some(function(n){try{z(n);}catch(n){t=n;}}),r.__H=void 0,t&&c.__e(t,r.__v));};var k="function"==typeof requestAnimationFrame;function w(n){var t,r=function(){clearTimeout(u),k&&cancelAnimationFrame(t),setTimeout(n);},u=setTimeout(r,35);k&&(t=requestAnimationFrame(r));}function z(n){var t=r,u=n.__c;"function"==typeof u&&(n.__c=void 0,u()),r=t;}function B(n){var t=r;n.__c=n.__(),r=t;}function C(n,t){return !n||n.length!==t.length||t.some(function(t,r){return t!==n[r]})}function D(n,t){return "function"==typeof t?t(n):t} + +function isRequestError(e) { + return e.ctx && e.event; +} +async function doRequest(endpoint, method = 'GET', body = null, mods = () => { }) { + const text = await doRawRequest(endpoint, method, body, mods); + return new Promise((resolve) => { + const data = JSON.parse(text); + resolve(data); + }); +} +function doRawRequest(endpoint, method = 'GET', body = null, mods = () => { }) { + const request = new XMLHttpRequest(); + request.open(method, `/${endpoint}`, true); + mods(request); + return new Promise((resolve, reject) => { + request.addEventListener('load', function (e) { + if (this.status >= 200 && this.status < 400) { + resolve(this.responseText); + } + else { + reject({ ctx: this, event: e }); + } + }); + request.withCredentials = true; + request.addEventListener('error', function (e) { + reject({ ctx: this, event: e }); + }); + if (body) { + request.send(body); + } + else { + request.send(); + } + }); +} + +class AuthedApi { + async isAuthed() { + try { + await doRequest('api/authed', 'GET', null); + return true; + } + catch (e) { + return false; + } + } +} + +class EnvApi { + getEnv() { + return doRequest('env', 'GET', null); + } +} + +class ShieldsApi { + getShields() { + return doRequest('api/shields', 'GET', null); + } + saveShield(n) { + if (n.ShieldID) { + return doRequest(`api/shield/${n.ShieldID}`, 'PUT', JSON.stringify(n)); + } + else { + return doRequest('api/shields', 'POST', JSON.stringify(n)); + } + } + deleteShield(n) { + return doRawRequest(`api/shield/${n.ShieldID}`, 'DELETE', JSON.stringify(n)); + } +} + +class UserAPITokensApi { + getTokens() { + return doRequest('api/user/tokens', 'GET', null); + } + createToken(description) { + return doRequest('api/user/tokens', 'POST', JSON.stringify({ Description: description })); + } + deleteToken(token) { + return doRawRequest(`api/user/tokens/${token.APITokenID}`, 'DELETE', null); + } +} + +class AbstractBaseController { + constructor(container, name) { + this.container = container; + this.name = name; + this.container.classList.add(`${this.name}--controller`); + } + attach(elm) { + elm.appendChild(this.container); + } + detach(elm) { + try { + elm.removeChild(this.container); + } + catch (e) { + return false; + } + return true; + } + getContainer() { + return this.container; + } + getName() { + return this.name; + } +} + +class ApiExampleController extends AbstractBaseController { + constructor(env, shield = null) { + super(document.createElement("div"), "api-example"); + this.env = env; + this.shield = shield; + this.preElm = document.createElement('pre'); + this.codeElm = document.createElement('code'); + this.examplesElm = document.createElement('ul'); + this.examples = [ + ['GitHub Action', gitHubActionExample, document.createElement('li')], + ['Curl', curlExample, document.createElement('li')], + ['JS', jsExample, document.createElement('li')], + ['PHP', phpExample, document.createElement('li')], + ]; + this.container.appendChild(this.examplesElm); + for (const example of this.examples) { + this.examplesElm.appendChild(example[2]); + example[2].textContent = example[0]; + example[2].addEventListener('click', () => this.selectExample(example)); + } + this.selectExample(this.examples[0]); + this.container.appendChild(this.preElm); + this.preElm.appendChild(this.codeElm); + } + selectExample(example) { + var _a, _b, _c, _d, _e, _f, _g, _h; + for (const ex of this.examples) { + ex[2].classList.remove('selected'); + } + this.codeElm.textContent = example[1](this.env, (_b = (_a = this.shield) === null || _a === void 0 ? void 0 : _a.Title) !== null && _b !== void 0 ? _b : 'Shielded.dev', (_d = (_c = this.shield) === null || _c === void 0 ? void 0 : _c.Text) !== null && _d !== void 0 ? _d : 'Rocks', (_f = (_e = this.shield) === null || _e === void 0 ? void 0 : _e.Color) !== null && _f !== void 0 ? _f : '0011aa', (_h = (_g = this.shield) === null || _g === void 0 ? void 0 : _g.Secret) !== null && _h !== void 0 ? _h : ''); + example[2].classList.add('selected'); + } +} +function curlExample(env, title, text, color, token) { + return `curl -X "POST" "https://${env.ApiHost}" \\ + -H 'Authorization: token ${addslashes_single_quotes(token)}' \\ + -H 'Content-Type: application/x-www-form-urlencoded; charset=utf-8' \\ + --data-urlencode 'title=${addslashes_single_quotes(title)}' \\ + --data-urlencode 'text=${addslashes_single_quotes(text)}' \\ + --data-urlencode 'color=${addslashes_single_quotes(color)}'`; +} +function addslashes_single_quotes(str) { + return `${str}`.replace(/\\/g, '\\$&').replace(/'/g, "\\'"); +} +function phpExample(env, title, text, color, token) { + return ` 'https://${env.ApiHost}', + CURLOPT_POST => true, + CURLOPT_RETURNTRANSFER => true, + CURLOPT_POSTFIELDS => [ + 'title' => ${JSON.stringify(title)}, + 'text' => ${JSON.stringify(text)}, + 'color' => ${JSON.stringify(color)}, + ], + CURLOPT_HTTPHEADER => [ + ${JSON.stringify('Authorization: token ' + token)}, + ], +]); + +curl_exec($ch); +if( curl_getinfo($ch, CURLINFO_HTTP_CODE) === 200 ) { + //ok +}`; +} +function jsExample(env, title, text, color, token) { + return `const params = new URLSearchParams(); + +params.append('title', ${JSON.stringify(title)}); +params.append('text', ${JSON.stringify(text)}); +params.append('color', ${JSON.stringify(color)}); + +fetch('https://${env.ApiHost}', { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + 'Authorization': ${JSON.stringify('token ' + token)}, + }, + body: params +}) +.then((result) => { + console.log('Success:', result); +}) +.catch((error) => { + console.error('Error:', error); +});`; +} +function gitHubActionExample(_, title, text, color, token) { + return `name: Update Shield +on: + push: + branches: + - master + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + + - name: Update Shielded.dev Badge + uses: shieldeddotdev/shielded-action@v1 + with: + # The token should be stored as a repository secret + shielded-token: ${JSON.stringify(token)} + title: ${JSON.stringify(title)} + text: ${JSON.stringify(text)} + color: ${JSON.stringify(color)}`; +} + +const shieldKeyPattern = /^[a-z0-9-]{3,64}$/; +const apiExamples = [ + ["GitHub Action", gitHubActionExample], + ["Curl", curlExample], + ["JS", jsExample], + ["PHP", phpExample], +]; +async function Dashboard(elm) { + if (elm === null) { + return; + } + const authApi = new AuthedApi(); + if (!await authApi.isAuthed()) { + window.location.href = "/"; + return; + } + const env = await (new EnvApi()).getEnv(); + R(u$1(DashboardApp, { env: env }), elm); +} +function DashboardApp({ env }) { + const [page, setPage] = d(currentPage()); + h(() => { + const updatePage = () => setPage(currentPage()); + window.addEventListener("hashchange", updatePage); + return () => window.removeEventListener("hashchange", updatePage); + }, []); + return u$1(S, { children: [u$1(DashboardNavigation, { page: page }), page === "user" ? u$1(S, { children: [u$1("h3", { children: "User tokens" }), u$1("div", { class: "dashboard--controller", children: u$1(APITokens, {}) })] }) : u$1(S, { children: [u$1("h3", { children: "Dashboard" }), u$1("div", { class: "dashboard--controller", children: u$1(Shields, { env: env }) })] })] }); +} +function DashboardNavigation({ page }) { + return u$1("nav", { class: "dashboard-navigation", "aria-label": "Dashboard navigation", children: [u$1("a", { href: "#/dashboard", "aria-current": page === "dashboard" ? "page" : undefined, children: "Shields" }), u$1("a", { href: "#/user", "aria-current": page === "user" ? "page" : undefined, children: "User tokens" })] }); +} +function Shields({ env }) { + const api = A(new ShieldsApi()).current; + const [shields, setShields] = d(null); + const [error, setError] = d(""); + h(() => { + void api.getShields() + .then(setShields) + .catch((requestError) => setError(errorMessage(requestError))); + }, [api]); + const createShield = async () => { + setError(""); + try { + const shield = await api.saveShield({ + Name: "New Shield", + Title: "New", + Color: "00AA55", + Text: "Shield", + }); + setShields((current) => [...(current || []), shield]); + setTimeout(() => window.scrollTo({ top: document.body.scrollHeight, behavior: "smooth" }), 100); + } + catch (requestError) { + setError(errorMessage(requestError)); + } + }; + const saveShield = async (shield) => { + setError(""); + try { + const savedShield = await api.saveShield(shield); + setShields((current) => (current || []).map((item) => item.ShieldID === savedShield.ShieldID ? savedShield : item)); + } + catch (requestError) { + setError(errorMessage(requestError)); + throw requestError; + } + }; + const deleteShield = async (shield) => { + setError(""); + try { + await api.deleteShield(shield); + setShields((current) => (current || []).filter((item) => item.ShieldID !== shield.ShieldID)); + } + catch (requestError) { + setError(errorMessage(requestError)); + } + }; + return u$1(S, { children: [u$1("button", { type: "button", class: "add-button primary", onClick: createShield, children: [u$1("span", { class: "icon", children: "\u2795" }), "New shield"] }), error !== "" && u$1("p", { children: error }), shields === null && error === "" && u$1("p", { children: "Loading shields\u2026" }), shields !== null && shields.map((shield) => u$1(ShieldForm, { shield: shield, env: env, onSave: saveShield, onDelete: deleteShield }, shield.ShieldID)), shields !== null && shields.length === 0 && u$1("h4", { class: "no-shields", children: "No shields yet. Click the button to get started." })] }); +} +function ShieldForm({ shield, env, onSave, onDelete }) { + const [draft, setDraft] = d(shield); + const draftRef = A(draft); + const saveTimeout = A(null); + const saveInFlight = A(false); + const pendingSave = A(null); + const [imageTick, setImageTick] = d(Date.now()); + const [example, setExample] = d(apiExamples[0]); + const [markdownCopied, setMarkdownCopied] = d(false); + const [secretCopied, setSecretCopied] = d(false); + const [secretVisible, setSecretVisible] = d(false); + h(() => () => { + if (saveTimeout.current !== null) { + clearTimeout(saveTimeout.current); + } + pendingSave.current = null; + }, []); + const flushSave = () => { + if (saveInFlight.current || pendingSave.current === null) { + saveTimeout.current = null; + return; + } + const next = pendingSave.current; + pendingSave.current = null; + saveTimeout.current = null; + saveInFlight.current = true; + void onSave(next) + .then(() => setImageTick(Date.now())) + .catch(() => undefined) + .finally(() => { + saveInFlight.current = false; + if (pendingSave.current !== null && saveTimeout.current === null) { + flushSave(); + } + }); + }; + const queueSave = (next) => { + if (saveTimeout.current !== null) { + clearTimeout(saveTimeout.current); + saveTimeout.current = null; + } + if (next.ShieldKey !== undefined && next.ShieldKey !== "" && !shieldKeyPattern.test(next.ShieldKey)) { + pendingSave.current = null; + return; + } + pendingSave.current = next; + saveTimeout.current = setTimeout(flushSave, 500); + }; + const handleInput = (event) => { + const input = event.target; + let next; + switch (input.name) { + case "Name": + case "ShieldKey": + case "Title": + case "Text": + next = { ...draftRef.current, [input.name]: input.value }; + break; + case "Color": + next = { ...draftRef.current, Color: input.value.replace(/^#/, "") }; + break; + default: + return; + } + draftRef.current = next; + setDraft(next); + queueSave(next); + }; + const deleteShield = async () => { + if (!confirm("Are you sure you want to delete this shield?")) { + return; + } + if (saveTimeout.current !== null) { + clearTimeout(saveTimeout.current); + } + await onDelete(draftRef.current); + }; + const markdown = `![${draft.Name}](https://${env.ImgHost}/s/${draft.PublicID})`; + const selectedExample = example[1](env, draft.Title, draft.Text, draft.Color, draft.Secret); + const shieldKeyInvalid = draft.ShieldKey !== undefined && draft.ShieldKey !== "" && !shieldKeyPattern.test(draft.ShieldKey); + const shieldKeyErrorID = `shield-${draft.ShieldID}-key-error`; + const markdownInputID = `shield-${draft.ShieldID}-markdown`; + const secretInputID = `shield-${draft.ShieldID}-secret`; + return u$1("form", { class: "shield--controller", onInput: handleInput, children: [u$1("section", { class: "name-input", children: [u$1(Input, { label: "Shield Name", name: "Name", value: draft.Name }), u$1(Input, { label: "Shield key", name: "ShieldKey", value: draft.ShieldKey || "", pattern: "[a-z0-9\\\\-]{3,64}", title: "Optional: 3-64 lowercase letters, digits, or hyphens", placeholder: "e.g. production-status", autoComplete: "off", spellcheck: false, "aria-invalid": shieldKeyInvalid, "aria-describedby": shieldKeyInvalid ? shieldKeyErrorID : undefined }), shieldKeyInvalid && u$1("p", { id: shieldKeyErrorID, class: "input-error", role: "alert", children: "Shield key must be 3-64 lowercase letters, digits, or hyphens." })] }), u$1("section", { class: "shield-container", children: u$1("img", { src: `https://${env.ImgHost}/s/${draft.PublicID}?break=${imageTick}`, alt: `${draft.Title}: ${draft.Text}` }) }), u$1("section", { class: "main-inputs", children: [u$1(Input, { label: "Title", name: "Title", value: draft.Title }), u$1(Input, { label: "Text", name: "Text", value: draft.Text }), u$1(Input, { label: "Color", name: "Color", value: `#${draft.Color.replace(/^#/, "")}`, type: "color", title: "Must be a hex color code" })] }), u$1("details", { class: "api-example", children: [u$1("summary", { children: "API Call Examples" }), u$1("div", { class: "api-example--controller", children: [u$1("ul", { children: apiExamples.map((item) => u$1("li", { class: item[0] === example[0] ? "selected" : "", onClick: () => setExample(item), children: item[0] }, item[0])) }), u$1("pre", { children: u$1("code", { children: selectedExample }) })] })] }), u$1("section", { class: "button-container", children: u$1("button", { type: "button", class: "danger", onClick: deleteShield, children: [u$1("span", { class: "icon", children: "\u274C" }), "Delete"] }) }), u$1("section", { class: "fancy-inputs", children: [u$1("label", { for: markdownInputID, children: "Markdown" }), u$1("div", { class: "markdown-input--controller", children: [u$1("input", { id: markdownInputID, value: markdown, readOnly: true, onClick: (event) => event.currentTarget.select() }), u$1("button", { type: "button", onClick: () => copy(markdown, setMarkdownCopied), children: markdownCopied ? "Copied!" : "Copy" })] }), u$1("label", { for: secretInputID, children: "This shield's API token" }), u$1("div", { class: "secret-input--controller", children: [u$1("input", { id: secretInputID, type: secretVisible ? "text" : "password", value: draft.Secret, readOnly: true, onClick: (event) => event.currentTarget.select() }), u$1("button", { type: "button", onClick: () => copy(draft.Secret, setSecretCopied), children: secretCopied ? "Copied!" : "Copy" }), u$1("button", { type: "button", onClick: () => setSecretVisible(!secretVisible), children: secretVisible ? "Hide" : "Reveal" })] })] })] }); +} +function Input({ label, ...attributes }) { + const generatedID = g(); + const id = attributes.id || generatedID; + return u$1("div", { class: "input-container", children: [u$1("label", { for: id, children: label }), u$1("input", { ...attributes, id: id })] }); +} +function APITokens() { + const api = A(new UserAPITokensApi()).current; + const [tokens, setTokens] = d(null); + const [description, setDescription] = d(""); + const [createdToken, setCreatedToken] = d(""); + const [copied, setCopied] = d(false); + const [error, setError] = d(""); + const [creating, setCreating] = d(false); + h(() => { + void api.getTokens() + .then(setTokens) + .catch((requestError) => setError(errorMessage(requestError))); + }, [api]); + const createToken = async (event) => { + event.preventDefault(); + const trimmedDescription = description.trim(); + if (trimmedDescription === "") { + setError("Description is required."); + return; + } + setCreating(true); + setError(""); + try { + const token = await api.createToken(trimmedDescription); + setTokens((current) => [token, ...(current || [])]); + setCreatedToken(token.Token); + setDescription(""); + setCopied(false); + } + catch (requestError) { + setError(errorMessage(requestError)); + } + finally { + setCreating(false); + } + }; + const revokeToken = async (token) => { + if (!confirm(`Revoke the token “${token.Description}”? This cannot be undone.`)) { + return; + } + setError(""); + try { + await api.deleteToken(token); + setTokens((current) => (current || []).filter((item) => item.APITokenID !== token.APITokenID)); + } + catch (requestError) { + setError(errorMessage(requestError)); + } + }; + return u$1("section", { class: "api-tokens--controller", children: [u$1("h3", { children: "User API tokens" }), u$1("p", { children: "Create a token to update or create any of your shields through the API." }), u$1("form", { onSubmit: createToken, children: [u$1("label", { for: "api-token-description", children: "Description" }), u$1("input", { id: "api-token-description", name: "description", value: description, onInput: (event) => setDescription(event.currentTarget.value), required: true, maxLength: 255, placeholder: "e.g. production deploy job" }), u$1("button", { type: "submit", class: "primary", disabled: creating, children: "Create token" })] }), error !== "" && u$1("p", { children: error }), createdToken !== "" && u$1("div", { class: "created-api-token", children: [u$1("p", { children: "Copy this token now. It will not be shown again." }), u$1("label", { for: "created-api-token", children: "New API token" }), u$1("input", { id: "created-api-token", value: createdToken, readOnly: true, onClick: (event) => event.currentTarget.select() }), u$1("button", { type: "button", onClick: () => copy(createdToken, setCopied), children: copied ? "Copied!" : "Copy" })] }), u$1("div", { children: [u$1("h4", { children: "Active tokens" }), tokens === null && error === "" && u$1("p", { children: "Loading API tokens\u2026" }), tokens !== null && tokens.length === 0 && u$1("p", { children: "No API tokens yet." }), tokens !== null && tokens.length > 0 && u$1("table", { children: [u$1("thead", { children: u$1("tr", { children: [u$1("th", { children: "Description" }), u$1("th", { children: "Created" }), u$1("th", { children: "Last used" }), u$1("th", {})] }) }), u$1("tbody", { children: tokens.map((token) => u$1("tr", { children: [u$1("td", { children: token.Description }), u$1("td", { children: formatTimestamp(token.Created) }), u$1("td", { children: token.LastUsed === null ? "Never" : formatTimestamp(token.LastUsed) }), u$1("td", { children: u$1("button", { type: "button", class: "danger", onClick: () => revokeToken(token), children: "Revoke" }) })] }, token.APITokenID)) })] })] })] }); +} +function currentPage() { + return window.location.hash === "#/user" ? "user" : "dashboard"; +} +function errorMessage(error) { + return isRequestError(error) ? error.ctx.responseText : "Unable to complete that request."; +} +function formatTimestamp(value) { + return new Date(value).toLocaleString(); +} +async function copy(value, setCopied) { + try { + await navigator.clipboard.writeText(value); + setCopied(true); + } + catch (error) { + console.error(error); + } +} + +async function Home(apiExampleElm) { + const envApi = new EnvApi(); + const env = await envApi.getEnv(); + const apiExample = new ApiExampleController(env); + apiExample.attach(apiExampleElm); +} + +export { Dashboard, Home }; diff --git a/static/style/style.css b/static/style/style.css index bcd281a..8c5fb7a 100644 --- a/static/style/style.css +++ b/static/style/style.css @@ -120,14 +120,14 @@ button.primary:hover, .home--app .primary.github-login:hover { box-shadow: 0px 0px 10px rgba(0, 0, 0, 0.8); } -.home--app article.home-core > .getting-started, .dashboard--controller .shield--controller { +.home--app article.home-core > .getting-started, .dashboard--controller .api-tokens--controller, .dashboard--controller .shield--controller { border: 1px solid #d1d9e0; border-radius: 6px; } .dashboard--controller > .add-button { display: block; - margin: 0 auto; + margin: 0 auto 1em; transform: scale(1.2); } .dashboard--controller > .add-button > .icon { @@ -138,6 +138,49 @@ button.primary:hover, .home--app .primary.github-login:hover { display: block; margin-bottom: 2em; } +.dashboard--controller .api-tokens--controller { + margin-top: 2rem; + padding: 1rem; +} +.dashboard--controller .api-tokens--controller > h3 { + margin-bottom: 0.5rem; +} +.dashboard--controller .api-tokens--controller > p, +.dashboard--controller .api-tokens--controller > form, +.dashboard--controller .api-tokens--controller > div { + margin-top: 0.75rem; +} +.dashboard--controller .api-tokens--controller form { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + align-items: center; +} +.dashboard--controller .api-tokens--controller form label { + font-weight: 600; +} +.dashboard--controller .api-tokens--controller form input { + flex: 1 1 16rem; +} +.dashboard--controller .api-tokens--controller table { + width: 100%; + margin-top: 0.5rem; +} +.dashboard--controller .api-tokens--controller table th, +.dashboard--controller .api-tokens--controller table td { + padding: 0.5rem; + border-bottom: 1px solid #d1d9e0; +} +.dashboard--controller .api-tokens--controller table th { + font-weight: 600; +} +.dashboard--controller .api-tokens--controller > .created-api-token { + display: flex; + gap: 0.5rem; +} +.dashboard--controller .api-tokens--controller > .created-api-token input { + flex: 1; +} .dashboard--controller .shield--controller { display: grid; } @@ -231,6 +274,11 @@ button.primary:hover, .home--app .primary.github-login:hover { .dashboard--controller .shield--controller .fancy-inputs input[type=color], .dashboard--controller .shield--controller .input-container input[type=color] { width: 5em; } +.dashboard--controller .shield--controller .input-error { + color: #d22937; + font-size: 0.85em; + margin-top: 0.35em; +} .dashboard--controller .shield--controller .fancy-inputs > .markdown-input--controller { margin-bottom: 0.3em; } @@ -261,6 +309,34 @@ button.primary:hover, .home--app .primary.github-login:hover { float: right; } +#dashboard .dashboard-navigation { + display: flex; + gap: 0.25rem; + margin-bottom: 1.5rem; + border-bottom: 1px solid #d1d9e0; +} +#dashboard .dashboard-navigation > a { + display: block; + padding: 0.5rem 0.9rem; + border: 1px solid #d1d9e0; + border-bottom: 0; + border-radius: 6px 6px 0 0; + background: #efefef; + color: #2a2a2a; + font-weight: 600; + text-decoration: none; +} +#dashboard .dashboard-navigation > a:hover, #dashboard .dashboard-navigation > a:focus-visible { + background: rgb(173.5, 184.5, 210.5); +} +#dashboard .dashboard-navigation > a[aria-current=page] { + position: relative; + bottom: -1px; + padding-bottom: calc(0.5rem + 1px); + background: white; + color: #5c72a6; +} + .home--app article p { margin: 0.6em 0; } diff --git a/ts/Controllers/ApiExampleController.ts b/ts/Controllers/ApiExampleController.ts index f8795de..510102f 100644 --- a/ts/Controllers/ApiExampleController.ts +++ b/ts/Controllers/ApiExampleController.ts @@ -47,7 +47,7 @@ export class ApiExampleController extends AbstractBaseController { } -function curlExample( +export function curlExample( env: EnvInterface, title: string, text: string, @@ -66,7 +66,7 @@ function addslashes_single_quotes(str: string) { return `${str}`.replace(/\\/g, '\\$&').replace(/'/g, "\\'"); }; -function phpExample( +export function phpExample( env: EnvInterface, title: string, text: string, @@ -97,7 +97,7 @@ if( curl_getinfo($ch, CURLINFO_HTTP_CODE) === 200 ) { }` } -function jsExample( +export function jsExample( env: EnvInterface, title: string, text: string, @@ -126,7 +126,7 @@ fetch('https://${env.ApiHost}', { });` } -function gitHubActionExample( +export function gitHubActionExample( _: EnvInterface, title: string, text: string, diff --git a/ts/Controllers/DashboardController.ts b/ts/Controllers/DashboardController.ts deleted file mode 100644 index ee88781..0000000 --- a/ts/Controllers/DashboardController.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { AbstractBaseController } from "../AbstractController"; -import { EnvInterface } from "../api/env"; -import { isRequestError } from "../api/request"; -import { ShieldsModel } from "../model/ShieldsModel"; -import { ErrorDialogController } from "./ErrorDialogController"; -import { ShieldController, ShieldImgRouter } from "./ShieldController"; - -export class DashboardController extends AbstractBaseController { - - private addBtn = document.createElement('button'); - private errDialog = new ErrorDialogController(); - - constructor(private model: ShieldsModel, private env: EnvInterface, private imgr : ShieldImgRouter) { - super(document.createElement('div'), 'dashboard'); - - this.container.append( - this.addBtn, - document.createElement('br'), - this.shieldsElm, - ); - - document.body.appendChild(this.errDialog.getContainer()); - - this.addBtn.innerText = 'New shield'; - this.addBtn.classList.add('add-button'); - this.addBtn.classList.add('primary'); - - const plusIcon = document.createElement('span'); - plusIcon.classList.add('icon'); - plusIcon.textContent = '➕'; - this.addBtn.prepend(plusIcon); - - this.addBtn.addEventListener('click', async ()=> { - await this.model.newShield(); - setTimeout(() => { - window.scrollTo({ top: document.body.scrollHeight, behavior: 'smooth' }); - }, 100); - }); - - this.render(); - } - - private shieldsElm = document.createElement('div'); - - public async render() { - this.shieldsElm.innerHTML = ''; - - let shields; - try { - shields = await this.model.getShields(); - }catch(e) { - console.log(e) - if(isRequestError(e)) { - this.errDialog.show(e.ctx.responseText); - } - return; - } - - for (const shield of shields) { - const sc = new ShieldController(shield, this.model, this.env, this.imgr); - sc.attach(this.shieldsElm); - } - - if( shields.length === 0 ) { - const msg = document.createElement('h4'); - msg.classList.add('no-shields'); - msg.innerText = 'No shields yet. Click the button to get started.'; - this.shieldsElm.appendChild(msg); - } - } - -} diff --git a/ts/Controllers/ErrorDialogController.ts b/ts/Controllers/ErrorDialogController.ts deleted file mode 100644 index c424295..0000000 --- a/ts/Controllers/ErrorDialogController.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { AbstractBaseController } from "../AbstractController"; - -export class ErrorDialogController extends AbstractBaseController { - constructor() { - super(document.createElement("dialog"), "error-dialog"); - } - - public show(message: string) { - this.container.innerText = message; - this.container.showModal(); - } -} diff --git a/ts/Controllers/MarkdownInputController.ts b/ts/Controllers/MarkdownInputController.ts deleted file mode 100644 index a768235..0000000 --- a/ts/Controllers/MarkdownInputController.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { AbstractBaseController } from "../AbstractController"; -import { ShieldInterface } from "../api/shields"; -import { ShieldImgRouter } from "./ShieldController"; - -export class MarkdownInputController extends AbstractBaseController { - - private input = document.createElement('input'); - private secretCopyButton = document.createElement('button'); - - constructor(shield: ShieldInterface, imgr: ShieldImgRouter) { - super(document.createElement("div"), "markdown-input"); - - this.input.value = imgr.shieldMarkdown(shield); - this.input.readOnly = true; - - this.input.addEventListener('click', () => { - this.input.select(); - }); - - this.secretCopyButton.type = 'button'; - - this.secretCopyButton.innerText = 'Copy'; - - this.container.appendChild(this.input); - this.container.appendChild(this.secretCopyButton); - - this.secretCopyButton.addEventListener('click', async () => { - await navigator.clipboard.writeText(this.input.value); - this.secretCopyButton.innerText = 'Copied!'; - }); - } - -} diff --git a/ts/Controllers/SecretInputContoller.ts b/ts/Controllers/SecretInputContoller.ts deleted file mode 100644 index 7985980..0000000 --- a/ts/Controllers/SecretInputContoller.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { AbstractBaseController } from "../AbstractController"; -import { ShieldInterface } from "../api/shields"; - -export class SecretInputController extends AbstractBaseController { - - private input = document.createElement('input'); - private secretCopyButton = document.createElement('button'); - private secretShowButton = document.createElement('button'); - - constructor(shield: ShieldInterface) { - super(document.createElement("div"), "secret-input"); - - this.input.type = 'password'; - this.input.value = shield.Secret; - this.input.readOnly = true; - - this.input.addEventListener('click', () => { - this.input.select(); - }); - - this.secretCopyButton.type = 'button'; - this.secretShowButton.type = 'button'; - - this.secretCopyButton.innerText = 'Copy'; - this.secretShowButton.innerText = 'Reveal'; - - this.container.appendChild(this.input); - this.container.appendChild(this.secretCopyButton); - this.container.appendChild(this.secretShowButton); - - this.secretCopyButton.addEventListener('click', async () => { - await navigator.clipboard.writeText(shield.Secret); - this.secretCopyButton.innerText = 'Copied!'; - }); - - this.secretShowButton.addEventListener('click', () => { - if (this.input.type == 'password') { - this.input.type = 'text'; - this.secretShowButton.innerText = 'Hide'; - } else { - this.input.type = 'password'; - this.secretShowButton.innerText = 'Reveal'; - } - }); - } -} diff --git a/ts/Controllers/ShieldController.ts b/ts/Controllers/ShieldController.ts deleted file mode 100644 index 2694648..0000000 --- a/ts/Controllers/ShieldController.ts +++ /dev/null @@ -1,165 +0,0 @@ -import { AbstractBaseController } from "../AbstractController"; -import { EnvInterface } from "../api/env"; -import { ShieldInterface } from "../api/shields"; -import { ShieldsModel } from "../model/ShieldsModel"; -import { ApiExampleController } from "./ApiExampleController"; -import { MarkdownInputController } from "./MarkdownInputController"; -import { SecretInputController } from "./SecretInputContoller"; - -export class ShieldController extends AbstractBaseController { - - private nameInput = document.createElement('input'); - private titleInput = document.createElement('input'); - private textInput = document.createElement('input'); - private colorInput = document.createElement('input'); - - private shieldImg = document.createElement('img'); - private updateBtn = document.createElement('button'); - private deleteBtn = document.createElement('button'); - - constructor(public readonly shield: ShieldInterface, model: ShieldsModel, env: EnvInterface, private imgr: ShieldImgRouter) { - super(document.createElement("form"), "shield"); - this.updateBtn.type = 'button'; - this.deleteBtn.type = 'button'; - - this.nameInput.value = shield.Name; - - this.titleInput.value = shield.Title; - this.textInput.value = shield.Text; - this.colorInput.value = '#' + shield.Color.replace(/^#/, ''); - - this.colorInput.pattern = "^#?([0-9a-fA-F]{3}){1,2}$"; - this.colorInput.title = "Must be a hex color code"; - this.colorInput.type = "color"; - - const nameInput = document.createElement('section'); - nameInput.classList.add('name-input'); - this.container.appendChild(nameInput); - - nameInput.appendChild(this.createInputContainer("Shield Name", this.nameInput)); - - const shieldContainer = document.createElement('section'); - shieldContainer.appendChild(this.shieldImg); - shieldContainer.classList.add('shield-container'); - this.container.appendChild(shieldContainer); - - const mainInputs = document.createElement('section'); - mainInputs.classList.add('main-inputs'); - this.container.appendChild(mainInputs); - - mainInputs.appendChild(this.createInputContainer("Title", this.titleInput)); - mainInputs.appendChild(this.createInputContainer("Text", this.textInput)); - mainInputs.appendChild(this.createInputContainer("Color", this.colorInput)); - - const apiExample = document.createElement('details'); - apiExample.classList.add('api-example'); - this.container.appendChild(apiExample); - - function renderExample(){ - apiExample.innerHTML = ''; - const ec = new ApiExampleController(env, shield); - const apiLabel = document.createElement('summary') - apiLabel.innerText = 'API Call Examples'; - apiExample.appendChild(apiLabel); - ec.attach(apiExample); - } - - renderExample(); - - this.container.addEventListener('input', () => { - shield.Name = this.nameInput.value; - shield.Title = this.titleInput.value; - shield.Text = this.textInput.value; - if (this.colorInput.value.startsWith('#')) { - shield.Color = this.colorInput.value.substring(1); - } else { - shield.Color = this.colorInput.value; - } - - renderExample(); - }); - - const buttonContainer = document.createElement('section'); - buttonContainer.classList.add('button-container'); - this.container.appendChild(buttonContainer); - - this.updateBtn.innerText = 'Update'; - this.updateBtn.classList.add('primary'); - buttonContainer.appendChild(this.updateBtn); - - const saveIcon = document.createElement('span'); - saveIcon.classList.add('icon'); - saveIcon.innerText = '💾'; - this.updateBtn.prepend(saveIcon); - - this.updateBtn.addEventListener('click', async () => { - if (!this.container.checkValidity()) { - this.container.reportValidity(); - return; - } - - await model.updateShield(shield); - }); - - this.deleteBtn.classList.add('danger'); - this.deleteBtn.innerText = 'Delete'; - - const xIcon = document.createElement('span'); - xIcon.classList.add('icon'); - xIcon.innerText = '❌'; - this.deleteBtn.prepend(xIcon); - - buttonContainer.appendChild(this.deleteBtn); - this.deleteBtn.addEventListener('click', () => { - if (confirm('Are you sure you want to delete this shield?')) { - model.deleteShield(shield); - } - }); - - const fancyInputs = document.createElement('section'); - fancyInputs.classList.add('fancy-inputs'); - this.container.appendChild(fancyInputs); - - const markdownLabel = document.createElement('label'); - markdownLabel.innerText = 'Markdown'; - fancyInputs.appendChild(markdownLabel); - (new MarkdownInputController(shield, this.imgr)).attach(fancyInputs); - - const apiTokenLabel = document.createElement('label'); - apiTokenLabel.innerText = 'API Token'; - fancyInputs.appendChild(apiTokenLabel); - (new SecretInputController(shield)).attach(fancyInputs); - - this.setImageWithCachebreaker(); - } - - private createInputContainer(label: string, input: HTMLInputElement) { - const div = document.createElement('div'); - div.classList.add('input-container'); - const labelEl = document.createElement('label'); - labelEl.innerText = label; - div.appendChild(labelEl); - div.appendChild(input); - - return div; - } - - private setImageWithCachebreaker() { - const ts = (new Date()).getTime(); - this.shieldImg.src = `${this.imgr.shieldURL(this.shield)}?break=${ts}`; - } - -} - -export class ShieldImgRouter { - public constructor(private readonly env: EnvInterface) { } - - public shieldURL(shield: ShieldInterface) { - return `https://${this.env.ImgHost}/s/${shield.PublicID}`; - } - - public shieldMarkdown(shield: ShieldInterface) { - return `![${shield.Name}](${this.shieldURL(shield)})`; - } -} - diff --git a/ts/Dashboard.ts b/ts/Dashboard.ts deleted file mode 100644 index ace99be..0000000 --- a/ts/Dashboard.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { AuthedApi } from "./api/authed"; -import { EnvApi } from "./api/env"; -import { ShieldsApi } from "./api/shields"; -import { DashboardController } from "./Controllers/DashboardController"; -import { ShieldImgRouter } from "./Controllers/ShieldController"; -import { ShieldsModel } from "./model/ShieldsModel"; - -export async function Dashboard(elm: HTMLElement) { - const authApi = new AuthedApi(); - if (!await authApi.isAuthed()) { - window.location.href = '/'; - } - - const envApi = new EnvApi(); - const env = await envApi.getEnv(); - const imgr = new ShieldImgRouter(env); - - const sapi = new ShieldsApi(); - const sm = new ShieldsModel(sapi); - const dc = new DashboardController(sm, env, imgr); - dc.attach(elm); - - sm.shieldEventEmitter.add(() => { - dc.render(); - }); -} diff --git a/ts/Dashboard.tsx b/ts/Dashboard.tsx new file mode 100644 index 0000000..3be12e6 --- /dev/null +++ b/ts/Dashboard.tsx @@ -0,0 +1,353 @@ +import { render } from "preact"; +import type { JSX } from "preact"; +import { useEffect, useId, useRef, useState } from "preact/hooks"; + +import { AuthedApi } from "./api/authed"; +import { EnvApi, EnvInterface } from "./api/env"; +import { isRequestError } from "./api/request"; +import { ShieldInterface, ShieldsApi } from "./api/shields"; +import { CreatedUserAPITokenInterface, UserAPITokenInterface, UserAPITokensApi } from "./api/tokens"; +import { + ApiExampleGeneratorInterface, + curlExample, + gitHubActionExample, + jsExample, + phpExample, +} from "./Controllers/ApiExampleController"; + +type Page = "dashboard" | "user"; + +const shieldKeyPattern = /^[a-z0-9-]{3,64}$/; +const apiExamples: [string, ApiExampleGeneratorInterface][] = [ + ["GitHub Action", gitHubActionExample], + ["Curl", curlExample], + ["JS", jsExample], + ["PHP", phpExample], +]; + +export async function Dashboard(elm: HTMLElement | null) { + if (elm === null) { + return; + } + + const authApi = new AuthedApi(); + if (!await authApi.isAuthed()) { + window.location.href = "/"; + return; + } + + const env = await (new EnvApi()).getEnv(); + render(, elm); +} + +function DashboardApp({ env }: { env: EnvInterface }) { + const [page, setPage] = useState(currentPage()); + + useEffect(() => { + const updatePage = () => setPage(currentPage()); + window.addEventListener("hashchange", updatePage); + return () => window.removeEventListener("hashchange", updatePage); + }, []); + + return <> + + {page === "user" ? <> +

User tokens

+
+ : <> +

Dashboard

+
+ } + ; +} + +function DashboardNavigation({ page }: { page: Page }) { + return ; +} + +function Shields({ env }: { env: EnvInterface }) { + const api = useRef(new ShieldsApi()).current; + const [shields, setShields] = useState(null); + const [error, setError] = useState(""); + + useEffect(() => { + void api.getShields() + .then(setShields) + .catch((requestError: unknown) => setError(errorMessage(requestError))); + }, [api]); + + const createShield = async () => { + setError(""); + try { + const shield = await api.saveShield({ + Name: "New Shield", + Title: "New", + Color: "00AA55", + Text: "Shield", + }); + setShields((current) => [...(current || []), shield]); + setTimeout(() => window.scrollTo({ top: document.body.scrollHeight, behavior: "smooth" }), 100); + } catch (requestError) { + setError(errorMessage(requestError)); + } + }; + + const saveShield = async (shield: ShieldInterface) => { + setError(""); + try { + const savedShield = await api.saveShield(shield); + setShields((current) => (current || []).map((item) => item.ShieldID === savedShield.ShieldID ? savedShield : item)); + } catch (requestError) { + setError(errorMessage(requestError)); + throw requestError; + } + }; + + const deleteShield = async (shield: ShieldInterface) => { + setError(""); + try { + await api.deleteShield(shield); + setShields((current) => (current || []).filter((item) => item.ShieldID !== shield.ShieldID)); + } catch (requestError) { + setError(errorMessage(requestError)); + } + }; + + return <> + + {error !== "" &&

{error}

} + {shields === null && error === "" &&

Loading shields…

} + {shields !== null && shields.map((shield) => )} + {shields !== null && shields.length === 0 &&

No shields yet. Click the button to get started.

} + ; +} + +interface ShieldFormProps { + shield: ShieldInterface; + env: EnvInterface; + onSave(shield: ShieldInterface): Promise; + onDelete(shield: ShieldInterface): Promise; +} + +function ShieldForm({ shield, env, onSave, onDelete }: ShieldFormProps) { + const [draft, setDraft] = useState(shield); + const draftRef = useRef(draft); + const saveTimeout = useRef | null>(null); + const saveInFlight = useRef(false); + const pendingSave = useRef(null); + const [imageTick, setImageTick] = useState(Date.now()); + const [example, setExample] = useState(apiExamples[0]); + const [markdownCopied, setMarkdownCopied] = useState(false); + const [secretCopied, setSecretCopied] = useState(false); + const [secretVisible, setSecretVisible] = useState(false); + + useEffect(() => () => { + if (saveTimeout.current !== null) { + clearTimeout(saveTimeout.current); + } + pendingSave.current = null; + }, []); + + const flushSave = () => { + if (saveInFlight.current || pendingSave.current === null) { + saveTimeout.current = null; + return; + } + + const next = pendingSave.current; + pendingSave.current = null; + saveTimeout.current = null; + saveInFlight.current = true; + void onSave(next) + .then(() => setImageTick(Date.now())) + .catch(() => undefined) + .finally(() => { + saveInFlight.current = false; + if (pendingSave.current !== null && saveTimeout.current === null) { + flushSave(); + } + }); + }; + + const queueSave = (next: ShieldInterface) => { + if (saveTimeout.current !== null) { + clearTimeout(saveTimeout.current); + saveTimeout.current = null; + } + + if (next.ShieldKey !== undefined && next.ShieldKey !== "" && !shieldKeyPattern.test(next.ShieldKey)) { + pendingSave.current = null; + return; + } + + pendingSave.current = next; + saveTimeout.current = setTimeout(flushSave, 500); + }; + + const handleInput = (event: JSX.TargetedEvent) => { + const input = event.target as HTMLInputElement; + let next: ShieldInterface; + switch (input.name) { + case "Name": + case "ShieldKey": + case "Title": + case "Text": + next = { ...draftRef.current, [input.name]: input.value }; + break; + case "Color": + next = { ...draftRef.current, Color: input.value.replace(/^#/, "") }; + break; + default: + return; + } + + draftRef.current = next; + setDraft(next); + queueSave(next); + }; + + const deleteShield = async () => { + if (!confirm("Are you sure you want to delete this shield?")) { + return; + } + if (saveTimeout.current !== null) { + clearTimeout(saveTimeout.current); + } + await onDelete(draftRef.current); + }; + + const markdown = `![${draft.Name}](https://${env.ImgHost}/s/${draft.PublicID})`; + const selectedExample = example[1](env, draft.Title, draft.Text, draft.Color, draft.Secret); + const shieldKeyInvalid = draft.ShieldKey !== undefined && draft.ShieldKey !== "" && !shieldKeyPattern.test(draft.ShieldKey); + const shieldKeyErrorID = `shield-${draft.ShieldID}-key-error`; + const markdownInputID = `shield-${draft.ShieldID}-markdown`; + const secretInputID = `shield-${draft.ShieldID}-secret`; + + return
+
+ + + {shieldKeyInvalid && } +
+
{`${draft.Title}:
+
+ + + +
+
+ API Call Examples +
+
    {apiExamples.map((item) =>
  • setExample(item)}>{item[0]}
  • )}
+
{selectedExample}
+
+
+
+
+ +
event.currentTarget.select()} />
+ +
event.currentTarget.select()} />
+
+
; +} + +function Input({ label, ...attributes }: JSX.InputHTMLAttributes & { label: string }) { + const generatedID = useId(); + const id = attributes.id || generatedID; + return
; +} + +function APITokens() { + const api = useRef(new UserAPITokensApi()).current; + const [tokens, setTokens] = useState(null); + const [description, setDescription] = useState(""); + const [createdToken, setCreatedToken] = useState(""); + const [copied, setCopied] = useState(false); + const [error, setError] = useState(""); + const [creating, setCreating] = useState(false); + + useEffect(() => { + void api.getTokens() + .then(setTokens) + .catch((requestError: unknown) => setError(errorMessage(requestError))); + }, [api]); + + const createToken = async (event: JSX.TargetedEvent) => { + event.preventDefault(); + const trimmedDescription = description.trim(); + if (trimmedDescription === "") { + setError("Description is required."); + return; + } + + setCreating(true); + setError(""); + try { + const token: CreatedUserAPITokenInterface = await api.createToken(trimmedDescription); + setTokens((current) => [token, ...(current || [])]); + setCreatedToken(token.Token); + setDescription(""); + setCopied(false); + } catch (requestError) { + setError(errorMessage(requestError)); + } finally { + setCreating(false); + } + }; + + const revokeToken = async (token: UserAPITokenInterface) => { + if (!confirm(`Revoke the token “${token.Description}”? This cannot be undone.`)) { + return; + } + setError(""); + try { + await api.deleteToken(token); + setTokens((current) => (current || []).filter((item) => item.APITokenID !== token.APITokenID)); + } catch (requestError) { + setError(errorMessage(requestError)); + } + }; + + return
+

User API tokens

+

Create a token to update or create any of your shields through the API.

+
+ + setDescription(event.currentTarget.value)} required maxLength={255} placeholder="e.g. production deploy job" /> + +
+ {error !== "" &&

{error}

} + {createdToken !== "" &&

Copy this token now. It will not be shown again.

event.currentTarget.select()} />
} +
+

Active tokens

+ {tokens === null && error === "" &&

Loading API tokens…

} + {tokens !== null && tokens.length === 0 &&

No API tokens yet.

} + {tokens !== null && tokens.length > 0 && {tokens.map((token) => )}
DescriptionCreatedLast used
{token.Description}{formatTimestamp(token.Created)}{token.LastUsed === null ? "Never" : formatTimestamp(token.LastUsed)}
} +
+
; +} + +function currentPage(): Page { + return window.location.hash === "#/user" ? "user" : "dashboard"; +} + +function errorMessage(error: unknown) { + return isRequestError(error) ? error.ctx.responseText : "Unable to complete that request."; +} + +function formatTimestamp(value: string) { + return new Date(value).toLocaleString(); +} + +async function copy(value: string, setCopied: (copied: boolean) => void) { + try { + await navigator.clipboard.writeText(value); + setCopied(true); + } catch (error) { + console.error(error); + } +} diff --git a/ts/EventEmitter.ts b/ts/EventEmitter.ts deleted file mode 100644 index 2ca0110..0000000 --- a/ts/EventEmitter.ts +++ /dev/null @@ -1,14 +0,0 @@ -export type Listener = (e: T) => void; - -export class EventEmitter { - protected listeners = new Set>(); - - public add(callback: Listener) { - this.listeners.add(callback); - } - - public trigger( event: T ) { - this.listeners.forEach((fn) => fn(event)); - } - -} diff --git a/ts/api/shields.ts b/ts/api/shields.ts index 8f0453c..709f815 100644 --- a/ts/api/shields.ts +++ b/ts/api/shields.ts @@ -3,6 +3,7 @@ import { doRawRequest, doRequest } from "./request"; export interface ShieldInterface { ShieldID?: number; PublicID?: string; + ShieldKey?: string; UserID: number; Name: string; Title: string; diff --git a/ts/api/tokens.ts b/ts/api/tokens.ts new file mode 100644 index 0000000..725621c --- /dev/null +++ b/ts/api/tokens.ts @@ -0,0 +1,27 @@ +import { doRawRequest, doRequest } from "./request"; + +export interface UserAPITokenInterface { + APITokenID: number; + UserID: number; + Description: string; + Created: string; + LastUsed: string | null; +} + +export interface CreatedUserAPITokenInterface extends UserAPITokenInterface { + Token: string; +} + +export class UserAPITokensApi { + public getTokens() { + return doRequest('api/user/tokens', 'GET', null); + } + + public createToken(description: string) { + return doRequest('api/user/tokens', 'POST', JSON.stringify({ Description: description })); + } + + public deleteToken(token: UserAPITokenInterface) { + return doRawRequest(`api/user/tokens/${token.APITokenID}`, 'DELETE', null); + } +} diff --git a/ts/model/ShieldsModel.ts b/ts/model/ShieldsModel.ts deleted file mode 100644 index 0b422b2..0000000 --- a/ts/model/ShieldsModel.ts +++ /dev/null @@ -1,102 +0,0 @@ -import { ShieldInterface, ShieldsApi } from "../api/shields"; -import { EventEmitter } from "../EventEmitter"; - -export type ShieldActions = "created" | "deleted" | "changed" | "updating" | "updated" | "activated"; - -export interface ShieldEvent { shield: ShieldInterface; event: ShieldActions; } - -export class ShieldsModel { - - public readonly shieldEventEmitter = new EventEmitter(); - - public constructor(private shieldsApi: ShieldsApi) { } - - private shields: ShieldInterface[] = []; - - public async getShields() { - if (this.shields.length < 1) { - this.shields = await this.shieldsApi.getShields(); - } - - return this.shields; - } - - private timeouts: { [s: number]: { timeout: ReturnType, resolves: (() => void)[] } } = {}; - - public async deleteShield(shield: ShieldInterface) { - const shieldId = shield.ShieldID; - if (!shieldId) { - throw Error("Attempting to delete unpersisted shield"); - } - - await this.shieldsApi.deleteShield(shield); - this.shields = this.shields.filter((n) => shield !== n); - - this.shieldEventEmitter.trigger({ shield, event: "deleted" }); - } - - public updateShield(shield: ShieldInterface, debounce: number = 100) { - const shieldId = shield.ShieldID; - if (!shieldId) { - throw Error("Attempting to update unpersisted shield"); - } - - let updated = false; - this.shields.map((n) => { - if (n.ShieldID == shieldId) { - updated = true; - return shield; - } - - return n; - }); - - if (!updated) { - throw Error("Failed to update shield"); - } - - if (this.timeouts[shieldId]) { - clearTimeout(this.timeouts[shieldId].timeout); - } - - this.shieldEventEmitter.trigger({ shield, event: "changed" }); - - return new Promise((resolve) => { - let resolves : (() => void)[] = [resolve]; - if(this.timeouts[shieldId]) { - resolves = [...this.timeouts[shieldId].resolves, ...resolves]; - } - - this.timeouts[shieldId] = { - timeout: setTimeout(async () => { - this.shieldEventEmitter.trigger({ shield, event: "updating" }); - await this.shieldsApi.saveShield(shield); - this.shieldEventEmitter.trigger({ shield, event: "updated" }); - - for (const r of this.timeouts[shieldId].resolves) { - r(); - } - this.timeouts[shieldId].resolves = []; - }, debounce), - - resolves, - }; - }); - } - - public async newShield() { - const shield = await this.shieldsApi.saveShield({ - Name: 'New Shield', - Title: 'New', - Color: '00AA55', - Text: 'Shield', - }); - - this.shields.push(shield); - - this.shieldEventEmitter.trigger({ shield, event: "created" }); - - return shield; - } - -} diff --git a/tsconfig.json b/tsconfig.json index 31e563e..6d7c5e9 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -2,16 +2,17 @@ "compilerOptions": { /* Basic Options */ // "incremental": true, /* Enable incremental compilation */ - "target": "ES2015", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019' or 'ESNEXT'. */ + "target": "ES2018", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019' or 'ESNEXT'. */ "module": "ES2015", /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', or 'ESNext'. */ "lib": [ "es5", "dom", "dom.iterable", - "es2015" + "es2018" ], /* Specify library files to be included in the compilation: */ "allowJs": false, /* Allow javascript files to be compiled. */ // "checkJs": true, /* Report errors in .js files. */ - // "jsx": "preserve", /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */ + "jsx": "react-jsx", /* Use the automatic JSX transform. */ + "jsxImportSource": "preact", /* Use Preact's JSX runtime. */ // "declaration": true, /* Generates corresponding '.d.ts' file. */ // "declarationMap": true, /* Generates a sourcemap for each corresponding '.d.ts' file. */ // "sourceMap": true, /* Generates corresponding '.map' file. */