From ea0c8fc8c7bfa28a137a257daccdae7413e55e8f Mon Sep 17 00:00:00 2001 From: Jesse Donat Date: Fri, 24 Jul 2026 10:29:04 -0500 Subject: [PATCH 01/37] Init AI setup --- AGENTS.md | 106 ++++++++++++++++++++++++++++++++++++++++ schema/000_BASELINE.sql | 54 ++++++++++++++++++++ 2 files changed, 160 insertions(+) create mode 100644 AGENTS.md create mode 100644 schema/000_BASELINE.sql diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..c1f6bf7 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,106 @@ +# 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. | + +## 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`. + +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 (there are currently no committed Go test files) +``` + +`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. + +### 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. + +### 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. +- 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 repository currently has SQL files but no migration runner. Record/run migrations using the deployment process until a runner is added; do not assume the Go binary applies them automatically. + +### TypeScript + +- This is framework-free DOM code. Controllers own DOM creation and are attached through `AbstractBaseController`; `ShieldsModel` owns the in-memory shield list and emits rerender events. +- 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. 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/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; From f352e9dd81cbaab98f744cc977a839ef28b06c89 Mon Sep 17 00:00:00 2001 From: Jesse Donat Date: Fri, 24 Jul 2026 11:13:52 -0500 Subject: [PATCH 02/37] rough in nice debug test tool --- Caddyfile.local | 55 ++++++++++++++++++++++++++ run-local.sh | 102 ++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 157 insertions(+) create mode 100644 Caddyfile.local create mode 100755 run-local.sh 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/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 From 195c706778f4de24103e5bfe7824f5292ac58b07 Mon Sep 17 00:00:00 2001 From: Jesse Donat Date: Fri, 24 Jul 2026 11:27:21 -0500 Subject: [PATCH 03/37] feat: add user API token management --- cmd/shielded/main.go | 8 + dashboardapitokenhandlers.go | 129 ++++ model/user_api_tokens.go | 152 ++++ schema/001_user_api_tokens.sql | 12 + scss/_dashboard.scss | 56 ++ static/main.js | 822 ++++++++++++++++++++++ static/style/style.css | 45 +- ts/Controllers/DashboardController.ts | 6 +- ts/Controllers/UserAPITokensController.ts | 175 +++++ ts/Dashboard.ts | 6 +- ts/api/tokens.ts | 27 + ts/model/UserAPITokensModel.ts | 43 ++ 12 files changed, 1478 insertions(+), 3 deletions(-) create mode 100644 dashboardapitokenhandlers.go create mode 100644 model/user_api_tokens.go create mode 100644 schema/001_user_api_tokens.sql create mode 100644 static/main.js create mode 100644 ts/Controllers/UserAPITokensController.ts create mode 100644 ts/api/tokens.ts create mode 100644 ts/model/UserAPITokensModel.ts diff --git a/cmd/shielded/main.go b/cmd/shielded/main.go index c79d092..e388a31 100644 --- a/cmd/shielded/main.go +++ b/cmd/shielded/main.go @@ -59,6 +59,7 @@ func main() { um := model.NewUserMapper(db) sm := model.NewShieldMapper(db) + tm := model.NewUserAPITokenMapper(db) ro := mux.NewRouter() ro.PathPrefix("/").Host("www." + rootHost).Handler( @@ -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/tokens", tih.HandleGET).Methods("GET") + wo.HandleFunc("/api/tokens", tih.HandlePOST).Methods("POST") + + th := shieldeddotdev.NewDashboardUserAPITokenHandler(tm, jwta) + wo.HandleFunc("/api/token/{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/dashboardapitokenhandlers.go b/dashboardapitokenhandlers.go new file mode 100644 index 0000000..42b8436 --- /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/token/"+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/user_api_tokens.go b/model/user_api_tokens.go new file mode 100644 index 0000000..69fb808 --- /dev/null +++ b/model/user_api_tokens.go @@ -0,0 +1,152 @@ +package model + +import ( + "crypto/rand" + "crypto/sha256" + "database/sql" + "encoding/base64" + "errors" + "strings" + "time" + "unicode/utf8" +) + +const MaxUserAPITokenDescriptionLength = 255 + +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, stamp_created, stamp_last_used FROM user_api_tokens WHERE user_id = ? ORDER BY stamp_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, stamp_created, stamp_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 bearer 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 +} + +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 := "sdt_" + base64.RawURLEncoding.EncodeToString(secret) + return plainToken, sha256.Sum256([]byte(plainToken)), nil +} diff --git a/schema/001_user_api_tokens.sql b/schema/001_user_api_tokens.sql new file mode 100644 index 0000000..a80213f --- /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, + `user_id` int(10) unsigned NOT NULL, + `description` varchar(255) NOT NULL, + `token_hash` binary(32) NOT NULL, + `stamp_created` timestamp NOT NULL DEFAULT current_timestamp(), + `stamp_last_used` timestamp NULL DEFAULT NULL, + 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/scss/_dashboard.scss b/scss/_dashboard.scss index 3e4831c..c90dbb1 100644 --- a/scss/_dashboard.scss +++ b/scss/_dashboard.scss @@ -20,6 +20,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; + } + } + + > div:nth-of-type(1) { + display: flex; + gap: 0.5rem; + + input { + flex: 1; + } + } + } + .shield--controller { display: grid; diff --git a/static/main.js b/static/main.js new file mode 100644 index 0000000..5d19881 --- /dev/null +++ b/static/main.js @@ -0,0 +1,822 @@ +/*! ***************************************************************************** +Copyright (c) Microsoft Corporation. + +Permission to use, copy, modify, and/or distribute this software for any +purpose with or without fee is hereby granted. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +PERFORMANCE OF THIS SOFTWARE. +***************************************************************************** */ +/* global Reflect, Promise */ + + +function __awaiter(thisArg, _arguments, P, generator) { + function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); } + return new (P || (P = Promise))(function (resolve, reject) { + function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } + function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } + function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); } + step((generator = generator.apply(thisArg, _arguments || [])).next()); + }); +} + +function isRequestError(e) { + return e.ctx && e.event; +} +function doRequest(endpoint_1) { + return __awaiter(this, arguments, void 0, function* (endpoint, method = 'GET', body = null, mods = () => { }) { + const text = yield 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 { + isAuthed() { + return __awaiter(this, void 0, void 0, function* () { + try { + yield 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/tokens', 'GET', null); + } + createToken(description) { + return doRequest('api/tokens', 'POST', JSON.stringify({ Description: description })); + } + deleteToken(token) { + return doRawRequest(`api/token/${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 ErrorDialogController extends AbstractBaseController { + constructor() { + super(document.createElement("dialog"), "error-dialog"); + } + show(message) { + this.container.innerText = message; + this.container.showModal(); + } +} + +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)}`; +} + +class MarkdownInputController extends AbstractBaseController { + constructor(shield, imgr) { + super(document.createElement("div"), "markdown-input"); + this.input = document.createElement('input'); + this.secretCopyButton = document.createElement('button'); + 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', () => __awaiter(this, void 0, void 0, function* () { + yield navigator.clipboard.writeText(this.input.value); + this.secretCopyButton.innerText = 'Copied!'; + })); + } +} + +class SecretInputController extends AbstractBaseController { + constructor(shield) { + super(document.createElement("div"), "secret-input"); + this.input = document.createElement('input'); + this.secretCopyButton = document.createElement('button'); + this.secretShowButton = document.createElement('button'); + 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', () => __awaiter(this, void 0, void 0, function* () { + yield 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'; + } + }); + } +} + +class ShieldController extends AbstractBaseController { + constructor(shield, model, env, imgr) { + super(document.createElement("form"), "shield"); + this.shield = shield; + this.imgr = imgr; + this.nameInput = document.createElement('input'); + this.titleInput = document.createElement('input'); + this.textInput = document.createElement('input'); + this.colorInput = document.createElement('input'); + this.shieldImg = document.createElement('img'); + this.updateBtn = document.createElement('button'); + this.deleteBtn = document.createElement('button'); + 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', () => __awaiter(this, void 0, void 0, function* () { + if (!this.container.checkValidity()) { + this.container.reportValidity(); + return; + } + yield 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(); + } + createInputContainer(label, input) { + 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; + } + setImageWithCachebreaker() { + const ts = (new Date()).getTime(); + this.shieldImg.src = `${this.imgr.shieldURL(this.shield)}?break=${ts}`; + } +} +class ShieldImgRouter { + constructor(env) { + this.env = env; + } + shieldURL(shield) { + return `https://${this.env.ImgHost}/s/${shield.PublicID}`; + } + shieldMarkdown(shield) { + return `![${shield.Name}](${this.shieldURL(shield)})`; + } +} + +class UserAPITokensController extends AbstractBaseController { + constructor(model) { + super(document.createElement('section'), 'api-tokens'); + this.model = model; + this.createForm = document.createElement('form'); + this.descriptionInput = document.createElement('input'); + this.createButton = document.createElement('button'); + this.messageElm = document.createElement('p'); + this.createdTokenElm = document.createElement('div'); + this.tokensElm = document.createElement('div'); + const heading = document.createElement('h3'); + heading.textContent = 'API Tokens'; + const explanation = document.createElement('p'); + explanation.textContent = 'Create a token for a future API. Tokens do not grant access to the current shield update API.'; + const descriptionLabel = document.createElement('label'); + descriptionLabel.htmlFor = 'api-token-description'; + descriptionLabel.textContent = 'Description'; + this.descriptionInput.id = 'api-token-description'; + this.descriptionInput.name = 'description'; + this.descriptionInput.required = true; + this.descriptionInput.maxLength = 255; + this.descriptionInput.placeholder = 'e.g. production deploy job'; + this.createButton.type = 'submit'; + this.createButton.textContent = 'Create token'; + this.createButton.classList.add('primary'); + this.createForm.append(descriptionLabel, this.descriptionInput, this.createButton); + this.container.append(heading, explanation, this.createForm, this.messageElm, this.createdTokenElm, this.tokensElm); + this.createForm.addEventListener('submit', (event) => __awaiter(this, void 0, void 0, function* () { + event.preventDefault(); + yield this.createToken(); + })); + this.model.tokenEventEmitter.add(() => { + this.render(); + }); + this.render(); + } + createToken() { + return __awaiter(this, void 0, void 0, function* () { + const description = this.descriptionInput.value.trim(); + this.descriptionInput.setCustomValidity(''); + if (description === '') { + this.descriptionInput.setCustomValidity('Description is required.'); + this.descriptionInput.reportValidity(); + return; + } + this.createButton.disabled = true; + this.setMessage(''); + try { + const token = yield this.model.createToken(description); + this.createForm.reset(); + this.showCreatedToken(token.Token); + } + catch (error) { + this.setMessage(this.errorMessage(error)); + } + finally { + this.createButton.disabled = false; + } + }); + } + render() { + return __awaiter(this, void 0, void 0, function* () { + this.tokensElm.innerHTML = ''; + let tokens; + try { + tokens = yield this.model.getTokens(); + } + catch (error) { + this.setMessage(this.errorMessage(error)); + return; + } + const heading = document.createElement('h4'); + heading.textContent = 'Active tokens'; + this.tokensElm.appendChild(heading); + if (tokens.length === 0) { + const empty = document.createElement('p'); + empty.textContent = 'No API tokens yet.'; + this.tokensElm.appendChild(empty); + return; + } + const table = document.createElement('table'); + const header = document.createElement('tr'); + for (const label of ['Description', 'Created', 'Last used', '']) { + const cell = document.createElement('th'); + cell.textContent = label; + header.appendChild(cell); + } + table.appendChild(header); + for (const token of tokens) { + const row = document.createElement('tr'); + row.appendChild(this.tableCell(token.Description)); + row.appendChild(this.tableCell(this.formatTimestamp(token.Created))); + row.appendChild(this.tableCell(token.LastUsed === null ? 'Never' : this.formatTimestamp(token.LastUsed))); + const actionCell = document.createElement('td'); + const revokeButton = document.createElement('button'); + revokeButton.type = 'button'; + revokeButton.textContent = 'Revoke'; + revokeButton.classList.add('danger'); + revokeButton.addEventListener('click', () => __awaiter(this, void 0, void 0, function* () { + if (confirm(`Revoke the token “${token.Description}”? This cannot be undone.`)) { + yield this.deleteToken(token); + } + })); + actionCell.appendChild(revokeButton); + row.appendChild(actionCell); + table.appendChild(row); + } + this.tokensElm.appendChild(table); + }); + } + deleteToken(token) { + return __awaiter(this, void 0, void 0, function* () { + this.setMessage(''); + try { + yield this.model.deleteToken(token); + } + catch (error) { + this.setMessage(this.errorMessage(error)); + } + }); + } + showCreatedToken(token) { + this.createdTokenElm.innerHTML = ''; + const warning = document.createElement('p'); + warning.textContent = 'Copy this token now. It will not be shown again.'; + const input = document.createElement('input'); + input.value = token; + input.readOnly = true; + input.addEventListener('click', () => input.select()); + const copyButton = document.createElement('button'); + copyButton.type = 'button'; + copyButton.textContent = 'Copy'; + copyButton.addEventListener('click', () => __awaiter(this, void 0, void 0, function* () { + yield navigator.clipboard.writeText(token); + copyButton.textContent = 'Copied!'; + })); + this.createdTokenElm.append(warning, input, copyButton); + } + tableCell(value) { + const cell = document.createElement('td'); + cell.textContent = value; + return cell; + } + formatTimestamp(value) { + return new Date(value).toLocaleString(); + } + errorMessage(error) { + if (isRequestError(error)) { + return error.ctx.responseText; + } + return 'Unable to manage API tokens.'; + } + setMessage(message) { + this.messageElm.textContent = message; + } +} + +class DashboardController extends AbstractBaseController { + constructor(model, tokensModel, env, imgr) { + super(document.createElement('div'), 'dashboard'); + this.model = model; + this.env = env; + this.imgr = imgr; + this.addBtn = document.createElement('button'); + this.errDialog = new ErrorDialogController(); + this.shieldsElm = document.createElement('div'); + 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', () => __awaiter(this, void 0, void 0, function* () { + yield this.model.newShield(); + setTimeout(() => { + window.scrollTo({ top: document.body.scrollHeight, behavior: 'smooth' }); + }, 100); + })); + (new UserAPITokensController(tokensModel)).attach(this.container); + this.render(); + } + render() { + return __awaiter(this, void 0, void 0, function* () { + this.shieldsElm.innerHTML = ''; + let shields; + try { + shields = yield 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); + } + }); + } +} + +class EventEmitter { + constructor() { + this.listeners = new Set(); + } + add(callback) { + this.listeners.add(callback); + } + trigger(event) { + this.listeners.forEach((fn) => fn(event)); + } +} + +class ShieldsModel { + constructor(shieldsApi) { + this.shieldsApi = shieldsApi; + this.shieldEventEmitter = new EventEmitter(); + this.shields = []; + this.timeouts = {}; + } + getShields() { + return __awaiter(this, void 0, void 0, function* () { + if (this.shields.length < 1) { + this.shields = yield this.shieldsApi.getShields(); + } + return this.shields; + }); + } + deleteShield(shield) { + return __awaiter(this, void 0, void 0, function* () { + const shieldId = shield.ShieldID; + if (!shieldId) { + throw Error("Attempting to delete unpersisted shield"); + } + yield this.shieldsApi.deleteShield(shield); + this.shields = this.shields.filter((n) => shield !== n); + this.shieldEventEmitter.trigger({ shield, event: "deleted" }); + }); + } + updateShield(shield, debounce = 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 = [resolve]; + if (this.timeouts[shieldId]) { + resolves = [...this.timeouts[shieldId].resolves, ...resolves]; + } + this.timeouts[shieldId] = { + timeout: setTimeout(() => __awaiter(this, void 0, void 0, function* () { + this.shieldEventEmitter.trigger({ shield, event: "updating" }); + yield 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, + }; + }); + } + newShield() { + return __awaiter(this, void 0, void 0, function* () { + const shield = yield this.shieldsApi.saveShield({ + Name: 'New Shield', + Title: 'New', + Color: '00AA55', + Text: 'Shield', + }); + this.shields.push(shield); + this.shieldEventEmitter.trigger({ shield, event: "created" }); + return shield; + }); + } +} + +class UserAPITokensModel { + constructor(tokensApi) { + this.tokensApi = tokensApi; + this.tokenEventEmitter = new EventEmitter(); + this.tokens = []; + this.loaded = false; + } + getTokens() { + return __awaiter(this, void 0, void 0, function* () { + if (!this.loaded) { + this.tokens = yield this.tokensApi.getTokens(); + this.loaded = true; + } + return this.tokens; + }); + } + createToken(description) { + return __awaiter(this, void 0, void 0, function* () { + const created = yield this.tokensApi.createToken(description); + const token = { + APITokenID: created.APITokenID, + UserID: created.UserID, + Description: created.Description, + Created: created.Created, + LastUsed: created.LastUsed, + }; + this.tokens.unshift(token); + this.tokenEventEmitter.trigger(token); + return created; + }); + } + deleteToken(token) { + return __awaiter(this, void 0, void 0, function* () { + yield this.tokensApi.deleteToken(token); + this.tokens = this.tokens.filter((current) => current.APITokenID !== token.APITokenID); + this.tokenEventEmitter.trigger(token); + }); + } +} + +function Dashboard(elm) { + return __awaiter(this, void 0, void 0, function* () { + const authApi = new AuthedApi(); + if (!(yield authApi.isAuthed())) { + window.location.href = '/'; + } + const envApi = new EnvApi(); + const env = yield envApi.getEnv(); + const imgr = new ShieldImgRouter(env); + const sapi = new ShieldsApi(); + const sm = new ShieldsModel(sapi); + const tokensApi = new UserAPITokensApi(); + const tokensModel = new UserAPITokensModel(tokensApi); + const dc = new DashboardController(sm, tokensModel, env, imgr); + dc.attach(elm); + sm.shieldEventEmitter.add(() => { + dc.render(); + }); + }); +} + +function Home(apiExampleElm) { + return __awaiter(this, void 0, void 0, function* () { + const envApi = new EnvApi(); + const env = yield 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..3858c0e 100644 --- a/static/style/style.css +++ b/static/style/style.css @@ -120,7 +120,7 @@ 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; } @@ -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 > div:nth-of-type(1) { + display: flex; + gap: 0.5rem; +} +.dashboard--controller .api-tokens--controller > div:nth-of-type(1) input { + flex: 1; +} .dashboard--controller .shield--controller { display: grid; } diff --git a/ts/Controllers/DashboardController.ts b/ts/Controllers/DashboardController.ts index ee88781..f759912 100644 --- a/ts/Controllers/DashboardController.ts +++ b/ts/Controllers/DashboardController.ts @@ -2,15 +2,17 @@ import { AbstractBaseController } from "../AbstractController"; import { EnvInterface } from "../api/env"; import { isRequestError } from "../api/request"; import { ShieldsModel } from "../model/ShieldsModel"; +import { UserAPITokensModel } from "../model/UserAPITokensModel"; import { ErrorDialogController } from "./ErrorDialogController"; import { ShieldController, ShieldImgRouter } from "./ShieldController"; +import { UserAPITokensController } from "./UserAPITokensController"; export class DashboardController extends AbstractBaseController { private addBtn = document.createElement('button'); private errDialog = new ErrorDialogController(); - constructor(private model: ShieldsModel, private env: EnvInterface, private imgr : ShieldImgRouter) { + constructor(private model: ShieldsModel, tokensModel: UserAPITokensModel, private env: EnvInterface, private imgr : ShieldImgRouter) { super(document.createElement('div'), 'dashboard'); this.container.append( @@ -37,6 +39,8 @@ export class DashboardController extends AbstractBaseController { }, 100); }); + (new UserAPITokensController(tokensModel)).attach(this.container); + this.render(); } diff --git a/ts/Controllers/UserAPITokensController.ts b/ts/Controllers/UserAPITokensController.ts new file mode 100644 index 0000000..9f6ff8f --- /dev/null +++ b/ts/Controllers/UserAPITokensController.ts @@ -0,0 +1,175 @@ +import { AbstractBaseController } from "../AbstractController"; +import { isRequestError } from "../api/request"; +import { UserAPITokenInterface } from "../api/tokens"; +import { UserAPITokensModel } from "../model/UserAPITokensModel"; + +export class UserAPITokensController extends AbstractBaseController { + + private readonly createForm = document.createElement('form'); + private readonly descriptionInput = document.createElement('input'); + private readonly createButton = document.createElement('button'); + private readonly messageElm = document.createElement('p'); + private readonly createdTokenElm = document.createElement('div'); + private readonly tokensElm = document.createElement('div'); + + public constructor(private model: UserAPITokensModel) { + super(document.createElement('section'), 'api-tokens'); + + const heading = document.createElement('h3'); + heading.textContent = 'API Tokens'; + const explanation = document.createElement('p'); + explanation.textContent = 'Create a token for a future API. Tokens do not grant access to the current shield update API.'; + + const descriptionLabel = document.createElement('label'); + descriptionLabel.htmlFor = 'api-token-description'; + descriptionLabel.textContent = 'Description'; + this.descriptionInput.id = 'api-token-description'; + this.descriptionInput.name = 'description'; + this.descriptionInput.required = true; + this.descriptionInput.maxLength = 255; + this.descriptionInput.placeholder = 'e.g. production deploy job'; + + this.createButton.type = 'submit'; + this.createButton.textContent = 'Create token'; + this.createButton.classList.add('primary'); + + this.createForm.append(descriptionLabel, this.descriptionInput, this.createButton); + this.container.append(heading, explanation, this.createForm, this.messageElm, this.createdTokenElm, this.tokensElm); + + this.createForm.addEventListener('submit', async (event) => { + event.preventDefault(); + await this.createToken(); + }); + + this.model.tokenEventEmitter.add(() => { + this.render(); + }); + this.render(); + } + + private async createToken() { + const description = this.descriptionInput.value.trim(); + this.descriptionInput.setCustomValidity(''); + if (description === '') { + this.descriptionInput.setCustomValidity('Description is required.'); + this.descriptionInput.reportValidity(); + return; + } + + this.createButton.disabled = true; + this.setMessage(''); + try { + const token = await this.model.createToken(description); + this.createForm.reset(); + this.showCreatedToken(token.Token); + } catch (error) { + this.setMessage(this.errorMessage(error)); + } finally { + this.createButton.disabled = false; + } + } + + private async render() { + this.tokensElm.innerHTML = ''; + + let tokens: UserAPITokenInterface[]; + try { + tokens = await this.model.getTokens(); + } catch (error) { + this.setMessage(this.errorMessage(error)); + return; + } + + const heading = document.createElement('h4'); + heading.textContent = 'Active tokens'; + this.tokensElm.appendChild(heading); + if (tokens.length === 0) { + const empty = document.createElement('p'); + empty.textContent = 'No API tokens yet.'; + this.tokensElm.appendChild(empty); + return; + } + + const table = document.createElement('table'); + const header = document.createElement('tr'); + for (const label of ['Description', 'Created', 'Last used', '']) { + const cell = document.createElement('th'); + cell.textContent = label; + header.appendChild(cell); + } + table.appendChild(header); + + for (const token of tokens) { + const row = document.createElement('tr'); + row.appendChild(this.tableCell(token.Description)); + row.appendChild(this.tableCell(this.formatTimestamp(token.Created))); + row.appendChild(this.tableCell(token.LastUsed === null ? 'Never' : this.formatTimestamp(token.LastUsed))); + + const actionCell = document.createElement('td'); + const revokeButton = document.createElement('button'); + revokeButton.type = 'button'; + revokeButton.textContent = 'Revoke'; + revokeButton.classList.add('danger'); + revokeButton.addEventListener('click', async () => { + if (confirm(`Revoke the token “${token.Description}”? This cannot be undone.`)) { + await this.deleteToken(token); + } + }); + actionCell.appendChild(revokeButton); + row.appendChild(actionCell); + table.appendChild(row); + } + + this.tokensElm.appendChild(table); + } + + private async deleteToken(token: UserAPITokenInterface) { + this.setMessage(''); + try { + await this.model.deleteToken(token); + } catch (error) { + this.setMessage(this.errorMessage(error)); + } + } + + private showCreatedToken(token: string) { + this.createdTokenElm.innerHTML = ''; + const warning = document.createElement('p'); + warning.textContent = 'Copy this token now. It will not be shown again.'; + const input = document.createElement('input'); + input.value = token; + input.readOnly = true; + input.addEventListener('click', () => input.select()); + const copyButton = document.createElement('button'); + copyButton.type = 'button'; + copyButton.textContent = 'Copy'; + copyButton.addEventListener('click', async () => { + await navigator.clipboard.writeText(token); + copyButton.textContent = 'Copied!'; + }); + + this.createdTokenElm.append(warning, input, copyButton); + } + + private tableCell(value: string) { + const cell = document.createElement('td'); + cell.textContent = value; + return cell; + } + + private formatTimestamp(value: string) { + return new Date(value).toLocaleString(); + } + + private errorMessage(error: unknown) { + if (isRequestError(error)) { + return error.ctx.responseText; + } + return 'Unable to manage API tokens.'; + } + + private setMessage(message: string) { + this.messageElm.textContent = message; + } + +} diff --git a/ts/Dashboard.ts b/ts/Dashboard.ts index ace99be..67df271 100644 --- a/ts/Dashboard.ts +++ b/ts/Dashboard.ts @@ -1,9 +1,11 @@ import { AuthedApi } from "./api/authed"; import { EnvApi } from "./api/env"; import { ShieldsApi } from "./api/shields"; +import { UserAPITokensApi } from "./api/tokens"; import { DashboardController } from "./Controllers/DashboardController"; import { ShieldImgRouter } from "./Controllers/ShieldController"; import { ShieldsModel } from "./model/ShieldsModel"; +import { UserAPITokensModel } from "./model/UserAPITokensModel"; export async function Dashboard(elm: HTMLElement) { const authApi = new AuthedApi(); @@ -17,7 +19,9 @@ export async function Dashboard(elm: HTMLElement) { const sapi = new ShieldsApi(); const sm = new ShieldsModel(sapi); - const dc = new DashboardController(sm, env, imgr); + const tokensApi = new UserAPITokensApi(); + const tokensModel = new UserAPITokensModel(tokensApi); + const dc = new DashboardController(sm, tokensModel, env, imgr); dc.attach(elm); sm.shieldEventEmitter.add(() => { diff --git a/ts/api/tokens.ts b/ts/api/tokens.ts new file mode 100644 index 0000000..cb0af6f --- /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/tokens', 'GET', null); + } + + public createToken(description: string) { + return doRequest('api/tokens', 'POST', JSON.stringify({ Description: description })); + } + + public deleteToken(token: UserAPITokenInterface) { + return doRawRequest(`api/token/${token.APITokenID}`, 'DELETE', null); + } +} diff --git a/ts/model/UserAPITokensModel.ts b/ts/model/UserAPITokensModel.ts new file mode 100644 index 0000000..da6daf7 --- /dev/null +++ b/ts/model/UserAPITokensModel.ts @@ -0,0 +1,43 @@ +import { CreatedUserAPITokenInterface, UserAPITokenInterface, UserAPITokensApi } from "../api/tokens"; +import { EventEmitter } from "../EventEmitter"; + +export class UserAPITokensModel { + + public readonly tokenEventEmitter = new EventEmitter(); + + private tokens: UserAPITokenInterface[] = []; + private loaded = false; + + public constructor(private tokensApi: UserAPITokensApi) { } + + public async getTokens() { + if (!this.loaded) { + this.tokens = await this.tokensApi.getTokens(); + this.loaded = true; + } + + return this.tokens; + } + + public async createToken(description: string): Promise { + const created = await this.tokensApi.createToken(description); + const token: UserAPITokenInterface = { + APITokenID: created.APITokenID, + UserID: created.UserID, + Description: created.Description, + Created: created.Created, + LastUsed: created.LastUsed, + }; + this.tokens.unshift(token); + this.tokenEventEmitter.trigger(token); + + return created; + } + + public async deleteToken(token: UserAPITokenInterface) { + await this.tokensApi.deleteToken(token); + this.tokens = this.tokens.filter((current) => current.APITokenID !== token.APITokenID); + this.tokenEventEmitter.trigger(token); + } + +} From 7de956dd9b7e06cf95c0a058a8decd83ec135b99 Mon Sep 17 00:00:00 2001 From: Jesse Donat Date: Fri, 24 Jul 2026 11:27:30 -0500 Subject: [PATCH 04/37] docs: describe local development and token architecture --- AGENTS.md | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index c1f6bf7..754054e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -18,6 +18,7 @@ Read [ARCHITECTURE.md](ARCHITECTURE.md) before changing routing, persistence, au | `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 @@ -39,6 +40,10 @@ 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 @@ -67,13 +72,14 @@ go test ./... # compile/test all Go packages (there - 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 bearer credentials: generate them with `crypto/rand`, store only a one-way hash, return the plaintext only at creation, and scope dashboard reads/deletes by the authenticated user. Do not wire them into the per-shield update API unless that API's contract is explicitly changed. ### 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. - 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 repository currently has SQL files but no migration runner. Record/run migrations using the deployment process until a runner is added; do not assume the Go binary applies them automatically. +- 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 From b0b9667a4aa2c03b18b329daff07598f6d458091 Mon Sep 17 00:00:00 2001 From: Jesse Donat Date: Fri, 24 Jul 2026 12:01:42 -0500 Subject: [PATCH 05/37] feat: add user settings SPA view --- cmd/shielded/main.go | 2 +- pages/common_templ.go | 18 +- pages/dashboard.templ | 9 +- pages/dashboard_templ.go | 4 +- pages/index_templ.go | 2 +- pages/privacy_templ.go | 2 +- static/main.js | 356 ++++++++++++++------------ ts/Controllers/DashboardController.ts | 6 +- ts/Dashboard.ts | 38 ++- ts/User.ts | 13 + 10 files changed, 259 insertions(+), 191 deletions(-) create mode 100644 ts/User.ts diff --git a/cmd/shielded/main.go b/cmd/shielded/main.go index e388a31..c7e5458 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 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..5db7061 100644 --- a/pages/dashboard.templ +++ b/pages/dashboard.templ @@ -9,9 +9,12 @@ templ DashboardPage(hosts Hosts) { @Header()
-
-
diff --git a/pages/dashboard_templ.go b/pages/dashboard_templ.go index 72d21e7..8c93cc8 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/static/main.js b/static/main.js index 5d19881..0b91411 100644 --- a/static/main.js +++ b/static/main.js @@ -100,18 +100,6 @@ class ShieldsApi { } } -class UserAPITokensApi { - getTokens() { - return doRequest('api/tokens', 'GET', null); - } - createToken(description) { - return doRequest('api/tokens', 'POST', JSON.stringify({ Description: description })); - } - deleteToken(token) { - return doRawRequest(`api/token/${token.APITokenID}`, 'DELETE', null); - } -} - class AbstractBaseController { constructor(container, name) { this.container = container; @@ -445,6 +433,164 @@ class ShieldImgRouter { } } +class DashboardController extends AbstractBaseController { + constructor(model, env, imgr) { + super(document.createElement('div'), 'dashboard'); + this.model = model; + this.env = env; + this.imgr = imgr; + this.addBtn = document.createElement('button'); + this.errDialog = new ErrorDialogController(); + this.shieldsElm = document.createElement('div'); + 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', () => __awaiter(this, void 0, void 0, function* () { + yield this.model.newShield(); + setTimeout(() => { + window.scrollTo({ top: document.body.scrollHeight, behavior: 'smooth' }); + }, 100); + })); + this.render(); + } + render() { + return __awaiter(this, void 0, void 0, function* () { + this.shieldsElm.innerHTML = ''; + let shields; + try { + shields = yield 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); + } + }); + } +} + +class EventEmitter { + constructor() { + this.listeners = new Set(); + } + add(callback) { + this.listeners.add(callback); + } + trigger(event) { + this.listeners.forEach((fn) => fn(event)); + } +} + +class ShieldsModel { + constructor(shieldsApi) { + this.shieldsApi = shieldsApi; + this.shieldEventEmitter = new EventEmitter(); + this.shields = []; + this.timeouts = {}; + } + getShields() { + return __awaiter(this, void 0, void 0, function* () { + if (this.shields.length < 1) { + this.shields = yield this.shieldsApi.getShields(); + } + return this.shields; + }); + } + deleteShield(shield) { + return __awaiter(this, void 0, void 0, function* () { + const shieldId = shield.ShieldID; + if (!shieldId) { + throw Error("Attempting to delete unpersisted shield"); + } + yield this.shieldsApi.deleteShield(shield); + this.shields = this.shields.filter((n) => shield !== n); + this.shieldEventEmitter.trigger({ shield, event: "deleted" }); + }); + } + updateShield(shield, debounce = 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 = [resolve]; + if (this.timeouts[shieldId]) { + resolves = [...this.timeouts[shieldId].resolves, ...resolves]; + } + this.timeouts[shieldId] = { + timeout: setTimeout(() => __awaiter(this, void 0, void 0, function* () { + this.shieldEventEmitter.trigger({ shield, event: "updating" }); + yield 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, + }; + }); + } + newShield() { + return __awaiter(this, void 0, void 0, function* () { + const shield = yield this.shieldsApi.saveShield({ + Name: 'New Shield', + Title: 'New', + Color: '00AA55', + Text: 'Shield', + }); + this.shields.push(shield); + this.shieldEventEmitter.trigger({ shield, event: "created" }); + return shield; + }); + } +} + +class UserAPITokensApi { + getTokens() { + return doRequest('api/tokens', 'GET', null); + } + createToken(description) { + return doRequest('api/tokens', 'POST', JSON.stringify({ Description: description })); + } + deleteToken(token) { + return doRawRequest(`api/token/${token.APITokenID}`, 'DELETE', null); + } +} + class UserAPITokensController extends AbstractBaseController { constructor(model) { super(document.createElement('section'), 'api-tokens'); @@ -602,153 +748,6 @@ class UserAPITokensController extends AbstractBaseController { } } -class DashboardController extends AbstractBaseController { - constructor(model, tokensModel, env, imgr) { - super(document.createElement('div'), 'dashboard'); - this.model = model; - this.env = env; - this.imgr = imgr; - this.addBtn = document.createElement('button'); - this.errDialog = new ErrorDialogController(); - this.shieldsElm = document.createElement('div'); - 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', () => __awaiter(this, void 0, void 0, function* () { - yield this.model.newShield(); - setTimeout(() => { - window.scrollTo({ top: document.body.scrollHeight, behavior: 'smooth' }); - }, 100); - })); - (new UserAPITokensController(tokensModel)).attach(this.container); - this.render(); - } - render() { - return __awaiter(this, void 0, void 0, function* () { - this.shieldsElm.innerHTML = ''; - let shields; - try { - shields = yield 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); - } - }); - } -} - -class EventEmitter { - constructor() { - this.listeners = new Set(); - } - add(callback) { - this.listeners.add(callback); - } - trigger(event) { - this.listeners.forEach((fn) => fn(event)); - } -} - -class ShieldsModel { - constructor(shieldsApi) { - this.shieldsApi = shieldsApi; - this.shieldEventEmitter = new EventEmitter(); - this.shields = []; - this.timeouts = {}; - } - getShields() { - return __awaiter(this, void 0, void 0, function* () { - if (this.shields.length < 1) { - this.shields = yield this.shieldsApi.getShields(); - } - return this.shields; - }); - } - deleteShield(shield) { - return __awaiter(this, void 0, void 0, function* () { - const shieldId = shield.ShieldID; - if (!shieldId) { - throw Error("Attempting to delete unpersisted shield"); - } - yield this.shieldsApi.deleteShield(shield); - this.shields = this.shields.filter((n) => shield !== n); - this.shieldEventEmitter.trigger({ shield, event: "deleted" }); - }); - } - updateShield(shield, debounce = 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 = [resolve]; - if (this.timeouts[shieldId]) { - resolves = [...this.timeouts[shieldId].resolves, ...resolves]; - } - this.timeouts[shieldId] = { - timeout: setTimeout(() => __awaiter(this, void 0, void 0, function* () { - this.shieldEventEmitter.trigger({ shield, event: "updating" }); - yield 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, - }; - }); - } - newShield() { - return __awaiter(this, void 0, void 0, function* () { - const shield = yield this.shieldsApi.saveShield({ - Name: 'New Shield', - Title: 'New', - Color: '00AA55', - Text: 'Shield', - }); - this.shields.push(shield); - this.shieldEventEmitter.trigger({ shield, event: "created" }); - return shield; - }); - } -} - class UserAPITokensModel { constructor(tokensApi) { this.tokensApi = tokensApi; @@ -789,24 +788,55 @@ class UserAPITokensModel { } } +function User(elm) { + const tokenSection = document.createElement('div'); + tokenSection.classList.add('dashboard--controller'); + elm.appendChild(tokenSection); + const tokensApi = new UserAPITokensApi(); + const tokensModel = new UserAPITokensModel(tokensApi); + (new UserAPITokensController(tokensModel)).attach(tokenSection); +} + function Dashboard(elm) { return __awaiter(this, void 0, void 0, function* () { const authApi = new AuthedApi(); if (!(yield authApi.isAuthed())) { window.location.href = '/'; + return; } const envApi = new EnvApi(); const env = yield envApi.getEnv(); const imgr = new ShieldImgRouter(env); const sapi = new ShieldsApi(); const sm = new ShieldsModel(sapi); - const tokensApi = new UserAPITokensApi(); - const tokensModel = new UserAPITokensModel(tokensApi); - const dc = new DashboardController(sm, tokensModel, env, imgr); - dc.attach(elm); + const dc = new DashboardController(sm, env, imgr); + const dashboardPage = document.createElement('article'); + const dashboardHeading = document.createElement('h3'); + dashboardHeading.innerText = 'Dashboard'; + dashboardPage.appendChild(dashboardHeading); + dc.attach(dashboardPage); + const userPage = document.createElement('article'); + const userHeading = document.createElement('h3'); + userHeading.innerText = 'User Settings'; + userPage.appendChild(userHeading); + User(userPage); sm.shieldEventEmitter.add(() => { dc.render(); }); + let activePage = null; + function renderPage() { + const nextPage = window.location.hash === '#/user' ? userPage : dashboardPage; + if (activePage === nextPage) { + return; + } + if (activePage !== null) { + elm.removeChild(activePage); + } + elm.appendChild(nextPage); + activePage = nextPage; + } + window.addEventListener('hashchange', renderPage); + renderPage(); }); } diff --git a/ts/Controllers/DashboardController.ts b/ts/Controllers/DashboardController.ts index f759912..ee88781 100644 --- a/ts/Controllers/DashboardController.ts +++ b/ts/Controllers/DashboardController.ts @@ -2,17 +2,15 @@ import { AbstractBaseController } from "../AbstractController"; import { EnvInterface } from "../api/env"; import { isRequestError } from "../api/request"; import { ShieldsModel } from "../model/ShieldsModel"; -import { UserAPITokensModel } from "../model/UserAPITokensModel"; import { ErrorDialogController } from "./ErrorDialogController"; import { ShieldController, ShieldImgRouter } from "./ShieldController"; -import { UserAPITokensController } from "./UserAPITokensController"; export class DashboardController extends AbstractBaseController { private addBtn = document.createElement('button'); private errDialog = new ErrorDialogController(); - constructor(private model: ShieldsModel, tokensModel: UserAPITokensModel, private env: EnvInterface, private imgr : ShieldImgRouter) { + constructor(private model: ShieldsModel, private env: EnvInterface, private imgr : ShieldImgRouter) { super(document.createElement('div'), 'dashboard'); this.container.append( @@ -39,8 +37,6 @@ export class DashboardController extends AbstractBaseController { }, 100); }); - (new UserAPITokensController(tokensModel)).attach(this.container); - this.render(); } diff --git a/ts/Dashboard.ts b/ts/Dashboard.ts index 67df271..788141d 100644 --- a/ts/Dashboard.ts +++ b/ts/Dashboard.ts @@ -1,16 +1,16 @@ import { AuthedApi } from "./api/authed"; import { EnvApi } from "./api/env"; import { ShieldsApi } from "./api/shields"; -import { UserAPITokensApi } from "./api/tokens"; import { DashboardController } from "./Controllers/DashboardController"; import { ShieldImgRouter } from "./Controllers/ShieldController"; import { ShieldsModel } from "./model/ShieldsModel"; -import { UserAPITokensModel } from "./model/UserAPITokensModel"; +import { User } from "./User"; export async function Dashboard(elm: HTMLElement) { const authApi = new AuthedApi(); if (!await authApi.isAuthed()) { window.location.href = '/'; + return; } const envApi = new EnvApi(); @@ -19,12 +19,38 @@ export async function Dashboard(elm: HTMLElement) { const sapi = new ShieldsApi(); const sm = new ShieldsModel(sapi); - const tokensApi = new UserAPITokensApi(); - const tokensModel = new UserAPITokensModel(tokensApi); - const dc = new DashboardController(sm, tokensModel, env, imgr); - dc.attach(elm); + const dc = new DashboardController(sm, env, imgr); + const dashboardPage = document.createElement('article'); + const dashboardHeading = document.createElement('h3'); + dashboardHeading.innerText = 'Dashboard'; + dashboardPage.appendChild(dashboardHeading); + dc.attach(dashboardPage); + + const userPage = document.createElement('article'); + const userHeading = document.createElement('h3'); + userHeading.innerText = 'User Settings'; + userPage.appendChild(userHeading); + User(userPage); sm.shieldEventEmitter.add(() => { dc.render(); }); + + let activePage: HTMLElement | null = null; + function renderPage() { + const nextPage = window.location.hash === '#/user' ? userPage : dashboardPage; + if (activePage === nextPage) { + return; + } + + if (activePage !== null) { + elm.removeChild(activePage); + } + + elm.appendChild(nextPage); + activePage = nextPage; + } + + window.addEventListener('hashchange', renderPage); + renderPage(); } diff --git a/ts/User.ts b/ts/User.ts new file mode 100644 index 0000000..7365627 --- /dev/null +++ b/ts/User.ts @@ -0,0 +1,13 @@ +import { UserAPITokensApi } from "./api/tokens"; +import { UserAPITokensController } from "./Controllers/UserAPITokensController"; +import { UserAPITokensModel } from "./model/UserAPITokensModel"; + +export function User(elm: HTMLElement) { + const tokenSection = document.createElement('div'); + tokenSection.classList.add('dashboard--controller'); + elm.appendChild(tokenSection); + + const tokensApi = new UserAPITokensApi(); + const tokensModel = new UserAPITokensModel(tokensApi); + (new UserAPITokensController(tokensModel)).attach(tokenSection); +} From 5c8e1334bdc8ab112a891e60dbb7bfc3157b0746 Mon Sep 17 00:00:00 2001 From: Jesse Donat Date: Fri, 24 Jul 2026 12:06:10 -0500 Subject: [PATCH 06/37] move dashboard --- authhandlers.go | 4 ++-- cmd/shielded/main.go | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) 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 c7e5458..ff9b93f 100644 --- a/cmd/shielded/main.go +++ b/cmd/shielded/main.go @@ -82,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() From 6b64b9fa40858515e897c95bdda2365dad1c32d5 Mon Sep 17 00:00:00 2001 From: Jesse Donat Date: Fri, 24 Jul 2026 17:44:59 -0500 Subject: [PATCH 07/37] feat: add bearer API shield updates --- apihandler.go | 98 +++++++++++++++++++++++++++++------- cmd/shielded/main.go | 2 +- dashboardapihandlers.go | 52 ++++++++++++++++++- dashboardapihandlers_test.go | 27 ++++++++++ model/shields.go | 51 +++++++++++++------ model/user_api_tokens.go | 21 ++++++++ schema/002_shield_api_id.sql | 3 ++ 7 files changed, 219 insertions(+), 35 deletions(-) create mode 100644 dashboardapihandlers_test.go create mode 100644 schema/002_shield_api_id.sql diff --git a/apihandler.go b/apihandler.go index d45c8a8..d7d2c27 100644 --- a/apihandler.go +++ b/apihandler.go @@ -11,30 +11,83 @@ import ( 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" { + if len(authParts) != 2 { http.Error(w, "missing secret", http.StatusBadRequest) return } - shield, err := ah.sm.GetFromSecret(authParts[1]) - 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])) - http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound) + var shield *model.Shield + var userToken *model.UserAPIToken + created := false + var err error + + switch authParts[0] { + case "token": + shield, err = ah.sm.GetFromSecret(authParts[1]) + 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") + http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound) + return + } + case "Bearer": + userToken, err = ah.tm.GetFromToken(authParts[1]) + 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 + } + + apiID := r.Header.Get("X-Shielded-Shield-ID") + if !validShieldAPIID(apiID) || apiID == "" { + http.Error(w, "X-Shielded-Shield-ID must be 5-64 lowercase letters, digits, or hyphens", http.StatusBadRequest) + return + } + + shield, err = ah.sm.GetFromUserIDAndAPIID(userToken.UserID, apiID) + 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 { + defaultColor, err := NormalizeColor("green") + if err != nil { + slog.Error("error selecting default shield color", slog.Any("error", err)) + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + return + } + shield = &model.Shield{ + UserID: userToken.UserID, + APIID: apiID, + Name: apiID, + Title: apiID, + Color: defaultColor, + Secret: stringWithCharset(40, "abcdefghjkmnpqrstuvwxyz23456789"), + } + created = true + } + default: + http.Error(w, "missing secret", http.StatusBadRequest) return } @@ -45,6 +98,12 @@ func (ah *ApiHandler) HandlePOST(w http.ResponseWriter, r *http.Request) { return } + for k := range r.Form { + if k != "title" && k != "text" && k != "color" { + http.Error(w, "invalid field: "+k, http.StatusBadRequest) + return + } + } if title := r.FormValue("title"); title != "" { shield.Title = title } @@ -61,21 +120,22 @@ func (ah *ApiHandler) HandlePOST(w http.ResponseWriter, r *http.Request) { 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 { slog.Error("error saving shield", slog.Any("error", err)) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) return } + if userToken != nil { + 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)) + } + } 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, }) diff --git a/cmd/shielded/main.go b/cmd/shielded/main.go index ff9b93f..ad74832 100644 --- a/cmd/shielded/main.go +++ b/cmd/shielded/main.go @@ -66,7 +66,7 @@ func main() { 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() diff --git a/dashboardapihandlers.go b/dashboardapihandlers.go index 4f4a6fc..cbcdc21 100644 --- a/dashboardapihandlers.go +++ b/dashboardapihandlers.go @@ -6,6 +6,7 @@ import ( "log/slog" "math/rand" "net/http" + "regexp" "strconv" "time" @@ -13,6 +14,25 @@ import ( "github.com/gorilla/mux" ) +var shieldAPIIDPattern = regexp.MustCompile(`^[a-z0-9-]{5,64}$`) + +func validShieldAPIID(apiID string) bool { + return apiID == "" || shieldAPIIDPattern.MatchString(apiID) +} + +func shieldAPIIDAvailable(sm *model.ShieldMapper, userID, shieldID int64, apiID string) (bool, error) { + if apiID == "" { + return true, nil + } + + shield, err := sm.GetFromUserIDAndAPIID(userID, apiID) + if err != nil { + return false, err + } + + return shield == nil || shield.ShieldID == shieldID, nil +} + type DashboardShieldApiIndexHandler struct { sm *model.ShieldMapper jwtAuth *JwtAuth @@ -63,13 +83,28 @@ func (sh *DashboardShieldApiIndexHandler) HandlePOST(w http.ResponseWriter, r *h http.Error(w, "failed to parse request body", http.StatusBadRequest) return } + if !validShieldAPIID(postShield.APIID) { + http.Error(w, "shield ID must be 5-64 lowercase letters, digits, or hyphens", http.StatusBadRequest) + return + } + available, err := shieldAPIIDAvailable(sh.sm, *id, 0, postShield.APIID) + if err != nil { + slog.Error("error checking shield API ID", slog.Any("error", err), slog.Any("id", *id)) + http.Error(w, "database error", http.StatusInternalServerError) + return + } + if !available { + http.Error(w, "shield ID is already in use", http.StatusConflict) + return + } uu := stringWithCharset(40, "abcdefghjkmnpqrstuvwxyz23456789") cleanShield := &model.Shield{ UserID: *id, - Name: postShield.Name, + APIID: postShield.APIID, + Name: postShield.Name, Title: postShield.Title, Text: postShield.Text, @@ -162,7 +197,22 @@ func (dh *DashboardShieldApiHandler) HandlePUT(w http.ResponseWriter, r *http.Re http.Error(w, "failed to parse request body", http.StatusBadRequest) return } + if !validShieldAPIID(putShield.APIID) { + http.Error(w, "shield ID must be 5-64 lowercase letters, digits, or hyphens", http.StatusBadRequest) + return + } + available, err := shieldAPIIDAvailable(dh.sm, shield.UserID, shield.ShieldID, putShield.APIID) + if err != nil { + slog.Error("error checking shield API ID", slog.Any("error", err), slog.Any("id", shield.UserID)) + http.Error(w, "database error", http.StatusInternalServerError) + return + } + if !available { + http.Error(w, "shield ID is already in use", http.StatusConflict) + return + } + shield.APIID = putShield.APIID shield.Name = putShield.Name shield.Title = putShield.Title diff --git a/dashboardapihandlers_test.go b/dashboardapihandlers_test.go new file mode 100644 index 0000000..6584c47 --- /dev/null +++ b/dashboardapihandlers_test.go @@ -0,0 +1,27 @@ +package shieldeddotdev + +import "testing" + +func TestValidShieldAPIID(t *testing.T) { + tests := []struct { + name string + apiID string + valid bool + }{ + {name: "empty is optional", apiID: "", valid: true}, + {name: "minimum length", apiID: "abcd-", valid: true}, + {name: "maximum length", apiID: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", valid: true}, + {name: "too short", apiID: "abcd", valid: false}, + {name: "too long", apiID: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", valid: false}, + {name: "uppercase", apiID: "Release-1", valid: false}, + {name: "underscore", apiID: "release_1", valid: false}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if got := validShieldAPIID(test.apiID); got != test.valid { + t.Errorf("validShieldAPIID(%q) = %t, want %t", test.apiID, got, test.valid) + } + }) + } +} diff --git a/model/shields.go b/model/shields.go index 7e7662b..8978b94 100644 --- a/model/shields.go +++ b/model/shields.go @@ -11,6 +11,7 @@ import ( type Shield struct { ShieldID int64 PublicID string + APIID 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.api_id, '') AS api_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.APIID, &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.api_id, '') AS api_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.APIID, &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.api_id, '') AS api_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.APIID, &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) GetFromUserIDAndAPIID(userID int64, apiID string) (*Shield, error) { + sh := &Shield{} + + row := sm.db.QueryRow("SELECT s.shield_id, s.public_id, COALESCE(s.api_id, '') AS api_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.api_id = ?", userID, apiID) + switch err := row.Scan(&sh.ShieldID, &sh.PublicID, &sh.APIID, &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.api_id, '') AS api_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.APIID, &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.api_id, '') AS api_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) 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.APIID, &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=?, api_id=?, name=?, title=?, text=?, color=?, secret=? WHERE shield_id = ?", + n.UserID, nullableString(n.APIID), 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, api_id, user_id, name, title, text, color, secret) VALUES (?,?,?,?,?,?,?,?)", + n.PublicID, nullableString(n.APIID), 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 index 69fb808..6ba89f5 100644 --- a/model/user_api_tokens.go +++ b/model/user_api_tokens.go @@ -110,6 +110,27 @@ func (tm *UserAPITokenMapper) DeleteFromUserIDAndID(userID, apiTokenID int64) er return err } +// GetFromToken finds a token from its plaintext bearer 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, stamp_created, stamp_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 stamp_last_used = CURRENT_TIMESTAMP() WHERE api_token_id = ?`, apiTokenID) + return err +} + type rowScanner interface { Scan(dest ...any) error } diff --git a/schema/002_shield_api_id.sql b/schema/002_shield_api_id.sql new file mode 100644 index 0000000..cbc8161 --- /dev/null +++ b/schema/002_shield_api_id.sql @@ -0,0 +1,3 @@ +ALTER TABLE `shields` + ADD COLUMN `api_id` varchar(64) DEFAULT NULL AFTER `public_id`, + ADD UNIQUE KEY `unq_shields_user_api_id` (`user_id`, `api_id`); From ddd2b5b63fcbde46d2a1c7a8e1debe4587df6679 Mon Sep 17 00:00:00 2001 From: Jesse Donat Date: Fri, 24 Jul 2026 17:45:38 -0500 Subject: [PATCH 08/37] refactor: migrate dashboard to preact --- AGENTS.md | 6 +- package-lock.json | 27 + package.json | 3 + pages/dashboard.templ | 3 +- pages/dashboard_templ.go | 2 +- scss/_dashboard.scss | 8 +- static/main.js | 816 ++++++---------------- static/style/style.css | 7 +- ts/Controllers/ApiExampleController.ts | 8 +- ts/Controllers/DashboardController.ts | 72 -- ts/Controllers/ErrorDialogController.ts | 12 - ts/Controllers/MarkdownInputController.ts | 33 - ts/Controllers/SecretInputContoller.ts | 46 -- ts/Controllers/ShieldController.ts | 165 ----- ts/Controllers/UserAPITokensController.ts | 175 ----- ts/Dashboard.ts | 56 -- ts/Dashboard.tsx | 322 +++++++++ ts/EventEmitter.ts | 14 - ts/User.ts | 13 - ts/api/shields.ts | 1 + ts/model/ShieldsModel.ts | 102 --- ts/model/UserAPITokensModel.ts | 43 -- tsconfig.json | 3 +- 23 files changed, 611 insertions(+), 1326 deletions(-) delete mode 100644 ts/Controllers/DashboardController.ts delete mode 100644 ts/Controllers/ErrorDialogController.ts delete mode 100644 ts/Controllers/MarkdownInputController.ts delete mode 100644 ts/Controllers/SecretInputContoller.ts delete mode 100644 ts/Controllers/ShieldController.ts delete mode 100644 ts/Controllers/UserAPITokensController.ts delete mode 100644 ts/Dashboard.ts create mode 100644 ts/Dashboard.tsx delete mode 100644 ts/EventEmitter.ts delete mode 100644 ts/User.ts delete mode 100644 ts/model/ShieldsModel.ts delete mode 100644 ts/model/UserAPITokensModel.ts diff --git a/AGENTS.md b/AGENTS.md index 754054e..9e0d0c2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -72,7 +72,7 @@ go test ./... # compile/test all Go packages (there - 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 bearer credentials: generate them with `crypto/rand`, store only a one-way hash, return the plaintext only at creation, and scope dashboard reads/deletes by the authenticated user. Do not wire them into the per-shield update API unless that API's contract is explicitly changed. +- User-level API tokens are bearer credentials: generate them with `crypto/rand`, store only a one-way hash, return the plaintext only at creation, and scope dashboard reads/deletes by the authenticated user. On the API host, `Authorization: Bearer ` requires `X-Shielded-Shield-ID`; it can update or create only that token owner's shield and records `stamp_last_used`. Preserve the legacy `Authorization: token ` contract unchanged. ### Database schema and migrations @@ -83,10 +83,10 @@ go test ./... # compile/test all Go packages (there ### TypeScript -- This is framework-free DOM code. Controllers own DOM creation and are attached through `AbstractBaseController`; `ShieldsModel` owns the in-memory shield list and emits rerender events. +- 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. Maintain tab indentation and run `make lint` after TypeScript changes. +- 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 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/dashboard.templ b/pages/dashboard.templ index 5db7061..bc0cc6a 100644 --- a/pages/dashboard.templ +++ b/pages/dashboard.templ @@ -16,13 +16,14 @@ templ DashboardPage(hosts Hosts) { User settings +
@Footer() diff --git a/pages/dashboard_templ.go b/pages/dashboard_templ.go index 8c93cc8..f94659a 100644 --- a/pages/dashboard_templ.go +++ b/pages/dashboard_templ.go @@ -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, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } diff --git a/scss/_dashboard.scss b/scss/_dashboard.scss index c90dbb1..2ddd91c 100644 --- a/scss/_dashboard.scss +++ b/scss/_dashboard.scss @@ -5,7 +5,7 @@ %dashboard { > .add-button { display: block; - margin: 0 auto; + margin: 0 auto 1em; transform: scale(1.2); @@ -212,6 +212,12 @@ @extend %input-container; } + .input-error { + color: variables.$danger; + font-size: 0.85em; + margin-top: 0.35em; + } + .name-input { @extend %padded-container; } diff --git a/static/main.js b/static/main.js index 0b91411..9ea4cb4 100644 --- a/static/main.js +++ b/static/main.js @@ -15,6 +15,18 @@ PERFORMANCE OF THIS SOFTWARE. /* global Reflect, Promise */ +function __rest(s, e) { + var t = {}; + for (var p in s) if (Object.prototype.hasOwnProperty.call(s, p) && e.indexOf(p) < 0) + t[p] = s[p]; + if (s != null && typeof Object.getOwnPropertySymbols === "function") + for (var i = 0, p = Object.getOwnPropertySymbols(s); i < p.length; i++) { + if (e.indexOf(p[i]) < 0 && Object.prototype.propertyIsEnumerable.call(s, p[i])) + t[p[i]] = s[p[i]]; + } + return t; +} + function __awaiter(thisArg, _arguments, P, generator) { function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); } return new (P || (P = Promise))(function (resolve, reject) { @@ -25,6 +37,12 @@ function __awaiter(thisArg, _arguments, P, generator) { }); } +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=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(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 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; } @@ -100,6 +118,18 @@ class ShieldsApi { } } +class UserAPITokensApi { + getTokens() { + return doRequest('api/tokens', 'GET', null); + } + createToken(description) { + return doRequest('api/tokens', 'POST', JSON.stringify({ Description: description })); + } + deleteToken(token) { + return doRawRequest(`api/token/${token.APITokenID}`, 'DELETE', null); + } +} + class AbstractBaseController { constructor(container, name) { this.container = container; @@ -126,16 +156,6 @@ class AbstractBaseController { } } -class ErrorDialogController extends AbstractBaseController { - constructor() { - super(document.createElement("dialog"), "error-dialog"); - } - show(message) { - this.container.innerText = message; - this.container.showModal(); - } -} - class ApiExampleController extends AbstractBaseController { constructor(env, shield = null) { super(document.createElement("div"), "api-example"); @@ -249,594 +269,224 @@ jobs: color: ${JSON.stringify(color)}`; } -class MarkdownInputController extends AbstractBaseController { - constructor(shield, imgr) { - super(document.createElement("div"), "markdown-input"); - this.input = document.createElement('input'); - this.secretCopyButton = document.createElement('button'); - 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', () => __awaiter(this, void 0, void 0, function* () { - yield navigator.clipboard.writeText(this.input.value); - this.secretCopyButton.innerText = 'Copied!'; - })); - } -} - -class SecretInputController extends AbstractBaseController { - constructor(shield) { - super(document.createElement("div"), "secret-input"); - this.input = document.createElement('input'); - this.secretCopyButton = document.createElement('button'); - this.secretShowButton = document.createElement('button'); - 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', () => __awaiter(this, void 0, void 0, function* () { - yield 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'; - } - }); - } +const shieldAPIIDPattern = /^[a-z0-9-]{5,64}$/; +const apiExamples = [ + ["GitHub Action", gitHubActionExample], + ["Curl", curlExample], + ["JS", jsExample], + ["PHP", phpExample], +]; +function Dashboard(elm) { + return __awaiter(this, void 0, void 0, function* () { + if (elm === null) { + return; + } + const authApi = new AuthedApi(); + if (!(yield authApi.isAuthed())) { + window.location.href = "/"; + return; + } + const env = yield (new EnvApi()).getEnv(); + R(u$1(DashboardApp, { env: env }), elm); + }); } - -class ShieldController extends AbstractBaseController { - constructor(shield, model, env, imgr) { - super(document.createElement("form"), "shield"); - this.shield = shield; - this.imgr = imgr; - this.nameInput = document.createElement('input'); - this.titleInput = document.createElement('input'); - this.textInput = document.createElement('input'); - this.colorInput = document.createElement('input'); - this.shieldImg = document.createElement('img'); - this.updateBtn = document.createElement('button'); - this.deleteBtn = document.createElement('button'); - 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', () => __awaiter(this, void 0, void 0, function* () { - if (!this.container.checkValidity()) { - this.container.reportValidity(); - return; - } - yield 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(); - } - createInputContainer(label, input) { - 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; - } - setImageWithCachebreaker() { - const ts = (new Date()).getTime(); - this.shieldImg.src = `${this.imgr.shieldURL(this.shield)}?break=${ts}`; - } +function DashboardApp({ env }) { + const [page, setPage] = d(currentPage()); + h(() => { + const updatePage = () => setPage(currentPage()); + window.addEventListener("hashchange", updatePage); + return () => window.removeEventListener("hashchange", updatePage); + }, []); + if (page === "user") { + return u$1(S, { children: [u$1("h3", { children: "User Settings" }), u$1("div", { class: "dashboard--controller", children: u$1(APITokens, {}) })] }); + } + return u$1(S, { children: [u$1("h3", { children: "Dashboard" }), u$1("div", { class: "dashboard--controller", children: u$1(Shields, { env: env }) })] }); } -class ShieldImgRouter { - constructor(env) { - this.env = env; - } - shieldURL(shield) { - return `https://${this.env.ImgHost}/s/${shield.PublicID}`; - } - shieldMarkdown(shield) { - return `![${shield.Name}](${this.shieldURL(shield)})`; - } +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 = () => __awaiter(this, void 0, void 0, function* () { + setError(""); + try { + const shield = yield 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 = (shield) => __awaiter(this, void 0, void 0, function* () { + setError(""); + try { + const savedShield = yield api.saveShield(shield); + setShields((current) => (current || []).map((item) => item.ShieldID === savedShield.ShieldID ? savedShield : item)); + } + catch (requestError) { + setError(errorMessage(requestError)); + throw requestError; + } + }); + const deleteShield = (shield) => __awaiter(this, void 0, void 0, function* () { + setError(""); + try { + yield 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." })] }); } - -class DashboardController extends AbstractBaseController { - constructor(model, env, imgr) { - super(document.createElement('div'), 'dashboard'); - this.model = model; - this.env = env; - this.imgr = imgr; - this.addBtn = document.createElement('button'); - this.errDialog = new ErrorDialogController(); - this.shieldsElm = document.createElement('div'); - 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', () => __awaiter(this, void 0, void 0, function* () { - yield this.model.newShield(); - setTimeout(() => { - window.scrollTo({ top: document.body.scrollHeight, behavior: 'smooth' }); - }, 100); - })); - this.render(); - } - render() { - return __awaiter(this, void 0, void 0, function* () { - this.shieldsElm.innerHTML = ''; - let shields; - try { - shields = yield this.model.getShields(); - } - catch (e) { - console.log(e); - if (isRequestError(e)) { - this.errDialog.show(e.ctx.responseText); - } +function ShieldForm({ shield, env, onSave, onDelete }) { + const [draft, setDraft] = d(shield); + const draftRef = A(draft); + const saveTimeout = 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); + } + }, []); + const queueSave = (next) => { + if (saveTimeout.current !== null) { + clearTimeout(saveTimeout.current); + saveTimeout.current = null; + } + if (next.APIID !== undefined && next.APIID !== "" && !shieldAPIIDPattern.test(next.APIID)) { + return; + } + saveTimeout.current = setTimeout(() => { + saveTimeout.current = null; + void onSave(next) + .then(() => setImageTick(Date.now())) + .catch(() => undefined); + }, 500); + }; + const handleInput = (event) => { + const input = event.target; + let next; + switch (input.name) { + case "Name": + case "APIID": + case "Title": + case "Text": + next = Object.assign(Object.assign({}, draftRef.current), { [input.name]: input.value }); + break; + case "Color": + next = Object.assign(Object.assign({}, draftRef.current), { Color: input.value.replace(/^#/, "") }); + break; + default: 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); - } - }); - } -} - -class EventEmitter { - constructor() { - this.listeners = new Set(); - } - add(callback) { - this.listeners.add(callback); - } - trigger(event) { - this.listeners.forEach((fn) => fn(event)); - } -} - -class ShieldsModel { - constructor(shieldsApi) { - this.shieldsApi = shieldsApi; - this.shieldEventEmitter = new EventEmitter(); - this.shields = []; - this.timeouts = {}; - } - getShields() { - return __awaiter(this, void 0, void 0, function* () { - if (this.shields.length < 1) { - this.shields = yield this.shieldsApi.getShields(); - } - return this.shields; - }); - } - deleteShield(shield) { - return __awaiter(this, void 0, void 0, function* () { - const shieldId = shield.ShieldID; - if (!shieldId) { - throw Error("Attempting to delete unpersisted shield"); - } - yield this.shieldsApi.deleteShield(shield); - this.shields = this.shields.filter((n) => shield !== n); - this.shieldEventEmitter.trigger({ shield, event: "deleted" }); - }); - } - updateShield(shield, debounce = 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); + draftRef.current = next; + setDraft(next); + queueSave(next); + }; + const deleteShield = () => __awaiter(this, void 0, void 0, function* () { + if (!confirm("Are you sure you want to delete this shield?")) { + return; } - this.shieldEventEmitter.trigger({ shield, event: "changed" }); - return new Promise((resolve) => { - let resolves = [resolve]; - if (this.timeouts[shieldId]) { - resolves = [...this.timeouts[shieldId].resolves, ...resolves]; - } - this.timeouts[shieldId] = { - timeout: setTimeout(() => __awaiter(this, void 0, void 0, function* () { - this.shieldEventEmitter.trigger({ shield, event: "updating" }); - yield 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, - }; - }); - } - newShield() { - return __awaiter(this, void 0, void 0, function* () { - const shield = yield this.shieldsApi.saveShield({ - Name: 'New Shield', - Title: 'New', - Color: '00AA55', - Text: 'Shield', - }); - this.shields.push(shield); - this.shieldEventEmitter.trigger({ shield, event: "created" }); - return shield; - }); - } + if (saveTimeout.current !== null) { + clearTimeout(saveTimeout.current); + } + yield 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 apiIDInvalid = draft.APIID !== undefined && draft.APIID !== "" && !shieldAPIIDPattern.test(draft.APIID); + const apiIDErrorID = `shield-${draft.ShieldID}-id-error`; + 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 ID", name: "APIID", value: draft.APIID || "", pattern: "[a-z0-9\\\\-]{5,64}", title: "Optional: 5-64 lowercase letters, digits, or hyphens", placeholder: "e.g. production-status", autoComplete: "off", spellcheck: false, "aria-invalid": apiIDInvalid, "aria-describedby": apiIDInvalid ? apiIDErrorID : undefined }), apiIDInvalid && u$1("p", { id: apiIDErrorID, class: "input-error", role: "alert", children: "Shield ID must be 5-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}` }) }), 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", { children: "Markdown" }), u$1("div", { class: "markdown-input--controller", children: [u$1("input", { 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", { children: "API Token" }), u$1("div", { class: "secret-input--controller", children: [u$1("input", { 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" })] })] })] }); } - -class UserAPITokensApi { - getTokens() { - return doRequest('api/tokens', 'GET', null); - } - createToken(description) { - return doRequest('api/tokens', 'POST', JSON.stringify({ Description: description })); - } - deleteToken(token) { - return doRawRequest(`api/token/${token.APITokenID}`, 'DELETE', null); - } +function Input(_a) { + var { label } = _a, attributes = __rest(_a, ["label"]); + return u$1("div", { class: "input-container", children: [u$1("label", { children: label }), u$1("input", Object.assign({}, attributes))] }); } - -class UserAPITokensController extends AbstractBaseController { - constructor(model) { - super(document.createElement('section'), 'api-tokens'); - this.model = model; - this.createForm = document.createElement('form'); - this.descriptionInput = document.createElement('input'); - this.createButton = document.createElement('button'); - this.messageElm = document.createElement('p'); - this.createdTokenElm = document.createElement('div'); - this.tokensElm = document.createElement('div'); - const heading = document.createElement('h3'); - heading.textContent = 'API Tokens'; - const explanation = document.createElement('p'); - explanation.textContent = 'Create a token for a future API. Tokens do not grant access to the current shield update API.'; - const descriptionLabel = document.createElement('label'); - descriptionLabel.htmlFor = 'api-token-description'; - descriptionLabel.textContent = 'Description'; - this.descriptionInput.id = 'api-token-description'; - this.descriptionInput.name = 'description'; - this.descriptionInput.required = true; - this.descriptionInput.maxLength = 255; - this.descriptionInput.placeholder = 'e.g. production deploy job'; - this.createButton.type = 'submit'; - this.createButton.textContent = 'Create token'; - this.createButton.classList.add('primary'); - this.createForm.append(descriptionLabel, this.descriptionInput, this.createButton); - this.container.append(heading, explanation, this.createForm, this.messageElm, this.createdTokenElm, this.tokensElm); - this.createForm.addEventListener('submit', (event) => __awaiter(this, void 0, void 0, function* () { - event.preventDefault(); - yield this.createToken(); - })); - this.model.tokenEventEmitter.add(() => { - this.render(); - }); - this.render(); - } - createToken() { - return __awaiter(this, void 0, void 0, function* () { - const description = this.descriptionInput.value.trim(); - this.descriptionInput.setCustomValidity(''); - if (description === '') { - this.descriptionInput.setCustomValidity('Description is required.'); - this.descriptionInput.reportValidity(); - return; - } - this.createButton.disabled = true; - this.setMessage(''); - try { - const token = yield this.model.createToken(description); - this.createForm.reset(); - this.showCreatedToken(token.Token); - } - catch (error) { - this.setMessage(this.errorMessage(error)); - } - finally { - this.createButton.disabled = false; - } - }); - } - render() { - return __awaiter(this, void 0, void 0, function* () { - this.tokensElm.innerHTML = ''; - let tokens; - try { - tokens = yield this.model.getTokens(); - } - catch (error) { - this.setMessage(this.errorMessage(error)); - return; - } - const heading = document.createElement('h4'); - heading.textContent = 'Active tokens'; - this.tokensElm.appendChild(heading); - if (tokens.length === 0) { - const empty = document.createElement('p'); - empty.textContent = 'No API tokens yet.'; - this.tokensElm.appendChild(empty); - return; - } - const table = document.createElement('table'); - const header = document.createElement('tr'); - for (const label of ['Description', 'Created', 'Last used', '']) { - const cell = document.createElement('th'); - cell.textContent = label; - header.appendChild(cell); - } - table.appendChild(header); - for (const token of tokens) { - const row = document.createElement('tr'); - row.appendChild(this.tableCell(token.Description)); - row.appendChild(this.tableCell(this.formatTimestamp(token.Created))); - row.appendChild(this.tableCell(token.LastUsed === null ? 'Never' : this.formatTimestamp(token.LastUsed))); - const actionCell = document.createElement('td'); - const revokeButton = document.createElement('button'); - revokeButton.type = 'button'; - revokeButton.textContent = 'Revoke'; - revokeButton.classList.add('danger'); - revokeButton.addEventListener('click', () => __awaiter(this, void 0, void 0, function* () { - if (confirm(`Revoke the token “${token.Description}”? This cannot be undone.`)) { - yield this.deleteToken(token); - } - })); - actionCell.appendChild(revokeButton); - row.appendChild(actionCell); - table.appendChild(row); - } - this.tokensElm.appendChild(table); - }); - } - deleteToken(token) { - return __awaiter(this, void 0, void 0, function* () { - this.setMessage(''); - try { - yield this.model.deleteToken(token); - } - catch (error) { - this.setMessage(this.errorMessage(error)); - } - }); - } - showCreatedToken(token) { - this.createdTokenElm.innerHTML = ''; - const warning = document.createElement('p'); - warning.textContent = 'Copy this token now. It will not be shown again.'; - const input = document.createElement('input'); - input.value = token; - input.readOnly = true; - input.addEventListener('click', () => input.select()); - const copyButton = document.createElement('button'); - copyButton.type = 'button'; - copyButton.textContent = 'Copy'; - copyButton.addEventListener('click', () => __awaiter(this, void 0, void 0, function* () { - yield navigator.clipboard.writeText(token); - copyButton.textContent = 'Copied!'; - })); - this.createdTokenElm.append(warning, input, copyButton); - } - tableCell(value) { - const cell = document.createElement('td'); - cell.textContent = value; - return cell; - } - formatTimestamp(value) { - return new Date(value).toLocaleString(); - } - errorMessage(error) { - if (isRequestError(error)) { - return error.ctx.responseText; +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 = (event) => __awaiter(this, void 0, void 0, function* () { + event.preventDefault(); + const trimmedDescription = description.trim(); + if (trimmedDescription === "") { + setError("Description is required."); + return; } - return 'Unable to manage API tokens.'; - } - setMessage(message) { - this.messageElm.textContent = message; - } + setCreating(true); + setError(""); + try { + const token = yield api.createToken(trimmedDescription); + setTokens((current) => [token, ...(current || [])]); + setCreatedToken(token.Token); + setDescription(""); + setCopied(false); + } + catch (requestError) { + setError(errorMessage(requestError)); + } + finally { + setCreating(false); + } + }); + const revokeToken = (token) => __awaiter(this, void 0, void 0, function* () { + if (!confirm(`Revoke the token “${token.Description}”? This cannot be undone.`)) { + return; + } + setError(""); + try { + yield 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: "API Tokens" }), u$1("p", { children: "Create a token to update or create 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", { children: [u$1("p", { children: "Copy this token now. It will not be shown again." }), u$1("input", { 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)) })] })] })] }); } - -class UserAPITokensModel { - constructor(tokensApi) { - this.tokensApi = tokensApi; - this.tokenEventEmitter = new EventEmitter(); - this.tokens = []; - this.loaded = false; - } - getTokens() { - return __awaiter(this, void 0, void 0, function* () { - if (!this.loaded) { - this.tokens = yield this.tokensApi.getTokens(); - this.loaded = true; - } - return this.tokens; - }); - } - createToken(description) { - return __awaiter(this, void 0, void 0, function* () { - const created = yield this.tokensApi.createToken(description); - const token = { - APITokenID: created.APITokenID, - UserID: created.UserID, - Description: created.Description, - Created: created.Created, - LastUsed: created.LastUsed, - }; - this.tokens.unshift(token); - this.tokenEventEmitter.trigger(token); - return created; - }); - } - deleteToken(token) { - return __awaiter(this, void 0, void 0, function* () { - yield this.tokensApi.deleteToken(token); - this.tokens = this.tokens.filter((current) => current.APITokenID !== token.APITokenID); - this.tokenEventEmitter.trigger(token); - }); - } +function currentPage() { + return window.location.hash === "#/user" ? "user" : "dashboard"; } - -function User(elm) { - const tokenSection = document.createElement('div'); - tokenSection.classList.add('dashboard--controller'); - elm.appendChild(tokenSection); - const tokensApi = new UserAPITokensApi(); - const tokensModel = new UserAPITokensModel(tokensApi); - (new UserAPITokensController(tokensModel)).attach(tokenSection); +function errorMessage(error) { + return isRequestError(error) ? error.ctx.responseText : "Unable to complete that request."; } - -function Dashboard(elm) { +function formatTimestamp(value) { + return new Date(value).toLocaleString(); +} +function copy(value, setCopied) { return __awaiter(this, void 0, void 0, function* () { - const authApi = new AuthedApi(); - if (!(yield authApi.isAuthed())) { - window.location.href = '/'; - return; + try { + yield navigator.clipboard.writeText(value); + setCopied(true); } - const envApi = new EnvApi(); - const env = yield envApi.getEnv(); - const imgr = new ShieldImgRouter(env); - const sapi = new ShieldsApi(); - const sm = new ShieldsModel(sapi); - const dc = new DashboardController(sm, env, imgr); - const dashboardPage = document.createElement('article'); - const dashboardHeading = document.createElement('h3'); - dashboardHeading.innerText = 'Dashboard'; - dashboardPage.appendChild(dashboardHeading); - dc.attach(dashboardPage); - const userPage = document.createElement('article'); - const userHeading = document.createElement('h3'); - userHeading.innerText = 'User Settings'; - userPage.appendChild(userHeading); - User(userPage); - sm.shieldEventEmitter.add(() => { - dc.render(); - }); - let activePage = null; - function renderPage() { - const nextPage = window.location.hash === '#/user' ? userPage : dashboardPage; - if (activePage === nextPage) { - return; - } - if (activePage !== null) { - elm.removeChild(activePage); - } - elm.appendChild(nextPage); - activePage = nextPage; + catch (error) { + console.error(error); } - window.addEventListener('hashchange', renderPage); - renderPage(); }); } diff --git a/static/style/style.css b/static/style/style.css index 3858c0e..d92bd77 100644 --- a/static/style/style.css +++ b/static/style/style.css @@ -127,7 +127,7 @@ button.primary:hover, .home--app .primary.github-login:hover { .dashboard--controller > .add-button { display: block; - margin: 0 auto; + margin: 0 auto 1em; transform: scale(1.2); } .dashboard--controller > .add-button > .icon { @@ -274,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; } 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/Controllers/UserAPITokensController.ts b/ts/Controllers/UserAPITokensController.ts deleted file mode 100644 index 9f6ff8f..0000000 --- a/ts/Controllers/UserAPITokensController.ts +++ /dev/null @@ -1,175 +0,0 @@ -import { AbstractBaseController } from "../AbstractController"; -import { isRequestError } from "../api/request"; -import { UserAPITokenInterface } from "../api/tokens"; -import { UserAPITokensModel } from "../model/UserAPITokensModel"; - -export class UserAPITokensController extends AbstractBaseController { - - private readonly createForm = document.createElement('form'); - private readonly descriptionInput = document.createElement('input'); - private readonly createButton = document.createElement('button'); - private readonly messageElm = document.createElement('p'); - private readonly createdTokenElm = document.createElement('div'); - private readonly tokensElm = document.createElement('div'); - - public constructor(private model: UserAPITokensModel) { - super(document.createElement('section'), 'api-tokens'); - - const heading = document.createElement('h3'); - heading.textContent = 'API Tokens'; - const explanation = document.createElement('p'); - explanation.textContent = 'Create a token for a future API. Tokens do not grant access to the current shield update API.'; - - const descriptionLabel = document.createElement('label'); - descriptionLabel.htmlFor = 'api-token-description'; - descriptionLabel.textContent = 'Description'; - this.descriptionInput.id = 'api-token-description'; - this.descriptionInput.name = 'description'; - this.descriptionInput.required = true; - this.descriptionInput.maxLength = 255; - this.descriptionInput.placeholder = 'e.g. production deploy job'; - - this.createButton.type = 'submit'; - this.createButton.textContent = 'Create token'; - this.createButton.classList.add('primary'); - - this.createForm.append(descriptionLabel, this.descriptionInput, this.createButton); - this.container.append(heading, explanation, this.createForm, this.messageElm, this.createdTokenElm, this.tokensElm); - - this.createForm.addEventListener('submit', async (event) => { - event.preventDefault(); - await this.createToken(); - }); - - this.model.tokenEventEmitter.add(() => { - this.render(); - }); - this.render(); - } - - private async createToken() { - const description = this.descriptionInput.value.trim(); - this.descriptionInput.setCustomValidity(''); - if (description === '') { - this.descriptionInput.setCustomValidity('Description is required.'); - this.descriptionInput.reportValidity(); - return; - } - - this.createButton.disabled = true; - this.setMessage(''); - try { - const token = await this.model.createToken(description); - this.createForm.reset(); - this.showCreatedToken(token.Token); - } catch (error) { - this.setMessage(this.errorMessage(error)); - } finally { - this.createButton.disabled = false; - } - } - - private async render() { - this.tokensElm.innerHTML = ''; - - let tokens: UserAPITokenInterface[]; - try { - tokens = await this.model.getTokens(); - } catch (error) { - this.setMessage(this.errorMessage(error)); - return; - } - - const heading = document.createElement('h4'); - heading.textContent = 'Active tokens'; - this.tokensElm.appendChild(heading); - if (tokens.length === 0) { - const empty = document.createElement('p'); - empty.textContent = 'No API tokens yet.'; - this.tokensElm.appendChild(empty); - return; - } - - const table = document.createElement('table'); - const header = document.createElement('tr'); - for (const label of ['Description', 'Created', 'Last used', '']) { - const cell = document.createElement('th'); - cell.textContent = label; - header.appendChild(cell); - } - table.appendChild(header); - - for (const token of tokens) { - const row = document.createElement('tr'); - row.appendChild(this.tableCell(token.Description)); - row.appendChild(this.tableCell(this.formatTimestamp(token.Created))); - row.appendChild(this.tableCell(token.LastUsed === null ? 'Never' : this.formatTimestamp(token.LastUsed))); - - const actionCell = document.createElement('td'); - const revokeButton = document.createElement('button'); - revokeButton.type = 'button'; - revokeButton.textContent = 'Revoke'; - revokeButton.classList.add('danger'); - revokeButton.addEventListener('click', async () => { - if (confirm(`Revoke the token “${token.Description}”? This cannot be undone.`)) { - await this.deleteToken(token); - } - }); - actionCell.appendChild(revokeButton); - row.appendChild(actionCell); - table.appendChild(row); - } - - this.tokensElm.appendChild(table); - } - - private async deleteToken(token: UserAPITokenInterface) { - this.setMessage(''); - try { - await this.model.deleteToken(token); - } catch (error) { - this.setMessage(this.errorMessage(error)); - } - } - - private showCreatedToken(token: string) { - this.createdTokenElm.innerHTML = ''; - const warning = document.createElement('p'); - warning.textContent = 'Copy this token now. It will not be shown again.'; - const input = document.createElement('input'); - input.value = token; - input.readOnly = true; - input.addEventListener('click', () => input.select()); - const copyButton = document.createElement('button'); - copyButton.type = 'button'; - copyButton.textContent = 'Copy'; - copyButton.addEventListener('click', async () => { - await navigator.clipboard.writeText(token); - copyButton.textContent = 'Copied!'; - }); - - this.createdTokenElm.append(warning, input, copyButton); - } - - private tableCell(value: string) { - const cell = document.createElement('td'); - cell.textContent = value; - return cell; - } - - private formatTimestamp(value: string) { - return new Date(value).toLocaleString(); - } - - private errorMessage(error: unknown) { - if (isRequestError(error)) { - return error.ctx.responseText; - } - return 'Unable to manage API tokens.'; - } - - private setMessage(message: string) { - this.messageElm.textContent = message; - } - -} diff --git a/ts/Dashboard.ts b/ts/Dashboard.ts deleted file mode 100644 index 788141d..0000000 --- a/ts/Dashboard.ts +++ /dev/null @@ -1,56 +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"; -import { User } from "./User"; - -export async function Dashboard(elm: HTMLElement) { - const authApi = new AuthedApi(); - if (!await authApi.isAuthed()) { - window.location.href = '/'; - return; - } - - 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); - const dashboardPage = document.createElement('article'); - const dashboardHeading = document.createElement('h3'); - dashboardHeading.innerText = 'Dashboard'; - dashboardPage.appendChild(dashboardHeading); - dc.attach(dashboardPage); - - const userPage = document.createElement('article'); - const userHeading = document.createElement('h3'); - userHeading.innerText = 'User Settings'; - userPage.appendChild(userHeading); - User(userPage); - - sm.shieldEventEmitter.add(() => { - dc.render(); - }); - - let activePage: HTMLElement | null = null; - function renderPage() { - const nextPage = window.location.hash === '#/user' ? userPage : dashboardPage; - if (activePage === nextPage) { - return; - } - - if (activePage !== null) { - elm.removeChild(activePage); - } - - elm.appendChild(nextPage); - activePage = nextPage; - } - - window.addEventListener('hashchange', renderPage); - renderPage(); -} diff --git a/ts/Dashboard.tsx b/ts/Dashboard.tsx new file mode 100644 index 0000000..d6e8dc6 --- /dev/null +++ b/ts/Dashboard.tsx @@ -0,0 +1,322 @@ +import { render } from "preact"; +import type { JSX } from "preact"; +import { useEffect, 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 shieldAPIIDPattern = /^[a-z0-9-]{5,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); + }, []); + + if (page === "user") { + return <> +

User Settings

+
+ ; + } + + return <> +

Dashboard

+
+ ; +} + +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 [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); + } + }, []); + + const queueSave = (next: ShieldInterface) => { + if (saveTimeout.current !== null) { + clearTimeout(saveTimeout.current); + saveTimeout.current = null; + } + + if (next.APIID !== undefined && next.APIID !== "" && !shieldAPIIDPattern.test(next.APIID)) { + return; + } + + saveTimeout.current = setTimeout(() => { + saveTimeout.current = null; + void onSave(next) + .then(() => setImageTick(Date.now())) + .catch(() => undefined); + }, 500); + }; + + const handleInput = (event: JSX.TargetedEvent) => { + const input = event.target as HTMLInputElement; + let next: ShieldInterface; + switch (input.name) { + case "Name": + case "APIID": + 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 apiIDInvalid = draft.APIID !== undefined && draft.APIID !== "" && !shieldAPIIDPattern.test(draft.APIID); + const apiIDErrorID = `shield-${draft.ShieldID}-id-error`; + + return
+
+ + + {apiIDInvalid && } +
+
+
+ + + +
+
+ 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 }) { + 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
+

API Tokens

+

Create a token to update or create 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/User.ts b/ts/User.ts deleted file mode 100644 index 7365627..0000000 --- a/ts/User.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { UserAPITokensApi } from "./api/tokens"; -import { UserAPITokensController } from "./Controllers/UserAPITokensController"; -import { UserAPITokensModel } from "./model/UserAPITokensModel"; - -export function User(elm: HTMLElement) { - const tokenSection = document.createElement('div'); - tokenSection.classList.add('dashboard--controller'); - elm.appendChild(tokenSection); - - const tokensApi = new UserAPITokensApi(); - const tokensModel = new UserAPITokensModel(tokensApi); - (new UserAPITokensController(tokensModel)).attach(tokenSection); -} diff --git a/ts/api/shields.ts b/ts/api/shields.ts index 8f0453c..6e0b228 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; + APIID?: string; UserID: number; Name: string; Title: string; 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/ts/model/UserAPITokensModel.ts b/ts/model/UserAPITokensModel.ts deleted file mode 100644 index da6daf7..0000000 --- a/ts/model/UserAPITokensModel.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { CreatedUserAPITokenInterface, UserAPITokenInterface, UserAPITokensApi } from "../api/tokens"; -import { EventEmitter } from "../EventEmitter"; - -export class UserAPITokensModel { - - public readonly tokenEventEmitter = new EventEmitter(); - - private tokens: UserAPITokenInterface[] = []; - private loaded = false; - - public constructor(private tokensApi: UserAPITokensApi) { } - - public async getTokens() { - if (!this.loaded) { - this.tokens = await this.tokensApi.getTokens(); - this.loaded = true; - } - - return this.tokens; - } - - public async createToken(description: string): Promise { - const created = await this.tokensApi.createToken(description); - const token: UserAPITokenInterface = { - APITokenID: created.APITokenID, - UserID: created.UserID, - Description: created.Description, - Created: created.Created, - LastUsed: created.LastUsed, - }; - this.tokens.unshift(token); - this.tokenEventEmitter.trigger(token); - - return created; - } - - public async deleteToken(token: UserAPITokenInterface) { - await this.tokensApi.deleteToken(token); - this.tokens = this.tokens.filter((current) => current.APITokenID !== token.APITokenID); - this.tokenEventEmitter.trigger(token); - } - -} diff --git a/tsconfig.json b/tsconfig.json index 31e563e..f462fa5 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -11,7 +11,8 @@ ], /* 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. */ From 93adec56045d8ea0d95e194a5ab084632fdf14c9 Mon Sep 17 00:00:00 2001 From: Jesse Donat Date: Fri, 24 Jul 2026 17:59:05 -0500 Subject: [PATCH 09/37] fix: address API token review feedback --- apihandler.go | 12 +++++++++--- dashboardapihandlers.go | 22 +++++++++++++++++++++- scss/_dashboard.scss | 2 +- static/main.js | 4 ++-- static/style/style.css | 4 ++-- ts/Dashboard.tsx | 4 ++-- 6 files changed, 37 insertions(+), 11 deletions(-) diff --git a/apihandler.go b/apihandler.go index d7d2c27..059e66b 100644 --- a/apihandler.go +++ b/apihandler.go @@ -32,7 +32,7 @@ func (ah *ApiHandler) HandlePOST(w http.ResponseWriter, r *http.Request) { created := false var err error - switch authParts[0] { + switch strings.ToLower(authParts[0]) { case "token": shield, err = ah.sm.GetFromSecret(authParts[1]) if err != nil { @@ -45,7 +45,7 @@ func (ah *ApiHandler) HandlePOST(w http.ResponseWriter, r *http.Request) { http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound) return } - case "Bearer": + case "bearer": userToken, err = ah.tm.GetFromToken(authParts[1]) if err != nil { slog.Error("error fetching user API token", slog.Any("error", err)) @@ -76,13 +76,19 @@ func (ah *ApiHandler) HandlePOST(w http.ResponseWriter, r *http.Request) { http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) return } + 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, APIID: apiID, Name: apiID, Title: apiID, Color: defaultColor, - Secret: stringWithCharset(40, "abcdefghjkmnpqrstuvwxyz23456789"), + Secret: secret, } created = true } diff --git a/dashboardapihandlers.go b/dashboardapihandlers.go index cbcdc21..73ba173 100644 --- a/dashboardapihandlers.go +++ b/dashboardapihandlers.go @@ -1,9 +1,11 @@ package shieldeddotdev import ( + cryptorand "crypto/rand" "encoding/json" "fmt" "log/slog" + "math/big" "math/rand" "net/http" "regexp" @@ -98,7 +100,12 @@ func (sh *DashboardShieldApiIndexHandler) HandlePOST(w http.ResponseWriter, r *h 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, @@ -138,6 +145,19 @@ func stringWithCharset(length int, charset string) string { return string(b) } +func secureStringWithCharset(length int, charset string) (string, error) { + b := make([]byte, length) + limit := big.NewInt(int64(len(charset))) + for i := range b { + index, err := cryptorand.Int(cryptorand.Reader, limit) + if err != nil { + return "", err + } + b[i] = charset[index.Int64()] + } + return string(b), nil +} + type DashboardShieldApiHandler struct { sm *model.ShieldMapper jwtAuth *JwtAuth diff --git a/scss/_dashboard.scss b/scss/_dashboard.scss index 2ddd91c..3b2de7d 100644 --- a/scss/_dashboard.scss +++ b/scss/_dashboard.scss @@ -66,7 +66,7 @@ } } - > div:nth-of-type(1) { + > .created-api-token { display: flex; gap: 0.5rem; diff --git a/static/main.js b/static/main.js index 9ea4cb4..910a695 100644 --- a/static/main.js +++ b/static/main.js @@ -412,7 +412,7 @@ function ShieldForm({ shield, env, onSave, onDelete }) { const selectedExample = example[1](env, draft.Title, draft.Text, draft.Color, draft.Secret); const apiIDInvalid = draft.APIID !== undefined && draft.APIID !== "" && !shieldAPIIDPattern.test(draft.APIID); const apiIDErrorID = `shield-${draft.ShieldID}-id-error`; - 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 ID", name: "APIID", value: draft.APIID || "", pattern: "[a-z0-9\\\\-]{5,64}", title: "Optional: 5-64 lowercase letters, digits, or hyphens", placeholder: "e.g. production-status", autoComplete: "off", spellcheck: false, "aria-invalid": apiIDInvalid, "aria-describedby": apiIDInvalid ? apiIDErrorID : undefined }), apiIDInvalid && u$1("p", { id: apiIDErrorID, class: "input-error", role: "alert", children: "Shield ID must be 5-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}` }) }), 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", { children: "Markdown" }), u$1("div", { class: "markdown-input--controller", children: [u$1("input", { 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", { children: "API Token" }), u$1("div", { class: "secret-input--controller", children: [u$1("input", { 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" })] })] })] }); + 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 ID", name: "APIID", value: draft.APIID || "", pattern: "[a-z0-9\\\\-]{5,64}", title: "Optional: 5-64 lowercase letters, digits, or hyphens", placeholder: "e.g. production-status", autoComplete: "off", spellcheck: false, "aria-invalid": apiIDInvalid, "aria-describedby": apiIDInvalid ? apiIDErrorID : undefined }), apiIDInvalid && u$1("p", { id: apiIDErrorID, class: "input-error", role: "alert", children: "Shield ID must be 5-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}` }) }), 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", { children: "Markdown" }), u$1("div", { class: "markdown-input--controller", children: [u$1("input", { 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", { children: "Legacy shield API token" }), u$1("div", { class: "secret-input--controller", children: [u$1("input", { 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(_a) { var { label } = _a, attributes = __rest(_a, ["label"]); @@ -467,7 +467,7 @@ function APITokens() { setError(errorMessage(requestError)); } }); - return u$1("section", { class: "api-tokens--controller", children: [u$1("h3", { children: "API Tokens" }), u$1("p", { children: "Create a token to update or create 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", { children: [u$1("p", { children: "Copy this token now. It will not be shown again." }), u$1("input", { 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)) })] })] })] }); + return u$1("section", { class: "api-tokens--controller", children: [u$1("h3", { children: "API Tokens" }), u$1("p", { children: "Create a token to update or create 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("input", { 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"; diff --git a/static/style/style.css b/static/style/style.css index d92bd77..6af0e42 100644 --- a/static/style/style.css +++ b/static/style/style.css @@ -174,11 +174,11 @@ button.primary:hover, .home--app .primary.github-login:hover { .dashboard--controller .api-tokens--controller table th { font-weight: 600; } -.dashboard--controller .api-tokens--controller > div:nth-of-type(1) { +.dashboard--controller .api-tokens--controller > .created-api-token { display: flex; gap: 0.5rem; } -.dashboard--controller .api-tokens--controller > div:nth-of-type(1) input { +.dashboard--controller .api-tokens--controller > .created-api-token input { flex: 1; } .dashboard--controller .shield--controller { diff --git a/ts/Dashboard.tsx b/ts/Dashboard.tsx index d6e8dc6..489028e 100644 --- a/ts/Dashboard.tsx +++ b/ts/Dashboard.tsx @@ -220,7 +220,7 @@ function ShieldForm({ shield, env, onSave, onDelete }: ShieldFormProps) {
event.currentTarget.select()} />
- +
event.currentTarget.select()} />
; @@ -290,7 +290,7 @@ function APITokens() { {error !== "" &&

{error}

} - {createdToken !== "" &&

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

event.currentTarget.select()} />
} + {createdToken !== "" &&

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

event.currentTarget.select()} />
}

Active tokens

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

Loading API tokens…

} From c16cafdb73f1907545fdc818db5726b4939f0935 Mon Sep 17 00:00:00 2001 From: Jesse Donat Date: Fri, 24 Jul 2026 18:02:49 -0500 Subject: [PATCH 10/37] docs: clarify per-shield and user token scopes --- AGENTS.md | 2 +- dashboardapihandlers.go | 12 ------------ static/main.js | 4 ++-- ts/Dashboard.tsx | 6 +++--- 4 files changed, 6 insertions(+), 18 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 9e0d0c2..549779b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -72,7 +72,7 @@ go test ./... # compile/test all Go packages (there - 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 bearer credentials: generate them with `crypto/rand`, store only a one-way hash, return the plaintext only at creation, and scope dashboard reads/deletes by the authenticated user. On the API host, `Authorization: Bearer ` requires `X-Shielded-Shield-ID`; it can update or create only that token owner's shield and records `stamp_last_used`. Preserve the legacy `Authorization: token ` contract unchanged. +- User-level API tokens are bearer credentials: generate them with `crypto/rand`, store only a one-way hash, return the plaintext only at creation, and scope dashboard reads/deletes by the authenticated user. On the API host, `Authorization: Bearer ` requires `X-Shielded-Shield-ID`; it can update or create only that token owner's shield and records `stamp_last_used`. Preserve the per-shield `Authorization: token ` contract unchanged. ### Database schema and migrations diff --git a/dashboardapihandlers.go b/dashboardapihandlers.go index 73ba173..c1f1193 100644 --- a/dashboardapihandlers.go +++ b/dashboardapihandlers.go @@ -6,11 +6,9 @@ import ( "fmt" "log/slog" "math/big" - "math/rand" "net/http" "regexp" "strconv" - "time" "github.com/ShieldedDotDev/shieldeddotdev/model" "github.com/gorilla/mux" @@ -135,16 +133,6 @@ 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 { - b := make([]byte, length) - for i := range b { - b[i] = charset[seededRand.Intn(len(charset))] - } - return string(b) -} - func secureStringWithCharset(length int, charset string) (string, error) { b := make([]byte, length) limit := big.NewInt(int64(len(charset))) diff --git a/static/main.js b/static/main.js index 910a695..2508932 100644 --- a/static/main.js +++ b/static/main.js @@ -412,7 +412,7 @@ function ShieldForm({ shield, env, onSave, onDelete }) { const selectedExample = example[1](env, draft.Title, draft.Text, draft.Color, draft.Secret); const apiIDInvalid = draft.APIID !== undefined && draft.APIID !== "" && !shieldAPIIDPattern.test(draft.APIID); const apiIDErrorID = `shield-${draft.ShieldID}-id-error`; - 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 ID", name: "APIID", value: draft.APIID || "", pattern: "[a-z0-9\\\\-]{5,64}", title: "Optional: 5-64 lowercase letters, digits, or hyphens", placeholder: "e.g. production-status", autoComplete: "off", spellcheck: false, "aria-invalid": apiIDInvalid, "aria-describedby": apiIDInvalid ? apiIDErrorID : undefined }), apiIDInvalid && u$1("p", { id: apiIDErrorID, class: "input-error", role: "alert", children: "Shield ID must be 5-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}` }) }), 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", { children: "Markdown" }), u$1("div", { class: "markdown-input--controller", children: [u$1("input", { 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", { children: "Legacy shield API token" }), u$1("div", { class: "secret-input--controller", children: [u$1("input", { 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" })] })] })] }); + 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 ID", name: "APIID", value: draft.APIID || "", pattern: "[a-z0-9\\\\-]{5,64}", title: "Optional: 5-64 lowercase letters, digits, or hyphens", placeholder: "e.g. production-status", autoComplete: "off", spellcheck: false, "aria-invalid": apiIDInvalid, "aria-describedby": apiIDInvalid ? apiIDErrorID : undefined }), apiIDInvalid && u$1("p", { id: apiIDErrorID, class: "input-error", role: "alert", children: "Shield ID must be 5-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}` }) }), 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", { children: "Markdown" }), u$1("div", { class: "markdown-input--controller", children: [u$1("input", { 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", { children: "This shield's API token" }), u$1("div", { class: "secret-input--controller", children: [u$1("input", { 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(_a) { var { label } = _a, attributes = __rest(_a, ["label"]); @@ -467,7 +467,7 @@ function APITokens() { setError(errorMessage(requestError)); } }); - return u$1("section", { class: "api-tokens--controller", children: [u$1("h3", { children: "API Tokens" }), u$1("p", { children: "Create a token to update or create 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("input", { 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)) })] })] })] }); + 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("input", { 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"; diff --git a/ts/Dashboard.tsx b/ts/Dashboard.tsx index 489028e..912d7fd 100644 --- a/ts/Dashboard.tsx +++ b/ts/Dashboard.tsx @@ -220,7 +220,7 @@ function ShieldForm({ shield, env, onSave, onDelete }: ShieldFormProps) {
event.currentTarget.select()} />
- +
event.currentTarget.select()} />
; @@ -282,8 +282,8 @@ function APITokens() { }; return
-

API Tokens

-

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

+

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" /> From fc3ea2b7107952b77aa68fade586774830bc1ea9 Mon Sep 17 00:00:00 2001 From: Jesse Donat Date: Fri, 24 Jul 2026 18:05:02 -0500 Subject: [PATCH 11/37] feat: prefix new user API tokens --- AGENTS.md | 2 +- model/user_api_tokens.go | 7 +++++-- model/user_api_tokens_test.go | 21 +++++++++++++++++++++ 3 files changed, 27 insertions(+), 3 deletions(-) create mode 100644 model/user_api_tokens_test.go diff --git a/AGENTS.md b/AGENTS.md index 549779b..ef2c01b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -72,7 +72,7 @@ go test ./... # compile/test all Go packages (there - 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 bearer credentials: generate them with `crypto/rand`, store only a one-way hash, return the plaintext only at creation, and scope dashboard reads/deletes by the authenticated user. On the API host, `Authorization: Bearer ` requires `X-Shielded-Shield-ID`; it can update or create only that token owner's shield and records `stamp_last_used`. Preserve the per-shield `Authorization: token ` contract unchanged. +- User-level API tokens are bearer credentials: generate them with `crypto/rand`, prefix newly issued values with `sdu_`, store only a one-way hash, return the plaintext only at creation, and scope dashboard reads/deletes by the authenticated user. On the API host, `Authorization: Bearer ` requires `X-Shielded-Shield-ID`; it can update or create only that token owner's shield and records `stamp_last_used`. Preserve the per-shield `Authorization: token ` contract unchanged. ### Database schema and migrations diff --git a/model/user_api_tokens.go b/model/user_api_tokens.go index 6ba89f5..4e05de9 100644 --- a/model/user_api_tokens.go +++ b/model/user_api_tokens.go @@ -11,7 +11,10 @@ import ( "unicode/utf8" ) -const MaxUserAPITokenDescriptionLength = 255 +const ( + MaxUserAPITokenDescriptionLength = 255 + UserAPITokenPrefix = "sdu_" +) var ( ErrUserAPITokenDescriptionRequired = errors.New("API token description is required") @@ -168,6 +171,6 @@ func newUserAPITokenSecret() (string, [sha256.Size]byte, error) { return "", [sha256.Size]byte{}, err } - plainToken := "sdt_" + base64.RawURLEncoding.EncodeToString(secret) + plainToken := UserAPITokenPrefix + base64.RawURLEncoding.EncodeToString(secret) return plainToken, sha256.Sum256([]byte(plainToken)), nil } diff --git a/model/user_api_tokens_test.go b/model/user_api_tokens_test.go new file mode 100644 index 0000000..3300f34 --- /dev/null +++ b/model/user_api_tokens_test.go @@ -0,0 +1,21 @@ +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") + } +} From 67ca42f8d9afa01dc249c9831361b60c38490a96 Mon Sep 17 00:00:00 2001 From: Jesse Donat Date: Fri, 24 Jul 2026 18:35:31 -0500 Subject: [PATCH 12/37] feat: route user tokens through token auth --- AGENTS.md | 2 +- apihandler.go | 32 ++++++++++++++------------------ model/user_api_tokens.go | 8 ++++++-- model/user_api_tokens_test.go | 9 +++++++++ 4 files changed, 30 insertions(+), 21 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index ef2c01b..c2ac628 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -72,7 +72,7 @@ go test ./... # compile/test all Go packages (there - 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 bearer credentials: generate them with `crypto/rand`, prefix newly issued values with `sdu_`, store only a one-way hash, return the plaintext only at creation, and scope dashboard reads/deletes by the authenticated user. On the API host, `Authorization: Bearer ` requires `X-Shielded-Shield-ID`; it can update or create only that token owner's shield and records `stamp_last_used`. Preserve the per-shield `Authorization: token ` contract unchanged. +- 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 `; `sdu_` values require `X-Shielded-Shield-ID`, can update or create only that token owner's shield, and record `stamp_last_used`. ### Database schema and migrations diff --git a/apihandler.go b/apihandler.go index 059e66b..2f985da 100644 --- a/apihandler.go +++ b/apihandler.go @@ -22,7 +22,7 @@ func NewApiHandler(sm *model.ShieldMapper, tm *model.UserAPITokenMapper, 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 { + if len(authParts) != 2 || authParts[0] != "token" { http.Error(w, "missing secret", http.StatusBadRequest) return } @@ -32,20 +32,7 @@ func (ah *ApiHandler) HandlePOST(w http.ResponseWriter, r *http.Request) { created := false var err error - switch strings.ToLower(authParts[0]) { - case "token": - shield, err = ah.sm.GetFromSecret(authParts[1]) - 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") - http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound) - return - } - case "bearer": + if model.IsUserAPIToken(authParts[1]) { userToken, err = ah.tm.GetFromToken(authParts[1]) if err != nil { slog.Error("error fetching user API token", slog.Any("error", err)) @@ -92,9 +79,18 @@ func (ah *ApiHandler) HandlePOST(w http.ResponseWriter, r *http.Request) { } created = true } - default: - http.Error(w, "missing secret", http.StatusBadRequest) - return + } else { + shield, err = ah.sm.GetFromSecret(authParts[1]) + 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") + http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound) + return + } } err = r.ParseForm() diff --git a/model/user_api_tokens.go b/model/user_api_tokens.go index 4e05de9..1dcb7f8 100644 --- a/model/user_api_tokens.go +++ b/model/user_api_tokens.go @@ -74,7 +74,7 @@ func (tm *UserAPITokenMapper) GetFromUserIDAndID(userID, apiTokenID int64) (*Use return token, nil } -// Create returns the public metadata and the plaintext bearer token. The +// 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) @@ -113,7 +113,7 @@ func (tm *UserAPITokenMapper) DeleteFromUserIDAndID(userID, apiTokenID int64) er return err } -// GetFromToken finds a token from its plaintext bearer value without exposing +// 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)) @@ -174,3 +174,7 @@ func newUserAPITokenSecret() (string, [sha256.Size]byte, error) { 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 index 3300f34..4003522 100644 --- a/model/user_api_tokens_test.go +++ b/model/user_api_tokens_test.go @@ -19,3 +19,12 @@ func TestNewUserAPITokenSecret(t *testing.T) { 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") + } +} From 19141e4e287fc0f4920423f569e0e909ac0abd17 Mon Sep 17 00:00:00 2001 From: Jesse Donat Date: Fri, 24 Jul 2026 18:50:32 -0500 Subject: [PATCH 13/37] refactor: rename shield API ID --- apihandler.go | 18 +++++++++--------- dashboardapihandlers.go | 30 +++++++++++++++--------------- dashboardapihandlers_test.go | 26 +++++++++++++------------- model/shields.go | 28 ++++++++++++++-------------- static/main.js | 12 ++++++------ ts/Dashboard.tsx | 14 +++++++------- ts/api/shields.ts | 2 +- 7 files changed, 65 insertions(+), 65 deletions(-) diff --git a/apihandler.go b/apihandler.go index 2f985da..675c31b 100644 --- a/apihandler.go +++ b/apihandler.go @@ -44,13 +44,13 @@ func (ah *ApiHandler) HandlePOST(w http.ResponseWriter, r *http.Request) { return } - apiID := r.Header.Get("X-Shielded-Shield-ID") - if !validShieldAPIID(apiID) || apiID == "" { + userShieldID := r.Header.Get("X-Shielded-Shield-ID") + if !validUserShieldID(userShieldID) || userShieldID == "" { http.Error(w, "X-Shielded-Shield-ID must be 5-64 lowercase letters, digits, or hyphens", http.StatusBadRequest) return } - shield, err = ah.sm.GetFromUserIDAndAPIID(userToken.UserID, apiID) + shield, err = ah.sm.GetFromUserIDAndUserShieldID(userToken.UserID, userShieldID) 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) @@ -70,12 +70,12 @@ func (ah *ApiHandler) HandlePOST(w http.ResponseWriter, r *http.Request) { return } shield = &model.Shield{ - UserID: userToken.UserID, - APIID: apiID, - Name: apiID, - Title: apiID, - Color: defaultColor, - Secret: secret, + UserID: userToken.UserID, + UserShieldID: userShieldID, + Name: userShieldID, + Title: userShieldID, + Color: defaultColor, + Secret: secret, } created = true } diff --git a/dashboardapihandlers.go b/dashboardapihandlers.go index c1f1193..6896292 100644 --- a/dashboardapihandlers.go +++ b/dashboardapihandlers.go @@ -14,18 +14,18 @@ import ( "github.com/gorilla/mux" ) -var shieldAPIIDPattern = regexp.MustCompile(`^[a-z0-9-]{5,64}$`) +var userShieldIDPattern = regexp.MustCompile(`^[a-z0-9-]{5,64}$`) -func validShieldAPIID(apiID string) bool { - return apiID == "" || shieldAPIIDPattern.MatchString(apiID) +func validUserShieldID(userShieldID string) bool { + return userShieldID == "" || userShieldIDPattern.MatchString(userShieldID) } -func shieldAPIIDAvailable(sm *model.ShieldMapper, userID, shieldID int64, apiID string) (bool, error) { - if apiID == "" { +func userShieldIDAvailable(sm *model.ShieldMapper, userID, shieldID int64, userShieldID string) (bool, error) { + if userShieldID == "" { return true, nil } - shield, err := sm.GetFromUserIDAndAPIID(userID, apiID) + shield, err := sm.GetFromUserIDAndUserShieldID(userID, userShieldID) if err != nil { return false, err } @@ -83,13 +83,13 @@ func (sh *DashboardShieldApiIndexHandler) HandlePOST(w http.ResponseWriter, r *h http.Error(w, "failed to parse request body", http.StatusBadRequest) return } - if !validShieldAPIID(postShield.APIID) { + if !validUserShieldID(postShield.UserShieldID) { http.Error(w, "shield ID must be 5-64 lowercase letters, digits, or hyphens", http.StatusBadRequest) return } - available, err := shieldAPIIDAvailable(sh.sm, *id, 0, postShield.APIID) + available, err := userShieldIDAvailable(sh.sm, *id, 0, postShield.UserShieldID) if err != nil { - slog.Error("error checking shield API ID", slog.Any("error", err), slog.Any("id", *id)) + slog.Error("error checking user shield ID", slog.Any("error", err), slog.Any("id", *id)) http.Error(w, "database error", http.StatusInternalServerError) return } @@ -108,8 +108,8 @@ func (sh *DashboardShieldApiIndexHandler) HandlePOST(w http.ResponseWriter, r *h cleanShield := &model.Shield{ UserID: *id, - APIID: postShield.APIID, - Name: postShield.Name, + UserShieldID: postShield.UserShieldID, + Name: postShield.Name, Title: postShield.Title, Text: postShield.Text, @@ -205,13 +205,13 @@ func (dh *DashboardShieldApiHandler) HandlePUT(w http.ResponseWriter, r *http.Re http.Error(w, "failed to parse request body", http.StatusBadRequest) return } - if !validShieldAPIID(putShield.APIID) { + if !validUserShieldID(putShield.UserShieldID) { http.Error(w, "shield ID must be 5-64 lowercase letters, digits, or hyphens", http.StatusBadRequest) return } - available, err := shieldAPIIDAvailable(dh.sm, shield.UserID, shield.ShieldID, putShield.APIID) + available, err := userShieldIDAvailable(dh.sm, shield.UserID, shield.ShieldID, putShield.UserShieldID) if err != nil { - slog.Error("error checking shield API ID", slog.Any("error", err), slog.Any("id", shield.UserID)) + slog.Error("error checking user shield ID", slog.Any("error", err), slog.Any("id", shield.UserID)) http.Error(w, "database error", http.StatusInternalServerError) return } @@ -220,7 +220,7 @@ func (dh *DashboardShieldApiHandler) HandlePUT(w http.ResponseWriter, r *http.Re return } - shield.APIID = putShield.APIID + shield.UserShieldID = putShield.UserShieldID shield.Name = putShield.Name shield.Title = putShield.Title diff --git a/dashboardapihandlers_test.go b/dashboardapihandlers_test.go index 6584c47..a8b6aa7 100644 --- a/dashboardapihandlers_test.go +++ b/dashboardapihandlers_test.go @@ -2,25 +2,25 @@ package shieldeddotdev import "testing" -func TestValidShieldAPIID(t *testing.T) { +func TestValidUserShieldID(t *testing.T) { tests := []struct { - name string - apiID string - valid bool + name string + userShieldID string + valid bool }{ - {name: "empty is optional", apiID: "", valid: true}, - {name: "minimum length", apiID: "abcd-", valid: true}, - {name: "maximum length", apiID: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", valid: true}, - {name: "too short", apiID: "abcd", valid: false}, - {name: "too long", apiID: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", valid: false}, - {name: "uppercase", apiID: "Release-1", valid: false}, - {name: "underscore", apiID: "release_1", valid: false}, + {name: "empty is optional", userShieldID: "", valid: true}, + {name: "minimum length", userShieldID: "abcd-", valid: true}, + {name: "maximum length", userShieldID: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", valid: true}, + {name: "too short", userShieldID: "abcd", valid: false}, + {name: "too long", userShieldID: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", valid: false}, + {name: "uppercase", userShieldID: "Release-1", valid: false}, + {name: "underscore", userShieldID: "release_1", valid: false}, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { - if got := validShieldAPIID(test.apiID); got != test.valid { - t.Errorf("validShieldAPIID(%q) = %t, want %t", test.apiID, got, test.valid) + if got := validUserShieldID(test.userShieldID); got != test.valid { + t.Errorf("validUserShieldID(%q) = %t, want %t", test.userShieldID, got, test.valid) } }) } diff --git a/model/shields.go b/model/shields.go index 8978b94..3c7ae60 100644 --- a/model/shields.go +++ b/model/shields.go @@ -9,10 +9,10 @@ import ( ) type Shield struct { - ShieldID int64 - PublicID string - APIID string - UserID int64 + ShieldID int64 + PublicID string + UserShieldID string + UserID int64 Name string @@ -52,7 +52,7 @@ func (sm *ShieldMapper) GetFromID(id int64) (*Shield, error) { sh := &Shield{} row := sm.db.QueryRow("SELECT s.shield_id, s.public_id, COALESCE(s.api_id, '') AS api_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.APIID, &sh.UserID, &sh.Name, &sh.Title, &sh.Text, &sh.Color, &sh.Secret, &sh.Created, &sh.Updated); err { + switch err := row.Scan(&sh.ShieldID, &sh.PublicID, &sh.UserShieldID, &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: @@ -66,7 +66,7 @@ func (sm *ShieldMapper) GetFromPublicID(publicID string) (*Shield, error) { sh := &Shield{} row := sm.db.QueryRow("SELECT s.shield_id, s.public_id, COALESCE(s.api_id, '') AS api_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.APIID, &sh.UserID, &sh.Name, &sh.Title, &sh.Text, &sh.Color, &sh.Secret, &sh.Created, &sh.Updated); err { + switch err := row.Scan(&sh.ShieldID, &sh.PublicID, &sh.UserShieldID, &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: @@ -80,7 +80,7 @@ func (sm *ShieldMapper) GetFromUserIDAndID(userID, id int64) (*Shield, error) { sh := &Shield{} row := sm.db.QueryRow("SELECT s.shield_id, s.public_id, COALESCE(s.api_id, '') AS api_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.APIID, &sh.UserID, &sh.Name, &sh.Title, &sh.Text, &sh.Color, &sh.Secret, &sh.Created, &sh.Updated); err { + switch err := row.Scan(&sh.ShieldID, &sh.PublicID, &sh.UserShieldID, &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: @@ -90,11 +90,11 @@ func (sm *ShieldMapper) GetFromUserIDAndID(userID, id int64) (*Shield, error) { } } -func (sm *ShieldMapper) GetFromUserIDAndAPIID(userID int64, apiID string) (*Shield, error) { +func (sm *ShieldMapper) GetFromUserIDAndUserShieldID(userID int64, userShieldID string) (*Shield, error) { sh := &Shield{} - row := sm.db.QueryRow("SELECT s.shield_id, s.public_id, COALESCE(s.api_id, '') AS api_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.api_id = ?", userID, apiID) - switch err := row.Scan(&sh.ShieldID, &sh.PublicID, &sh.APIID, &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.api_id, '') AS api_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.api_id = ?", userID, userShieldID) + switch err := row.Scan(&sh.ShieldID, &sh.PublicID, &sh.UserShieldID, &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: @@ -108,7 +108,7 @@ func (sm *ShieldMapper) GetFromSecret(secret string) (*Shield, error) { sh := &Shield{} row := sm.db.QueryRow("SELECT s.shield_id, s.public_id, COALESCE(s.api_id, '') AS api_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.APIID, &sh.UserID, &sh.Name, &sh.Title, &sh.Text, &sh.Color, &sh.Secret, &sh.Created, &sh.Updated); err { + switch err := row.Scan(&sh.ShieldID, &sh.PublicID, &sh.UserShieldID, &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: @@ -129,7 +129,7 @@ func (sm *ShieldMapper) GetFromUserID(userID int64) ([]*Shield, error) { for rows.Next() { sh := &Shield{} - err := rows.Scan(&sh.ShieldID, &sh.PublicID, &sh.APIID, &sh.UserID, &sh.Name, &sh.Title, &sh.Text, &sh.Color, &sh.Secret, &sh.Created, &sh.Updated) + err := rows.Scan(&sh.ShieldID, &sh.PublicID, &sh.UserShieldID, &sh.UserID, &sh.Name, &sh.Title, &sh.Text, &sh.Color, &sh.Secret, &sh.Created, &sh.Updated) if err != nil { return out, err } @@ -157,7 +157,7 @@ func (sm *ShieldMapper) Save(n *Shield) error { if n.ShieldID > 0 { _, err = tx.Exec("UPDATE shields SET user_id=?, api_id=?, name=?, title=?, text=?, color=?, secret=? WHERE shield_id = ?", - n.UserID, nullableString(n.APIID), n.Name, n.Title, n.Text, n.Color, n.Secret, n.ShieldID) + n.UserID, nullableString(n.UserShieldID), n.Name, n.Title, n.Text, n.Color, n.Secret, n.ShieldID) if err != nil { return err } @@ -168,7 +168,7 @@ func (sm *ShieldMapper) Save(n *Shield) error { } n.PublicID = p res, err := tx.Exec("INSERT INTO shields (public_id, api_id, user_id, name, title, text, color, secret) VALUES (?,?,?,?,?,?,?,?)", - n.PublicID, nullableString(n.APIID), n.UserID, n.Name, n.Title, n.Text, n.Color, n.Secret) + n.PublicID, nullableString(n.UserShieldID), n.UserID, n.Name, n.Title, n.Text, n.Color, n.Secret) if err != nil { return err } diff --git a/static/main.js b/static/main.js index 2508932..34b0bc7 100644 --- a/static/main.js +++ b/static/main.js @@ -269,7 +269,7 @@ jobs: color: ${JSON.stringify(color)}`; } -const shieldAPIIDPattern = /^[a-z0-9-]{5,64}$/; +const userShieldIDPattern = /^[a-z0-9-]{5,64}$/; const apiExamples = [ ["GitHub Action", gitHubActionExample], ["Curl", curlExample], @@ -369,7 +369,7 @@ function ShieldForm({ shield, env, onSave, onDelete }) { clearTimeout(saveTimeout.current); saveTimeout.current = null; } - if (next.APIID !== undefined && next.APIID !== "" && !shieldAPIIDPattern.test(next.APIID)) { + if (next.UserShieldID !== undefined && next.UserShieldID !== "" && !userShieldIDPattern.test(next.UserShieldID)) { return; } saveTimeout.current = setTimeout(() => { @@ -384,7 +384,7 @@ function ShieldForm({ shield, env, onSave, onDelete }) { let next; switch (input.name) { case "Name": - case "APIID": + case "UserShieldID": case "Title": case "Text": next = Object.assign(Object.assign({}, draftRef.current), { [input.name]: input.value }); @@ -410,9 +410,9 @@ function ShieldForm({ shield, env, onSave, onDelete }) { }); const markdown = `![${draft.Name}](https://${env.ImgHost}/s/${draft.PublicID})`; const selectedExample = example[1](env, draft.Title, draft.Text, draft.Color, draft.Secret); - const apiIDInvalid = draft.APIID !== undefined && draft.APIID !== "" && !shieldAPIIDPattern.test(draft.APIID); - const apiIDErrorID = `shield-${draft.ShieldID}-id-error`; - 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 ID", name: "APIID", value: draft.APIID || "", pattern: "[a-z0-9\\\\-]{5,64}", title: "Optional: 5-64 lowercase letters, digits, or hyphens", placeholder: "e.g. production-status", autoComplete: "off", spellcheck: false, "aria-invalid": apiIDInvalid, "aria-describedby": apiIDInvalid ? apiIDErrorID : undefined }), apiIDInvalid && u$1("p", { id: apiIDErrorID, class: "input-error", role: "alert", children: "Shield ID must be 5-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}` }) }), 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", { children: "Markdown" }), u$1("div", { class: "markdown-input--controller", children: [u$1("input", { 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", { children: "This shield's API token" }), u$1("div", { class: "secret-input--controller", children: [u$1("input", { 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" })] })] })] }); + const userShieldIDInvalid = draft.UserShieldID !== undefined && draft.UserShieldID !== "" && !userShieldIDPattern.test(draft.UserShieldID); + const userShieldIDErrorID = `shield-${draft.ShieldID}-id-error`; + 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 ID", name: "UserShieldID", value: draft.UserShieldID || "", pattern: "[a-z0-9\\\\-]{5,64}", title: "Optional: 5-64 lowercase letters, digits, or hyphens", placeholder: "e.g. production-status", autoComplete: "off", spellcheck: false, "aria-invalid": userShieldIDInvalid, "aria-describedby": userShieldIDInvalid ? userShieldIDErrorID : undefined }), userShieldIDInvalid && u$1("p", { id: userShieldIDErrorID, class: "input-error", role: "alert", children: "Shield ID must be 5-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}` }) }), 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", { children: "Markdown" }), u$1("div", { class: "markdown-input--controller", children: [u$1("input", { 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", { children: "This shield's API token" }), u$1("div", { class: "secret-input--controller", children: [u$1("input", { 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(_a) { var { label } = _a, attributes = __rest(_a, ["label"]); diff --git a/ts/Dashboard.tsx b/ts/Dashboard.tsx index 912d7fd..15a19f1 100644 --- a/ts/Dashboard.tsx +++ b/ts/Dashboard.tsx @@ -17,7 +17,7 @@ import { type Page = "dashboard" | "user"; -const shieldAPIIDPattern = /^[a-z0-9-]{5,64}$/; +const userShieldIDPattern = /^[a-z0-9-]{5,64}$/; const apiExamples: [string, ApiExampleGeneratorInterface][] = [ ["GitHub Action", gitHubActionExample], ["Curl", curlExample], @@ -148,7 +148,7 @@ function ShieldForm({ shield, env, onSave, onDelete }: ShieldFormProps) { saveTimeout.current = null; } - if (next.APIID !== undefined && next.APIID !== "" && !shieldAPIIDPattern.test(next.APIID)) { + if (next.UserShieldID !== undefined && next.UserShieldID !== "" && !userShieldIDPattern.test(next.UserShieldID)) { return; } @@ -165,7 +165,7 @@ function ShieldForm({ shield, env, onSave, onDelete }: ShieldFormProps) { let next: ShieldInterface; switch (input.name) { case "Name": - case "APIID": + case "UserShieldID": case "Title": case "Text": next = { ...draftRef.current, [input.name]: input.value }; @@ -194,14 +194,14 @@ function ShieldForm({ shield, env, onSave, onDelete }: ShieldFormProps) { const markdown = `![${draft.Name}](https://${env.ImgHost}/s/${draft.PublicID})`; const selectedExample = example[1](env, draft.Title, draft.Text, draft.Color, draft.Secret); - const apiIDInvalid = draft.APIID !== undefined && draft.APIID !== "" && !shieldAPIIDPattern.test(draft.APIID); - const apiIDErrorID = `shield-${draft.ShieldID}-id-error`; + const userShieldIDInvalid = draft.UserShieldID !== undefined && draft.UserShieldID !== "" && !userShieldIDPattern.test(draft.UserShieldID); + const userShieldIDErrorID = `shield-${draft.ShieldID}-id-error`; return
- - {apiIDInvalid && } + + {userShieldIDInvalid && }
diff --git a/ts/api/shields.ts b/ts/api/shields.ts index 6e0b228..6c99045 100644 --- a/ts/api/shields.ts +++ b/ts/api/shields.ts @@ -3,7 +3,7 @@ import { doRawRequest, doRequest } from "./request"; export interface ShieldInterface { ShieldID?: number; PublicID?: string; - APIID?: string; + UserShieldID?: string; UserID: number; Name: string; Title: string; From a24114ce5a3bdd174cb23c051dad69ae4e94d6ae Mon Sep 17 00:00:00 2001 From: Jesse Donat Date: Sat, 25 Jul 2026 06:00:41 -0500 Subject: [PATCH 14/37] fix: rename user tokens page --- pages/dashboard.templ | 2 +- pages/dashboard_templ.go | 2 +- static/main.js | 2 +- ts/Dashboard.tsx | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/pages/dashboard.templ b/pages/dashboard.templ index bc0cc6a..2b08629 100644 --- a/pages/dashboard.templ +++ b/pages/dashboard.templ @@ -13,7 +13,7 @@ templ DashboardPage(hosts Hosts) {
diff --git a/pages/dashboard_templ.go b/pages/dashboard_templ.go index f94659a..d6fa917 100644 --- a/pages/dashboard_templ.go +++ b/pages/dashboard_templ.go @@ -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, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } diff --git a/static/main.js b/static/main.js index 34b0bc7..2882d7c 100644 --- a/static/main.js +++ b/static/main.js @@ -298,7 +298,7 @@ function DashboardApp({ env }) { return () => window.removeEventListener("hashchange", updatePage); }, []); if (page === "user") { - return u$1(S, { children: [u$1("h3", { children: "User Settings" }), u$1("div", { class: "dashboard--controller", children: u$1(APITokens, {}) })] }); + return u$1(S, { children: [u$1("h3", { children: "User tokens" }), u$1("div", { class: "dashboard--controller", children: u$1(APITokens, {}) })] }); } return u$1(S, { children: [u$1("h3", { children: "Dashboard" }), u$1("div", { class: "dashboard--controller", children: u$1(Shields, { env: env }) })] }); } diff --git a/ts/Dashboard.tsx b/ts/Dashboard.tsx index 15a19f1..7f6d305 100644 --- a/ts/Dashboard.tsx +++ b/ts/Dashboard.tsx @@ -51,7 +51,7 @@ function DashboardApp({ env }: { env: EnvInterface }) { if (page === "user") { return <> -

User Settings

+

User tokens

; } From 98f9f693fcd933eac69640f236f245a303749a7f Mon Sep 17 00:00:00 2001 From: Jesse Donat Date: Sat, 25 Jul 2026 06:05:35 -0500 Subject: [PATCH 15/37] refactor: rename shield ID database column --- model/shields.go | 16 ++++++++-------- schema/002_shield_api_id.sql | 3 --- schema/002_user_shield_id.sql | 3 +++ 3 files changed, 11 insertions(+), 11 deletions(-) delete mode 100644 schema/002_shield_api_id.sql create mode 100644 schema/002_user_shield_id.sql diff --git a/model/shields.go b/model/shields.go index 3c7ae60..69adf43 100644 --- a/model/shields.go +++ b/model/shields.go @@ -51,7 +51,7 @@ 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, COALESCE(s.api_id, '') AS api_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) + row := sm.db.QueryRow("SELECT s.shield_id, s.public_id, COALESCE(s.user_shield_id, '') AS user_shield_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.UserShieldID, &sh.UserID, &sh.Name, &sh.Title, &sh.Text, &sh.Color, &sh.Secret, &sh.Created, &sh.Updated); err { case sql.ErrNoRows: return nil, nil @@ -65,7 +65,7 @@ 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, COALESCE(s.api_id, '') AS api_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) + row := sm.db.QueryRow("SELECT s.shield_id, s.public_id, COALESCE(s.user_shield_id, '') AS user_shield_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.UserShieldID, &sh.UserID, &sh.Name, &sh.Title, &sh.Text, &sh.Color, &sh.Secret, &sh.Created, &sh.Updated); err { case sql.ErrNoRows: return nil, nil @@ -79,7 +79,7 @@ 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, COALESCE(s.api_id, '') AS api_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) + row := sm.db.QueryRow("SELECT s.shield_id, s.public_id, COALESCE(s.user_shield_id, '') AS user_shield_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.UserShieldID, &sh.UserID, &sh.Name, &sh.Title, &sh.Text, &sh.Color, &sh.Secret, &sh.Created, &sh.Updated); err { case sql.ErrNoRows: return nil, nil @@ -93,7 +93,7 @@ func (sm *ShieldMapper) GetFromUserIDAndID(userID, id int64) (*Shield, error) { func (sm *ShieldMapper) GetFromUserIDAndUserShieldID(userID int64, userShieldID string) (*Shield, error) { sh := &Shield{} - row := sm.db.QueryRow("SELECT s.shield_id, s.public_id, COALESCE(s.api_id, '') AS api_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.api_id = ?", userID, userShieldID) + row := sm.db.QueryRow("SELECT s.shield_id, s.public_id, COALESCE(s.user_shield_id, '') AS user_shield_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.user_shield_id = ?", userID, userShieldID) switch err := row.Scan(&sh.ShieldID, &sh.PublicID, &sh.UserShieldID, &sh.UserID, &sh.Name, &sh.Title, &sh.Text, &sh.Color, &sh.Secret, &sh.Created, &sh.Updated); err { case sql.ErrNoRows: return nil, nil @@ -107,7 +107,7 @@ func (sm *ShieldMapper) GetFromUserIDAndUserShieldID(userID int64, userShieldID func (sm *ShieldMapper) GetFromSecret(secret string) (*Shield, error) { sh := &Shield{} - row := sm.db.QueryRow("SELECT s.shield_id, s.public_id, COALESCE(s.api_id, '') AS api_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) + row := sm.db.QueryRow("SELECT s.shield_id, s.public_id, COALESCE(s.user_shield_id, '') AS user_shield_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.UserShieldID, &sh.UserID, &sh.Name, &sh.Title, &sh.Text, &sh.Color, &sh.Secret, &sh.Created, &sh.Updated); err { case sql.ErrNoRows: return nil, nil @@ -121,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, COALESCE(s.api_id, '') AS api_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.user_shield_id, '') AS user_shield_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) if err != nil { return out, err } @@ -156,7 +156,7 @@ func (sm *ShieldMapper) Save(n *Shield) error { }() if n.ShieldID > 0 { - _, err = tx.Exec("UPDATE shields SET user_id=?, api_id=?, name=?, title=?, text=?, color=?, secret=? WHERE shield_id = ?", + _, err = tx.Exec("UPDATE shields SET user_id=?, user_shield_id=?, name=?, title=?, text=?, color=?, secret=? WHERE shield_id = ?", n.UserID, nullableString(n.UserShieldID), n.Name, n.Title, n.Text, n.Color, n.Secret, n.ShieldID) if err != nil { return err @@ -167,7 +167,7 @@ func (sm *ShieldMapper) Save(n *Shield) error { return err } n.PublicID = p - res, err := tx.Exec("INSERT INTO shields (public_id, api_id, user_id, name, title, text, color, secret) VALUES (?,?,?,?,?,?,?,?)", + res, err := tx.Exec("INSERT INTO shields (public_id, user_shield_id, user_id, name, title, text, color, secret) VALUES (?,?,?,?,?,?,?,?)", n.PublicID, nullableString(n.UserShieldID), n.UserID, n.Name, n.Title, n.Text, n.Color, n.Secret) if err != nil { return err diff --git a/schema/002_shield_api_id.sql b/schema/002_shield_api_id.sql deleted file mode 100644 index cbc8161..0000000 --- a/schema/002_shield_api_id.sql +++ /dev/null @@ -1,3 +0,0 @@ -ALTER TABLE `shields` - ADD COLUMN `api_id` varchar(64) DEFAULT NULL AFTER `public_id`, - ADD UNIQUE KEY `unq_shields_user_api_id` (`user_id`, `api_id`); diff --git a/schema/002_user_shield_id.sql b/schema/002_user_shield_id.sql new file mode 100644 index 0000000..96732c5 --- /dev/null +++ b/schema/002_user_shield_id.sql @@ -0,0 +1,3 @@ +ALTER TABLE `shields` + ADD COLUMN `user_shield_id` varchar(64) DEFAULT NULL AFTER `public_id`, + ADD UNIQUE KEY `unq_shields_user_shield_id` (`user_id`, `user_shield_id`); From c07273a21da799e452e22847080058d5eaf3d65a Mon Sep 17 00:00:00 2001 From: Jesse Donat Date: Sat, 25 Jul 2026 06:15:23 -0500 Subject: [PATCH 16/37] feat: select user token updates by form ID --- AGENTS.md | 2 +- apihandler.go | 59 ++++++++++++++++++++++++++++++--------------------- 2 files changed, 36 insertions(+), 25 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index c2ac628..773b5ee 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -72,7 +72,7 @@ go test ./... # compile/test all Go packages (there - 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 `; `sdu_` values require `X-Shielded-Shield-ID`, can update or create only that token owner's shield, and record `stamp_last_used`. +- 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 `; form field `id` selects a user-token update/create and is required for `sdu_` values, while its absence requires a per-shield token. Successful user-token writes record `stamp_last_used`. ### Database schema and migrations diff --git a/apihandler.go b/apihandler.go index 675c31b..505c93f 100644 --- a/apihandler.go +++ b/apihandler.go @@ -32,7 +32,33 @@ func (ah *ApiHandler) HandlePOST(w http.ResponseWriter, r *http.Request) { created := false var err error - if model.IsUserAPIToken(authParts[1]) { + err = r.ParseForm() + if 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 != "id" && k != "title" && k != "text" && k != "color" { + http.Error(w, "invalid field: "+k, http.StatusBadRequest) + return + } + } + + userShieldID, hasUserShieldID := r.Form["id"] + if hasUserShieldID { + if len(userShieldID) != 1 || userShieldID[0] == "" || !validUserShieldID(userShieldID[0]) { + http.Error(w, "id must be 5-64 lowercase letters, digits, or hyphens", http.StatusBadRequest) + return + } + } + + if hasUserShieldID { + if !model.IsUserAPIToken(authParts[1]) { + http.Error(w, "id requires a user token", http.StatusBadRequest) + return + } userToken, err = ah.tm.GetFromToken(authParts[1]) if err != nil { slog.Error("error fetching user API token", slog.Any("error", err)) @@ -44,13 +70,7 @@ func (ah *ApiHandler) HandlePOST(w http.ResponseWriter, r *http.Request) { return } - userShieldID := r.Header.Get("X-Shielded-Shield-ID") - if !validUserShieldID(userShieldID) || userShieldID == "" { - http.Error(w, "X-Shielded-Shield-ID must be 5-64 lowercase letters, digits, or hyphens", http.StatusBadRequest) - return - } - - shield, err = ah.sm.GetFromUserIDAndUserShieldID(userToken.UserID, userShieldID) + shield, err = ah.sm.GetFromUserIDAndUserShieldID(userToken.UserID, userShieldID[0]) 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) @@ -71,15 +91,19 @@ func (ah *ApiHandler) HandlePOST(w http.ResponseWriter, r *http.Request) { } shield = &model.Shield{ UserID: userToken.UserID, - UserShieldID: userShieldID, - Name: userShieldID, - Title: userShieldID, + UserShieldID: userShieldID[0], + Name: userShieldID[0], + Title: userShieldID[0], Color: defaultColor, Secret: secret, } created = true } } else { + if model.IsUserAPIToken(authParts[1]) { + http.Error(w, "user token requires an id", http.StatusBadRequest) + return + } shield, err = ah.sm.GetFromSecret(authParts[1]) if err != nil { slog.Error("error fetching shield from secret", slog.Any("error", err)) @@ -93,19 +117,6 @@ func (ah *ApiHandler) HandlePOST(w http.ResponseWriter, r *http.Request) { } } - err = r.ParseForm() - if 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 != "title" && k != "text" && k != "color" { - http.Error(w, "invalid field: "+k, http.StatusBadRequest) - return - } - } if title := r.FormValue("title"); title != "" { shield.Title = title } From 0b2c0e623a9e293469a9582124b573d3d1f0db23 Mon Sep 17 00:00:00 2001 From: Jesse Donat Date: Sat, 25 Jul 2026 06:25:12 -0500 Subject: [PATCH 17/37] refactor: split API token update handlers --- apihandler.go | 159 ++++++++++++++++++++++++++++---------------------- 1 file changed, 90 insertions(+), 69 deletions(-) diff --git a/apihandler.go b/apihandler.go index 505c93f..ac00b4c 100644 --- a/apihandler.go +++ b/apihandler.go @@ -20,20 +20,7 @@ func NewApiHandler(sm *model.ShieldMapper, tm *model.UserAPITokenMapper, 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) - return - } - - var shield *model.Shield - var userToken *model.UserAPIToken - created := false - var err error - - err = r.ParseForm() - if err != nil { + 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 @@ -48,75 +35,109 @@ func (ah *ApiHandler) HandlePOST(w http.ResponseWriter, r *http.Request) { userShieldID, hasUserShieldID := r.Form["id"] if hasUserShieldID { - if len(userShieldID) != 1 || userShieldID[0] == "" || !validUserShieldID(userShieldID[0]) { - http.Error(w, "id must be 5-64 lowercase letters, digits, or hyphens", http.StatusBadRequest) - return - } + ah.handleUserTokenPOST(w, r, userShieldID) + return } - if hasUserShieldID { - if !model.IsUserAPIToken(authParts[1]) { - http.Error(w, "id requires a user token", http.StatusBadRequest) - return - } - userToken, err = ah.tm.GetFromToken(authParts[1]) - 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 - } + ah.handleShieldTokenPOST(w, r) +} + +func (ah *ApiHandler) handleUserTokenPOST(w http.ResponseWriter, r *http.Request, userShieldIDs []string) { + if len(userShieldIDs) != 1 || userShieldIDs[0] == "" || !validUserShieldID(userShieldIDs[0]) { + http.Error(w, "id must be 5-64 lowercase letters, digits, or hyphens", http.StatusBadRequest) + return + } + + token, ok := apiRequestToken(w, r) + if !ok { + return + } + if !model.IsUserAPIToken(token) { + http.Error(w, "id 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 + } - shield, err = ah.sm.GetFromUserIDAndUserShieldID(userToken.UserID, userShieldID[0]) + shield, err := ah.sm.GetFromUserIDAndUserShieldID(userToken.UserID, userShieldIDs[0]) + 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 + } + created := shield == nil + if created { + defaultColor, err := NormalizeColor("green") if err != nil { - slog.Error("error fetching shield from user API token", slog.Any("error", err), slog.Int64("user_id", userToken.UserID)) + slog.Error("error selecting default shield color", slog.Any("error", err)) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) return } - if shield == nil { - defaultColor, err := NormalizeColor("green") - if err != nil { - slog.Error("error selecting default shield color", slog.Any("error", err)) - http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) - return - } - 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, - UserShieldID: userShieldID[0], - Name: userShieldID[0], - Title: userShieldID[0], - Color: defaultColor, - Secret: secret, - } - created = true - } - } else { - if model.IsUserAPIToken(authParts[1]) { - http.Error(w, "user token requires an id", http.StatusBadRequest) - return - } - shield, err = ah.sm.GetFromSecret(authParts[1]) + secret, err := secureStringWithCharset(40, "abcdefghjkmnpqrstuvwxyz23456789") if err != nil { - slog.Error("error fetching shield from secret", slog.Any("error", err)) + slog.Error("error generating shield secret", slog.Any("error", err)) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) return } - if shield == nil { - slog.Info("shield not found") - http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound) - return + shield = &model.Shield{ + UserID: userToken.UserID, + UserShieldID: userShieldIDs[0], + Name: userShieldIDs[0], + Title: userShieldIDs[0], + Color: defaultColor, + Secret: secret, } } + ah.saveShieldFromForm(w, r, shield, userToken, 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 an id", http.StatusBadRequest) + return + } + + 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") + http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound) + return + } + + ah.saveShieldFromForm(w, r, shield, nil, false) +} + +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(w http.ResponseWriter, r *http.Request, shield *model.Shield, userToken *model.UserAPIToken, created bool) { + var err error + if title := r.FormValue("title"); title != "" { shield.Title = title } From e588790227bae27346385cfaa1cf8093afed226a Mon Sep 17 00:00:00 2001 From: Jesse Donat Date: Sat, 25 Jul 2026 06:34:19 -0500 Subject: [PATCH 18/37] refactor: record user token use on authentication --- AGENTS.md | 2 +- apihandler.go | 15 ++++++--------- 2 files changed, 7 insertions(+), 10 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 773b5ee..74429ba 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -72,7 +72,7 @@ go test ./... # compile/test all Go packages (there - 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 `; form field `id` selects a user-token update/create and is required for `sdu_` values, while its absence requires a per-shield token. Successful user-token writes record `stamp_last_used`. +- 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 `; form field `id` selects a user-token update/create and is required for `sdu_` values, while its absence requires a per-shield token. Valid user-token authentication records `stamp_last_used`. ### Database schema and migrations diff --git a/apihandler.go b/apihandler.go index ac00b4c..2d3bec1 100644 --- a/apihandler.go +++ b/apihandler.go @@ -67,6 +67,9 @@ func (ah *ApiHandler) handleUserTokenPOST(w http.ResponseWriter, r *http.Request 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.GetFromUserIDAndUserShieldID(userToken.UserID, userShieldIDs[0]) if err != nil { @@ -98,7 +101,7 @@ func (ah *ApiHandler) handleUserTokenPOST(w http.ResponseWriter, r *http.Request } } - ah.saveShieldFromForm(w, r, shield, userToken, created) + ah.saveShieldFromForm(w, r, shield, created) } func (ah *ApiHandler) handleShieldTokenPOST(w http.ResponseWriter, r *http.Request) { @@ -123,7 +126,7 @@ func (ah *ApiHandler) handleShieldTokenPOST(w http.ResponseWriter, r *http.Reque return } - ah.saveShieldFromForm(w, r, shield, nil, false) + ah.saveShieldFromForm(w, r, shield, false) } func apiRequestToken(w http.ResponseWriter, r *http.Request) (string, bool) { @@ -135,7 +138,7 @@ func apiRequestToken(w http.ResponseWriter, r *http.Request) (string, bool) { return authParts[1], true } -func (ah *ApiHandler) saveShieldFromForm(w http.ResponseWriter, r *http.Request, shield *model.Shield, userToken *model.UserAPIToken, created bool) { +func (ah *ApiHandler) saveShieldFromForm(w http.ResponseWriter, r *http.Request, shield *model.Shield, created bool) { var err error if title := r.FormValue("title"); title != "" { @@ -160,12 +163,6 @@ func (ah *ApiHandler) saveShieldFromForm(w http.ResponseWriter, r *http.Request, http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) return } - if userToken != nil { - 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)) - } - } - w.Header().Set("Content-Type", "application/json") if created { w.WriteHeader(http.StatusCreated) From 9956b0b4dd3e5e46ba6ffe86dad75df708b3f5fe Mon Sep 17 00:00:00 2001 From: Jesse Donat Date: Sat, 25 Jul 2026 06:36:04 -0500 Subject: [PATCH 19/37] refactor: read API shield ID from form value --- AGENTS.md | 2 +- apihandler.go | 16 ++++++++-------- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 74429ba..0a62bcb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -72,7 +72,7 @@ go test ./... # compile/test all Go packages (there - 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 `; form field `id` selects a user-token update/create and is required for `sdu_` values, while its absence requires a per-shield token. Valid user-token authentication records `stamp_last_used`. +- 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 `id` 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 `stamp_last_used`. ### Database schema and migrations diff --git a/apihandler.go b/apihandler.go index 2d3bec1..fb2d6c6 100644 --- a/apihandler.go +++ b/apihandler.go @@ -33,8 +33,8 @@ func (ah *ApiHandler) HandlePOST(w http.ResponseWriter, r *http.Request) { } } - userShieldID, hasUserShieldID := r.Form["id"] - if hasUserShieldID { + userShieldID := r.FormValue("id") + if userShieldID != "" { ah.handleUserTokenPOST(w, r, userShieldID) return } @@ -42,8 +42,8 @@ func (ah *ApiHandler) HandlePOST(w http.ResponseWriter, r *http.Request) { ah.handleShieldTokenPOST(w, r) } -func (ah *ApiHandler) handleUserTokenPOST(w http.ResponseWriter, r *http.Request, userShieldIDs []string) { - if len(userShieldIDs) != 1 || userShieldIDs[0] == "" || !validUserShieldID(userShieldIDs[0]) { +func (ah *ApiHandler) handleUserTokenPOST(w http.ResponseWriter, r *http.Request, userShieldID string) { + if !validUserShieldID(userShieldID) { http.Error(w, "id must be 5-64 lowercase letters, digits, or hyphens", http.StatusBadRequest) return } @@ -71,7 +71,7 @@ func (ah *ApiHandler) handleUserTokenPOST(w http.ResponseWriter, r *http.Request slog.Error("error recording user API token use", slog.Any("error", err), slog.Int64("api_token_id", userToken.APITokenID)) } - shield, err := ah.sm.GetFromUserIDAndUserShieldID(userToken.UserID, userShieldIDs[0]) + shield, err := ah.sm.GetFromUserIDAndUserShieldID(userToken.UserID, userShieldID) 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) @@ -93,9 +93,9 @@ func (ah *ApiHandler) handleUserTokenPOST(w http.ResponseWriter, r *http.Request } shield = &model.Shield{ UserID: userToken.UserID, - UserShieldID: userShieldIDs[0], - Name: userShieldIDs[0], - Title: userShieldIDs[0], + UserShieldID: userShieldID, + Name: userShieldID, + Title: userShieldID, Color: defaultColor, Secret: secret, } From 5d6ec5c954fc981bd07807f10027e1e078ec20ee Mon Sep 17 00:00:00 2001 From: Jesse Donat Date: Sat, 25 Jul 2026 06:38:46 -0500 Subject: [PATCH 20/37] refactor: return API shield save results --- apihandler.go | 47 +++++++++++++++++++++++++++++++++++------------ 1 file changed, 35 insertions(+), 12 deletions(-) diff --git a/apihandler.go b/apihandler.go index fb2d6c6..0dc543f 100644 --- a/apihandler.go +++ b/apihandler.go @@ -2,6 +2,7 @@ package shieldeddotdev import ( "encoding/json" + "errors" "log/slog" "net/http" "strings" @@ -9,6 +10,8 @@ import ( "github.com/ShieldedDotDev/shieldeddotdev/model" ) +var errInvalidShieldColor = errors.New("invalid shield color") + type ApiHandler struct { sm *model.ShieldMapper tm *model.UserAPITokenMapper @@ -77,8 +80,7 @@ func (ah *ApiHandler) handleUserTokenPOST(w http.ResponseWriter, r *http.Request http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) return } - created := shield == nil - if created { + if shield == nil { defaultColor, err := NormalizeColor("green") if err != nil { slog.Error("error selecting default shield color", slog.Any("error", err)) @@ -101,7 +103,12 @@ func (ah *ApiHandler) handleUserTokenPOST(w http.ResponseWriter, r *http.Request } } - ah.saveShieldFromForm(w, r, shield, created) + 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) { @@ -126,7 +133,12 @@ func (ah *ApiHandler) handleShieldTokenPOST(w http.ResponseWriter, r *http.Reque return } - ah.saveShieldFromForm(w, r, shield, false) + created, err := ah.saveShieldFromForm(r, shield) + if err != nil { + ah.handleSaveShieldError(w, err) + return + } + ah.writeShieldResponse(w, shield, created) } func apiRequestToken(w http.ResponseWriter, r *http.Request) (string, bool) { @@ -138,8 +150,8 @@ func apiRequestToken(w http.ResponseWriter, r *http.Request) (string, bool) { return authParts[1], true } -func (ah *ApiHandler) saveShieldFromForm(w http.ResponseWriter, r *http.Request, shield *model.Shield, created bool) { - var err error +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 @@ -150,19 +162,30 @@ func (ah *ApiHandler) saveShieldFromForm(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 } - err = ah.sm.Save(shield) + 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, err + } + + 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 } + + 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) From 26c93022d0446129f4c64943330a5e2a4332ca26 Mon Sep 17 00:00:00 2001 From: Jesse Donat Date: Sat, 25 Jul 2026 06:41:42 -0500 Subject: [PATCH 21/37] feat: allow API user shield ID updates --- AGENTS.md | 2 +- apihandler.go | 27 ++++++++++++++++++++++++--- 2 files changed, 25 insertions(+), 4 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 0a62bcb..b2d07f5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -72,7 +72,7 @@ go test ./... # compile/test all Go packages (there - 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 `id` 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 `stamp_last_used`. +- 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 `id` selects a user-token update/create and is required for `sdu_` values, while a blank or absent value requires a per-shield token. Either workflow can set a non-empty `user_shield_id` form value. Valid user-token authentication records `stamp_last_used`. ### Database schema and migrations diff --git a/apihandler.go b/apihandler.go index 0dc543f..1484735 100644 --- a/apihandler.go +++ b/apihandler.go @@ -10,7 +10,11 @@ import ( "github.com/ShieldedDotDev/shieldeddotdev/model" ) -var errInvalidShieldColor = errors.New("invalid shield color") +var ( + errInvalidShieldColor = errors.New("invalid shield color") + errInvalidUserShieldID = errors.New("invalid user shield ID") + errUserShieldIDInUse = errors.New("user shield ID is already in use") +) type ApiHandler struct { sm *model.ShieldMapper @@ -30,7 +34,7 @@ func (ah *ApiHandler) HandlePOST(w http.ResponseWriter, r *http.Request) { } for k := range r.Form { - if k != "id" && k != "title" && k != "text" && k != "color" { + if k != "id" && k != "user_shield_id" && k != "title" && k != "text" && k != "color" { http.Error(w, "invalid field: "+k, http.StatusBadRequest) return } @@ -166,6 +170,19 @@ func (ah *ApiHandler) saveShieldFromForm(r *http.Request, shield *model.Shield) } shield.Color = color } + if userShieldID := r.FormValue("user_shield_id"); userShieldID != "" { + if !validUserShieldID(userShieldID) { + return created, errInvalidUserShieldID + } + available, err := userShieldIDAvailable(ah.sm, shield.UserID, shield.ShieldID, userShieldID) + if err != nil { + return created, err + } + if !available { + return created, errUserShieldIDInUse + } + shield.UserShieldID = userShieldID + } err := ah.sm.Save(shield) if err != nil { @@ -176,10 +193,14 @@ func (ah *ApiHandler) saveShieldFromForm(r *http.Request, shield *model.Shield) } func (ah *ApiHandler) handleSaveShieldError(w http.ResponseWriter, err error) { - if errors.Is(err, errInvalidShieldColor) { + if errors.Is(err, errInvalidShieldColor) || errors.Is(err, errInvalidUserShieldID) { http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) return } + if errors.Is(err, errUserShieldIDInUse) { + http.Error(w, err.Error(), http.StatusConflict) + return + } slog.Error("error saving shield", slog.Any("error", err)) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) From 17ff1e62a264d790901eed92f950ab7ca041be75 Mon Sep 17 00:00:00 2001 From: Jesse Donat Date: Sat, 25 Jul 2026 06:45:17 -0500 Subject: [PATCH 22/37] fix: use dashboard defaults for API-created shields --- apihandler.go | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/apihandler.go b/apihandler.go index 1484735..0d0d5fa 100644 --- a/apihandler.go +++ b/apihandler.go @@ -85,12 +85,6 @@ func (ah *ApiHandler) handleUserTokenPOST(w http.ResponseWriter, r *http.Request return } if shield == nil { - defaultColor, err := NormalizeColor("green") - if err != nil { - slog.Error("error selecting default shield color", slog.Any("error", err)) - http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) - return - } secret, err := secureStringWithCharset(40, "abcdefghjkmnpqrstuvwxyz23456789") if err != nil { slog.Error("error generating shield secret", slog.Any("error", err)) @@ -100,9 +94,10 @@ func (ah *ApiHandler) handleUserTokenPOST(w http.ResponseWriter, r *http.Request shield = &model.Shield{ UserID: userToken.UserID, UserShieldID: userShieldID, - Name: userShieldID, - Title: userShieldID, - Color: defaultColor, + Name: "New Shield", + Title: "New", + Text: "Shield", + Color: "00AA55", Secret: secret, } } From faed7188b3e9049e6544ed3dfb017afa8bc27453 Mon Sep 17 00:00:00 2001 From: Jesse Donat Date: Sat, 25 Jul 2026 06:50:57 -0500 Subject: [PATCH 23/37] feat: style dashboard navigation as tabs --- pages/dashboard.templ | 6 ------ pages/dashboard_templ.go | 2 +- scss/_dashboard.scss | 32 ++++++++++++++++++++++++++++++++ static/main.js | 8 ++++---- static/style/style.css | 27 +++++++++++++++++++++++++++ ts/Dashboard.tsx | 22 ++++++++++++++-------- 6 files changed, 78 insertions(+), 19 deletions(-) diff --git a/pages/dashboard.templ b/pages/dashboard.templ index 2b08629..f184982 100644 --- a/pages/dashboard.templ +++ b/pages/dashboard.templ @@ -10,12 +10,6 @@ templ DashboardPage(hosts Hosts) { @Header()
-
diff --git a/pages/dashboard_templ.go b/pages/dashboard_templ.go index d6fa917..30e0095 100644 --- a/pages/dashboard_templ.go +++ b/pages/dashboard_templ.go @@ -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, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } diff --git a/scss/_dashboard.scss b/scss/_dashboard.scss index 3b2de7d..32b0db6 100644 --- a/scss/_dashboard.scss +++ b/scss/_dashboard.scss @@ -3,6 +3,38 @@ @use "shared"; %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; + } + } + } + > .add-button { display: block; margin: 0 auto 1em; diff --git a/static/main.js b/static/main.js index 2882d7c..7d45141 100644 --- a/static/main.js +++ b/static/main.js @@ -297,10 +297,10 @@ function DashboardApp({ env }) { window.addEventListener("hashchange", updatePage); return () => window.removeEventListener("hashchange", updatePage); }, []); - if (page === "user") { - return u$1(S, { children: [u$1("h3", { children: "User tokens" }), u$1("div", { class: "dashboard--controller", children: u$1(APITokens, {}) })] }); - } - return u$1(S, { children: [u$1("h3", { children: "Dashboard" }), u$1("div", { class: "dashboard--controller", children: u$1(Shields, { env: env }) })] }); + 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; diff --git a/static/style/style.css b/static/style/style.css index 6af0e42..a09b842 100644 --- a/static/style/style.css +++ b/static/style/style.css @@ -125,6 +125,33 @@ button.primary:hover, .home--app .primary.github-login:hover { border-radius: 6px; } +.dashboard--controller .dashboard-navigation { + display: flex; + gap: 0.25rem; + margin-bottom: 1.5rem; + border-bottom: 1px solid #d1d9e0; +} +.dashboard--controller .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--controller .dashboard-navigation > a:hover, .dashboard--controller .dashboard-navigation > a:focus-visible { + background: rgb(173.5, 184.5, 210.5); +} +.dashboard--controller .dashboard-navigation > a[aria-current=page] { + position: relative; + bottom: -1px; + padding-bottom: calc(0.5rem + 1px); + background: white; + color: #5c72a6; +} .dashboard--controller > .add-button { display: block; margin: 0 auto 1em; diff --git a/ts/Dashboard.tsx b/ts/Dashboard.tsx index 7f6d305..af72686 100644 --- a/ts/Dashboard.tsx +++ b/ts/Dashboard.tsx @@ -49,19 +49,25 @@ function DashboardApp({ env }: { env: EnvInterface }) { return () => window.removeEventListener("hashchange", updatePage); }, []); - if (page === "user") { - return <> + return <> + + {page === "user" ? <>

User tokens

- ; - } - - return <> -

Dashboard

-
+ : <> +

Dashboard

+
+ } ; } +function DashboardNavigation({ page }: { page: Page }) { + return ; +} + function Shields({ env }: { env: EnvInterface }) { const api = useRef(new ShieldsApi()).current; const [shields, setShields] = useState(null); From 4e095c875412917963e620c7e86ad8abda5317eb Mon Sep 17 00:00:00 2001 From: Jesse Donat Date: Sat, 25 Jul 2026 06:58:41 -0500 Subject: [PATCH 24/37] fix: apply dashboard tab styles --- scss/_dashboard.scss | 63 +++++++++++++++++++++--------------------- static/style/style.css | 55 ++++++++++++++++++------------------ 2 files changed, 60 insertions(+), 58 deletions(-) diff --git a/scss/_dashboard.scss b/scss/_dashboard.scss index 32b0db6..66d3287 100644 --- a/scss/_dashboard.scss +++ b/scss/_dashboard.scss @@ -3,37 +3,6 @@ @use "shared"; %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; - } - } - } > .add-button { display: block; @@ -312,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/style/style.css b/static/style/style.css index a09b842..8c5fb7a 100644 --- a/static/style/style.css +++ b/static/style/style.css @@ -125,33 +125,6 @@ button.primary:hover, .home--app .primary.github-login:hover { border-radius: 6px; } -.dashboard--controller .dashboard-navigation { - display: flex; - gap: 0.25rem; - margin-bottom: 1.5rem; - border-bottom: 1px solid #d1d9e0; -} -.dashboard--controller .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--controller .dashboard-navigation > a:hover, .dashboard--controller .dashboard-navigation > a:focus-visible { - background: rgb(173.5, 184.5, 210.5); -} -.dashboard--controller .dashboard-navigation > a[aria-current=page] { - position: relative; - bottom: -1px; - padding-bottom: calc(0.5rem + 1px); - background: white; - color: #5c72a6; -} .dashboard--controller > .add-button { display: block; margin: 0 auto 1em; @@ -336,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; } From 01ca0c1b775a4bf4b4172a9aa04a526d8b98f4ce Mon Sep 17 00:00:00 2001 From: Jesse Donat Date: Sat, 25 Jul 2026 07:03:50 -0500 Subject: [PATCH 25/37] feat: return shield user ID from API --- apihandler.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apihandler.go b/apihandler.go index 0d0d5fa..bae0f18 100644 --- a/apihandler.go +++ b/apihandler.go @@ -207,6 +207,7 @@ func (ah *ApiHandler) writeShieldResponse(w http.ResponseWriter, shield *model.S w.WriteHeader(http.StatusCreated) } json.NewEncoder(w).Encode(map[string]string{ - "ShieldURL": "https://" + ah.imgHost + "/s/" + shield.PublicID, + "ShieldURL": "https://" + ah.imgHost + "/s/" + shield.PublicID, + "ShieldUserID": shield.UserShieldID, }) } From 75218425af7a0a19681e6ab58b74a252ed64b4c7 Mon Sep 17 00:00:00 2001 From: Jesse Donat Date: Sat, 25 Jul 2026 07:12:53 -0500 Subject: [PATCH 26/37] refactor: rename user shield ID to shield key --- AGENTS.md | 3 +- apihandler.go | 54 +++++++++++++++++------------------ dashboardapihandlers.go | 38 ++++++++++++------------ dashboardapihandlers_test.go | 26 ++++++++--------- model/shields.go | 42 +++++++++++++-------------- schema/002_shield_key.sql | 3 ++ schema/002_user_shield_id.sql | 3 -- static/main.js | 12 ++++---- ts/Dashboard.tsx | 14 ++++----- ts/api/shields.ts | 2 +- 10 files changed, 99 insertions(+), 98 deletions(-) create mode 100644 schema/002_shield_key.sql delete mode 100644 schema/002_user_shield_id.sql diff --git a/AGENTS.md b/AGENTS.md index b2d07f5..2cd3a29 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -72,12 +72,13 @@ go test ./... # compile/test all Go packages (there - 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 `id` selects a user-token update/create and is required for `sdu_` values, while a blank or absent value requires a per-shield token. Either workflow can set a non-empty `user_shield_id` form value. Valid user-token authentication records `stamp_last_used`. +- 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 `id` selects a user-token update/create and is required for `sdu_` values, while a blank or absent value requires a per-shield token. Either workflow can set a non-empty `shield_key` form value. Valid user-token authentication records `stamp_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. diff --git a/apihandler.go b/apihandler.go index bae0f18..fc66a66 100644 --- a/apihandler.go +++ b/apihandler.go @@ -11,9 +11,9 @@ import ( ) var ( - errInvalidShieldColor = errors.New("invalid shield color") - errInvalidUserShieldID = errors.New("invalid user shield ID") - errUserShieldIDInUse = errors.New("user shield ID is already in use") + errInvalidShieldColor = errors.New("invalid shield color") + errInvalidShieldKey = errors.New("invalid shield key") + errShieldKeyInUse = errors.New("shield key is already in use") ) type ApiHandler struct { @@ -34,23 +34,23 @@ func (ah *ApiHandler) HandlePOST(w http.ResponseWriter, r *http.Request) { } for k := range r.Form { - if k != "id" && k != "user_shield_id" && k != "title" && k != "text" && k != "color" { + if k != "id" && k != "shield_key" && k != "title" && k != "text" && k != "color" { http.Error(w, "invalid field: "+k, http.StatusBadRequest) return } } - userShieldID := r.FormValue("id") - if userShieldID != "" { - ah.handleUserTokenPOST(w, r, userShieldID) + shieldKey := r.FormValue("id") + if shieldKey != "" { + ah.handleUserTokenPOST(w, r, shieldKey) return } ah.handleShieldTokenPOST(w, r) } -func (ah *ApiHandler) handleUserTokenPOST(w http.ResponseWriter, r *http.Request, userShieldID string) { - if !validUserShieldID(userShieldID) { +func (ah *ApiHandler) handleUserTokenPOST(w http.ResponseWriter, r *http.Request, shieldKey string) { + if !validShieldKey(shieldKey) { http.Error(w, "id must be 5-64 lowercase letters, digits, or hyphens", http.StatusBadRequest) return } @@ -78,7 +78,7 @@ func (ah *ApiHandler) handleUserTokenPOST(w http.ResponseWriter, r *http.Request slog.Error("error recording user API token use", slog.Any("error", err), slog.Int64("api_token_id", userToken.APITokenID)) } - shield, err := ah.sm.GetFromUserIDAndUserShieldID(userToken.UserID, userShieldID) + 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) @@ -92,13 +92,13 @@ func (ah *ApiHandler) handleUserTokenPOST(w http.ResponseWriter, r *http.Request return } shield = &model.Shield{ - UserID: userToken.UserID, - UserShieldID: userShieldID, - Name: "New Shield", - Title: "New", - Text: "Shield", - Color: "00AA55", - Secret: secret, + UserID: userToken.UserID, + ShieldKey: shieldKey, + Name: "New Shield", + Title: "New", + Text: "Shield", + Color: "00AA55", + Secret: secret, } } @@ -165,18 +165,18 @@ func (ah *ApiHandler) saveShieldFromForm(r *http.Request, shield *model.Shield) } shield.Color = color } - if userShieldID := r.FormValue("user_shield_id"); userShieldID != "" { - if !validUserShieldID(userShieldID) { - return created, errInvalidUserShieldID + if shieldKey := r.FormValue("shield_key"); shieldKey != "" { + if !validShieldKey(shieldKey) { + return created, errInvalidShieldKey } - available, err := userShieldIDAvailable(ah.sm, shield.UserID, shield.ShieldID, userShieldID) + available, err := shieldKeyAvailable(ah.sm, shield.UserID, shield.ShieldID, shieldKey) if err != nil { return created, err } if !available { - return created, errUserShieldIDInUse + return created, errShieldKeyInUse } - shield.UserShieldID = userShieldID + shield.ShieldKey = shieldKey } err := ah.sm.Save(shield) @@ -188,11 +188,11 @@ func (ah *ApiHandler) saveShieldFromForm(r *http.Request, shield *model.Shield) } func (ah *ApiHandler) handleSaveShieldError(w http.ResponseWriter, err error) { - if errors.Is(err, errInvalidShieldColor) || errors.Is(err, errInvalidUserShieldID) { + if errors.Is(err, errInvalidShieldColor) || errors.Is(err, errInvalidShieldKey) { http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) return } - if errors.Is(err, errUserShieldIDInUse) { + if errors.Is(err, errShieldKeyInUse) { http.Error(w, err.Error(), http.StatusConflict) return } @@ -207,7 +207,7 @@ func (ah *ApiHandler) writeShieldResponse(w http.ResponseWriter, shield *model.S w.WriteHeader(http.StatusCreated) } json.NewEncoder(w).Encode(map[string]string{ - "ShieldURL": "https://" + ah.imgHost + "/s/" + shield.PublicID, - "ShieldUserID": shield.UserShieldID, + "ShieldURL": "https://" + ah.imgHost + "/s/" + shield.PublicID, + "ShieldKey": shield.ShieldKey, }) } diff --git a/dashboardapihandlers.go b/dashboardapihandlers.go index 6896292..e87ddcd 100644 --- a/dashboardapihandlers.go +++ b/dashboardapihandlers.go @@ -14,18 +14,18 @@ import ( "github.com/gorilla/mux" ) -var userShieldIDPattern = regexp.MustCompile(`^[a-z0-9-]{5,64}$`) +var shieldKeyPattern = regexp.MustCompile(`^[a-z0-9-]{5,64}$`) -func validUserShieldID(userShieldID string) bool { - return userShieldID == "" || userShieldIDPattern.MatchString(userShieldID) +func validShieldKey(shieldKey string) bool { + return shieldKey == "" || shieldKeyPattern.MatchString(shieldKey) } -func userShieldIDAvailable(sm *model.ShieldMapper, userID, shieldID int64, userShieldID string) (bool, error) { - if userShieldID == "" { +func shieldKeyAvailable(sm *model.ShieldMapper, userID, shieldID int64, shieldKey string) (bool, error) { + if shieldKey == "" { return true, nil } - shield, err := sm.GetFromUserIDAndUserShieldID(userID, userShieldID) + shield, err := sm.GetFromUserIDAndShieldKey(userID, shieldKey) if err != nil { return false, err } @@ -83,18 +83,18 @@ func (sh *DashboardShieldApiIndexHandler) HandlePOST(w http.ResponseWriter, r *h http.Error(w, "failed to parse request body", http.StatusBadRequest) return } - if !validUserShieldID(postShield.UserShieldID) { - http.Error(w, "shield ID must be 5-64 lowercase letters, digits, or hyphens", http.StatusBadRequest) + if !validShieldKey(postShield.ShieldKey) { + http.Error(w, "shield key must be 5-64 lowercase letters, digits, or hyphens", http.StatusBadRequest) return } - available, err := userShieldIDAvailable(sh.sm, *id, 0, postShield.UserShieldID) + available, err := shieldKeyAvailable(sh.sm, *id, 0, postShield.ShieldKey) if err != nil { - slog.Error("error checking user shield ID", slog.Any("error", err), slog.Any("id", *id)) + 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 ID is already in use", http.StatusConflict) + http.Error(w, "shield key is already in use", http.StatusConflict) return } @@ -108,8 +108,8 @@ func (sh *DashboardShieldApiIndexHandler) HandlePOST(w http.ResponseWriter, r *h cleanShield := &model.Shield{ UserID: *id, - UserShieldID: postShield.UserShieldID, - Name: postShield.Name, + ShieldKey: postShield.ShieldKey, + Name: postShield.Name, Title: postShield.Title, Text: postShield.Text, @@ -205,22 +205,22 @@ func (dh *DashboardShieldApiHandler) HandlePUT(w http.ResponseWriter, r *http.Re http.Error(w, "failed to parse request body", http.StatusBadRequest) return } - if !validUserShieldID(putShield.UserShieldID) { - http.Error(w, "shield ID must be 5-64 lowercase letters, digits, or hyphens", http.StatusBadRequest) + if !validShieldKey(putShield.ShieldKey) { + http.Error(w, "shield key must be 5-64 lowercase letters, digits, or hyphens", http.StatusBadRequest) return } - available, err := userShieldIDAvailable(dh.sm, shield.UserID, shield.ShieldID, putShield.UserShieldID) + available, err := shieldKeyAvailable(dh.sm, shield.UserID, shield.ShieldID, putShield.ShieldKey) if err != nil { - slog.Error("error checking user shield ID", slog.Any("error", err), slog.Any("id", shield.UserID)) + 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 ID is already in use", http.StatusConflict) + http.Error(w, "shield key is already in use", http.StatusConflict) return } - shield.UserShieldID = putShield.UserShieldID + shield.ShieldKey = putShield.ShieldKey shield.Name = putShield.Name shield.Title = putShield.Title diff --git a/dashboardapihandlers_test.go b/dashboardapihandlers_test.go index a8b6aa7..b8a6e84 100644 --- a/dashboardapihandlers_test.go +++ b/dashboardapihandlers_test.go @@ -2,25 +2,25 @@ package shieldeddotdev import "testing" -func TestValidUserShieldID(t *testing.T) { +func TestValidShieldKey(t *testing.T) { tests := []struct { - name string - userShieldID string - valid bool + name string + shieldKey string + valid bool }{ - {name: "empty is optional", userShieldID: "", valid: true}, - {name: "minimum length", userShieldID: "abcd-", valid: true}, - {name: "maximum length", userShieldID: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", valid: true}, - {name: "too short", userShieldID: "abcd", valid: false}, - {name: "too long", userShieldID: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", valid: false}, - {name: "uppercase", userShieldID: "Release-1", valid: false}, - {name: "underscore", userShieldID: "release_1", valid: false}, + {name: "empty is optional", shieldKey: "", valid: true}, + {name: "minimum length", shieldKey: "abcd-", valid: true}, + {name: "maximum length", shieldKey: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", valid: true}, + {name: "too short", shieldKey: "abcd", 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 := validUserShieldID(test.userShieldID); got != test.valid { - t.Errorf("validUserShieldID(%q) = %t, want %t", test.userShieldID, got, test.valid) + if got := validShieldKey(test.shieldKey); got != test.valid { + t.Errorf("validShieldKey(%q) = %t, want %t", test.shieldKey, got, test.valid) } }) } diff --git a/model/shields.go b/model/shields.go index 69adf43..bcfd9ed 100644 --- a/model/shields.go +++ b/model/shields.go @@ -9,10 +9,10 @@ import ( ) type Shield struct { - ShieldID int64 - PublicID string - UserShieldID string - UserID int64 + ShieldID int64 + PublicID string + ShieldKey string + UserID int64 Name string @@ -51,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, COALESCE(s.user_shield_id, '') AS user_shield_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.UserShieldID, &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: @@ -65,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, COALESCE(s.user_shield_id, '') AS user_shield_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.UserShieldID, &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: @@ -79,8 +79,8 @@ 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, COALESCE(s.user_shield_id, '') AS user_shield_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.UserShieldID, &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: @@ -90,11 +90,11 @@ func (sm *ShieldMapper) GetFromUserIDAndID(userID, id int64) (*Shield, error) { } } -func (sm *ShieldMapper) GetFromUserIDAndUserShieldID(userID int64, userShieldID string) (*Shield, error) { +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.user_shield_id, '') AS user_shield_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.user_shield_id = ?", userID, userShieldID) - switch err := row.Scan(&sh.ShieldID, &sh.PublicID, &sh.UserShieldID, &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_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: @@ -107,8 +107,8 @@ func (sm *ShieldMapper) GetFromUserIDAndUserShieldID(userID int64, userShieldID func (sm *ShieldMapper) GetFromSecret(secret string) (*Shield, error) { sh := &Shield{} - row := sm.db.QueryRow("SELECT s.shield_id, s.public_id, COALESCE(s.user_shield_id, '') AS user_shield_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.UserShieldID, &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: @@ -121,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, COALESCE(s.user_shield_id, '') AS user_shield_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 } @@ -129,7 +129,7 @@ func (sm *ShieldMapper) GetFromUserID(userID int64) ([]*Shield, error) { for rows.Next() { sh := &Shield{} - err := rows.Scan(&sh.ShieldID, &sh.PublicID, &sh.UserShieldID, &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 } @@ -156,8 +156,8 @@ func (sm *ShieldMapper) Save(n *Shield) error { }() if n.ShieldID > 0 { - _, err = tx.Exec("UPDATE shields SET user_id=?, user_shield_id=?, name=?, title=?, text=?, color=?, secret=? WHERE shield_id = ?", - n.UserID, nullableString(n.UserShieldID), 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 } @@ -167,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_shield_id, user_id, name, title, text, color, secret) VALUES (?,?,?,?,?,?,?,?)", - n.PublicID, nullableString(n.UserShieldID), 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 } 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/schema/002_user_shield_id.sql b/schema/002_user_shield_id.sql deleted file mode 100644 index 96732c5..0000000 --- a/schema/002_user_shield_id.sql +++ /dev/null @@ -1,3 +0,0 @@ -ALTER TABLE `shields` - ADD COLUMN `user_shield_id` varchar(64) DEFAULT NULL AFTER `public_id`, - ADD UNIQUE KEY `unq_shields_user_shield_id` (`user_id`, `user_shield_id`); diff --git a/static/main.js b/static/main.js index 7d45141..32bc7b1 100644 --- a/static/main.js +++ b/static/main.js @@ -269,7 +269,7 @@ jobs: color: ${JSON.stringify(color)}`; } -const userShieldIDPattern = /^[a-z0-9-]{5,64}$/; +const shieldKeyPattern = /^[a-z0-9-]{5,64}$/; const apiExamples = [ ["GitHub Action", gitHubActionExample], ["Curl", curlExample], @@ -369,7 +369,7 @@ function ShieldForm({ shield, env, onSave, onDelete }) { clearTimeout(saveTimeout.current); saveTimeout.current = null; } - if (next.UserShieldID !== undefined && next.UserShieldID !== "" && !userShieldIDPattern.test(next.UserShieldID)) { + if (next.ShieldKey !== undefined && next.ShieldKey !== "" && !shieldKeyPattern.test(next.ShieldKey)) { return; } saveTimeout.current = setTimeout(() => { @@ -384,7 +384,7 @@ function ShieldForm({ shield, env, onSave, onDelete }) { let next; switch (input.name) { case "Name": - case "UserShieldID": + case "ShieldKey": case "Title": case "Text": next = Object.assign(Object.assign({}, draftRef.current), { [input.name]: input.value }); @@ -410,9 +410,9 @@ function ShieldForm({ shield, env, onSave, onDelete }) { }); const markdown = `![${draft.Name}](https://${env.ImgHost}/s/${draft.PublicID})`; const selectedExample = example[1](env, draft.Title, draft.Text, draft.Color, draft.Secret); - const userShieldIDInvalid = draft.UserShieldID !== undefined && draft.UserShieldID !== "" && !userShieldIDPattern.test(draft.UserShieldID); - const userShieldIDErrorID = `shield-${draft.ShieldID}-id-error`; - 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 ID", name: "UserShieldID", value: draft.UserShieldID || "", pattern: "[a-z0-9\\\\-]{5,64}", title: "Optional: 5-64 lowercase letters, digits, or hyphens", placeholder: "e.g. production-status", autoComplete: "off", spellcheck: false, "aria-invalid": userShieldIDInvalid, "aria-describedby": userShieldIDInvalid ? userShieldIDErrorID : undefined }), userShieldIDInvalid && u$1("p", { id: userShieldIDErrorID, class: "input-error", role: "alert", children: "Shield ID must be 5-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}` }) }), 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", { children: "Markdown" }), u$1("div", { class: "markdown-input--controller", children: [u$1("input", { 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", { children: "This shield's API token" }), u$1("div", { class: "secret-input--controller", children: [u$1("input", { 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" })] })] })] }); + const shieldKeyInvalid = draft.ShieldKey !== undefined && draft.ShieldKey !== "" && !shieldKeyPattern.test(draft.ShieldKey); + const shieldKeyErrorID = `shield-${draft.ShieldID}-key-error`; + 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\\\\-]{5,64}", title: "Optional: 5-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 5-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}` }) }), 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", { children: "Markdown" }), u$1("div", { class: "markdown-input--controller", children: [u$1("input", { 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", { children: "This shield's API token" }), u$1("div", { class: "secret-input--controller", children: [u$1("input", { 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(_a) { var { label } = _a, attributes = __rest(_a, ["label"]); diff --git a/ts/Dashboard.tsx b/ts/Dashboard.tsx index af72686..3a661e6 100644 --- a/ts/Dashboard.tsx +++ b/ts/Dashboard.tsx @@ -17,7 +17,7 @@ import { type Page = "dashboard" | "user"; -const userShieldIDPattern = /^[a-z0-9-]{5,64}$/; +const shieldKeyPattern = /^[a-z0-9-]{5,64}$/; const apiExamples: [string, ApiExampleGeneratorInterface][] = [ ["GitHub Action", gitHubActionExample], ["Curl", curlExample], @@ -154,7 +154,7 @@ function ShieldForm({ shield, env, onSave, onDelete }: ShieldFormProps) { saveTimeout.current = null; } - if (next.UserShieldID !== undefined && next.UserShieldID !== "" && !userShieldIDPattern.test(next.UserShieldID)) { + if (next.ShieldKey !== undefined && next.ShieldKey !== "" && !shieldKeyPattern.test(next.ShieldKey)) { return; } @@ -171,7 +171,7 @@ function ShieldForm({ shield, env, onSave, onDelete }: ShieldFormProps) { let next: ShieldInterface; switch (input.name) { case "Name": - case "UserShieldID": + case "ShieldKey": case "Title": case "Text": next = { ...draftRef.current, [input.name]: input.value }; @@ -200,14 +200,14 @@ function ShieldForm({ shield, env, onSave, onDelete }: ShieldFormProps) { const markdown = `![${draft.Name}](https://${env.ImgHost}/s/${draft.PublicID})`; const selectedExample = example[1](env, draft.Title, draft.Text, draft.Color, draft.Secret); - const userShieldIDInvalid = draft.UserShieldID !== undefined && draft.UserShieldID !== "" && !userShieldIDPattern.test(draft.UserShieldID); - const userShieldIDErrorID = `shield-${draft.ShieldID}-id-error`; + const shieldKeyInvalid = draft.ShieldKey !== undefined && draft.ShieldKey !== "" && !shieldKeyPattern.test(draft.ShieldKey); + const shieldKeyErrorID = `shield-${draft.ShieldID}-key-error`; return
- - {userShieldIDInvalid && } + + {shieldKeyInvalid && }
diff --git a/ts/api/shields.ts b/ts/api/shields.ts index 6c99045..709f815 100644 --- a/ts/api/shields.ts +++ b/ts/api/shields.ts @@ -3,7 +3,7 @@ import { doRawRequest, doRequest } from "./request"; export interface ShieldInterface { ShieldID?: number; PublicID?: string; - UserShieldID?: string; + ShieldKey?: string; UserID: number; Name: string; Title: string; From b67bc3e5b9aebb080bedab0c823e039ce2e73554 Mon Sep 17 00:00:00 2001 From: Jesse Donat Date: Sat, 25 Jul 2026 07:19:08 -0500 Subject: [PATCH 27/37] docs: add architecture overview --- ARCHITECTURE.md | 247 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 247 insertions(+) create mode 100644 ARCHITECTURE.md diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 0000000..e8bbdb6 --- /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/tokens` | GET | Returns the current user's API-token metadata. Plaintext token values and hashes are never returned. | +| `/api/tokens` | POST | Creates a user-level API token from a required description. Returns its metadata and plaintext token once with `201 Created`. | +| `/api/token/{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 `id` selects a user-token request: it is required for `sdu_` tokens, while blank or absent `id` values require a per-shield token. Both accept optional `title`, `text`, `color`, and non-empty `shield_key` fields, normalize/validate color, and return `{"ShieldURL":"https://img-host/s/public-id","ShieldKey":"shield-key"}`. A user-token request scopes the `id` 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 IDs 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 stamp_created + timestamp stamp_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 5-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 an `id` 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 `stamp_created` timestamp, and a nullable `stamp_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/tokens`; list responses expose only metadata. Deletion revokes a token by deleting its row. The API host receives it as `Authorization: token ` with an `id` form field, hashes the supplied value, scopes the requested ID to the token's user, and updates `stamp_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 `id` and update one shield; `sdu_` user-token values include `id=` and can update any shield owned by that user. Either flow can include `shield_key=` to assign a non-empty shield key. +2. `ApiHandler` uses a non-empty `id` 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 `id` find one shield by secret. +3. The handler validates its known form field names, records `stamp_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 `id` 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. From 5f82759990d9b6c366846f4178c349f18bcb7dcf Mon Sep 17 00:00:00 2001 From: Jesse Donat Date: Sat, 25 Jul 2026 07:28:30 -0500 Subject: [PATCH 28/37] fix: improve dashboard accessibility --- AGENTS.md | 6 +++++- static/main.js | 14 +++++++++----- ts/Dashboard.tsx | 20 ++++++++++++-------- 3 files changed, 26 insertions(+), 14 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 2cd3a29..6398ecf 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -51,7 +51,7 @@ 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 (there are currently no committed Go test files) +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. @@ -65,6 +65,10 @@ go test ./... # compile/test all Go packages (there - 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. diff --git a/static/main.js b/static/main.js index 32bc7b1..c5cc9c4 100644 --- a/static/main.js +++ b/static/main.js @@ -37,11 +37,11 @@ function __awaiter(thisArg, _arguments, P, generator) { }); } -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=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(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;s2&&(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 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} +var t,r,u,i,o=0,f=[],c=l$1,e=c.__b,a=c.__r,v=c.diffed,l=c.__c,m=c.unmount,p=c.__;function s(n,t){c.__h&&c.__h(r,n,o||t),o=0;var u=r.__H||(r.__H={__:[],__h:[]});return n>=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; @@ -412,11 +412,15 @@ function ShieldForm({ shield, env, onSave, onDelete }) { 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`; - 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\\\\-]{5,64}", title: "Optional: 5-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 5-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}` }) }), 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", { children: "Markdown" }), u$1("div", { class: "markdown-input--controller", children: [u$1("input", { 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", { children: "This shield's API token" }), u$1("div", { class: "secret-input--controller", children: [u$1("input", { 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" })] })] })] }); + 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\\\\-]{5,64}", title: "Optional: 5-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 5-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(_a) { var { label } = _a, attributes = __rest(_a, ["label"]); - return u$1("div", { class: "input-container", children: [u$1("label", { children: label }), u$1("input", Object.assign({}, 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", Object.assign({}, attributes, { id: id }))] }); } function APITokens() { const api = A(new UserAPITokensApi()).current; @@ -467,7 +471,7 @@ function APITokens() { 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("input", { 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)) })] })] })] }); + 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"; diff --git a/ts/Dashboard.tsx b/ts/Dashboard.tsx index 3a661e6..f44f0b8 100644 --- a/ts/Dashboard.tsx +++ b/ts/Dashboard.tsx @@ -1,6 +1,6 @@ import { render } from "preact"; import type { JSX } from "preact"; -import { useEffect, useRef, useState } from "preact/hooks"; +import { useEffect, useId, useRef, useState } from "preact/hooks"; import { AuthedApi } from "./api/authed"; import { EnvApi, EnvInterface } from "./api/env"; @@ -202,6 +202,8 @@ function ShieldForm({ shield, env, onSave, onDelete }: ShieldFormProps) { 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
@@ -209,7 +211,7 @@ function ShieldForm({ shield, env, onSave, onDelete }: ShieldFormProps) { {shieldKeyInvalid && }
-
+
{`${draft.Title}:
@@ -224,16 +226,18 @@ function ShieldForm({ shield, env, onSave, onDelete }: ShieldFormProps) {
- -
event.currentTarget.select()} />
- -
event.currentTarget.select()} />
+ +
event.currentTarget.select()} />
+ +
event.currentTarget.select()} />
; } function Input({ label, ...attributes }: JSX.InputHTMLAttributes & { label: string }) { - return
; + const generatedID = useId(); + const id = attributes.id || generatedID; + return
; } function APITokens() { @@ -296,7 +300,7 @@ function APITokens() { {error !== "" &&

{error}

} - {createdToken !== "" &&

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

event.currentTarget.select()} />
} + {createdToken !== "" &&

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

event.currentTarget.select()} />
}

Active tokens

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

Loading API tokens…

} From cb906fcff70146629e7cba4630138cdf3850efcc Mon Sep 17 00:00:00 2001 From: Jesse Donat Date: Sat, 25 Jul 2026 07:36:31 -0500 Subject: [PATCH 29/37] fix: serialize dashboard autosaves --- static/main.js | 199 ++++++++++++++++++++--------------------------- ts/Dashboard.tsx | 33 ++++++-- tsconfig.json | 4 +- 3 files changed, 113 insertions(+), 123 deletions(-) diff --git a/static/main.js b/static/main.js index c5cc9c4..1d3954d 100644 --- a/static/main.js +++ b/static/main.js @@ -1,42 +1,3 @@ -/*! ***************************************************************************** -Copyright (c) Microsoft Corporation. - -Permission to use, copy, modify, and/or distribute this software for any -purpose with or without fee is hereby granted. - -THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH -REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY -AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, -INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM -LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR -OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR -PERFORMANCE OF THIS SOFTWARE. -***************************************************************************** */ -/* global Reflect, Promise */ - - -function __rest(s, e) { - var t = {}; - for (var p in s) if (Object.prototype.hasOwnProperty.call(s, p) && e.indexOf(p) < 0) - t[p] = s[p]; - if (s != null && typeof Object.getOwnPropertySymbols === "function") - for (var i = 0, p = Object.getOwnPropertySymbols(s); i < p.length; i++) { - if (e.indexOf(p[i]) < 0 && Object.prototype.propertyIsEnumerable.call(s, p[i])) - t[p[i]] = s[p[i]]; - } - return t; -} - -function __awaiter(thisArg, _arguments, P, generator) { - function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); } - return new (P || (P = Promise))(function (resolve, reject) { - function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } - function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } - function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); } - step((generator = generator.apply(thisArg, _arguments || [])).next()); - }); -} - 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 { }) { - const text = yield doRawRequest(endpoint, method, body, mods); - return new Promise((resolve) => { - const data = JSON.parse(text); - resolve(data); - }); +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 = () => { }) { @@ -82,16 +41,14 @@ function doRawRequest(endpoint, method = 'GET', body = null, mods = () => { }) { } class AuthedApi { - isAuthed() { - return __awaiter(this, void 0, void 0, function* () { - try { - yield doRequest('api/authed', 'GET', null); - return true; - } - catch (e) { - return false; - } - }); + async isAuthed() { + try { + await doRequest('api/authed', 'GET', null); + return true; + } + catch (e) { + return false; + } } } @@ -276,19 +233,17 @@ const apiExamples = [ ["JS", jsExample], ["PHP", phpExample], ]; -function Dashboard(elm) { - return __awaiter(this, void 0, void 0, function* () { - if (elm === null) { - return; - } - const authApi = new AuthedApi(); - if (!(yield authApi.isAuthed())) { - window.location.href = "/"; - return; - } - const env = yield (new EnvApi()).getEnv(); - R(u$1(DashboardApp, { env: env }), elm); - }); +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()); @@ -311,10 +266,10 @@ function Shields({ env }) { .then(setShields) .catch((requestError) => setError(errorMessage(requestError))); }, [api]); - const createShield = () => __awaiter(this, void 0, void 0, function* () { + const createShield = async () => { setError(""); try { - const shield = yield api.saveShield({ + const shield = await api.saveShield({ Name: "New Shield", Title: "New", Color: "00AA55", @@ -326,34 +281,36 @@ function Shields({ env }) { catch (requestError) { setError(errorMessage(requestError)); } - }); - const saveShield = (shield) => __awaiter(this, void 0, void 0, function* () { + }; + const saveShield = async (shield) => { setError(""); try { - const savedShield = yield api.saveShield(shield); + 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 = (shield) => __awaiter(this, void 0, void 0, function* () { + }; + const deleteShield = async (shield) => { setError(""); try { - yield api.deleteShield(shield); + 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); @@ -363,21 +320,38 @@ function ShieldForm({ shield, env, onSave, onDelete }) { 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; } - saveTimeout.current = setTimeout(() => { - saveTimeout.current = null; - void onSave(next) - .then(() => setImageTick(Date.now())) - .catch(() => undefined); - }, 500); + pendingSave.current = next; + saveTimeout.current = setTimeout(flushSave, 500); }; const handleInput = (event) => { const input = event.target; @@ -387,10 +361,10 @@ function ShieldForm({ shield, env, onSave, onDelete }) { case "ShieldKey": case "Title": case "Text": - next = Object.assign(Object.assign({}, draftRef.current), { [input.name]: input.value }); + next = { ...draftRef.current, [input.name]: input.value }; break; case "Color": - next = Object.assign(Object.assign({}, draftRef.current), { Color: input.value.replace(/^#/, "") }); + next = { ...draftRef.current, Color: input.value.replace(/^#/, "") }; break; default: return; @@ -399,15 +373,15 @@ function ShieldForm({ shield, env, onSave, onDelete }) { setDraft(next); queueSave(next); }; - const deleteShield = () => __awaiter(this, void 0, void 0, function* () { + const deleteShield = async () => { if (!confirm("Are you sure you want to delete this shield?")) { return; } if (saveTimeout.current !== null) { clearTimeout(saveTimeout.current); } - yield onDelete(draftRef.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); @@ -416,11 +390,10 @@ function ShieldForm({ shield, env, onSave, onDelete }) { 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\\\\-]{5,64}", title: "Optional: 5-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 5-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(_a) { - var { label } = _a, attributes = __rest(_a, ["label"]); +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", Object.assign({}, attributes, { id: id }))] }); + 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; @@ -435,7 +408,7 @@ function APITokens() { .then(setTokens) .catch((requestError) => setError(errorMessage(requestError))); }, [api]); - const createToken = (event) => __awaiter(this, void 0, void 0, function* () { + const createToken = async (event) => { event.preventDefault(); const trimmedDescription = description.trim(); if (trimmedDescription === "") { @@ -445,7 +418,7 @@ function APITokens() { setCreating(true); setError(""); try { - const token = yield api.createToken(trimmedDescription); + const token = await api.createToken(trimmedDescription); setTokens((current) => [token, ...(current || [])]); setCreatedToken(token.Token); setDescription(""); @@ -457,20 +430,20 @@ function APITokens() { finally { setCreating(false); } - }); - const revokeToken = (token) => __awaiter(this, void 0, void 0, function* () { + }; + const revokeToken = async (token) => { if (!confirm(`Revoke the token “${token.Description}”? This cannot be undone.`)) { return; } setError(""); try { - yield api.deleteToken(token); + 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() { @@ -482,25 +455,21 @@ function errorMessage(error) { function formatTimestamp(value) { return new Date(value).toLocaleString(); } -function copy(value, setCopied) { - return __awaiter(this, void 0, void 0, function* () { - try { - yield navigator.clipboard.writeText(value); - setCopied(true); - } - catch (error) { - console.error(error); - } - }); +async function copy(value, setCopied) { + try { + await navigator.clipboard.writeText(value); + setCopied(true); + } + catch (error) { + console.error(error); + } } -function Home(apiExampleElm) { - return __awaiter(this, void 0, void 0, function* () { - const envApi = new EnvApi(); - const env = yield envApi.getEnv(); - const apiExample = new ApiExampleController(env); - apiExample.attach(apiExampleElm); - }); +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/ts/Dashboard.tsx b/ts/Dashboard.tsx index f44f0b8..bb2d8f0 100644 --- a/ts/Dashboard.tsx +++ b/ts/Dashboard.tsx @@ -136,6 +136,8 @@ 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); @@ -146,8 +148,30 @@ function ShieldForm({ shield, env, onSave, onDelete }: ShieldFormProps) { 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); @@ -155,15 +179,12 @@ function ShieldForm({ shield, env, onSave, onDelete }: ShieldFormProps) { } if (next.ShieldKey !== undefined && next.ShieldKey !== "" && !shieldKeyPattern.test(next.ShieldKey)) { + pendingSave.current = null; return; } - saveTimeout.current = setTimeout(() => { - saveTimeout.current = null; - void onSave(next) - .then(() => setImageTick(Date.now())) - .catch(() => undefined); - }, 500); + pendingSave.current = next; + saveTimeout.current = setTimeout(flushSave, 500); }; const handleInput = (event: JSX.TargetedEvent) => { diff --git a/tsconfig.json b/tsconfig.json index f462fa5..6d7c5e9 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -2,12 +2,12 @@ "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. */ From 80c1f3552e0fa2e404d4a3062dc89e59ebaf4663 Mon Sep 17 00:00:00 2001 From: Jesse Donat Date: Sat, 25 Jul 2026 07:38:16 -0500 Subject: [PATCH 30/37] fix: return conflict for duplicate shield keys --- apihandler.go | 2 +- dashboardapihandlers.go | 16 ++++++++++++++++ dashboardapihandlers_test.go | 26 +++++++++++++++++++++++++- 3 files changed, 42 insertions(+), 2 deletions(-) diff --git a/apihandler.go b/apihandler.go index fc66a66..b99250f 100644 --- a/apihandler.go +++ b/apihandler.go @@ -192,7 +192,7 @@ func (ah *ApiHandler) handleSaveShieldError(w http.ResponseWriter, err error) { http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) return } - if errors.Is(err, errShieldKeyInUse) { + if errors.Is(err, errShieldKeyInUse) || shieldKeyInUseError(err) { http.Error(w, err.Error(), http.StatusConflict) return } diff --git a/dashboardapihandlers.go b/dashboardapihandlers.go index e87ddcd..31e2eef 100644 --- a/dashboardapihandlers.go +++ b/dashboardapihandlers.go @@ -3,14 +3,17 @@ package shieldeddotdev import ( cryptorand "crypto/rand" "encoding/json" + "errors" "fmt" "log/slog" "math/big" "net/http" "regexp" "strconv" + "strings" "github.com/ShieldedDotDev/shieldeddotdev/model" + "github.com/go-sql-driver/mysql" "github.com/gorilla/mux" ) @@ -33,6 +36,11 @@ func shieldKeyAvailable(sm *model.ShieldMapper, userID, shieldID int64, shieldKe 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 @@ -119,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) @@ -228,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 index b8a6e84..d079dfd 100644 --- a/dashboardapihandlers_test.go +++ b/dashboardapihandlers_test.go @@ -1,6 +1,10 @@ package shieldeddotdev -import "testing" +import ( + "testing" + + "github.com/go-sql-driver/mysql" +) func TestValidShieldKey(t *testing.T) { tests := []struct { @@ -25,3 +29,23 @@ func TestValidShieldKey(t *testing.T) { }) } } + +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) + } + }) + } +} From c6972bf8e5bea6556d8df596ca824dfcd01492cf Mon Sep 17 00:00:00 2001 From: Jesse Donat Date: Sat, 25 Jul 2026 07:38:24 -0500 Subject: [PATCH 31/37] docs: describe user API token columns --- schema/001_user_api_tokens.sql | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/schema/001_user_api_tokens.sql b/schema/001_user_api_tokens.sql index a80213f..ebb5420 100644 --- a/schema/001_user_api_tokens.sql +++ b/schema/001_user_api_tokens.sql @@ -1,10 +1,10 @@ CREATE TABLE `user_api_tokens` ( - `api_token_id` int(10) unsigned NOT NULL AUTO_INCREMENT, - `user_id` int(10) unsigned NOT NULL, - `description` varchar(255) NOT NULL, - `token_hash` binary(32) NOT NULL, - `stamp_created` timestamp NOT NULL DEFAULT current_timestamp(), - `stamp_last_used` timestamp NULL DEFAULT NULL, + `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', + `stamp_created` timestamp NOT NULL DEFAULT current_timestamp() COMMENT 'Time the API token was created', + `stamp_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`), From 0c44ce89d272c4afd09592f5ed2d3e35fc054027 Mon Sep 17 00:00:00 2001 From: Jesse Donat Date: Sat, 25 Jul 2026 07:59:36 -0500 Subject: [PATCH 32/37] fix: use shield key as API selector --- AGENTS.md | 2 +- ARCHITECTURE.md | 12 ++++++------ apihandler.go | 30 +++++++----------------------- apihandler_test.go | 35 +++++++++++++++++++++++++++++++++++ 4 files changed, 49 insertions(+), 30 deletions(-) create mode 100644 apihandler_test.go diff --git a/AGENTS.md b/AGENTS.md index 6398ecf..5279ba5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -76,7 +76,7 @@ go test ./... # compile/test all Go packages - 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 `id` selects a user-token update/create and is required for `sdu_` values, while a blank or absent value requires a per-shield token. Either workflow can set a non-empty `shield_key` form value. Valid user-token authentication records `stamp_last_used`. +- 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 `stamp_last_used`. ### Database schema and migrations diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index e8bbdb6..d4a25ac 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -81,7 +81,7 @@ For local HTTPS, `Caddyfile.local` accepts both the three canonical names and th | Route | Current handler behavior | | --- | --- | -| `/` | `ApiHandler.HandlePOST` is mounted without a router method restriction. All clients use `Authorization: token `. A non-empty form field `id` selects a user-token request: it is required for `sdu_` tokens, while blank or absent `id` values require a per-shield token. Both accept optional `title`, `text`, `color`, and non-empty `shield_key` fields, normalize/validate color, and return `{"ShieldURL":"https://img-host/s/public-id","ShieldKey":"shield-key"}`. A user-token request scopes the `id` 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 IDs and fields produce `400`. | +| `/` | `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`. @@ -152,7 +152,7 @@ erDiagram 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 5-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 an `id` form field for user-token requests, without exposing the internal numeric `shield_id` or changing the per-shield token API. +`shield_key` stores the optional `ShieldKey`. When set, it must be 5-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. @@ -160,7 +160,7 @@ The mapper uses transactions for shield inserts, updates, and deletes. Reads are `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 `stamp_created` timestamp, and a nullable `stamp_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/tokens`; list responses expose only metadata. Deletion revokes a token by deleting its row. The API host receives it as `Authorization: token ` with an `id` form field, hashes the supplied value, scopes the requested ID to the token's user, and updates `stamp_last_used` immediately after valid token authentication. +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/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 `stamp_last_used` immediately after valid token authentication. ## Request flows @@ -198,8 +198,8 @@ The dashboard is a single-page application. Its `#/user` client-side view contai ### External update and README rendering -1. An external client posts form fields to `https://api.shielded.dev/` with `Authorization: token `. Per-shield values omit `id` and update one shield; `sdu_` user-token values include `id=` and can update any shield owned by that user. Either flow can include `shield_key=` to assign a non-empty shield key. -2. `ApiHandler` uses a non-empty `id` 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 `id` find one shield by secret. +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 `stamp_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. @@ -238,7 +238,7 @@ These are descriptions of behavior visible in the code, useful when planning cha - 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 `id` and update their `last_used` timestamp. +- 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. diff --git a/apihandler.go b/apihandler.go index b99250f..3b65db8 100644 --- a/apihandler.go +++ b/apihandler.go @@ -12,8 +12,6 @@ import ( var ( errInvalidShieldColor = errors.New("invalid shield color") - errInvalidShieldKey = errors.New("invalid shield key") - errShieldKeyInUse = errors.New("shield key is already in use") ) type ApiHandler struct { @@ -34,13 +32,13 @@ func (ah *ApiHandler) HandlePOST(w http.ResponseWriter, r *http.Request) { } for k := range r.Form { - if k != "id" && k != "shield_key" && k != "title" && k != "text" && k != "color" { + if k != "shield_key" && k != "title" && k != "text" && k != "color" { http.Error(w, "invalid field: "+k, http.StatusBadRequest) return } } - shieldKey := r.FormValue("id") + shieldKey := r.FormValue("shield_key") if shieldKey != "" { ah.handleUserTokenPOST(w, r, shieldKey) return @@ -51,7 +49,7 @@ func (ah *ApiHandler) HandlePOST(w http.ResponseWriter, r *http.Request) { func (ah *ApiHandler) handleUserTokenPOST(w http.ResponseWriter, r *http.Request, shieldKey string) { if !validShieldKey(shieldKey) { - http.Error(w, "id must be 5-64 lowercase letters, digits, or hyphens", http.StatusBadRequest) + http.Error(w, "shield_key must be 5-64 lowercase letters, digits, or hyphens", http.StatusBadRequest) return } @@ -60,7 +58,7 @@ func (ah *ApiHandler) handleUserTokenPOST(w http.ResponseWriter, r *http.Request return } if !model.IsUserAPIToken(token) { - http.Error(w, "id requires a user token", http.StatusBadRequest) + http.Error(w, "shield_key requires a user token", http.StatusBadRequest) return } @@ -116,7 +114,7 @@ func (ah *ApiHandler) handleShieldTokenPOST(w http.ResponseWriter, r *http.Reque return } if model.IsUserAPIToken(token) { - http.Error(w, "user token requires an id", http.StatusBadRequest) + http.Error(w, "user token requires a shield_key", http.StatusBadRequest) return } @@ -165,20 +163,6 @@ func (ah *ApiHandler) saveShieldFromForm(r *http.Request, shield *model.Shield) } shield.Color = color } - if shieldKey := r.FormValue("shield_key"); shieldKey != "" { - if !validShieldKey(shieldKey) { - return created, errInvalidShieldKey - } - available, err := shieldKeyAvailable(ah.sm, shield.UserID, shield.ShieldID, shieldKey) - if err != nil { - return created, err - } - if !available { - return created, errShieldKeyInUse - } - shield.ShieldKey = shieldKey - } - err := ah.sm.Save(shield) if err != nil { return created, err @@ -188,11 +172,11 @@ func (ah *ApiHandler) saveShieldFromForm(r *http.Request, shield *model.Shield) } func (ah *ApiHandler) handleSaveShieldError(w http.ResponseWriter, err error) { - if errors.Is(err, errInvalidShieldColor) || errors.Is(err, errInvalidShieldKey) { + if errors.Is(err, errInvalidShieldColor) { http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) return } - if errors.Is(err, errShieldKeyInUse) || shieldKeyInUseError(err) { + if shieldKeyInUseError(err) { http.Error(w, err.Error(), http.StatusConflict) return } diff --git a/apihandler_test.go b/apihandler_test.go new file mode 100644 index 0000000..5bc784c --- /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=bad")) + 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 5-64 lowercase letters, digits, or hyphens\n"; got != want { + t.Errorf("body = %q, want %q", got, want) + } +} From b3d45153c4c618a1f816b964d8c4ad083b18972b Mon Sep 17 00:00:00 2001 From: Jesse Donat Date: Sun, 26 Jul 2026 17:29:28 -0500 Subject: [PATCH 33/37] refactor: rename user token date columns --- AGENTS.md | 2 +- ARCHITECTURE.md | 10 +++++----- model/user_api_tokens.go | 8 ++++---- schema/001_user_api_tokens.sql | 4 ++-- 4 files changed, 12 insertions(+), 12 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 5279ba5..e74498b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -76,7 +76,7 @@ go test ./... # compile/test all Go packages - 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 `stamp_last_used`. +- 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 diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index d4a25ac..3aac594 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -140,8 +140,8 @@ erDiagram int_unsigned user_id FK varchar_255 description binary_32 token_hash UK - timestamp stamp_created - timestamp stamp_last_used + timestamp date_created + timestamp date_last_used } ``` @@ -158,9 +158,9 @@ The mapper uses transactions for shield inserts, updates, and deletes. Reads are ### 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 `stamp_created` timestamp, and a nullable `stamp_last_used` timestamp; `null` means it has not been used. The `001` migration records actual point-in-time fields as MySQL `TIMESTAMP` columns. +`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/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 `stamp_last_used` immediately after valid token authentication. +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/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 @@ -200,7 +200,7 @@ The dashboard is a single-page application. Its `#/user` client-side view contai 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 `stamp_last_used` immediately after valid user-token authentication, normalizes a supplied color, saves the row, and returns the stable public image URL. +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. diff --git a/model/user_api_tokens.go b/model/user_api_tokens.go index 1dcb7f8..7bbe5bc 100644 --- a/model/user_api_tokens.go +++ b/model/user_api_tokens.go @@ -40,7 +40,7 @@ func NewUserAPITokenMapper(db *sql.DB) *UserAPITokenMapper { } func (tm *UserAPITokenMapper) GetFromUserID(userID int64) ([]*UserAPIToken, error) { - rows, err := tm.db.Query(`SELECT api_token_id, user_id, description, stamp_created, stamp_last_used FROM user_api_tokens WHERE user_id = ? ORDER BY stamp_created DESC, api_token_id DESC`, userID) + 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 } @@ -62,7 +62,7 @@ func (tm *UserAPITokenMapper) GetFromUserID(userID int64) ([]*UserAPIToken, erro } func (tm *UserAPITokenMapper) GetFromUserIDAndID(userID, apiTokenID int64) (*UserAPIToken, error) { - row := tm.db.QueryRow(`SELECT api_token_id, user_id, description, stamp_created, stamp_last_used FROM user_api_tokens WHERE user_id = ? AND api_token_id = ?`, userID, apiTokenID) + 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 @@ -117,7 +117,7 @@ func (tm *UserAPITokenMapper) DeleteFromUserIDAndID(userID, apiTokenID int64) er // 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, stamp_created, stamp_last_used FROM user_api_tokens WHERE token_hash = ?`, tokenHash[:]) + 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 @@ -130,7 +130,7 @@ func (tm *UserAPITokenMapper) GetFromToken(plainToken string) (*UserAPIToken, er } func (tm *UserAPITokenMapper) MarkUsed(apiTokenID int64) error { - _, err := tm.db.Exec(`UPDATE user_api_tokens SET stamp_last_used = CURRENT_TIMESTAMP() WHERE api_token_id = ?`, apiTokenID) + _, err := tm.db.Exec(`UPDATE user_api_tokens SET date_last_used = CURRENT_TIMESTAMP() WHERE api_token_id = ?`, apiTokenID) return err } diff --git a/schema/001_user_api_tokens.sql b/schema/001_user_api_tokens.sql index ebb5420..d803ba8 100644 --- a/schema/001_user_api_tokens.sql +++ b/schema/001_user_api_tokens.sql @@ -3,8 +3,8 @@ CREATE TABLE `user_api_tokens` ( `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', - `stamp_created` timestamp NOT NULL DEFAULT current_timestamp() COMMENT 'Time the API token was created', - `stamp_last_used` timestamp NULL DEFAULT NULL COMMENT 'Time the API token was last used', + `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`), From a57403283a9eb0072454e008f7eda9f30ede57ca Mon Sep 17 00:00:00 2001 From: Jesse Donat Date: Sun, 26 Jul 2026 17:29:41 -0500 Subject: [PATCH 34/37] feat: allow three-character shield keys --- ARCHITECTURE.md | 2 +- apihandler.go | 2 +- apihandler_test.go | 4 ++-- dashboardapihandlers.go | 6 +++--- dashboardapihandlers_test.go | 4 ++-- static/main.js | 4 ++-- ts/Dashboard.tsx | 6 +++--- 7 files changed, 14 insertions(+), 14 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 3aac594..cfedb6b 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -152,7 +152,7 @@ erDiagram 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 5-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. +`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. diff --git a/apihandler.go b/apihandler.go index 3b65db8..4a98400 100644 --- a/apihandler.go +++ b/apihandler.go @@ -49,7 +49,7 @@ func (ah *ApiHandler) HandlePOST(w http.ResponseWriter, r *http.Request) { func (ah *ApiHandler) handleUserTokenPOST(w http.ResponseWriter, r *http.Request, shieldKey string) { if !validShieldKey(shieldKey) { - http.Error(w, "shield_key must be 5-64 lowercase letters, digits, or hyphens", http.StatusBadRequest) + http.Error(w, "shield_key must be 3-64 lowercase letters, digits, or hyphens", http.StatusBadRequest) return } diff --git a/apihandler_test.go b/apihandler_test.go index 5bc784c..5447832 100644 --- a/apihandler_test.go +++ b/apihandler_test.go @@ -20,7 +20,7 @@ func TestApiHandlerRejectsIDFormField(t *testing.T) { } func TestApiHandlerValidatesShieldKeyBeforeAuthentication(t *testing.T) { - req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader("shield_key=bad")) + req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader("shield_key=ab")) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") response := httptest.NewRecorder() @@ -29,7 +29,7 @@ func TestApiHandlerValidatesShieldKeyBeforeAuthentication(t *testing.T) { 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 5-64 lowercase letters, digits, or hyphens\n"; got != want { + 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/dashboardapihandlers.go b/dashboardapihandlers.go index 31e2eef..6678eaa 100644 --- a/dashboardapihandlers.go +++ b/dashboardapihandlers.go @@ -17,7 +17,7 @@ import ( "github.com/gorilla/mux" ) -var shieldKeyPattern = regexp.MustCompile(`^[a-z0-9-]{5,64}$`) +var shieldKeyPattern = regexp.MustCompile(`^[a-z0-9-]{3,64}$`) func validShieldKey(shieldKey string) bool { return shieldKey == "" || shieldKeyPattern.MatchString(shieldKey) @@ -92,7 +92,7 @@ func (sh *DashboardShieldApiIndexHandler) HandlePOST(w http.ResponseWriter, r *h return } if !validShieldKey(postShield.ShieldKey) { - http.Error(w, "shield key must be 5-64 lowercase letters, digits, or hyphens", http.StatusBadRequest) + 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) @@ -218,7 +218,7 @@ func (dh *DashboardShieldApiHandler) HandlePUT(w http.ResponseWriter, r *http.Re return } if !validShieldKey(putShield.ShieldKey) { - http.Error(w, "shield key must be 5-64 lowercase letters, digits, or hyphens", http.StatusBadRequest) + 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) diff --git a/dashboardapihandlers_test.go b/dashboardapihandlers_test.go index d079dfd..7a9ae56 100644 --- a/dashboardapihandlers_test.go +++ b/dashboardapihandlers_test.go @@ -13,9 +13,9 @@ func TestValidShieldKey(t *testing.T) { valid bool }{ {name: "empty is optional", shieldKey: "", valid: true}, - {name: "minimum length", shieldKey: "abcd-", valid: true}, + {name: "minimum length", shieldKey: "abc", valid: true}, {name: "maximum length", shieldKey: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", valid: true}, - {name: "too short", shieldKey: "abcd", valid: false}, + {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}, diff --git a/static/main.js b/static/main.js index 1d3954d..6853f74 100644 --- a/static/main.js +++ b/static/main.js @@ -226,7 +226,7 @@ jobs: color: ${JSON.stringify(color)}`; } -const shieldKeyPattern = /^[a-z0-9-]{5,64}$/; +const shieldKeyPattern = /^[a-z0-9-]{3,64}$/; const apiExamples = [ ["GitHub Action", gitHubActionExample], ["Curl", curlExample], @@ -388,7 +388,7 @@ function ShieldForm({ shield, env, onSave, onDelete }) { 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\\\\-]{5,64}", title: "Optional: 5-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 5-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" })] })] })] }); + 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(); diff --git a/ts/Dashboard.tsx b/ts/Dashboard.tsx index bb2d8f0..3be12e6 100644 --- a/ts/Dashboard.tsx +++ b/ts/Dashboard.tsx @@ -17,7 +17,7 @@ import { type Page = "dashboard" | "user"; -const shieldKeyPattern = /^[a-z0-9-]{5,64}$/; +const shieldKeyPattern = /^[a-z0-9-]{3,64}$/; const apiExamples: [string, ApiExampleGeneratorInterface][] = [ ["GitHub Action", gitHubActionExample], ["Curl", curlExample], @@ -229,8 +229,8 @@ function ShieldForm({ shield, env, onSave, onDelete }: ShieldFormProps) { return
- - {shieldKeyInvalid && } + + {shieldKeyInvalid && }
{`${draft.Title}:
From 25fedfee82fbbe350aea0f33f903df91678d9e70 Mon Sep 17 00:00:00 2001 From: Jesse Donat Date: Sun, 26 Jul 2026 17:34:25 -0500 Subject: [PATCH 35/37] refactor: move user token endpoints --- ARCHITECTURE.md | 8 ++++---- cmd/shielded/main.go | 6 +++--- dashboardapitokenhandlers.go | 2 +- static/main.js | 6 +++--- ts/api/tokens.ts | 6 +++--- 5 files changed, 14 insertions(+), 14 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index cfedb6b..68e4d29 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -71,9 +71,9 @@ For local HTTPS, `Caddyfile.local` accepts both the three canonical names and th | `/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/tokens` | GET | Returns the current user's API-token metadata. Plaintext token values and hashes are never returned. | -| `/api/tokens` | POST | Creates a user-level API token from a required description. Returns its metadata and plaintext token once with `201 Created`. | -| `/api/token/{id}` | DELETE | Revokes an owned user-level API token and returns `204 No Content`. | +| `/user/tokens` | GET | Returns the current user's API-token metadata. Plaintext token values and hashes are never returned. | +| `/user/tokens` | POST | Creates a user-level API token from a required description. Returns its metadata and plaintext token once with `201 Created`. | +| `/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. | @@ -160,7 +160,7 @@ The mapper uses transactions for shield inserts, updates, and deletes. Reads are `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/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. +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 /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 diff --git a/cmd/shielded/main.go b/cmd/shielded/main.go index ad74832..5c69b02 100644 --- a/cmd/shielded/main.go +++ b/cmd/shielded/main.go @@ -110,11 +110,11 @@ func main() { wo.HandleFunc("/api/shield/{id:[0-9]+}", sah.HandleDELETE).Methods("DELETE") tih := shieldeddotdev.NewDashboardUserAPITokenIndexHandler(tm, jwta) - wo.HandleFunc("/api/tokens", tih.HandleGET).Methods("GET") - wo.HandleFunc("/api/tokens", tih.HandlePOST).Methods("POST") + wo.HandleFunc("/user/tokens", tih.HandleGET).Methods("GET") + wo.HandleFunc("/user/tokens", tih.HandlePOST).Methods("POST") th := shieldeddotdev.NewDashboardUserAPITokenHandler(tm, jwta) - wo.HandleFunc("/api/token/{id:[0-9]+}", th.HandleDELETE).Methods("DELETE") + wo.HandleFunc("/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{ diff --git a/dashboardapitokenhandlers.go b/dashboardapitokenhandlers.go index 42b8436..3bd20a6 100644 --- a/dashboardapitokenhandlers.go +++ b/dashboardapitokenhandlers.go @@ -69,7 +69,7 @@ func (th *DashboardUserAPITokenIndexHandler) HandlePOST(w http.ResponseWriter, r return } - w.Header().Set("Location", "/api/token/"+strconv.FormatInt(token.APITokenID, 10)) + w.Header().Set("Location", "/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 { diff --git a/static/main.js b/static/main.js index 6853f74..eadaeef 100644 --- a/static/main.js +++ b/static/main.js @@ -77,13 +77,13 @@ class ShieldsApi { class UserAPITokensApi { getTokens() { - return doRequest('api/tokens', 'GET', null); + return doRequest('user/tokens', 'GET', null); } createToken(description) { - return doRequest('api/tokens', 'POST', JSON.stringify({ Description: description })); + return doRequest('user/tokens', 'POST', JSON.stringify({ Description: description })); } deleteToken(token) { - return doRawRequest(`api/token/${token.APITokenID}`, 'DELETE', null); + return doRawRequest(`user/tokens/${token.APITokenID}`, 'DELETE', null); } } diff --git a/ts/api/tokens.ts b/ts/api/tokens.ts index cb0af6f..d4ebe0c 100644 --- a/ts/api/tokens.ts +++ b/ts/api/tokens.ts @@ -14,14 +14,14 @@ export interface CreatedUserAPITokenInterface extends UserAPITokenInterface { export class UserAPITokensApi { public getTokens() { - return doRequest('api/tokens', 'GET', null); + return doRequest('user/tokens', 'GET', null); } public createToken(description: string) { - return doRequest('api/tokens', 'POST', JSON.stringify({ Description: description })); + return doRequest('user/tokens', 'POST', JSON.stringify({ Description: description })); } public deleteToken(token: UserAPITokenInterface) { - return doRawRequest(`api/token/${token.APITokenID}`, 'DELETE', null); + return doRawRequest(`user/tokens/${token.APITokenID}`, 'DELETE', null); } } From 6d7d402eced5cc94f77bf405e4fdad2da1794be9 Mon Sep 17 00:00:00 2001 From: Jesse Donat Date: Sun, 26 Jul 2026 17:37:33 -0500 Subject: [PATCH 36/37] fix: namespace user token endpoints --- ARCHITECTURE.md | 8 ++++---- cmd/shielded/main.go | 6 +++--- dashboardapitokenhandlers.go | 2 +- static/main.js | 6 +++--- ts/api/tokens.ts | 6 +++--- 5 files changed, 14 insertions(+), 14 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 68e4d29..98e9eb3 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -71,9 +71,9 @@ For local HTTPS, `Caddyfile.local` accepts both the three canonical names and th | `/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`. | -| `/user/tokens` | GET | Returns the current user's API-token metadata. Plaintext token values and hashes are never returned. | -| `/user/tokens` | POST | Creates a user-level API token from a required description. Returns its metadata and plaintext token once with `201 Created`. | -| `/user/tokens/{id}` | DELETE | Revokes an owned user-level API token 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. | @@ -160,7 +160,7 @@ The mapper uses transactions for shield inserts, updates, and deletes. Reads are `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 /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. +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 diff --git a/cmd/shielded/main.go b/cmd/shielded/main.go index 5c69b02..13335fe 100644 --- a/cmd/shielded/main.go +++ b/cmd/shielded/main.go @@ -110,11 +110,11 @@ func main() { wo.HandleFunc("/api/shield/{id:[0-9]+}", sah.HandleDELETE).Methods("DELETE") tih := shieldeddotdev.NewDashboardUserAPITokenIndexHandler(tm, jwta) - wo.HandleFunc("/user/tokens", tih.HandleGET).Methods("GET") - wo.HandleFunc("/user/tokens", tih.HandlePOST).Methods("POST") + 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("/user/tokens/{id:[0-9]+}", th.HandleDELETE).Methods("DELETE") + 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{ diff --git a/dashboardapitokenhandlers.go b/dashboardapitokenhandlers.go index 3bd20a6..c657884 100644 --- a/dashboardapitokenhandlers.go +++ b/dashboardapitokenhandlers.go @@ -69,7 +69,7 @@ func (th *DashboardUserAPITokenIndexHandler) HandlePOST(w http.ResponseWriter, r return } - w.Header().Set("Location", "/user/tokens/"+strconv.FormatInt(token.APITokenID, 10)) + 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 { diff --git a/static/main.js b/static/main.js index eadaeef..edb92ee 100644 --- a/static/main.js +++ b/static/main.js @@ -77,13 +77,13 @@ class ShieldsApi { class UserAPITokensApi { getTokens() { - return doRequest('user/tokens', 'GET', null); + return doRequest('api/user/tokens', 'GET', null); } createToken(description) { - return doRequest('user/tokens', 'POST', JSON.stringify({ Description: description })); + return doRequest('api/user/tokens', 'POST', JSON.stringify({ Description: description })); } deleteToken(token) { - return doRawRequest(`user/tokens/${token.APITokenID}`, 'DELETE', null); + return doRawRequest(`api/user/tokens/${token.APITokenID}`, 'DELETE', null); } } diff --git a/ts/api/tokens.ts b/ts/api/tokens.ts index d4ebe0c..725621c 100644 --- a/ts/api/tokens.ts +++ b/ts/api/tokens.ts @@ -14,14 +14,14 @@ export interface CreatedUserAPITokenInterface extends UserAPITokenInterface { export class UserAPITokensApi { public getTokens() { - return doRequest('user/tokens', 'GET', null); + return doRequest('api/user/tokens', 'GET', null); } public createToken(description: string) { - return doRequest('user/tokens', 'POST', JSON.stringify({ Description: description })); + return doRequest('api/user/tokens', 'POST', JSON.stringify({ Description: description })); } public deleteToken(token: UserAPITokenInterface) { - return doRawRequest(`user/tokens/${token.APITokenID}`, 'DELETE', null); + return doRawRequest(`api/user/tokens/${token.APITokenID}`, 'DELETE', null); } } From 0e872c39c149dd1c5159c90f8ec7679d23cc35ef Mon Sep 17 00:00:00 2001 From: Jesse Donat Date: Sun, 26 Jul 2026 17:44:16 -0500 Subject: [PATCH 37/37] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- apihandler.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apihandler.go b/apihandler.go index 4a98400..0b1de01 100644 --- a/apihandler.go +++ b/apihandler.go @@ -177,7 +177,7 @@ func (ah *ApiHandler) handleSaveShieldError(w http.ResponseWriter, err error) { return } if shieldKeyInUseError(err) { - http.Error(w, err.Error(), http.StatusConflict) + http.Error(w, "shield key is already in use", http.StatusConflict) return }