From 944df9cdf857957aa950a7ed01a5017f280333d2 Mon Sep 17 00:00:00 2001 From: Brittany Ellich Date: Fri, 15 May 2026 10:44:29 -0700 Subject: [PATCH] =?UTF-8?q?feat(event):=20step=205=20=E2=80=94=20public=20?= =?UTF-8?q?scan-to-checkin=20flow?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - port internal/event (Record/CreateInput/Cache/Get/Put/LookupByQRToken/IsOngoing) - port internal/checkin (Put + Current) - features/event/{handlers,routes}.go: EventScan, EventQR, EventScanCheckin, EventFlushLocal - features/event/pages/event.templ: EventScanView landing page - wire TargetCurrentEvent + also-checkin into features/connect: * Connect handler queries checkin.Current + event.Get to populate event chip * ConnectConfirm re-validates claimed event_uri server-side then writes checkin * connection record carries event_uri when also-checkin is honored - router: register event.SetupRoutes after connect routes (all unauthed responses match step 4 pattern): - GET /e/{token} -> 404 unknown / 200 landing - GET /e/{token}/qr.svg -> 404 unknown / SVG - POST /e/{token}/checkin -> 302 /signin (unauthed) / 303 /?checkedin=1 - POST /event/flush-local -> 302 /signin (unauthed) / 200 JSON admin event CRUD intentionally deferred to step 6. tests: internal/event + internal/checkin pass. --- .gitignore | 1 + cmd/web/main.go | 43 +- config/config.go | 73 +- features/auth/handlers.go | 244 +++ features/auth/pages/signin.templ | 50 + features/auth/pages/signin_atproto.templ | 62 + features/auth/pages/signin_atproto_templ.go | 111 ++ features/auth/pages/signin_templ.go | 75 + features/auth/routes.go | 27 + features/auth/session.go | 51 + features/common/layouts/base.templ | 11 +- features/common/layouts/base_templ.go | 16 +- features/connect/handlers.go | 331 ++++ features/connect/pages/connect.templ | 150 ++ features/connect/pages/connect_templ.go | 342 ++++ features/connect/routes.go | 25 + features/event/handlers.go | 172 ++ features/event/pages/event.templ | 80 + features/event/pages/event_templ.go | 216 +++ features/event/routes.go | 26 + features/index/handlers.go | 12 +- features/index/pages/index.templ | 100 +- features/index/pages/index_templ.go | 43 +- features/index/routes.go | 13 +- features/profile/handlers.go | 250 +++ features/profile/pages/profile.templ | 221 +++ features/profile/pages/profile_edit.templ | 332 ++++ features/profile/pages/profile_edit_templ.go | 574 +++++++ features/profile/pages/profile_templ.go | 496 ++++++ features/profile/routes.go | 23 + features/profile/view.go | 142 ++ go.mod | 26 +- go.sum | 89 ++ internal/checkin/checkin.go | 109 ++ internal/checkin/checkin_test.go | 177 +++ internal/connection/connection.go | 102 ++ internal/connection/connection_test.go | 42 + internal/connection/drain.go | 77 + internal/connection/queue.go | 127 ++ internal/connection/queue_test.go | 187 +++ internal/db/db.go | 98 ++ internal/db/db_test.go | 50 + internal/db/fs.go | 9 + internal/db/migrations/001_init.sql | 21 + internal/db/migrations/002_oauth.sql | 31 + .../db/migrations/003_pending_connections.sql | 30 + .../db/migrations/004_events_checkins.sql | 48 + .../db/migrations/005_admin_users_badges.sql | 60 + internal/event/event.go | 149 ++ internal/event/event_test.go | 171 ++ internal/event/put.go | 268 ++++ internal/oauthclient/oauthclient.go | 79 + internal/oauthclient/oauthclient_test.go | 111 ++ internal/oauthstore/store.go | 149 ++ internal/oauthstore/store_test.go | 272 ++++ internal/profile/profile.go | 302 ++++ internal/profile/profile_test.go | 252 +++ internal/profile/remarshal.go | 14 + internal/qrcode/qrcode.go | 104 ++ internal/qrcode/qrcode_test.go | 83 + internal/session/session.go | 96 ++ internal/session/session_test.go | 170 ++ internal/users/users.go | 246 +++ router/router.go | 42 +- web/resources/static/css/terminal.css | 1383 +++++++++++++++++ web/resources/static/js/interests.js | 170 ++ web/resources/static/js/profile.js | 116 ++ 67 files changed, 9717 insertions(+), 55 deletions(-) create mode 100644 features/auth/handlers.go create mode 100644 features/auth/pages/signin.templ create mode 100644 features/auth/pages/signin_atproto.templ create mode 100644 features/auth/pages/signin_atproto_templ.go create mode 100644 features/auth/pages/signin_templ.go create mode 100644 features/auth/routes.go create mode 100644 features/auth/session.go create mode 100644 features/connect/handlers.go create mode 100644 features/connect/pages/connect.templ create mode 100644 features/connect/pages/connect_templ.go create mode 100644 features/connect/routes.go create mode 100644 features/event/handlers.go create mode 100644 features/event/pages/event.templ create mode 100644 features/event/pages/event_templ.go create mode 100644 features/event/routes.go create mode 100644 features/profile/handlers.go create mode 100644 features/profile/pages/profile.templ create mode 100644 features/profile/pages/profile_edit.templ create mode 100644 features/profile/pages/profile_edit_templ.go create mode 100644 features/profile/pages/profile_templ.go create mode 100644 features/profile/routes.go create mode 100644 features/profile/view.go create mode 100644 internal/checkin/checkin.go create mode 100644 internal/checkin/checkin_test.go create mode 100644 internal/connection/connection.go create mode 100644 internal/connection/connection_test.go create mode 100644 internal/connection/drain.go create mode 100644 internal/connection/queue.go create mode 100644 internal/connection/queue_test.go create mode 100644 internal/db/db.go create mode 100644 internal/db/db_test.go create mode 100644 internal/db/fs.go create mode 100644 internal/db/migrations/001_init.sql create mode 100644 internal/db/migrations/002_oauth.sql create mode 100644 internal/db/migrations/003_pending_connections.sql create mode 100644 internal/db/migrations/004_events_checkins.sql create mode 100644 internal/db/migrations/005_admin_users_badges.sql create mode 100644 internal/event/event.go create mode 100644 internal/event/event_test.go create mode 100644 internal/event/put.go create mode 100644 internal/oauthclient/oauthclient.go create mode 100644 internal/oauthclient/oauthclient_test.go create mode 100644 internal/oauthstore/store.go create mode 100644 internal/oauthstore/store_test.go create mode 100644 internal/profile/profile.go create mode 100644 internal/profile/profile_test.go create mode 100644 internal/profile/remarshal.go create mode 100644 internal/qrcode/qrcode.go create mode 100644 internal/qrcode/qrcode_test.go create mode 100644 internal/session/session.go create mode 100644 internal/session/session_test.go create mode 100644 internal/users/users.go create mode 100644 web/resources/static/css/terminal.css create mode 100644 web/resources/static/js/interests.js create mode 100644 web/resources/static/js/profile.js diff --git a/.gitignore b/.gitignore index 99c46c6..f26189a 100644 --- a/.gitignore +++ b/.gitignore @@ -14,6 +14,7 @@ !*.go !*.templ +!*.sql !go.sum !go.mod diff --git a/cmd/web/main.go b/cmd/web/main.go index 49626af..c27ce90 100644 --- a/cmd/web/main.go +++ b/cmd/web/main.go @@ -1,14 +1,18 @@ package main import ( + "atmoquest/config" + "atmoquest/internal/db" + "atmoquest/internal/oauthclient" + "atmoquest/internal/oauthstore" + "atmoquest/internal/session" + "atmoquest/nats" + "atmoquest/router" "context" "fmt" "log/slog" "net" "net/http" - "atmoquest/config" - "atmoquest/nats" - "atmoquest/router" "os" "os/signal" "time" @@ -16,7 +20,6 @@ import ( "github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5/middleware" "github.com/go-chi/httplog/v3" - "github.com/gorilla/sessions" "golang.org/x/sync/errgroup" ) @@ -37,18 +40,36 @@ func run(ctx context.Context) error { })) slog.SetDefault(logger) + if err := config.Global.Validate(); err != nil { + return fmt.Errorf("invalid config: %w", err) + } + + conn, err := db.Open(config.Global.DatabaseURL) + if err != nil { + return fmt.Errorf("open db: %w", err) + } + defer conn.Close() + if err := db.Migrate(conn); err != nil { + return fmt.Errorf("migrate db: %w", err) + } + slog.Info("db ready", "dsn", config.Global.DatabaseURL) + r := chi.NewMux() r.Use( httplog.RequestLogger(logger, nil), middleware.Recoverer, ) - sessionStore := sessions.NewCookieStore([]byte(config.Global.SessionSecret)) - sessionStore.MaxAge(86400 * 30) - sessionStore.Options.Path = "/" - sessionStore.Options.HttpOnly = true - sessionStore.Options.Secure = false - sessionStore.Options.SameSite = http.SameSiteLaxMode + sessionMgr, err := session.New(config.Global) + if err != nil { + return fmt.Errorf("session manager: %w", err) + } + + oauthApp, oauthClientID, err := oauthclient.Build(config.Global, oauthstore.New(conn)) + if err != nil { + return fmt.Errorf("build oauth client: %w", err) + } + slog.Info("oauth client ready", "client_id", oauthClientID, "localhost", config.Global.IsLocalhost()) ns, err := nats.SetupNATS(ctx) if err != nil { @@ -57,7 +78,7 @@ func run(ctx context.Context) error { eg, egctx := errgroup.WithContext(ctx) - if err := router.SetupRoutes(egctx, r, sessionStore, ns); err != nil { + if err := router.SetupRoutes(egctx, r, sessionMgr, oauthApp, ns, conn); err != nil { return fmt.Errorf("error setting up routes: %w", err) } diff --git a/config/config.go b/config/config.go index bf552e0..bee1c8e 100644 --- a/config/config.go +++ b/config/config.go @@ -1,8 +1,10 @@ package config import ( + "fmt" "log/slog" "os" + "strings" "sync" "github.com/joho/godotenv" @@ -16,11 +18,30 @@ const ( ) type Config struct { - Environment Environment - Host string - Port string - LogLevel slog.Level + Environment Environment + Host string + Port string + LogLevel slog.Level + + // SessionSecret is the HMAC key for gorilla cookie sessions. Required + // in prod; defaulted in dev so getting started is friction-free. SessionSecret string + + // PublicURL is the externally-visible origin of this server, used to + // derive OAuth client_id, redirect URIs, and cookie security defaults. + // Examples: "http://127.0.0.1:8080" (dev), "https://atmo.quest" (prod). + PublicURL string + + // DatabaseURL is the SQLite DSN for the local app DB (admin flags, event + // cache, OAuth sessions, etc.). Example: + // file:data/atmoquest.db?_pragma=journal_mode(WAL)&_pragma=foreign_keys(ON) + DatabaseURL string + + // OAuthPrivateKeyPath is the on-disk location of the ES256 (P-256) private + // key used to sign client_assertion JWTs when running as a confidential + // OAuth client. Auto-generated on first run if the file is missing. + // Ignored when IsLocalhost() is true (loopback dev uses a public client). + OAuthPrivateKeyPath string } var ( @@ -44,9 +65,13 @@ func getEnv(key, fallback string) string { func loadBase() *Config { godotenv.Load() + host := getEnv("HOST", "0.0.0.0") + port := getEnv("PORT", "8080") + defaultPublicURL := fmt.Sprintf("http://127.0.0.1:%s", port) + return &Config{ - Host: getEnv("HOST", "0.0.0.0"), - Port: getEnv("PORT", "8080"), + Host: host, + Port: port, LogLevel: func() slog.Level { switch os.Getenv("LOG_LEVEL") { case "DEBUG": @@ -61,6 +86,40 @@ func loadBase() *Config { return slog.LevelInfo } }(), - SessionSecret: getEnv("SESSION_SECRET", "session-secret"), + SessionSecret: getEnv("SESSION_SECRET", "session-secret"), + PublicURL: getEnv("PUBLIC_URL", defaultPublicURL), + DatabaseURL: getEnv("DATABASE_URL", "file:data/atmoquest.db?_pragma=journal_mode(WAL)&_pragma=foreign_keys(ON)"), + OAuthPrivateKeyPath: getEnv("OAUTH_PRIVATE_KEY_PATH", "data/oauth_key.pem"), + } +} + +// Validate enforces invariants that aren't safe to enforce in init(). Called +// from main() so misconfiguration surfaces as a clean fatal error with a +// message, not a mid-request panic. +func (c *Config) Validate() error { + if c.DatabaseURL == "" { + return fmt.Errorf("DATABASE_URL is required") + } + if c.PublicURL == "" { + return fmt.Errorf("PUBLIC_URL is required") } + if c.Environment == Prod { + if c.SessionSecret == "" || c.SessionSecret == "session-secret" { + return fmt.Errorf("SESSION_SECRET must be set to a real secret in production") + } + } + return nil +} + +// IsLocalhost reports whether PublicURL is a loopback origin. When true the +// OAuth client runs in loopback/public mode (no client_assertion, no signing +// key) because Authorization Servers reject http:// client_ids elsewhere. +func (c *Config) IsLocalhost() bool { + u := c.PublicURL + return strings.HasPrefix(u, "http://localhost") || strings.HasPrefix(u, "http://127.0.0.1") +} + +// IsSecure reports whether PublicURL is https — used to decide cookie Secure flag. +func (c *Config) IsSecure() bool { + return strings.HasPrefix(c.PublicURL, "https://") } diff --git a/features/auth/handlers.go b/features/auth/handlers.go new file mode 100644 index 0000000..0cf2f2a --- /dev/null +++ b/features/auth/handlers.go @@ -0,0 +1,244 @@ +// Package auth wires the OAuth-backed sign-in / callback / logout endpoints, +// the signin page chooser, and a ResumeSession helper that other features +// use to look up the current viewer's indigo OAuth session from the cookie. +package auth + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "log/slog" + "net/http" + "strings" + "time" + + "github.com/bluesky-social/indigo/atproto/auth/oauth" + "github.com/bluesky-social/indigo/atproto/syntax" + + "atmoquest/features/auth/pages" + "atmoquest/internal/connection" + "atmoquest/internal/profile" + "atmoquest/internal/session" + "atmoquest/internal/users" +) + +// Handlers holds the dependencies the auth feature needs. +type Handlers struct { + DB *sql.DB + OAuth *oauth.ClientApp + Sessions *session.Manager + // ConnQueue is the SQLite-backed pending-connection queue. May be nil in + // tests; when non-nil, OAuthCallback drains it on successful login. + ConnQueue *connection.Queue +} + +// NewHandlers wires the auth feature. +func NewHandlers(conn *sql.DB, oauthApp *oauth.ClientApp, sess *session.Manager) *Handlers { + return &Handlers{DB: conn, OAuth: oauthApp, Sessions: sess} +} + +// Signin renders the chooser page: sign in vs create new Atmosphere account. +func (h *Handlers) Signin(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + if err := pages.Signin().Render(r.Context(), w); err != nil { + slog.Error("render signin", "err", err) + } +} + +// SigninATProto renders the handle-entry form for the existing-account path. +func (h *Handlers) SigninATProto(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + if err := pages.SigninATProto("").Render(r.Context(), w); err != nil { + slog.Error("render signin/atproto", "err", err) + } +} + +// OAuthLogin starts the OAuth flow: takes a handle / DID / PDS URL, calls +// indigo's StartAuthFlow, redirects to the AS. +func (h *Handlers) OAuthLogin(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + http.Error(w, "invalid form", http.StatusBadRequest) + return + } + identifier := strings.TrimSpace(r.FormValue("handle")) + if identifier == "" { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.WriteHeader(http.StatusBadRequest) + _ = pages.SigninATProto("enter a handle, DID, or PDS URL").Render(r.Context(), w) + return + } + + redirectURL, err := h.OAuth.StartAuthFlow(r.Context(), identifier) + if err != nil { + // Indigo already emits a slog.Warn with the full AS response body + // from parseAuthErrorReason — check the line above this one in the + // log for the AS's error_description. We log identifier + client_id + // + callback_url here so the two log entries are easy to correlate. + slog.Warn("oauth start failed", + "identifier", identifier, + "client_id", h.OAuth.Config.ClientID, + "callback_url", h.OAuth.Config.CallbackURL, + "scopes", h.OAuth.Config.Scopes, + "err", err, + ) + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.WriteHeader(http.StatusBadRequest) + _ = pages.SigninATProto("couldn't start sign-in: " + sanitizeAuthError(err)).Render(r.Context(), w) + return + } + slog.Info("oauth start ok", "identifier", identifier, "redirect", redirectURL) + http.Redirect(w, r, redirectURL, http.StatusFound) +} + +// OAuthCallback completes the OAuth flow: indigo verifies the code, persists +// the session, and we set the user-facing session cookie. +func (h *Handlers) OAuthCallback(w http.ResponseWriter, r *http.Request) { + sessData, err := h.OAuth.ProcessCallback(r.Context(), r.URL.Query()) + if err != nil { + slog.Warn("oauth callback", "err", err) + var asErr *oauth.AuthRequestCallbackError + msg := "sign-in failed" + if errors.As(err, &asErr) { + msg = "sign-in failed: " + sanitizeASCode(asErr.ErrorCode) + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.WriteHeader(http.StatusBadRequest) + _ = pages.SigninATProto(msg).Render(r.Context(), w) + return + } + + if err := h.Sessions.Set(w, r, sessData.AccountDID.String(), sessData.SessionID); err != nil { + slog.Error("set session cookie", "err", err) + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + + // Best-effort: touch the users table so the admin UI knows about this DID. + // Enrichment (handle resolution, Bluesky profile fetch) lands when the + // profile + admin features are ported. + go h.recordUserLogin(sessData) + + // Best-effort: flush any reciprocal connection writes queued while this + // user was offline. Runs synchronously so the user lands on /profile with + // their freshly-flushed connections visible. Errors here don't block the + // redirect — Drain swallows per-row failures and leaves them in the queue + // for the next login. + if h.ConnQueue != nil { + sess, err := h.OAuth.ResumeSession(r.Context(), sessData.AccountDID, sessData.SessionID) + if err == nil { + res, err := connection.Drain(r.Context(), h.ConnQueue, sess, slog.Default()) + if err != nil { + slog.Warn("connect drain", "did", sessData.AccountDID.String(), "err", err) + } else if res.Written > 0 || res.Skipped > 0 { + slog.Info("connect drain", "did", sessData.AccountDID.String(), "written", res.Written, "skipped", res.Skipped) + } + } else { + slog.Warn("connect drain: resume session", "did", sessData.AccountDID.String(), "err", err) + } + } + + http.Redirect(w, r, "/profile", http.StatusFound) +} + +// OAuthLogout revokes tokens (best-effort), clears the session, redirects home. +func (h *Handlers) OAuthLogout(w http.ResponseWriter, r *http.Request) { + did, sid := h.Sessions.Get(r) + if did != "" && sid != "" { + if parsed, err := syntax.ParseDID(did); err == nil { + if err := h.OAuth.Logout(r.Context(), parsed, sid); err != nil { + slog.Warn("oauth logout", "did", did, "err", err) + } + } + } + h.Sessions.Clear(w, r) + http.Redirect(w, r, "/", http.StatusFound) +} + +// OAuthClientMetadata serves the public /oauth/client-metadata.json that the +// AS fetches to learn about us. In localhost-dev mode the URL is never +// actually fetched, but we serve it anyway for parity. +func (h *Handlers) OAuthClientMetadata(w http.ResponseWriter, _ *http.Request) { + doc := h.OAuth.Config.ClientMetadata() + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Cache-Control", "public, max-age=300") + if err := json.NewEncoder(w).Encode(doc); err != nil { + slog.Error("encode client metadata", "err", err) + } +} + +// OAuthJWKS serves the public JWKS for confidential clients. Returns an empty +// keys array for public clients. +func (h *Handlers) OAuthJWKS(w http.ResponseWriter, _ *http.Request) { + jwks := h.OAuth.Config.PublicJWKS() + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Cache-Control", "public, max-age=300") + if err := json.NewEncoder(w).Encode(jwks); err != nil { + slog.Error("encode jwks", "err", err) + } +} + +// recordUserLogin upserts a row in the users table for this DID and tries +// to enrich it with the user's current Bluesky display name. Runs in a +// goroutine off the OAuth callback's request context — uses +// context.Background() with a short timeout so a slow PDS doesn't keep the +// goroutine alive forever. All errors are logged; nothing is returned. +// +// Handle resolution (via an atproto identity directory) is deferred until +// the admin step; the users.Touch COALESCE preserves any previously-stored +// handle across enrichment-less re-logins. +func (h *Handlers) recordUserLogin(sessData *oauth.ClientSessionData) { + if h.DB == nil || sessData == nil { + return + } + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + did := sessData.AccountDID + displayName := "" + + // Best-effort: fetch the public Bluesky profile from the user's PDS to + // pick up display_name. A miss is fine — users.Touch COALESCEs. + if sessData.HostURL != "" { + if bsky, err := profile.FetchBluesky(ctx, sessData.HostURL, did); err == nil && bsky != nil { + displayName = bsky.DisplayName + } else if err != nil { + slog.Debug("recordUserLogin: bluesky fetch", "did", did.String(), "err", err) + } + } + + if err := users.Touch(ctx, h.DB, did, "", displayName); err != nil { + slog.Warn("recordUserLogin: users.Touch", "did", did.String(), "err", err) + } +} + +// sanitizeAuthError strips anything that looks like a token or a long opaque +// string from an error message before showing it in HTML. Belt-and-braces; +// templ also escapes the resulting string. +func sanitizeAuthError(err error) string { + s := err.Error() + if len(s) > 200 { + s = s[:200] + "…" + } + return s +} + +// sanitizeASCode whitelists characters allowed in an OAuth error code so we +// can render an AS-provided string without worrying about it. +func sanitizeASCode(code string) string { + if code == "" { + return "unknown error" + } + out := make([]byte, 0, len(code)) + for i := 0; i < len(code) && i < 64; i++ { + c := code[i] + switch { + case c >= 'a' && c <= 'z', c >= 'A' && c <= 'Z', c >= '0' && c <= '9', c == '_', c == '-': + out = append(out, c) + } + } + if len(out) == 0 { + return "unknown error" + } + return string(out) +} diff --git a/features/auth/pages/signin.templ b/features/auth/pages/signin.templ new file mode 100644 index 0000000..0d15765 --- /dev/null +++ b/features/auth/pages/signin.templ @@ -0,0 +1,50 @@ +package pages + +import "atmoquest/features/common/layouts" + +// Signin renders the chooser: existing Atmosphere account vs create a new one. +// The create path is a stub for now — we'll wire it later. +templ Signin() { + @layouts.Base("sign in — atmo.quest", "Sign in with your ATProto account.") { +
+
+
+
+
+
~/atmo.quest — auth
+
+
+
+ you{ "@" }atmo.quest:~$ + auth --pick +
+
choose how to start your quest.
+
+ step 1 of 1 · pick a path +

sign in

+

+ atmo.quest is built on ATProto. Your records live in your repo, not ours. You'll sign in with the same identity you use anywhere on the open social web. +

+
+
+ +
option a
+
sign in with your Atmosphere account
+
already have a handle like you.bsky.social or any ATProto PDS? Go this way.
+
▸ continue ↵
+
+
+
option b
+
create an Atmosphere account
+
new to the open social web? We'll help you pick a host and get a handle.
+
▸ soon …
+
+
+
+
+
● ATProto OAuth · DPoP · PKCE
+
← cancel
+
+
+ } +} diff --git a/features/auth/pages/signin_atproto.templ b/features/auth/pages/signin_atproto.templ new file mode 100644 index 0000000..6bfb243 --- /dev/null +++ b/features/auth/pages/signin_atproto.templ @@ -0,0 +1,62 @@ +package pages + +import "atmoquest/features/common/layouts" + +// SigninATProto renders the handle entry form. errorMsg is empty unless we're +// re-rendering after a validation or OAuth failure. +templ SigninATProto(errorMsg string) { + @layouts.Base("sign in with ATProto — atmo.quest", "Enter your handle, DID, or PDS URL to start the OAuth flow.") { +
+
+
+
+
+
~/atmo.quest — auth/atproto
+
+
+
+ you{ "@" }atmo.quest:~$ + login --handle +
+
enter your handle, DID, or PDS URL. we'll send you to your account host to approve.
+
+ + if errorMsg != "" { + + } +
+ + ← back +
+
+

+ you'll be redirected to your account host (usually a PDS) to approve. atmo.quest never sees your password. +

+
+
+
● ATProto OAuth · DPoP · PKCE
+
cancel
+
+
+ } +} diff --git a/features/auth/pages/signin_atproto_templ.go b/features/auth/pages/signin_atproto_templ.go new file mode 100644 index 0000000..dc0147a --- /dev/null +++ b/features/auth/pages/signin_atproto_templ.go @@ -0,0 +1,111 @@ +// Code generated by templ - DO NOT EDIT. + +// templ: version: v0.3.1020 +package pages + +//lint:file-ignore SA4006 This context is only used if a nested component is present. + +import "github.com/a-h/templ" +import templruntime "github.com/a-h/templ/runtime" + +import "atmoquest/features/common/layouts" + +// SigninATProto renders the handle entry form. errorMsg is empty unless we're +// re-rendering after a validation or OAuth failure. +func SigninATProto(errorMsg string) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var1 := templ.GetChildren(ctx) + if templ_7745c5c3_Var1 == nil { + templ_7745c5c3_Var1 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Var2 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "
~/atmo.quest — auth/atproto
you") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var3 string + templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs("@") + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/auth/pages/signin_atproto.templ`, Line: 18, Col: 56} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "atmo.quest:~$ login --handle
enter your handle, DID, or PDS URL. we'll send you to your account host to approve.
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if errorMsg != "" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "
err: ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var5 string + templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(errorMsg) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/auth/pages/signin_atproto.templ`, Line: 44, Col: 61} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "
← back

you'll be redirected to your account host (usually a PDS) to approve. atmo.quest never sees your password.

● ATProto OAuth · DPoP · PKCE
cancel
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) + templ_7745c5c3_Err = layouts.Base("sign in with ATProto — atmo.quest", "Enter your handle, DID, or PDS URL to start the OAuth flow.").Render(templ.WithChildren(ctx, templ_7745c5c3_Var2), templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +var _ = templruntime.GeneratedTemplate diff --git a/features/auth/pages/signin_templ.go b/features/auth/pages/signin_templ.go new file mode 100644 index 0000000..137bb89 --- /dev/null +++ b/features/auth/pages/signin_templ.go @@ -0,0 +1,75 @@ +// Code generated by templ - DO NOT EDIT. + +// templ: version: v0.3.1020 +package pages + +//lint:file-ignore SA4006 This context is only used if a nested component is present. + +import "github.com/a-h/templ" +import templruntime "github.com/a-h/templ/runtime" + +import "atmoquest/features/common/layouts" + +// Signin renders the chooser: existing Atmosphere account vs create a new one. +// The create path is a stub for now — we'll wire it later. +func Signin() templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var1 := templ.GetChildren(ctx) + if templ_7745c5c3_Var1 == nil { + templ_7745c5c3_Var1 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Var2 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "
~/atmo.quest — auth
you") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var3 string + templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs("@") + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/auth/pages/signin.templ`, Line: 18, Col: 56} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "atmo.quest:~$ auth --pick
choose how to start your quest.
step 1 of 1 · pick a path

sign in

atmo.quest is built on ATProto. Your records live in your repo, not ours. You'll sign in with the same identity you use anywhere on the open social web.

option a
sign in with your Atmosphere account
already have a handle like you.bsky.social or any ATProto PDS? Go this way.
▸ continue ↵
option b
create an Atmosphere account
new to the open social web? We'll help you pick a host and get a handle.
▸ soon …
● ATProto OAuth · DPoP · PKCE
← cancel
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) + templ_7745c5c3_Err = layouts.Base("sign in — atmo.quest", "Sign in with your ATProto account.").Render(templ.WithChildren(ctx, templ_7745c5c3_Var2), templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +var _ = templruntime.GeneratedTemplate diff --git a/features/auth/routes.go b/features/auth/routes.go new file mode 100644 index 0000000..7049c5c --- /dev/null +++ b/features/auth/routes.go @@ -0,0 +1,27 @@ +package auth + +import ( + "github.com/go-chi/chi/v5" +) + +// SetupRoutes wires the auth feature's HTTP routes against a pre-built +// Handlers value. Other features (profile, connect, event, admin) also depend +// on the same Handlers for session resume, so the router constructs it once +// and passes it to each feature. +// +// - GET /signin — chooser page +// - GET /signin/atproto — handle entry form +// - POST /oauth/login — start auth flow (PAR → AS redirect) +// - GET /oauth/callback — finish auth flow, set cookie, redirect +// - POST /oauth/logout — revoke + clear cookie + redirect home +// - GET /oauth/client-metadata.json — public client metadata doc +// - GET /oauth/jwks.json — public JWKS (empty for public clients) +func SetupRoutes(router chi.Router, h *Handlers) { + router.Get("/signin", h.Signin) + router.Get("/signin/atproto", h.SigninATProto) + router.Post("/oauth/login", h.OAuthLogin) + router.Get("/oauth/callback", h.OAuthCallback) + router.Post("/oauth/logout", h.OAuthLogout) + router.Get("/oauth/client-metadata.json", h.OAuthClientMetadata) + router.Get("/oauth/jwks.json", h.OAuthJWKS) +} diff --git a/features/auth/session.go b/features/auth/session.go new file mode 100644 index 0000000..6d9b617 --- /dev/null +++ b/features/auth/session.go @@ -0,0 +1,51 @@ +package auth + +import ( + "errors" + "net/http" + + "github.com/bluesky-social/indigo/atproto/auth/oauth" + "github.com/bluesky-social/indigo/atproto/syntax" +) + +// ErrNoSession is returned by ResumeSession when no auth cookie is present. +// Other features should check for this with errors.Is and redirect to /signin. +var ErrNoSession = errors.New("auth: no session") + +// ResumeSession reads the session cookie and asks indigo to resume the OAuth +// session for the resulting (DID, SessionID). Returns ErrNoSession when no +// cookie is present so callers can distinguish "logged out" from "bad token". +// +// Use this from any authenticated handler. Example: +// +// did, sess, err := h.Auth.ResumeSession(r) +// if err != nil { http.Redirect(w, r, "/signin", http.StatusFound); return } +func (h *Handlers) ResumeSession(r *http.Request) (syntax.DID, *oauth.ClientSession, error) { + didStr, sid := h.Sessions.Get(r) + if didStr == "" || sid == "" { + return "", nil, ErrNoSession + } + did, err := syntax.ParseDID(didStr) + if err != nil { + return "", nil, err + } + sess, err := h.OAuth.ResumeSession(r.Context(), did, sid) + if err != nil { + return did, nil, err + } + return did, sess, nil +} + +// RequireSession is a small helper that redirects to /signin?next= if +// no session is present, otherwise returns the resumed indigo session. +// +// did, sess, ok := h.Auth.RequireSession(w, r) +// if !ok { return } +func (h *Handlers) RequireSession(w http.ResponseWriter, r *http.Request) (syntax.DID, *oauth.ClientSession, bool) { + did, sess, err := h.ResumeSession(r) + if err != nil { + http.Redirect(w, r, "/signin?next="+r.URL.Path, http.StatusFound) + return "", nil, false + } + return did, sess, true +} diff --git a/features/common/layouts/base.templ b/features/common/layouts/base.templ index 3b7cbdb..78c77e9 100644 --- a/features/common/layouts/base.templ +++ b/features/common/layouts/base.templ @@ -14,17 +14,22 @@ templ Base(title, description string) { - + // Terminal aesthetic uses JetBrains Mono as the body face and + // Instrument Serif italic for display accents. The other + // families are kept around for non-terminal feature work. + - + if config.Global.Environment == config.Dev {
} - { children... } +
+ { children... } +
} diff --git a/features/common/layouts/base_templ.go b/features/common/layouts/base_templ.go index 47e4ca6..10f25e6 100644 --- a/features/common/layouts/base_templ.go +++ b/features/common/layouts/base_templ.go @@ -60,14 +60,14 @@ func Base(title, description string) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "\">") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } templ_7745c5c3_Err = templ_7745c5c3_Var1.Render(ctx, templ_7745c5c3_Buffer) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } diff --git a/features/connect/handlers.go b/features/connect/handlers.go new file mode 100644 index 0000000..c07a347 --- /dev/null +++ b/features/connect/handlers.go @@ -0,0 +1,331 @@ +// Package connect implements the QR-driven /c/{did} flow: +// +// - /profile/qr.svg — SVG QR encoding /c/ +// - /c/{did} — landing page rendered for the scanner +// - /c/{did}/confirm — POST that writes the connection record +// - /connect/flush-local — drain localStorage-stashed targets post-login +// +// Reciprocity is async: the viewer's own quest.atmo.connection record is +// written synchronously to their PDS; the target's reciprocal is enqueued +// in pending_connections and drained on their next login. +package connect + +import ( + "database/sql" + "encoding/json" + "errors" + "log/slog" + "net/http" + "strings" + "time" + + "github.com/bluesky-social/indigo/atproto/syntax" + "github.com/go-chi/chi/v5" + + "atmoquest/config" + "atmoquest/features/auth" + "atmoquest/features/connect/pages" + "atmoquest/internal/checkin" + "atmoquest/internal/connection" + "atmoquest/internal/event" + "atmoquest/internal/profile" + "atmoquest/internal/qrcode" +) + +// Handlers holds the dependencies the connect feature needs. +type Handlers struct { + DB *sql.DB + Auth *auth.Handlers + Queue *connection.Queue +} + +// NewHandlers wires the connect feature against the shared auth handlers, +// the app DB, and the pending-connection queue. +func NewHandlers(conn *sql.DB, authH *auth.Handlers, queue *connection.Queue) *Handlers { + return &Handlers{DB: conn, Auth: authH, Queue: queue} +} + +// ProfileQR serves an SVG QR code for the authenticated user's profile URL. +// The QR encodes /c/ so any standard QR scanner opens the +// connect page in a browser — no atmo.quest-aware app required. +func (h *Handlers) ProfileQR(w http.ResponseWriter, r *http.Request) { + did, _, ok := h.Auth.RequireSession(w, r) + if !ok { + return + } + svg, err := qrcode.EncodeSVG(connectURL(did), qrcode.DefaultOptions()) + if err != nil { + slog.Error("qr encode", "did", did.String(), "err", err) + http.Error(w, "qr encode failed", http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "image/svg+xml") + // Cache for a minute on the client. The QR contents are derived purely + // from the DID + PublicURL so they don't change for a given user. + w.Header().Set("Cache-Control", "private, max-age=60") + _, _ = w.Write(svg) +} + +// Connect renders the landing page that a QR scan opens. Behavior depends on +// whether the viewer is logged in: +// +// - Logged in: render a confirmation page with a "connect" button that +// POSTs to /c/{did}/confirm. +// - Not logged in: render a sign-in prompt + emit a [data-queue-did] +// marker so profile.js stashes the target DID in localStorage for the +// post-login flush. +// +// Self-connect is rejected up front with a friendly note. +func (h *Handlers) Connect(w http.ResponseWriter, r *http.Request) { + targetStr := chi.URLParam(r, "did") + target, err := syntax.ParseDID(targetStr) + if err != nil { + http.NotFound(w, r) + return + } + + viewerDIDStr, viewerSID := h.Auth.Sessions.Get(r) + viewerLoggedIn := viewerDIDStr != "" && viewerSID != "" + + // Pull the target's public profile so we can show their display name and + // avatar on the confirm screen. Public records — no auth needed. + pds := h.lookupPDSForDID(r, target) + bsky, err := profile.FetchBluesky(r.Context(), pds, target) + if err != nil && !errors.Is(err, profile.ErrNotFound) { + slog.Info("connect: fetch bsky profile", "target", target.String(), "err", err) + bsky = nil + } + + view := pages.ConnectView{ + TargetDID: target.String(), + TargetDisplayName: blueskyDisplayName(bsky), + TargetAvatarURL: blueskyAvatarURL(pds, target, bsky), + TargetBio: blueskyBio(bsky), + ViewerLoggedIn: viewerLoggedIn, + SelfConnect: viewerLoggedIn && viewerDIDStr == target.String(), + } + + // If the target is currently checked into an ongoing event, surface it as + // a chip so a logged-in scanner can opt in to also-check-in. + if viewerLoggedIn && !view.SelfConnect { + if evURI, ok, err := checkin.Current(r.Context(), h.DB, target); err == nil && ok { + if ev, err := event.Get(r.Context(), h.DB, evURI); err == nil && ev.IsOngoing(time.Now()) { + view.TargetCurrentEvent = &pages.ConnectEventChip{ + Name: ev.Name, + Location: ev.Location, + EventURI: ev.URI, + } + } + } + } + + w.Header().Set("Content-Type", "text/html; charset=utf-8") + if err := pages.Connect(view).Render(r.Context(), w); err != nil { + slog.Error("render connect", "err", err) + } +} + +// ConnectConfirm handles the "yes, connect" button on the /c/{did} page. +// +// 1. Validate the target DID and reject self-connect. +// 2. Optionally also-check-in the viewer to the target's ongoing event. +// 3. Write a quest.atmo.connection record to the viewer's PDS (with an +// event ref when the also-check-in box was ticked). +// 4. Enqueue a reciprocal write for the target's next login. +// 5. If also-check-in was requested, write the check-in record. +// 6. Redirect back to /profile with a ?connected= flash. +func (h *Handlers) ConnectConfirm(w http.ResponseWriter, r *http.Request) { + viewerDID, viewerSess, ok := h.Auth.RequireSession(w, r) + if !ok { + return + } + targetStr := chi.URLParam(r, "did") + target, err := syntax.ParseDID(targetStr) + if err != nil { + http.Error(w, "invalid target DID", http.StatusBadRequest) + return + } + if target == viewerDID { + http.Redirect(w, r, "/profile?connected=self", http.StatusSeeOther) + return + } + + // If the form requested also-check-in, validate the event URI matches a + // real ongoing event the target is currently in. We re-derive the + // target's current event server-side (don't trust the hidden field + // alone) — the hidden field is just an integrity hint. + var checkinEventURI string + if formCheckboxOn(r, "checkin") { + claimed := strings.TrimSpace(r.FormValue("event_uri")) + if claimed != "" { + if curr, ok, err := checkin.Current(r.Context(), h.DB, target); err == nil && ok && curr == claimed { + if ev, err := event.Get(r.Context(), h.DB, claimed); err == nil && ev.IsOngoing(time.Now()) { + checkinEventURI = claimed + } + } + } + } + + // Step 1: viewer's own connection record. If we resolved a check-in + // event, attach it to the connection too — the lexicon allows optional + // event linkage and it's nice provenance. + connRec := connection.Record{With: target} + if checkinEventURI != "" { + connRec.EventURI = checkinEventURI + } + if _, _, err := connection.Put(r.Context(), viewerSess, connRec); err != nil { + slog.Warn("connect confirm: viewer record", "viewer", viewerDID.String(), "target", target.String(), "err", err) + http.Error(w, "couldn't write your connection record — try again", http.StatusBadGateway) + return + } + + // Step 2: enqueue the target's reciprocal record for next-login drain. + // Best-effort — a failure here doesn't roll back the viewer's record. + if err := h.Queue.Enqueue(r.Context(), target, viewerDID); err != nil { + slog.Info("connect confirm: target deferred", "target", target.String(), "err", err) + } + + // Step 3: if also-check-in was requested + validated, write the viewer's + // checkin record. Best-effort — the connection has already landed, so + // log + carry on rather than 500. + if checkinEventURI != "" { + if _, err := checkin.Put(r.Context(), viewerSess, h.DB, checkinEventURI, time.Time{}); err != nil { + slog.Warn("connect confirm: also-checkin", "event_uri", checkinEventURI, "err", err) + } + } + + http.Redirect(w, r, "/profile?connected="+target.String(), http.StatusSeeOther) +} + +// ConnectFlushLocal accepts a POST with a JSON body listing target DIDs that +// were stashed in localStorage while the user was unauthenticated. For each +// target, we run the same logic as /c/{did}/confirm: write the viewer's +// record + enqueue the target's reciprocal write. +// +// Body shape: +// +// { "targets": ["did:plc:abc…", "did:plc:def…"] } +// +// Response shape: +// +// { "written": 2, "skipped": 0, "errors": [] } +func (h *Handlers) ConnectFlushLocal(w http.ResponseWriter, r *http.Request) { + viewerDID, viewerSess, ok := h.Auth.RequireSession(w, r) + if !ok { + return + } + + var body struct { + Targets []string `json:"targets"` + } + if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 16*1024)).Decode(&body); err != nil { + http.Error(w, "invalid JSON", http.StatusBadRequest) + return + } + + written, skipped := 0, 0 + errs := make([]string, 0) + for _, raw := range body.Targets { + raw = strings.TrimSpace(raw) + if raw == "" { + continue + } + target, err := syntax.ParseDID(raw) + if err != nil { + skipped++ + errs = append(errs, "invalid DID: "+truncate(raw, 64)) + continue + } + if target == viewerDID { + // silently skip self + continue + } + if _, _, err := connection.Put(r.Context(), viewerSess, connection.Record{With: target}); err != nil { + slog.Warn("flush-local: viewer record", "target", target.String(), "err", err) + skipped++ + errs = append(errs, "write failed for "+target.String()) + continue + } + if err := h.Queue.Enqueue(r.Context(), target, viewerDID); err != nil { + slog.Warn("flush-local: enqueue", "target", target.String(), "err", err) + } + written++ + } + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "written": written, + "skipped": skipped, + "errors": errs, + }) +} + +// connectURL builds the absolute URL that QR codes encode. +func connectURL(did syntax.DID) string { + return strings.TrimRight(config.Global.PublicURL, "/") + "/c/" + did.String() +} + +// lookupPDSForDID returns the PDS host for the given DID, using whatever we +// can find without a network roundtrip. v1: if we have a session row for the +// DID, use its HostURL; otherwise fall back to bsky.social. +// +// A proper identity-directory-backed lookup is a follow-up (admin step). +func (h *Handlers) lookupPDSForDID(r *http.Request, did syntax.DID) string { + if h.DB == nil { + return "https://bsky.social" + } + // SQLite supports the JSON `->>` operator out of the box. The data + // column is a JSON BLOB containing oauth.ClientSessionData. + var host string + err := h.DB.QueryRowContext(r.Context(), ` + SELECT data ->> 'host_url' FROM oauth_sessions + WHERE did = ? + ORDER BY updated_at DESC LIMIT 1 + `, did.String()).Scan(&host) + if err == nil && host != "" { + return host + } + return "https://bsky.social" +} + +// formCheckboxOn reports whether an HTML checkbox with the given name was +// ticked. HTML checkboxes only submit when checked; we accept any of the +// common "on" values browsers send. +func formCheckboxOn(r *http.Request, name string) bool { + v := strings.TrimSpace(r.FormValue(name)) + switch v { + case "", "0", "off", "false", "no": + return false + } + return true +} + +// truncate clips s to at most n runes, suffixing with an ellipsis if cut. +func truncate(s string, n int) string { + r := []rune(s) + if len(r) <= n { + return s + } + return string(r[:n]) + "…" +} + +func blueskyDisplayName(b *profile.BlueskyRecord) string { + if b == nil { + return "" + } + return b.DisplayName +} + +func blueskyBio(b *profile.BlueskyRecord) string { + if b == nil { + return "" + } + return b.Description +} + +func blueskyAvatarURL(pds string, did syntax.DID, b *profile.BlueskyRecord) string { + if b == nil { + return "" + } + return profile.AvatarURL(pds, did, b.Avatar.CID()) +} diff --git a/features/connect/pages/connect.templ b/features/connect/pages/connect.templ new file mode 100644 index 0000000..7fe9487 --- /dev/null +++ b/features/connect/pages/connect.templ @@ -0,0 +1,150 @@ +package pages + +import "atmoquest/features/common/layouts" + +// ConnectView is the data the Connect handler renders. +type ConnectView struct { + TargetDID string + TargetDisplayName string + TargetAvatarURL string + TargetBio string + // ViewerLoggedIn is true when the scanner has a valid session cookie. + ViewerLoggedIn bool + // SelfConnect is true when the viewer scanned their own QR. + SelfConnect bool + // TargetCurrentEvent is non-nil when the QR owner is currently checked + // into an ongoing event. When set (and the viewer is signed in) the + // confirm form renders an opt-in checkbox to also check the scanner + // into the same event. Per the spec, check-ins are always + // user-confirmed — never silent. + TargetCurrentEvent *ConnectEventChip +} + +// ConnectEventChip is the small projection of an event we render inline on +// the connect page. EventURI is what the form POST sends back so the +// handler can resolve it to a checkin record write. +type ConnectEventChip struct { + Name string + Location string + EventURI string +} + +templ Connect(v ConnectView) { + @layouts.Base("connect — atmo.quest", "Connect with another atmo.quest profile.") { +
+
+
+
+
+
~/atmoquest — connect
+
+
+
+ you{ "@" }atmoquest:~$ + connect --with { shortDID(v.TargetDID) } +
+ +
+
+ if v.TargetAvatarURL != "" { + { + } else { + + } +
+ if v.TargetDisplayName != "" { +

{ v.TargetDisplayName }

+ } else { +

(no display name)

+ } +

{ v.TargetDID }

+ if v.TargetBio != "" { +

{ v.TargetBio }

+ } + + if v.SelfConnect { + + + } else if v.ViewerLoggedIn { +
+ if v.TargetCurrentEvent != nil { + + // Pass the event URI through so the handler + // doesn't need to re-derive it (and can't be + // tricked into checking into a stale one if + // the target moves between renders). + + } +
+ + cancel +
+
+

+ this writes a quest.atmo.connection record to your PDS. + a matching record is queued for theirs and flushed the next time they log in. +

+ } else { +
+ heads up: + you're not signed in. we've stashed this connection in your browser — + it'll be created automatically right after you sign in. +
+ + } +
+
+
+
● writes to quest.atmo.connection
+
v0.1
+
+
+ if !v.ViewerLoggedIn && !v.SelfConnect { + // The presence of [data-queue-did] is the signal to profile.js. + // It reads the attribute value, calls queueConnection(), and the + // next time the user lands on /profile (post-login) the + // localStorage queue gets POSTed to /connect/flush-local. + + + } + } +} + +// shortDID renders a short version of a DID for the prompt line. +// did:plc:abcdefghij1234567890 -> did:plc:abcde… +func shortDID(did string) string { + if len(did) <= 18 { + return did + } + return did[:18] + "…" +} + +// avatarAlt produces an accessible alt text for the avatar image. +func avatarAlt(displayName string) string { + if displayName == "" { + return "profile picture" + } + return displayName + "'s profile picture" +} diff --git a/features/connect/pages/connect_templ.go b/features/connect/pages/connect_templ.go new file mode 100644 index 0000000..218f2d8 --- /dev/null +++ b/features/connect/pages/connect_templ.go @@ -0,0 +1,342 @@ +// Code generated by templ - DO NOT EDIT. + +// templ: version: v0.3.1020 +package pages + +//lint:file-ignore SA4006 This context is only used if a nested component is present. + +import "github.com/a-h/templ" +import templruntime "github.com/a-h/templ/runtime" + +import "atmoquest/features/common/layouts" + +// ConnectView is the data the Connect handler renders. +type ConnectView struct { + TargetDID string + TargetDisplayName string + TargetAvatarURL string + TargetBio string + // ViewerLoggedIn is true when the scanner has a valid session cookie. + ViewerLoggedIn bool + // SelfConnect is true when the viewer scanned their own QR. + SelfConnect bool + // TargetCurrentEvent is non-nil when the QR owner is currently checked + // into an ongoing event. When set (and the viewer is signed in) the + // confirm form renders an opt-in checkbox to also check the scanner + // into the same event. Per the spec, check-ins are always + // user-confirmed — never silent. + TargetCurrentEvent *ConnectEventChip +} + +// ConnectEventChip is the small projection of an event we render inline on +// the connect page. EventURI is what the form POST sends back so the +// handler can resolve it to a checkin record write. +type ConnectEventChip struct { + Name string + Location string + EventURI string +} + +func Connect(v ConnectView) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var1 := templ.GetChildren(ctx) + if templ_7745c5c3_Var1 == nil { + templ_7745c5c3_Var1 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Var2 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "
~/atmoquest — connect
you") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var3 string + templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs("@") + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/connect/pages/connect.templ`, Line: 43, Col: 56} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "atmoquest:~$ connect --with ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var4 string + templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(shortDID(v.TargetDID)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/connect/pages/connect.templ`, Line: 44, Col: 61} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if v.TargetAvatarURL != "" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "\"")") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "
?
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if v.TargetDisplayName != "" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var7 string + templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(v.TargetDisplayName) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/connect/pages/connect.templ`, Line: 56, Col: 52} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "

(no display name)

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var8 string + templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(v.TargetDID) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/connect/pages/connect.templ`, Line: 60, Col: 67} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if v.TargetBio != "" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var9 string + templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(v.TargetBio) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/connect/pages/connect.templ`, Line: 62, Col: 42} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + if v.SelfConnect { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "
heads up: that's your own QR code — you can't connect to yourself.
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else if v.ViewerLoggedIn { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if v.TargetCurrentEvent != nil { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, " ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "
cancel

this writes a quest.atmo.connection record to your PDS. a matching record is queued for theirs and flushed the next time they log in.

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "
heads up: you're not signed in. we've stashed this connection in your browser — it'll be created automatically right after you sign in.
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "
● writes to quest.atmo.connection
v0.1
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if !v.ViewerLoggedIn && !v.SelfConnect { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, " ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + return nil + }) + templ_7745c5c3_Err = layouts.Base("connect — atmo.quest", "Connect with another atmo.quest profile.").Render(templ.WithChildren(ctx, templ_7745c5c3_Var2), templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +// shortDID renders a short version of a DID for the prompt line. +// did:plc:abcdefghij1234567890 -> did:plc:abcde… +func shortDID(did string) string { + if len(did) <= 18 { + return did + } + return did[:18] + "…" +} + +// avatarAlt produces an accessible alt text for the avatar image. +func avatarAlt(displayName string) string { + if displayName == "" { + return "profile picture" + } + return displayName + "'s profile picture" +} + +var _ = templruntime.GeneratedTemplate diff --git a/features/connect/routes.go b/features/connect/routes.go new file mode 100644 index 0000000..289ed93 --- /dev/null +++ b/features/connect/routes.go @@ -0,0 +1,25 @@ +package connect + +import ( + "database/sql" + + "github.com/go-chi/chi/v5" + + "atmoquest/features/auth" + "atmoquest/internal/connection" +) + +// SetupRoutes wires the connect feature's HTTP routes. +// +// - GET /profile/qr.svg — SVG QR for the signed-in user's connect URL +// - GET /c/{did} — landing page for a QR scan +// - POST /c/{did}/confirm — write the connection record(s) +// - POST /connect/flush-local — drain localStorage queue post-login +func SetupRoutes(router chi.Router, conn *sql.DB, authH *auth.Handlers, queue *connection.Queue) { + h := NewHandlers(conn, authH, queue) + + router.Get("/profile/qr.svg", h.ProfileQR) + router.Get("/c/{did}", h.Connect) + router.Post("/c/{did}/confirm", h.ConnectConfirm) + router.Post("/connect/flush-local", h.ConnectFlushLocal) +} diff --git a/features/event/handlers.go b/features/event/handlers.go new file mode 100644 index 0000000..ba2a8aa --- /dev/null +++ b/features/event/handlers.go @@ -0,0 +1,172 @@ +// Package event implements the public scan-to-checkin flow: +// +// - /e/{token} — landing page rendered for the scanner +// - /e/{token}/qr.svg — SVG QR encoding /e/ +// - /e/{token}/checkin — POST that writes the checkin record +// - /event/flush-local — drain localStorage-stashed tokens post-login +// +// Event creation + the admin event list live in features/admin (step 6). +package event + +import ( + "database/sql" + "encoding/json" + "log/slog" + "net/http" + "strings" + "time" + + "github.com/go-chi/chi/v5" + + "atmoquest/config" + "atmoquest/features/auth" + "atmoquest/features/event/pages" + "atmoquest/internal/checkin" + "atmoquest/internal/event" + "atmoquest/internal/qrcode" +) + +// Handlers holds the dependencies the event feature needs. +type Handlers struct { + DB *sql.DB + Auth *auth.Handlers +} + +// NewHandlers wires the event feature against the shared auth handlers and DB. +func NewHandlers(conn *sql.DB, authH *auth.Handlers) *Handlers { + return &Handlers{DB: conn, Auth: authH} +} + +// EventQR serves an SVG QR code for an event's public scan URL. Anyone can +// fetch it (printed posters, organizer slides, etc.). +func (h *Handlers) EventQR(w http.ResponseWriter, r *http.Request) { + token := chi.URLParam(r, "token") + if _, err := event.LookupByQRToken(r.Context(), h.DB, token); err != nil { + http.NotFound(w, r) + return + } + target := strings.TrimRight(config.Global.PublicURL, "/") + "/e/" + token + svg, err := qrcode.EncodeSVG(target, qrcode.DefaultOptions()) + if err != nil { + slog.Error("event qr encode", "token", token, "err", err) + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "image/svg+xml") + w.Header().Set("Cache-Control", "public, max-age=300") + _, _ = w.Write(svg) +} + +// EventScan handles GET /e/{token}. +// +// - Logged-out: render a landing page that stashes the token in +// localStorage and prompts sign-in. After login, the flush-local script +// POSTs the queued tokens back to /event/flush-local. +// - Logged-in: render a confirm page with "check me in" CTA. Posting +// /e/{token}/checkin runs checkin.Put. +func (h *Handlers) EventScan(w http.ResponseWriter, r *http.Request) { + token := chi.URLParam(r, "token") + ev, err := event.LookupByQRToken(r.Context(), h.DB, token) + if err != nil { + http.NotFound(w, r) + return + } + + didStr, sid := h.Auth.Sessions.Get(r) + view := pages.EventScanView{ + Token: token, + EventName: ev.Name, + EventLocation: ev.Location, + EventStartTime: ev.StartTime.Format("Mon Jan 2 · 3:04 PM MST"), + EventEndTime: ev.EndTime.Format("Mon Jan 2 · 3:04 PM MST"), + IsOngoing: ev.IsOngoing(time.Now()), + ViewerLoggedIn: didStr != "" && sid != "", + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + if err := pages.EventScan(view).Render(r.Context(), w); err != nil { + slog.Error("render event scan", "err", err) + } +} + +// EventScanCheckin handles POST /e/{token}/checkin from a logged-in visitor +// who confirmed the check-in CTA. +func (h *Handlers) EventScanCheckin(w http.ResponseWriter, r *http.Request) { + _, viewerSess, ok := h.Auth.RequireSession(w, r) + if !ok { + return + } + token := chi.URLParam(r, "token") + ev, err := event.LookupByQRToken(r.Context(), h.DB, token) + if err != nil { + http.NotFound(w, r) + return + } + if _, err := checkin.Put(r.Context(), viewerSess, h.DB, ev.URI, time.Time{}); err != nil { + slog.Warn("event scan checkin", "event_uri", ev.URI, "err", err) + http.Error(w, "failed to check in: "+err.Error(), http.StatusBadGateway) + return + } + http.Redirect(w, r, "/?checkedin=1", http.StatusSeeOther) +} + +// EventFlushLocal accepts a POST with a JSON body listing event QR tokens +// that were stashed in localStorage while the user was unauthenticated. For +// each known token we write a check-in record. +// +// Body: { "tokens": ["abc…", "def…"] } +// Response: { "written": N, "skipped": N, "errors": [...] } +// +// Unknown / malformed tokens are silently dropped so a stale queue doesn't +// bounce the client between reloads. +func (h *Handlers) EventFlushLocal(w http.ResponseWriter, r *http.Request) { + _, viewerSess, ok := h.Auth.RequireSession(w, r) + if !ok { + return + } + var body struct { + Tokens []string `json:"tokens"` + } + if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 16*1024)).Decode(&body); err != nil { + http.Error(w, "invalid JSON", http.StatusBadRequest) + return + } + + written, skipped := 0, 0 + errs := make([]string, 0) + for _, t := range body.Tokens { + t = strings.TrimSpace(t) + if t == "" { + continue + } + ev, err := event.LookupByQRToken(r.Context(), h.DB, t) + if err != nil { + // Unknown token — drop silently. Don't append an error so the + // client treats it as a clean flush and clears its queue. + skipped++ + continue + } + if _, err := checkin.Put(r.Context(), viewerSess, h.DB, ev.URI, time.Time{}); err != nil { + slog.Warn("event flush-local: checkin", "event_uri", ev.URI, "err", err) + skipped++ + errs = append(errs, "checkin failed for "+truncate(t, 20)) + continue + } + written++ + } + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "written": written, + "skipped": skipped, + "errors": errs, + }) +} + +// truncate clips s to at most n runes, suffixing with an ellipsis if cut. +func truncate(s string, n int) string { + r := []rune(s) + if len(r) <= n { + return s + } + return string(r[:n]) + "…" +} diff --git a/features/event/pages/event.templ b/features/event/pages/event.templ new file mode 100644 index 0000000..4c4e877 --- /dev/null +++ b/features/event/pages/event.templ @@ -0,0 +1,80 @@ +package pages + +import "atmoquest/features/common/layouts" + +// EventScanView feeds the /e/{token} landing page. +type EventScanView struct { + Token string + EventName string + EventLocation string + EventStartTime string + EventEndTime string + IsOngoing bool + ViewerLoggedIn bool +} + +templ EventScan(v EventScanView) { + @layouts.Base("event — atmo.quest", "Check in to an atmo.quest event.") { +
+
+
+
+
+
~/atmo.quest — event
+
+
+
+ you{ "@" }atmo.quest:~$ + checkin --event { v.Token } +
+ +
+

{ v.EventName }

+ if v.EventLocation != "" { +

📍 { v.EventLocation }

+ } +

{ v.EventStartTime }  →  { v.EventEndTime }

+ + if !v.IsOngoing { +
+ heads up: + this event isn't currently ongoing. you can still check in once it starts. +
+ } + + if v.ViewerLoggedIn { +
+
+ + cancel +
+
+

+ this writes a quest.atmo.checkin record to your PDS. +

+ } else { +
+ heads up: + you're not signed in. we've stashed this event in your browser — + you'll be checked in automatically right after you sign in. +
+ + } +
+
+
+
● event check-in
+
v0.2
+
+
+ if !v.ViewerLoggedIn { + // profile.js looks for [data-queue-event] and stashes the + // token in localStorage for post-login flush. + + } + + } +} diff --git a/features/event/pages/event_templ.go b/features/event/pages/event_templ.go new file mode 100644 index 0000000..8bf82aa --- /dev/null +++ b/features/event/pages/event_templ.go @@ -0,0 +1,216 @@ +// Code generated by templ - DO NOT EDIT. + +// templ: version: v0.3.1020 +package pages + +//lint:file-ignore SA4006 This context is only used if a nested component is present. + +import "github.com/a-h/templ" +import templruntime "github.com/a-h/templ/runtime" + +import "atmoquest/features/common/layouts" + +// EventScanView feeds the /e/{token} landing page. +type EventScanView struct { + Token string + EventName string + EventLocation string + EventStartTime string + EventEndTime string + IsOngoing bool + ViewerLoggedIn bool +} + +func EventScan(v EventScanView) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var1 := templ.GetChildren(ctx) + if templ_7745c5c3_Var1 == nil { + templ_7745c5c3_Var1 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Var2 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "
~/atmo.quest — event
you") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var3 string + templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs("@") + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/event/pages/event.templ`, Line: 27, Col: 56} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "atmo.quest:~$ checkin --event ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var4 string + templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(v.Token) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/event/pages/event.templ`, Line: 28, Col: 48} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var5 string + templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(v.EventName) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/event/pages/event.templ`, Line: 32, Col: 41} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if v.EventLocation != "" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "

📍 ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var6 string + templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(v.EventLocation) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/event/pages/event.templ`, Line: 34, Col: 50} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var7 string + templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(v.EventStartTime) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/event/pages/event.templ`, Line: 36, Col: 45} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "  →  ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var8 string + templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(v.EventEndTime) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/event/pages/event.templ`, Line: 36, Col: 80} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if !v.IsOngoing { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "
heads up: this event isn't currently ongoing. you can still check in once it starts.
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + if v.ViewerLoggedIn { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "
cancel

this writes a quest.atmo.checkin record to your PDS.

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "
heads up: you're not signed in. we've stashed this event in your browser — you'll be checked in automatically right after you sign in.
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "
● event check-in
v0.2
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if !v.ViewerLoggedIn { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, " ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, " ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) + templ_7745c5c3_Err = layouts.Base("event — atmo.quest", "Check in to an atmo.quest event.").Render(templ.WithChildren(ctx, templ_7745c5c3_Var2), templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +var _ = templruntime.GeneratedTemplate diff --git a/features/event/routes.go b/features/event/routes.go new file mode 100644 index 0000000..1aefeb6 --- /dev/null +++ b/features/event/routes.go @@ -0,0 +1,26 @@ +package event + +import ( + "database/sql" + + "github.com/go-chi/chi/v5" + + "atmoquest/features/auth" +) + +// SetupRoutes wires the event feature's public HTTP routes. +// +// - GET /e/{token} — landing page for a QR scan +// - GET /e/{token}/qr.svg — SVG QR for the event scan URL +// - POST /e/{token}/checkin — write a quest.atmo.checkin record +// - POST /event/flush-local — drain localStorage queue post-login +// +// Event creation + admin event list live in features/admin (step 6). +func SetupRoutes(router chi.Router, conn *sql.DB, authH *auth.Handlers) { + h := NewHandlers(conn, authH) + + router.Get("/e/{token}", h.EventScan) + router.Get("/e/{token}/qr.svg", h.EventQR) + router.Post("/e/{token}/checkin", h.EventScanCheckin) + router.Post("/event/flush-local", h.EventFlushLocal) +} diff --git a/features/index/handlers.go b/features/index/handlers.go index f3e49db..5e1b6c5 100644 --- a/features/index/handlers.go +++ b/features/index/handlers.go @@ -1,15 +1,21 @@ package index import ( + "database/sql" "net/http" "atmoquest/features/index/pages" ) -type Handlers struct{} +// Handlers holds dependencies the index feature needs. Right now that's +// just a DB handle; threading it through unblocks the upcoming +// "currently checked in" home-page query without churning signatures later. +type Handlers struct { + DB *sql.DB +} -func NewHandlers() *Handlers { - return &Handlers{} +func NewHandlers(conn *sql.DB) *Handlers { + return &Handlers{DB: conn} } func (h *Handlers) IndexPage(w http.ResponseWriter, r *http.Request) { diff --git a/features/index/pages/index.templ b/features/index/pages/index.templ index 0cf1b6a..88c4764 100644 --- a/features/index/pages/index.templ +++ b/features/index/pages/index.templ @@ -1,18 +1,100 @@ package pages import ( - "atmoquest/features/common/components" "atmoquest/features/common/layouts" ) +// IndexPage renders the guest (logged-out) marketing landing. Once we wire +// OAuth + session into the new repo this will branch on session state and +// render an authed dashboard instead. templ IndexPage() { - @layouts.Base("Atmoquest", "Onboarding for the ATmosphere") { -
-
-

Atmoquest

-

Welcome to the ATmosphere.

-
- @components.Navigation() -
+ @layouts.Base("atmo.quest — conference companion on ATProto", "Scan a QR, write a note, leave with a real follow-up list. Your data goes to your repo, not ours.") { +
+
+
+
+
+
~/atmo.quest — zsh
+
+
+
+ you{ "@" }cascadiajs:~$ + whoami +
+
+ a person at a conference. you just met someone interesting and now their handle is gone. +
+ +
+ init: v0.1 · CascadiaJS 2026 +

atmo.quest 

+

+ An event companion { "for" } the open social web. Scan a QR, write a note, leave with a real follow-up list. Your data goes to your repo, not ours. +

+ +
+
+
+
● ATProto · OAuth ready · guest mode enabled
+
--:--:--
+
+
+ +
+
+
+
+
+
quest_log.md
+
+
+
+

Side Quests { "// available" }

+ +
+
◆
+
+
Make your first connection
+
Scan someone's QR code. A quest.atmo.connection record gets written to your PDS. They get one too. That's the protocol bit.
+
+1 badge first connect
+
+
+ +
+
◆
+
+
Check in to CascadiaJS
+
Drops a checkin record dated June 1–2, 2026. You'll find it in your repo forever.
+
+1 badge attendee
+
+
+ +
+
◆
+
+
Take a note while it's fresh
+
Private notes, attached to each connection. Read them Monday morning when you forget who Sarah was.
+
unlocks with first connection
+
+
+ +
+
◆
+
+
Help unlock the conference rewards
+
As more people join, new things appear — leaderboard, interest matching, end-of-conf stats. Earned together.
+
group quest see /unlocks
+
+
+
+
+
+ +
+ built on atproto · open source · made by PDX ATProto +
} } diff --git a/features/index/pages/index_templ.go b/features/index/pages/index_templ.go index 0b4ff5d..be2f3fc 100644 --- a/features/index/pages/index_templ.go +++ b/features/index/pages/index_templ.go @@ -9,10 +9,12 @@ import "github.com/a-h/templ" import templruntime "github.com/a-h/templ/runtime" import ( - "atmoquest/features/common/components" "atmoquest/features/common/layouts" ) +// IndexPage renders the guest (logged-out) marketing landing. Once we wire +// OAuth + session into the new repo this will branch on session state and +// render an authed dashboard instead. func IndexPage() templ.Component { return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context @@ -46,21 +48,52 @@ func IndexPage() templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "

Atmoquest

Welcome to the ATmosphere.

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "
~/atmo.quest — zsh
you") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = components.Navigation().Render(ctx, templ_7745c5c3_Buffer) + var templ_7745c5c3_Var3 string + templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs("@") + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/index/pages/index.templ`, Line: 21, Col: 56} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "cascadiajs:~$ whoami
a person at a conference. you just met someone interesting and now their handle is gone.
init: v0.1 · CascadiaJS 2026

atmo.quest 

An event companion ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var4 string + templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs("for") + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/index/pages/index.templ`, Line: 32, Col: 56} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, " the open social web. Scan a QR, write a note, leave with a real follow-up list. Your data goes to your repo, not ours.

● ATProto · OAuth ready · guest mode enabled
--:--:--
quest_log.md

Side Quests ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var5 string + templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs("// available") + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/index/pages/index.templ`, Line: 55, Col: 57} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "
◆
Make your first connection
Scan someone's QR code. A quest.atmo.connection record gets written to your PDS. They get one too. That's the protocol bit.
+1 badge first connect
◆
Check in to CascadiaJS
Drops a checkin record dated June 1–2, 2026. You'll find it in your repo forever.
+1 badge attendee
◆
Take a note while it's fresh
Private notes, attached to each connection. Read them Monday morning when you forget who Sarah was.
unlocks with first connection
◆
Help unlock the conference rewards
As more people join, new things appear — leaderboard, interest matching, end-of-conf stats. Earned together.
group quest see /unlocks
built on atproto · open source · made by PDX ATProto
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } return nil }) - templ_7745c5c3_Err = layouts.Base("Atmoquest", "Onboarding for the ATmosphere").Render(templ.WithChildren(ctx, templ_7745c5c3_Var2), templ_7745c5c3_Buffer) + templ_7745c5c3_Err = layouts.Base("atmo.quest — conference companion on ATProto", "Scan a QR, write a note, leave with a real follow-up list. Your data goes to your repo, not ours.").Render(templ.WithChildren(ctx, templ_7745c5c3_Var2), templ_7745c5c3_Buffer) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } diff --git a/features/index/routes.go b/features/index/routes.go index 74654c4..c1e42ef 100644 --- a/features/index/routes.go +++ b/features/index/routes.go @@ -1,9 +1,16 @@ package index -import "github.com/go-chi/chi/v5" +import ( + "database/sql" -func SetupRoutes(router chi.Router) { - handlers := NewHandlers() + "github.com/go-chi/chi/v5" +) + +// SetupRoutes wires the index feature's routes. The DB handle is held by the +// handler struct so future endpoints (e.g. "currently checked into" lookups) +// can query it without changing the registration site. +func SetupRoutes(router chi.Router, conn *sql.DB) { + handlers := NewHandlers(conn) router.Get("/", handlers.IndexPage) } diff --git a/features/profile/handlers.go b/features/profile/handlers.go new file mode 100644 index 0000000..f360266 --- /dev/null +++ b/features/profile/handlers.go @@ -0,0 +1,250 @@ +// Package profile renders the signed-in user's profile and edit form, and +// writes the quest.atmo.profile record on save. +// +// The viewer's identity comes from features/auth (resumeSession). Profile +// reads are public, unauthenticated atclient calls against the user's PDS. +// Writes go through the user's OAuth session. +package profile + +import ( + "database/sql" + "errors" + "log/slog" + "net/http" + "strings" + + "atmoquest/features/auth" + "atmoquest/features/profile/pages" + "atmoquest/internal/profile" +) + +// Handlers holds the dependencies the profile feature needs. +type Handlers struct { + DB *sql.DB + Auth *auth.Handlers +} + +// NewHandlers wires the profile feature against the shared auth handlers +// (used for session resume) and the app DB. +func NewHandlers(conn *sql.DB, authH *auth.Handlers) *Handlers { + return &Handlers{DB: conn, Auth: authH} +} + +// Profile renders the signed-in user's profile. Pulls display name, bio, and +// avatar from app.bsky.actor.profile, then overlays atmo.quest-specific +// fields (bio override, interests, links) from quest.atmo.profile. +func (h *Handlers) Profile(w http.ResponseWriter, r *http.Request) { + did, sess, ok := h.Auth.RequireSession(w, r) + if !ok { + return + } + pds := sess.Data.HostURL + + bsky, err := profile.FetchBluesky(r.Context(), pds, did) + if err != nil && !errors.Is(err, profile.ErrNotFound) { + slog.Warn("profile: fetch bsky", "did", did.String(), "err", err) + bsky = nil + } + quest, err := profile.FetchQuest(r.Context(), pds, did) + if err != nil && !errors.Is(err, profile.ErrNotFound) { + slog.Warn("profile: fetch quest", "did", did.String(), "err", err) + quest = nil + } + + view := buildProfileView(did, pds, sess.Data.Scopes, bsky, quest) + view.QRURL = "/profile/qr.svg" + view.ConnectedDID = strings.TrimSpace(r.URL.Query().Get("connected")) + + w.Header().Set("Content-Type", "text/html; charset=utf-8") + if err := pages.Profile(view).Render(r.Context(), w); err != nil { + slog.Error("render profile", "err", err) + } +} + +// ProfileEdit renders the form to edit the quest.atmo.profile record. +func (h *Handlers) ProfileEdit(w http.ResponseWriter, r *http.Request) { + did, sess, ok := h.Auth.RequireSession(w, r) + if !ok { + return + } + pds := sess.Data.HostURL + + bsky, err := profile.FetchBluesky(r.Context(), pds, did) + if err != nil && !errors.Is(err, profile.ErrNotFound) { + slog.Warn("profile edit: fetch bsky", "did", did.String(), "err", err) + bsky = nil + } + quest, err := profile.FetchQuest(r.Context(), pds, did) + if err != nil && !errors.Is(err, profile.ErrNotFound) { + slog.Warn("profile edit: fetch quest", "did", did.String(), "err", err) + quest = nil + } + + view := buildProfileEditView(did, pds, bsky, quest, "") + w.Header().Set("Content-Type", "text/html; charset=utf-8") + if err := pages.ProfileEdit(view).Render(r.Context(), w); err != nil { + slog.Error("render profile edit", "err", err) + } +} + +// ProfileSave handles POST /profile/edit. Parses the form, validates, +// writes the quest.atmo.profile record, redirects back to /profile on +// success — or re-renders the form with an error. +func (h *Handlers) ProfileSave(w http.ResponseWriter, r *http.Request) { + did, sess, ok := h.Auth.RequireSession(w, r) + if !ok { + return + } + if err := r.ParseForm(); err != nil { + http.Error(w, "invalid form", http.StatusBadRequest) + return + } + + rec, formErr := parseProfileForm(r) + if formErr != "" { + bsky, _ := profile.FetchBluesky(r.Context(), sess.Data.HostURL, did) + view := buildProfileEditViewFromRec(did, sess.Data.HostURL, bsky, rec, formErr) + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.WriteHeader(http.StatusBadRequest) + _ = pages.ProfileEdit(view).Render(r.Context(), w) + return + } + + if _, err := profile.PutQuest(r.Context(), sess, did, rec); err != nil { + slog.Warn("profile save", "did", did.String(), "err", err) + bsky, _ := profile.FetchBluesky(r.Context(), sess.Data.HostURL, did) + view := buildProfileEditViewFromRec(did, sess.Data.HostURL, bsky, rec, "couldn't save to your PDS — please try again") + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.WriteHeader(http.StatusBadGateway) + _ = pages.ProfileEdit(view).Render(r.Context(), w) + return + } + + slog.Info("profile saved", "did", did.String()) + http.Redirect(w, r, "/profile", http.StatusSeeOther) +} + +// parseProfileForm extracts a QuestRecord from a submitted edit form. Returns +// a human-readable error string on validation failure, "" on success. +// +// Form fields: +// - bio (single string, may be empty) +// - interests (single string, comma- or newline-separated) +// - link_label_0..link_label_4 + link_url_0..link_url_4 (paired) +func parseProfileForm(r *http.Request) (profile.QuestRecord, string) { + rec := profile.QuestRecord{} + + rec.Bio = strings.TrimSpace(r.FormValue("bio")) + if n := len([]rune(rec.Bio)); n > profile.MaxBioRunes { + return rec, "bio is too long (max " + itoa(profile.MaxBioRunes) + " characters)" + } + + rec.Interests = parseInterests(r.FormValue("interests")) + if len(rec.Interests) > profile.MaxInterests { + return rec, "too many interests (max " + itoa(profile.MaxInterests) + ")" + } + + rec.Location = strings.TrimSpace(r.FormValue("location")) + if n := len([]rune(rec.Location)); n > 120 { + return rec, "location is too long (max 120 characters)" + } + rec.WorksAt = strings.TrimSpace(r.FormValue("works_at")) + if n := len([]rune(rec.WorksAt)); n > 120 { + return rec, "\"works at\" is too long (max 120 characters)" + } + rec.ContactMethod = strings.TrimSpace(r.FormValue("contact_method")) + if n := len([]rune(rec.ContactMethod)); n > 200 { + return rec, "contact info is too long (max 200 characters)" + } + + links := make([]profile.Link, 0, profile.MaxLinks) + for i := 0; i < profile.MaxLinks; i++ { + label := strings.TrimSpace(r.FormValue("link_label_" + itoa(i))) + raw := strings.TrimSpace(r.FormValue("link_url_" + itoa(i))) + if label == "" && raw == "" { + continue + } + if label == "" { + return rec, "link #" + itoa(i+1) + " needs a label" + } + if raw == "" { + return rec, "link #" + itoa(i+1) + " needs a URL" + } + u, err := validateLinkURL(raw) + if err != nil { + return rec, "link #" + itoa(i+1) + ": " + err.Error() + } + links = append(links, profile.Link{Label: label, URL: u}) + } + rec.Links = links + + // Status toggles. The form submits "1" when checked and omits the field + // when unchecked — we model "unset" as nil so a toggle-off cleanly + // removes the pill on next render. + if formCheckboxOn(r, "hiring") { + t := true + rec.Hiring = &t + } else { + f := false + rec.Hiring = &f + } + if formCheckboxOn(r, "looking") { + t := true + rec.Looking = &t + } else { + f := false + rec.Looking = &f + } + + return rec, "" +} + +func formCheckboxOn(r *http.Request, name string) bool { + v := strings.TrimSpace(r.FormValue(name)) + switch strings.ToLower(v) { + case "1", "on", "true", "yes": + return true + default: + return false + } +} + +// parseInterests splits the textarea on commas and newlines, trims each, +// dedupes (case-insensitive) preserving first-seen order, and drops empties. +func parseInterests(raw string) []string { + if strings.TrimSpace(raw) == "" { + return nil + } + parts := strings.FieldsFunc(raw, func(r rune) bool { + return r == ',' || r == '\n' || r == '\r' + }) + seen := make(map[string]struct{}, len(parts)) + out := make([]string, 0, len(parts)) + for _, p := range parts { + p = strings.TrimSpace(p) + if p == "" { + continue + } + key := strings.ToLower(p) + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + if len([]rune(p)) > 64 { + runes := []rune(p) + p = string(runes[:64]) + } + out = append(out, p) + } + return out +} + +// validateLinkURL accepts http(s) URLs only. Returns the normalized URL or an +// error suitable for surfacing in the form. +func validateLinkURL(raw string) (string, error) { + u, err := parseHTTPURL(raw) + if err != nil { + return "", err + } + return u.String(), nil +} diff --git a/features/profile/pages/profile.templ b/features/profile/pages/profile.templ new file mode 100644 index 0000000..3803608 --- /dev/null +++ b/features/profile/pages/profile.templ @@ -0,0 +1,221 @@ +package pages + +import "atmoquest/features/common/layouts" + +// ProfileLink mirrors profile.Link for the view layer. +type ProfileLink struct { + Label string + URL string +} + +// ProfileView is the data the profile handler passes into the template. +type ProfileView struct { + DID string + PDSHost string + Scopes []string + DisplayName string + AvatarURL string + // Bio is the *effective* bio to show — atmo.quest override if set, else + // the Bluesky description, else empty. + Bio string + // BioFromBsky is true when Bio came from the Bluesky description (i.e. + // the user hasn't set an atmo.quest override). + BioFromBsky bool + Interests []string + Links []ProfileLink + // Optional atmo.quest profile fields. Rendered as detail rows only when + // non-empty — missing fields are hidden, not stubbed. + Location string + WorksAt string + ContactMethod string + // Hiring / Looking surface as pills above the bio when true. nil/false + // means "don't show". Modeled as plain bools on the view because the + // "unset" semantics live one layer up (in the record). + Hiring bool + Looking bool + // QRURL is the path that returns the SVG QR code for this profile. + QRURL string + // ConnectedDID, if non-empty, triggers a "✓ connected with …" banner — + // set from the ?connected= query string after a successful scan + // confirmation. + ConnectedDID string +} + +templ Profile(v ProfileView) { + @layouts.Base("profile — atmo.quest", "Your atmo.quest profile — display name, bio, interests, links.") { +
+
+
+
+
+
~/atmo.quest — profile
+
+
+
+ you{ "@" }atmo.quest:~$ + whoami --verbose +
+ +
+ if v.ConnectedDID != "" { +
+ ✓ + if v.ConnectedDID == "self" { + you can't connect with yourself. + } else { + connected · { v.ConnectedDID } + } +
+ } +
+ +

▸ tap your photo to share a QR · tap again to flip back

+
+ + if v.DisplayName != "" { +

{ v.DisplayName }

+ } else { +

(no display name)

+ } + + if v.Hiring || v.Looking { +
+ if v.Hiring { + + + hiring + + } + if v.Looking { + + + looking for work + + } +
+ } + + if v.Bio != "" || v.WorksAt != "" || v.Location != "" || v.ContactMethod != "" { +
+ if v.Bio != "" { +
+
bio
+
{ v.Bio }
+
+ } + if v.WorksAt != "" { +
+
works at
+
{ v.WorksAt }
+
+ } + if v.Location != "" { +
+
based in
+
{ v.Location }
+
+ } + if v.ContactMethod != "" { +
+
contact
+
{ v.ContactMethod }
+
+ } +
+ if v.Bio != "" && v.BioFromBsky { +

▸ bio from bluesky · override on atmo.quest

+ } + } else { +

+ no bio yet — add one +

+ } + + if len(v.Interests) > 0 { +
+ +
+ for _, t := range v.Interests { + { t } + } +
+
+ } + + if len(v.Links) > 0 { +
+ + +
+ } + + +
+ +
+ session details +
+
DID
+
{ v.DID }
+
PDS
+
{ v.PDSHost }
+
scopes
+
+ if len(v.Scopes) == 0 { + none + } else { + for _, s := range v.Scopes { + { s } + } + } +
+
+
+ +
+
+ +
+ ← home +
+
+
+
● session bound · DPoP · refresh auto
+
v0.1
+
+
+ + } +} + +// avatarAlt produces an accessible alt text for the avatar image. +func avatarAlt(displayName string) string { + if displayName == "" { + return "your profile picture" + } + return displayName + "'s profile picture" +} diff --git a/features/profile/pages/profile_edit.templ b/features/profile/pages/profile_edit.templ new file mode 100644 index 0000000..15cb6b0 --- /dev/null +++ b/features/profile/pages/profile_edit.templ @@ -0,0 +1,332 @@ +package pages + +import ( + "atmoquest/features/common/layouts" + "strconv" +) + +// ProfileEditView is the data the edit form template renders. Reuses +// ProfileLink from profile.templ. All Bluesky fields are read-only display +// hints; the only editable atmo.quest fields are Bio, Interests, Links. +type ProfileEditView struct { + DID string + DisplayName string + AvatarURL string + // BlueskyBio is shown as a "fallback" hint under the bio field when the + // user hasn't set their own override. + BlueskyBio string + Bio string + Interests []string + Links []ProfileLink + // Optional atmo.quest profile fields, free-text. + Location string + WorksAt string + ContactMethod string + // Hiring / Looking surface as toggle-style checkboxes in the form. The + // underlying record uses *bool so we can distinguish "unset" from + // "explicitly false", but the form only ever submits "true" or absent + // (standard checkbox semantics) — so a plain bool here is fine. + Hiring bool + Looking bool + // Error is rendered above the form when set (e.g. validation failure). + Error string +} + +// suggestedInterests is a small curated starter list that populates the +// for autocomplete. Folks can also type a brand-new one — the +// datalist is a suggestion surface, not a hard whitelist. +var suggestedInterests = []string{ + "javascript", "typescript", "go", "rust", "python", + "react", "vue", "svelte", + "web standards", "open source", "developer tools", + "design systems", "accessibility", "type design", + "atproto", "bluesky", "decentralization", + "coffee", "tea", "cocktails", + "hiking", "cycling", "running", "climbing", + "reading", "writing", "podcasts", + "photography", "music", "vinyl", "live music", + "board games", "video games", "ttrpgs", + "cats", "dogs", "houseplants", + "cooking", "baking", + "travel", "languages", + "hiring", "looking-for-work", "mentoring", +} + +templ ProfileEdit(v ProfileEditView) { + @layouts.Base("edit profile — atmo.quest", "Edit your atmo.quest profile.") { +
+
+
+
+
+
~/atmo.quest — profile/edit
+
+
+
+ you{ "@" }atmo.quest:~$ + profile --edit +
+ +
+
+ if v.AvatarURL != "" { + { + } else { + + } +
+ if v.DisplayName != "" { +

{ v.DisplayName }

+ } else { +

(no display name)

+ } +

+ display name and avatar are read from app.bsky.actor.profile — edit them on bluesky. +

+
+ + if v.Error != "" { + + } + +
+
+ +
+ +
+
+ if v.BlueskyBio != "" { + leave blank to fall back to your bluesky bio: +
+ { v.BlueskyBio } + } else { + tell people what you're about. up to 256 characters. + } +
+
+ +
+ +
+ +
+
where you spend your weekdays. up to 120 characters.
+
+ +
+ +
+ +
+
city, region, or vibe. up to 120 characters.
+
+ +
+ +
+ +
+
how people should reach out after a connect. up to 200 characters.
+
+ +
+ +
+
+ for _, t := range v.Interests { + + { t } + + + } +
+ + // The canonical, server-submitted value. JS keeps + // this in sync (comma-joined) so the existing + // parseInterests handler stays unchanged. + +
+ + for _, s := range suggestedInterests { + + } + +
+ pick from suggestions or add your own. up to 30, 64 characters each. +
+
+ +
+ status +
+ + +
+
+ shown as pills above your bio. toggle off to hide. +
+
+ + + +
+ + cancel +
+
+
+
+
● writes to quest.atmo.profile on your PDS
+
v0.1
+
+
+ + } +} + +// profileLinkRow renders a single label+URL row of the links editor. +templ profileLinkRow(idx int, l ProfileLink) { + +} + +// interestsAsText joins the interest list back into the comma-separated form +// the textarea expects. +func interestsAsText(tags []string) string { + out := "" + for i, t := range tags { + if i > 0 { + out += ", " + } + out += t + } + return out +} + +// linkAt returns the link at index i, or a zero ProfileLink if out of bounds. +func linkAt(links []ProfileLink, i int) ProfileLink { + if i < 0 || i >= len(links) { + return ProfileLink{} + } + return links[i] +} + +// bioPlaceholder shows the user's Bluesky bio as a placeholder hint when they +// haven't typed an override yet. +func bioPlaceholder(blueskyBio string) string { + if blueskyBio == "" { + return "say something about yourself…" + } + return blueskyBio +} diff --git a/features/profile/pages/profile_edit_templ.go b/features/profile/pages/profile_edit_templ.go new file mode 100644 index 0000000..d246fc1 --- /dev/null +++ b/features/profile/pages/profile_edit_templ.go @@ -0,0 +1,574 @@ +// Code generated by templ - DO NOT EDIT. + +// templ: version: v0.3.1020 +package pages + +//lint:file-ignore SA4006 This context is only used if a nested component is present. + +import "github.com/a-h/templ" +import templruntime "github.com/a-h/templ/runtime" + +import ( + "atmoquest/features/common/layouts" + "strconv" +) + +// ProfileEditView is the data the edit form template renders. Reuses +// ProfileLink from profile.templ. All Bluesky fields are read-only display +// hints; the only editable atmo.quest fields are Bio, Interests, Links. +type ProfileEditView struct { + DID string + DisplayName string + AvatarURL string + // BlueskyBio is shown as a "fallback" hint under the bio field when the + // user hasn't set their own override. + BlueskyBio string + Bio string + Interests []string + Links []ProfileLink + // Optional atmo.quest profile fields, free-text. + Location string + WorksAt string + ContactMethod string + // Hiring / Looking surface as toggle-style checkboxes in the form. The + // underlying record uses *bool so we can distinguish "unset" from + // "explicitly false", but the form only ever submits "true" or absent + // (standard checkbox semantics) — so a plain bool here is fine. + Hiring bool + Looking bool + // Error is rendered above the form when set (e.g. validation failure). + Error string +} + +// suggestedInterests is a small curated starter list that populates the +// for autocomplete. Folks can also type a brand-new one — the +// datalist is a suggestion surface, not a hard whitelist. +var suggestedInterests = []string{ + "javascript", "typescript", "go", "rust", "python", + "react", "vue", "svelte", + "web standards", "open source", "developer tools", + "design systems", "accessibility", "type design", + "atproto", "bluesky", "decentralization", + "coffee", "tea", "cocktails", + "hiking", "cycling", "running", "climbing", + "reading", "writing", "podcasts", + "photography", "music", "vinyl", "live music", + "board games", "video games", "ttrpgs", + "cats", "dogs", "houseplants", + "cooking", "baking", + "travel", "languages", + "hiring", "looking-for-work", "mentoring", +} + +func ProfileEdit(v ProfileEditView) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var1 := templ.GetChildren(ctx) + if templ_7745c5c3_Var1 == nil { + templ_7745c5c3_Var1 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Var2 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "
~/atmo.quest — profile/edit
you") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var3 string + templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs("@") + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/profile/pages/profile_edit.templ`, Line: 66, Col: 56} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "atmo.quest:~$ profile --edit
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if v.AvatarURL != "" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "\"")") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "
?
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if v.DisplayName != "" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var6 string + templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(v.DisplayName) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/profile/pages/profile_edit.templ`, Line: 79, Col: 46} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "

(no display name)

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "

display name and avatar are read from app.bsky.actor.profile — edit them on bluesky.

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if v.Error != "" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "
error: ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var7 string + templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(v.Error) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/profile/pages/profile_edit.templ`, Line: 90, Col: 61} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if v.BlueskyBio != "" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "leave blank to fall back to your bluesky bio:
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var10 string + templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(v.BlueskyBio) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/profile/pages/profile_edit.templ`, Line: 111, Col: 42} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "tell people what you're about. up to 256 characters.") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "
where you spend your weekdays. up to 120 characters.
city, region, or vibe. up to 120 characters.
how people should reach out after a connect. up to 200 characters.
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + for _, t := range v.Interests { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var15 string + templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(t) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/profile/pages/profile_edit.templ`, Line: 177, Col: 42} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, " ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + for _, s := range suggestedInterests { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "
pick from suggestions or add your own. up to 30, 64 characters each.
status
shown as pills above your bio. toggle off to hide.
links (up to 5) ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + for i := 0; i < 5; i++ { + templ_7745c5c3_Err = profileLinkRow(i, linkAt(v.Links, i)).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "
pinned links shown on your profile. label + URL. http(s) only.
cancel
● writes to quest.atmo.profile on your PDS
v0.1
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) + templ_7745c5c3_Err = layouts.Base("edit profile — atmo.quest", "Edit your atmo.quest profile.").Render(templ.WithChildren(ctx, templ_7745c5c3_Var2), templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +// profileLinkRow renders a single label+URL row of the links editor. +func profileLinkRow(idx int, l ProfileLink) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var19 := templ.GetChildren(ctx) + if templ_7745c5c3_Var19 == nil { + templ_7745c5c3_Var19 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +// interestsAsText joins the interest list back into the comma-separated form +// the textarea expects. +func interestsAsText(tags []string) string { + out := "" + for i, t := range tags { + if i > 0 { + out += ", " + } + out += t + } + return out +} + +// linkAt returns the link at index i, or a zero ProfileLink if out of bounds. +func linkAt(links []ProfileLink, i int) ProfileLink { + if i < 0 || i >= len(links) { + return ProfileLink{} + } + return links[i] +} + +// bioPlaceholder shows the user's Bluesky bio as a placeholder hint when they +// haven't typed an override yet. +func bioPlaceholder(blueskyBio string) string { + if blueskyBio == "" { + return "say something about yourself…" + } + return blueskyBio +} + +var _ = templruntime.GeneratedTemplate diff --git a/features/profile/pages/profile_templ.go b/features/profile/pages/profile_templ.go new file mode 100644 index 0000000..93f1f8c --- /dev/null +++ b/features/profile/pages/profile_templ.go @@ -0,0 +1,496 @@ +// Code generated by templ - DO NOT EDIT. + +// templ: version: v0.3.1020 +package pages + +//lint:file-ignore SA4006 This context is only used if a nested component is present. + +import "github.com/a-h/templ" +import templruntime "github.com/a-h/templ/runtime" + +import "atmoquest/features/common/layouts" + +// ProfileLink mirrors profile.Link for the view layer. +type ProfileLink struct { + Label string + URL string +} + +// ProfileView is the data the profile handler passes into the template. +type ProfileView struct { + DID string + PDSHost string + Scopes []string + DisplayName string + AvatarURL string + // Bio is the *effective* bio to show — atmo.quest override if set, else + // the Bluesky description, else empty. + Bio string + // BioFromBsky is true when Bio came from the Bluesky description (i.e. + // the user hasn't set an atmo.quest override). + BioFromBsky bool + Interests []string + Links []ProfileLink + // Optional atmo.quest profile fields. Rendered as detail rows only when + // non-empty — missing fields are hidden, not stubbed. + Location string + WorksAt string + ContactMethod string + // Hiring / Looking surface as pills above the bio when true. nil/false + // means "don't show". Modeled as plain bools on the view because the + // "unset" semantics live one layer up (in the record). + Hiring bool + Looking bool + // QRURL is the path that returns the SVG QR code for this profile. + QRURL string + // ConnectedDID, if non-empty, triggers a "✓ connected with …" banner — + // set from the ?connected= query string after a successful scan + // confirmation. + ConnectedDID string +} + +func Profile(v ProfileView) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var1 := templ.GetChildren(ctx) + if templ_7745c5c3_Var1 == nil { + templ_7745c5c3_Var1 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Var2 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "
~/atmo.quest — profile
you") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var3 string + templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs("@") + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/profile/pages/profile.templ`, Line: 55, Col: 56} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "atmo.quest:~$ whoami --verbose
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if v.ConnectedDID != "" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "
✓ ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if v.ConnectedDID == "self" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "you can't connect with yourself.") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "connected · ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var4 string + templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(v.ConnectedDID) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/profile/pages/profile.templ`, Line: 66, Col: 63} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "

▸ tap your photo to share a QR · tap again to flip back

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if v.DisplayName != "" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var8 string + templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(v.DisplayName) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/profile/pages/profile.templ`, Line: 91, Col: 46} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "

(no display name)

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + if v.Hiring || v.Looking { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if v.Hiring { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "● hiring ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + if v.Looking { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "● looking for work") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + if v.Bio != "" || v.WorksAt != "" || v.Location != "" || v.ContactMethod != "" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if v.Bio != "" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "
bio
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var9 string + templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(v.Bio) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/profile/pages/profile.templ`, Line: 118, Col: 51} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + if v.WorksAt != "" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "
works at
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var10 string + templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(v.WorksAt) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/profile/pages/profile.templ`, Line: 124, Col: 42} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + if v.Location != "" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "
based in
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var11 string + templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(v.Location) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/profile/pages/profile.templ`, Line: 130, Col: 43} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + if v.ContactMethod != "" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "
contact
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var12 string + templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(v.ContactMethod) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/profile/pages/profile.templ`, Line: 136, Col: 48} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if v.Bio != "" && v.BioFromBsky { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "

▸ bio from bluesky · override on atmo.quest

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + } else { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "

no bio yet — add one

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + if len(v.Interests) > 0 { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + for _, t := range v.Interests { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var13 string + templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(t) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/profile/pages/profile.templ`, Line: 154, Col: 45} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + if len(v.Links) > 0 { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "
session details
DID
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var16 string + templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(v.DID) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/profile/pages/profile.templ`, Line: 183, Col: 37} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "
PDS
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var17 string + templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(v.PDSHost) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/profile/pages/profile.templ`, Line: 185, Col: 41} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "
scopes
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if len(v.Scopes) == 0 { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "none") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else { + for _, s := range v.Scopes { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var18 string + templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(s) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/profile/pages/profile.templ`, Line: 192, Col: 38} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "
← home
● session bound · DPoP · refresh auto
v0.1
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) + templ_7745c5c3_Err = layouts.Base("profile — atmo.quest", "Your atmo.quest profile — display name, bio, interests, links.").Render(templ.WithChildren(ctx, templ_7745c5c3_Var2), templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +// avatarAlt produces an accessible alt text for the avatar image. +func avatarAlt(displayName string) string { + if displayName == "" { + return "your profile picture" + } + return displayName + "'s profile picture" +} + +var _ = templruntime.GeneratedTemplate diff --git a/features/profile/routes.go b/features/profile/routes.go new file mode 100644 index 0000000..6a3a112 --- /dev/null +++ b/features/profile/routes.go @@ -0,0 +1,23 @@ +package profile + +import ( + "database/sql" + + "github.com/go-chi/chi/v5" + + "atmoquest/features/auth" +) + +// SetupRoutes wires the profile feature's HTTP routes. All three require a +// session; the handlers redirect to /signin on miss. +// +// - GET /profile — read-only profile view +// - GET /profile/edit — edit form +// - POST /profile/edit — save quest.atmo.profile to the user's PDS +func SetupRoutes(router chi.Router, conn *sql.DB, authH *auth.Handlers) { + h := NewHandlers(conn, authH) + + router.Get("/profile", h.Profile) + router.Get("/profile/edit", h.ProfileEdit) + router.Post("/profile/edit", h.ProfileSave) +} diff --git a/features/profile/view.go b/features/profile/view.go new file mode 100644 index 0000000..1836825 --- /dev/null +++ b/features/profile/view.go @@ -0,0 +1,142 @@ +package profile + +import ( + "errors" + "net/url" + "strconv" + + "github.com/bluesky-social/indigo/atproto/syntax" + + "atmoquest/features/profile/pages" + "atmoquest/internal/profile" +) + +// buildProfileView turns the fetched records into the view-model rendered by +// pages.Profile. Soft-fails on any nil input. +func buildProfileView(did syntax.DID, pds string, scopes []string, bsky *profile.BlueskyRecord, quest *profile.QuestRecord) pages.ProfileView { + view := pages.ProfileView{ + DID: did.String(), + PDSHost: pds, + Scopes: scopes, + DisplayName: blueskyDisplayName(bsky), + AvatarURL: blueskyAvatarURL(pds, did, bsky), + Bio: profile.EffectiveBio(quest, bsky), + BioFromBsky: questBioIsEmpty(quest), + } + if quest != nil { + view.Interests = append(view.Interests, quest.Interests...) + for _, l := range quest.Links { + view.Links = append(view.Links, pages.ProfileLink{Label: l.Label, URL: l.URL}) + } + view.Hiring = boolDeref(quest.Hiring) + view.Looking = boolDeref(quest.Looking) + view.Location = quest.Location + view.WorksAt = quest.WorksAt + view.ContactMethod = quest.ContactMethod + } + return view +} + +// buildProfileEditView pre-populates the edit form from the existing records. +func buildProfileEditView(did syntax.DID, pds string, bsky *profile.BlueskyRecord, quest *profile.QuestRecord, errMsg string) pages.ProfileEditView { + v := pages.ProfileEditView{ + DID: did.String(), + DisplayName: blueskyDisplayName(bsky), + AvatarURL: blueskyAvatarURL(pds, did, bsky), + BlueskyBio: blueskyBio(bsky), + Error: errMsg, + } + if quest != nil { + v.Bio = quest.Bio + v.Interests = append(v.Interests, quest.Interests...) + for _, l := range quest.Links { + v.Links = append(v.Links, pages.ProfileLink{Label: l.Label, URL: l.URL}) + } + v.Hiring = boolDeref(quest.Hiring) + v.Looking = boolDeref(quest.Looking) + v.Location = quest.Location + v.WorksAt = quest.WorksAt + v.ContactMethod = quest.ContactMethod + } + return v +} + +// buildProfileEditViewFromRec rebuilds the edit view from in-flight form +// values rather than the PDS — used when re-rendering after a validation / +// save error so the user doesn't lose what they typed. +func buildProfileEditViewFromRec(did syntax.DID, pds string, bsky *profile.BlueskyRecord, rec profile.QuestRecord, errMsg string) pages.ProfileEditView { + v := pages.ProfileEditView{ + DID: did.String(), + DisplayName: blueskyDisplayName(bsky), + AvatarURL: blueskyAvatarURL(pds, did, bsky), + BlueskyBio: blueskyBio(bsky), + Bio: rec.Bio, + Interests: append([]string(nil), rec.Interests...), + Hiring: boolDeref(rec.Hiring), + Looking: boolDeref(rec.Looking), + Location: rec.Location, + WorksAt: rec.WorksAt, + ContactMethod: rec.ContactMethod, + Error: errMsg, + } + for _, l := range rec.Links { + v.Links = append(v.Links, pages.ProfileLink{Label: l.Label, URL: l.URL}) + } + return v +} + +// boolDeref reads a *bool, defaulting to false when nil. Lets handlers pass +// the record's optional flag straight into view-model bool fields. +func boolDeref(b *bool) bool { + if b == nil { + return false + } + return *b +} + +func blueskyDisplayName(b *profile.BlueskyRecord) string { + if b == nil { + return "" + } + return b.DisplayName +} + +func blueskyBio(b *profile.BlueskyRecord) string { + if b == nil { + return "" + } + return b.Description +} + +func blueskyAvatarURL(pds string, did syntax.DID, b *profile.BlueskyRecord) string { + if b == nil { + return "" + } + return profile.AvatarURL(pds, did, b.Avatar.CID()) +} + +func questBioIsEmpty(q *profile.QuestRecord) bool { + if q == nil { + return true + } + return q.Bio == "" +} + +// parseHTTPURL validates that raw is a syntactically-valid http/https URL with +// a non-empty host. Returns the parsed URL on success. +func parseHTTPURL(raw string) (*url.URL, error) { + u, err := url.Parse(raw) + if err != nil { + return nil, errors.New("invalid URL") + } + if u.Scheme != "http" && u.Scheme != "https" { + return nil, errors.New("URL must start with http:// or https://") + } + if u.Host == "" { + return nil, errors.New("URL must have a host") + } + return u, nil +} + +// itoa is a tiny alias to keep parse code legible. +func itoa(n int) string { return strconv.Itoa(n) } diff --git a/go.mod b/go.mod index 7010ef4..5fe92e1 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module atmoquest -go 1.25.0 +go 1.26 require ( github.com/go-chi/chi/v5 v5.2.5 @@ -10,6 +10,7 @@ require ( require ( github.com/a-h/templ v0.3.1020 github.com/benbjohnson/hashfs v0.2.2 + github.com/bluesky-social/indigo v0.0.0-20260428083920-ce62b8fce9e0 github.com/delaneyj/toolbelt v0.9.1 github.com/evanw/esbuild v0.28.0 github.com/go-chi/httplog/v3 v3.3.0 @@ -17,6 +18,7 @@ require ( github.com/joho/godotenv v1.5.1 github.com/nats-io/nats-server/v2 v2.14.0 github.com/starfederation/datastar-go v1.2.1 + modernc.org/sqlite v1.40.1 ) require ( @@ -31,11 +33,13 @@ require ( github.com/alecthomas/chroma/v2 v2.15.0 // indirect github.com/andybalholm/brotli v1.2.1 // indirect github.com/antithesishq/antithesis-sdk-go v0.7.0 // indirect + github.com/beorn7/perks v1.0.1 // indirect github.com/bep/godartsass v1.2.0 // indirect github.com/bep/godartsass/v2 v2.1.0 // indirect github.com/bep/golibsass v1.2.0 // indirect github.com/cenkalti/backoff v2.2.1+incompatible // indirect github.com/cenkalti/backoff/v4 v4.3.0 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/chainguard-dev/git-urls v1.0.2 // indirect github.com/cilium/ebpf v0.11.0 // indirect github.com/cli/browser v1.3.0 // indirect @@ -49,6 +53,8 @@ require ( github.com/derekparker/trie v0.0.0-20230829180723-39f4de51ef7d // indirect github.com/dlclark/regexp2 v1.11.5 // indirect github.com/dominikbraun/graph v0.23.0 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/earthboundkid/versioninfo/v2 v2.24.1 // indirect github.com/elliotchance/orderedmap/v3 v3.1.0 // indirect github.com/emirpasic/gods v1.18.1 // indirect github.com/fatih/color v1.18.0 // indirect @@ -63,11 +69,15 @@ require ( github.com/go-task/template v0.1.0 // indirect github.com/gobwas/glob v0.2.3 // indirect github.com/gohugoio/hugo v0.134.3 // indirect + github.com/golang-jwt/jwt/v5 v5.2.2 // indirect github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect github.com/google/go-dap v0.12.0 // indirect + github.com/google/go-querystring v1.1.0 // indirect github.com/google/go-tpm v0.9.8 // indirect + github.com/google/uuid v1.6.0 // indirect github.com/gorilla/securecookie v1.1.2 // indirect github.com/hashicorp/golang-lru v1.0.2 // indirect + github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect github.com/kevinburke/ssh_config v1.2.0 // indirect @@ -77,22 +87,31 @@ require ( github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-runewidth v0.0.16 // indirect github.com/mattn/go-zglob v0.0.6 // indirect + github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0 // indirect github.com/minio/highwayhash v1.0.4 // indirect github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect + github.com/mr-tron/base58 v1.2.0 // indirect github.com/natefinch/atomic v1.0.1 // indirect github.com/nats-io/jwt/v2 v2.8.1 // indirect github.com/nats-io/nats.go v1.52.0 // indirect github.com/nats-io/nkeys v0.4.15 // indirect github.com/nats-io/nuid v1.0.1 // indirect + github.com/ncruces/go-strftime v1.0.0 // indirect github.com/pelletier/go-toml v1.9.5 // indirect github.com/pelletier/go-toml/v2 v2.2.3 // indirect github.com/pjbgf/sha1cd v0.3.2 // indirect + github.com/prometheus/client_golang v1.17.0 // indirect + github.com/prometheus/client_model v0.5.0 // indirect + github.com/prometheus/common v0.45.0 // indirect + github.com/prometheus/procfs v0.12.0 // indirect github.com/radovskyb/watcher v1.0.7 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect github.com/sajari/fuzzy v1.0.0 // indirect github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect github.com/skeema/knownhosts v1.3.1 // indirect + github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e // indirect github.com/spf13/afero v1.11.0 // indirect github.com/spf13/cast v1.7.0 // indirect github.com/spf13/cobra v1.9.1 // indirect @@ -101,6 +120,8 @@ require ( github.com/valyala/bytebufferpool v1.0.0 // indirect github.com/xanzy/ssh-agent v0.3.3 // indirect github.com/zeebo/xxh3 v1.1.0 // indirect + gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b // indirect + gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02 // indirect go.starlark.net v0.0.0-20231101134539-556fd59b42f6 // indirect golang.org/x/arch v0.11.0 // indirect golang.org/x/crypto v0.51.0 // indirect @@ -116,6 +137,9 @@ require ( google.golang.org/protobuf v1.36.11 // indirect gopkg.in/warnings.v0 v0.1.2 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect + modernc.org/libc v1.67.1 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.11.0 // indirect mvdan.cc/sh/v3 v3.11.0 // indirect ) diff --git a/go.sum b/go.sum index 096f75f..3391a2d 100644 --- a/go.sum +++ b/go.sum @@ -40,6 +40,8 @@ github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPd github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= github.com/benbjohnson/hashfs v0.2.2 h1:vFZtksphM5LcnMRFctj49jCUkCc7wp3NP6INyfjkse4= github.com/benbjohnson/hashfs v0.2.2/go.mod h1:7OMXaMVo1YkfiIPxKrl7OXkUTUgWjmsAKyR+E6xDIRM= +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/bep/clocks v0.5.0 h1:hhvKVGLPQWRVsBP/UB7ErrHYIO42gINVbvqxvYTPVps= github.com/bep/clocks v0.5.0/go.mod h1:SUq3q+OOq41y2lRQqH5fsOoxN8GbxSiT6jvoVVLCVhU= github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY= @@ -66,6 +68,8 @@ github.com/bep/overlayfs v0.9.2 h1:qJEmFInsW12L7WW7dOTUhnMfyk/fN9OCDEO5Gr8HSDs= github.com/bep/overlayfs v0.9.2/go.mod h1:aYY9W7aXQsGcA7V9x/pzeR8LjEgIxbtisZm8Q7zPz40= github.com/bep/tmc v0.5.1 h1:CsQnSC6MsomH64gw0cT5f+EwQDcvZz4AazKunFwTpuI= github.com/bep/tmc v0.5.1/go.mod h1:tGYHN8fS85aJPhDLgXETVKp+PR382OvFi2+q2GkGsq0= +github.com/bluesky-social/indigo v0.0.0-20260428083920-ce62b8fce9e0 h1:N1c6zWfPBQ4hiCRqSP6cbdlsX38w2i9cLgGuruM1UyE= +github.com/bluesky-social/indigo v0.0.0-20260428083920-ce62b8fce9e0/go.mod h1:JqQkz8lrOI6YZivP38GHmtVOTtzsNToITKj1gMpU5Jo= github.com/cenkalti/backoff v2.2.1+incompatible h1:tNowT99t7UNflLxfYYSlKYsBpXdEet03Pg2g16Swow4= github.com/cenkalti/backoff v2.2.1+incompatible/go.mod h1:90ReRw6GdpyfrHakVjL/QHaoyV4aDUVVkXQJJJ3NXXM= github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= @@ -110,6 +114,10 @@ github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZ github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/dominikbraun/graph v0.23.0 h1:TdZB4pPqCLFxYhdyMFb1TBdFxp8XLcJfTTBQucVPgCo= github.com/dominikbraun/graph v0.23.0/go.mod h1:yOjYyogZLY1LSG9E33JWZJiq5k83Qy2C6POAuiViluc= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/earthboundkid/versioninfo/v2 v2.24.1 h1:SJTMHaoUx3GzjjnUO1QzP3ZXK6Ee/nbWyCm58eY3oUg= +github.com/earthboundkid/versioninfo/v2 v2.24.1/go.mod h1:VcWEooDEuyUJnMfbdTh0uFN4cfEIg+kHMuWB2CDCLjw= github.com/elazarl/goproxy v1.7.2 h1:Y2o6urb7Eule09PjlhQRGNsqRfPmYI3KKQLFpCAV3+o= github.com/elazarl/goproxy v1.7.2/go.mod h1:82vkLNir0ALaW14Rc399OTTjyNREgmdL2cVoIbS6XaE= github.com/elliotchance/orderedmap/v3 v3.1.0 h1:j4DJ5ObEmMBt/lcwIecKcoRxIQUEnw0L804lXYDt/pg= @@ -182,6 +190,8 @@ github.com/gohugoio/locales v0.14.0 h1:Q0gpsZwfv7ATHMbcTNepFd59H7GoykzWJIxi113XG github.com/gohugoio/locales v0.14.0/go.mod h1:ip8cCAv/cnmVLzzXtiTpPwgJ4xhKZranqNqtoIu0b/4= github.com/gohugoio/localescompressed v1.0.1 h1:KTYMi8fCWYLswFyJAeOtuk/EkXR/KPTHHNN9OS+RTxo= github.com/gohugoio/localescompressed v1.0.1/go.mod h1:jBF6q8D7a0vaEmcWPNcAjUZLJaIVNiwvM3WlmTvooB0= +github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8= +github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ= github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw= @@ -201,15 +211,22 @@ github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMyw github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.7/go.mod h1:n+brtR0CgQNWTVd5ZUFpTBC8YFBDLK/h/bpaJ8/DtOE= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/go-dap v0.12.0 h1:rVcjv3SyMIrpaOoTAdFDyHs99CwVOItIJGKLQFQhNeM= github.com/google/go-dap v0.12.0/go.mod h1:tNjCASCm5cqePi/RVXXWEVqtnNLV1KTWtYOqu6rZNzc= +github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= +github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo= github.com/google/go-tpm v0.9.8/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY= github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA= github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo= github.com/gorilla/sessions v1.4.0 h1:kpIYOp/oi6MG/p5PgxApU8srsSw9tuFbt46Lt7auzqQ= @@ -226,6 +243,8 @@ github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2 github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/invopop/yaml v0.2.0 h1:7zky/qH+O0DwAyoobXUqvVBwgBFRxKoQ/3FjcVpjTMY= github.com/invopop/yaml v0.2.0/go.mod h1:2XuRLgs/ouIrW3XNzuNj7J3Nvu/Dig5MXvbCEdiBN3Q= +github.com/ipfs/go-cid v0.4.1 h1:A/T3qGvxi4kpKWWcPC/PgbvDA2bjVLO7n4UeVwnbs/s= +github.com/ipfs/go-cid v0.4.1/go.mod h1:uQHwDeX4c6CtyrFwdqyhpNcxVewur1M7l7fNU7LKwZk= github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A= github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo= github.com/jdkato/prose v1.2.1 h1:Fp3UnJmLVISmlc57BgKUzdjr0lOtjqTZicL3PaYy6cU= @@ -268,16 +287,32 @@ github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6T github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/mattn/go-zglob v0.0.6 h1:mP8RnmCgho4oaUYDIDn6GNxYk+qJGUs8fJLn+twYj2A= github.com/mattn/go-zglob v0.0.6/go.mod h1:MxxjyoXXnMxfIpxTK2GAkw1w8glPsQILx3N5wrKakiY= +github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0 h1:jWpvCLoY8Z/e3VKvlsiIGKtc+UG6U5vzxaoagmhXfyg= +github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0/go.mod h1:QUyp042oQthUoa9bqDv0ER0wrtXnBruoNd7aNjkbP+k= github.com/minio/highwayhash v1.0.4 h1:asJizugGgchQod2ja9NJlGOWq4s7KsAWr5XUc9Clgl4= github.com/minio/highwayhash v1.0.4/go.mod h1:GGYsuwP/fPD6Y9hMiXuapVvlIUEhFhMTh0rxU3ik1LQ= +github.com/minio/sha256-simd v1.0.1 h1:6kaan5IFmwTNynnKKpDHe6FWHohJOHhCPchzK49dzMM= +github.com/minio/sha256-simd v1.0.1/go.mod h1:Pz6AKMiUdngCLpeTL/RJY1M9rUuPMYujV5xJjtbRSN8= github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4= github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE= github.com/mitchellh/mapstructure v1.5.1-0.20231216201459-8508981c8b6c h1:cqn374mizHuIWj+OSJCajGr/phAmuMug9qIX3l9CflE= github.com/mitchellh/mapstructure v1.5.1-0.20231216201459-8508981c8b6c/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 h1:RWengNIwukTxcDr9M+97sNutRR1RKhG96O6jWumTTnw= github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826/go.mod h1:TaXosZuwdSHYgviHp1DAtfrULt5eUgsSMsZf+YrPgl8= +github.com/mr-tron/base58 v1.2.0 h1:T/HDJBh4ZCPbU39/+c3rRvE0uKBQlU27+QI8LJ4t64o= +github.com/mr-tron/base58 v1.2.0/go.mod h1:BinMc/sQntlIE1frQmRFPUoPA1Zkr8VRgBdjWI2mNwc= github.com/muesli/smartcrop v0.3.0 h1:JTlSkmxWg/oQ1TcLDoypuirdE8Y/jzNirQeLkxpA6Oc= github.com/muesli/smartcrop v0.3.0/go.mod h1:i2fCI/UorTfgEpPPLWiFBv4pye+YAG78RwcQLUkocpI= +github.com/multiformats/go-base32 v0.1.0 h1:pVx9xoSPqEIQG8o+UbAe7DNi51oej1NtK+aGkbLYxPE= +github.com/multiformats/go-base32 v0.1.0/go.mod h1:Kj3tFY6zNr+ABYMqeUNeGvkIC/UYgtWibDcT0rExnbI= +github.com/multiformats/go-base36 v0.2.0 h1:lFsAbNOGeKtuKozrtBsAkSVhv1p9D0/qedU9rQyccr0= +github.com/multiformats/go-base36 v0.2.0/go.mod h1:qvnKE++v+2MWCfePClUEjE78Z7P2a1UV0xHgWc0hkp4= +github.com/multiformats/go-multibase v0.2.0 h1:isdYCVLvksgWlMW9OZRYJEa9pZETFivncJHmHnnd87g= +github.com/multiformats/go-multibase v0.2.0/go.mod h1:bFBZX4lKCA/2lyOFSAoKH5SS6oPyjtnzK/XTFDPkNuk= +github.com/multiformats/go-multihash v0.2.3 h1:7Lyc8XfX/IY2jWb/gI7JP+o7JEq9hOa7BFvVU9RSh+U= +github.com/multiformats/go-multihash v0.2.3/go.mod h1:dXgKXCXjBzdscBLk9JkjINiEsCKRVch90MdaGiKsvSM= +github.com/multiformats/go-varint v0.0.7 h1:sWSGR+f/eu5ABZA2ZpYKBILXTTs9JWpdEM/nEGOHFS8= +github.com/multiformats/go-varint v0.0.7/go.mod h1:r8PUYw/fD/SjBCiKOoDlGF6QawOELpZAu9eioSos/OU= github.com/natefinch/atomic v1.0.1 h1:ZPYKxkqQOx3KZ+RsbnP/YsgvxWQPGxjC0oBt2AhwV0A= github.com/natefinch/atomic v1.0.1/go.mod h1:N/D/ELrljoqDyT3rZrsUmtsuzvHkeB/wWjHV22AZRbM= github.com/nats-io/jwt/v2 v2.8.1 h1:V0xpGuD/N8Mi+fQNDynXohVvp7ZztevW5io8CUWlPmU= @@ -290,6 +325,8 @@ github.com/nats-io/nkeys v0.4.15 h1:JACV5jRVO9V856KOapQ7x+EY8Jo3qw1vJt/9Jpwzkk4= github.com/nats-io/nkeys v0.4.15/go.mod h1:CpMchTXC9fxA5zrMo4KpySxNjiDVvr8ANOSZdiNfUrs= github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw= github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c= +github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= +github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/niklasfasching/go-org v1.7.0 h1:vyMdcMWWTe/XmANk19F4k8XGBYg0GQ/gJGMimOjGMek= github.com/niklasfasching/go-org v1.7.0/go.mod h1:WuVm4d45oePiE0eX25GqTDQIt/qPW1T9DGkRscqLW5o= github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec= @@ -313,9 +350,19 @@ github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINE github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus/client_golang v1.17.0 h1:rl2sfwZMtSthVU752MqfjQozy7blglC+1SOtjMAMh+Q= +github.com/prometheus/client_golang v1.17.0/go.mod h1:VeL+gMmOAxkS2IqfCq0ZmHSL+LjWfWDUmp1mBz9JgUY= github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/prometheus/client_model v0.5.0 h1:VQw1hfvPvk3Uv6Qf29VrPF32JB6rtbgI6cYPYQjL0Qw= +github.com/prometheus/client_model v0.5.0/go.mod h1:dTiFglRmd66nLR9Pv9f0mZi7B7fk5Pm3gvsjB5tr+kI= +github.com/prometheus/common v0.45.0 h1:2BGz0eBc2hdMDLnO/8n0jeB3oPrt2D08CekT0lneoxM= +github.com/prometheus/common v0.45.0/go.mod h1:YJmSTw9BoKxJplESWWxlbyttQR4uaEcGyv9MZjVOJsY= +github.com/prometheus/procfs v0.12.0 h1:jluTpSng7V9hY0O2R9DzzJHYb2xULk9VTR1V1R/k6Bo= +github.com/prometheus/procfs v0.12.0/go.mod h1:pcuDEFsWDnvcgNzo4EEweacyhjeA9Zk3cnaOZAZEfOo= github.com/radovskyb/watcher v1.0.7 h1:AYePLih6dpmS32vlHfhCeli8127LzkIgwJGcwwe8tUE= github.com/radovskyb/watcher v1.0.7/go.mod h1:78okwvY5wPdzcb1UYnip1pvrZNIVEIh/Cm+ZuvsUYIg= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= @@ -331,6 +378,10 @@ github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3/go.mod h1:A0bzQcvG github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= github.com/skeema/knownhosts v1.3.1 h1:X2osQ+RAjK76shCbvhHHHVl3ZlgDm8apHEHFqRjnBY8= github.com/skeema/knownhosts v1.3.1/go.mod h1:r7KTdC8l4uxWRyK2TpQZ/1o5HaSzh06ePQNxPwTcfiY= +github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0= +github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M= +github.com/spaolacci/murmur3 v1.1.0 h1:7c1g84S4BPRrfL5Xrdp6fOJ206sU9y293DDHaoy0bLI= +github.com/spaolacci/murmur3 v1.1.0/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8= github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY= github.com/spf13/cast v1.7.0 h1:ntdiHjuueXFgm5nzDRdOS4yfT43P5Fnud6DH50rz/7w= @@ -367,6 +418,8 @@ github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6Kllzaw github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= github.com/valyala/gozstd v1.20.1 h1:xPnnnvjmaDDitMFfDxmQ4vpx0+3CdTg2o3lALvXTU/g= github.com/valyala/gozstd v1.20.1/go.mod h1:y5Ew47GLlP37EkTB+B4s7r6A5rdaeB7ftbl9zoYiIPQ= +github.com/whyrusleeping/cbor-gen v0.2.1-0.20241030202151-b7a6831be65e h1:28X54ciEwwUxyHn9yrZfl5ojgF4CBNLWX7LR0rvBkf4= +github.com/whyrusleeping/cbor-gen v0.2.1-0.20241030202151-b7a6831be65e/go.mod h1:pM99HXyEbSQHcosHc0iW7YFmwnscr+t9Te4ibko05so= github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM= github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw= github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= @@ -379,6 +432,10 @@ github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ= github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0= github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs= github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s= +gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b h1:CzigHMRySiX3drau9C6Q5CAbNIApmLdat5jPMqChvDA= +gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b/go.mod h1:/y/V339mxv2sZmYYR64O07VuCpdNZqCTwO8ZcouTMI8= +gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02 h1:qwDnMxjkyLmAFgcfgTnfJrmYKWhHnci3GjDqcZp1M3Q= +gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02/go.mod h1:JTnUj0mpYiAsuZLmKjTx/ex3AtMowcCgnE7YNyCEP0I= go.starlark.net v0.0.0-20231101134539-556fd59b42f6 h1:+eC0F/k4aBLC4szgOcjd7bDTEnpxADJyWJE0yowgM3E= go.starlark.net v0.0.0-20231101134539-556fd59b42f6/go.mod h1:LcLNIzVOMp4oV+uusnpk+VU+SzXaJakUuBjoCSWH5dM= golang.org/x/arch v0.11.0 h1:KXV8WWKCXm6tRpLirl2szsO5j/oOODwZf4hATmGVNs4= @@ -443,6 +500,8 @@ golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBn golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c= golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20240716161551-93cc26a95ae9 h1:LLhsEBxRTBLuKlQxFBYUOU8xyFgXv6cOTp2HASDlsDk= +golang.org/x/xerrors v0.0.0-20240716161551-93cc26a95ae9/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90= google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= @@ -477,5 +536,35 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +lukechampine.com/blake3 v1.2.1 h1:YuqqRuaqsGV71BV/nm9xlI0MKUv4QC54jQnBChWbGnI= +lukechampine.com/blake3 v1.2.1/go.mod h1:0OFRp7fBtAylGVCO40o87sbupkyIGgbpv1+M1k1LM6k= +modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis= +modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= +modernc.org/ccgo/v4 v4.30.1 h1:4r4U1J6Fhj98NKfSjnPUN7Ze2c6MnAdL0hWw6+LrJpc= +modernc.org/ccgo/v4 v4.30.1/go.mod h1:bIOeI1JL54Utlxn+LwrFyjCx2n2RDiYEaJVSrgdrRfM= +modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA= +modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc= +modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= +modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= +modernc.org/gc/v3 v3.1.1 h1:k8T3gkXWY9sEiytKhcgyiZ2L0DTyCQ/nvX+LoCljoRE= +modernc.org/gc/v3 v3.1.1/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= +modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= +modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= +modernc.org/libc v1.67.1 h1:bFaqOaa5/zbWYJo8aW0tXPX21hXsngG2M7mckCnFSVk= +modernc.org/libc v1.67.1/go.mod h1:QvvnnJ5P7aitu0ReNpVIEyesuhmDLQ8kaEoyMjIFZJA= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= +modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8= +modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= +modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= +modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= +modernc.org/sqlite v1.40.1 h1:VfuXcxcUWWKRBuP8+BR9L7VnmusMgBNNnBYGEe9w/iY= +modernc.org/sqlite v1.40.1/go.mod h1:9fjQZ0mB1LLP0GYrp39oOJXx/I2sxEnZtzCmEQIKvGE= +modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= +modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= mvdan.cc/sh/v3 v3.11.0 h1:q5h+XMDRfUGUedCqFFsjoFjrhwf2Mvtt1rkMvVz0blw= mvdan.cc/sh/v3 v3.11.0/go.mod h1:LRM+1NjoYCzuq/WZ6y44x14YNAI0NK7FLPeQSaFagGg= diff --git a/internal/checkin/checkin.go b/internal/checkin/checkin.go new file mode 100644 index 0000000..ac665ea --- /dev/null +++ b/internal/checkin/checkin.go @@ -0,0 +1,109 @@ +// Package checkin writes quest.atmo.checkin records to a user's PDS and +// keeps a local cache for fast "what am I currently checked into" lookups. +// +// The cache is best-effort: every check-in we write goes into our local +// `checkins` table immediately so the home page can render without an +// extra PDS roundtrip. If the row is missing (e.g. user re-installed and +// hasn't re-synced yet), the home page falls back to "no current event". +package checkin + +import ( + "context" + "database/sql" + "errors" + "fmt" + "time" + + "github.com/bluesky-social/indigo/atproto/auth/oauth" + "github.com/bluesky-social/indigo/atproto/syntax" +) + +const ( + // NSID of the check-in record lexicon. + NSID = "quest.atmo.checkin" + + // XRPC procedure name for record creation. + nsidCreateRecord = "com.atproto.repo.createRecord" +) + +// Put writes a new check-in record to the authenticated user's PDS and +// caches a row locally. EventURI is required and must be a valid at-uri. +// +// Each call produces a new TID-keyed record; multiple check-ins to the +// same event are allowed (e.g. user re-checks in on day 2 of a multi-day +// conference). Caller must hold the `repo:quest.atmo.checkin` OAuth scope. +// +// Local cache write is best-effort — failure is logged by the caller but +// shouldn't bubble up as an error to the user (the PDS record is the +// source of truth, the cache row will get rebuilt on next read). +func Put(ctx context.Context, sess *oauth.ClientSession, db *sql.DB, eventURI string, at time.Time) (recordURI string, err error) { + if sess == nil { + return "", errors.New("checkin: nil oauth session") + } + if eventURI == "" { + return "", errors.New("checkin: missing event URI") + } + if at.IsZero() { + at = time.Now().UTC() + } + + value := map[string]any{ + "$type": NSID, + "event": eventURI, + "checkedInAt": at.UTC().Format(time.RFC3339), + } + input := map[string]any{ + "repo": sess.Data.AccountDID.String(), + "collection": NSID, + // `validate` omitted intentionally — PDSes without our lexicon + // reject `validate: true` with "Unknown lexicon type". + "record": value, + } + var out struct { + URI string `json:"uri"` + CID string `json:"cid"` + } + if err := sess.APIClient().Post(ctx, syntax.NSID(nsidCreateRecord), input, &out); err != nil { + return "", fmt.Errorf("createRecord %s: %w", NSID, err) + } + + // Cache locally. We do this *after* the PDS write succeeds so a failed + // write doesn't leave a phantom checkin in our DB. + if db != nil { + _, _ = db.ExecContext(ctx, ` + INSERT INTO checkins (record_uri, did, event_uri, checked_in_at, cached_at) + VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP) + ON CONFLICT(record_uri) DO NOTHING + `, out.URI, sess.Data.AccountDID.String(), eventURI, at.UTC()) + } + + return out.URI, nil +} + +// Current returns the user's most recent check-in whose referenced event is +// currently ongoing. Returns ("", false, nil) if the user has no active +// check-in. The returned string is the event's at-uri, suitable for passing +// to `event.Get`. +// +// "Most recent" ties are broken by `checked_in_at DESC` — e.g. a user who +// checks in once at the start and again later picks up the later check-in, +// which is the natural reading of "where are you right now". +func Current(ctx context.Context, db *sql.DB, did syntax.DID) (eventURI string, ok bool, err error) { + row := db.QueryRowContext(ctx, ` + SELECT c.event_uri + FROM checkins c + JOIN events e ON e.uri = c.event_uri + WHERE c.did = ? + AND CURRENT_TIMESTAMP BETWEEN e.start_time AND e.end_time + ORDER BY c.checked_in_at DESC + LIMIT 1 + `, did.String()) + err = row.Scan(&eventURI) + if err == sql.ErrNoRows { + return "", false, nil + } + if err != nil { + return "", false, err + } + return eventURI, true, nil +} diff --git a/internal/checkin/checkin_test.go b/internal/checkin/checkin_test.go new file mode 100644 index 0000000..14d10fb --- /dev/null +++ b/internal/checkin/checkin_test.go @@ -0,0 +1,177 @@ +package checkin + +import ( + "context" + "database/sql" + "path/filepath" + "testing" + "time" + + "github.com/bluesky-social/indigo/atproto/auth/oauth" + "github.com/bluesky-social/indigo/atproto/syntax" + + atdb "atmoquest/internal/db" +) + +// newTestDB returns a freshly-migrated SQLite DB scoped to the test. +func newTestDB(t *testing.T) (context.Context, *sql.DB) { + t.Helper() + dir := t.TempDir() + dsn := "file:" + filepath.Join(dir, "checkin.db") + "?_pragma=foreign_keys(ON)" + conn, err := atdb.Open(dsn) + if err != nil { + t.Fatalf("db open: %v", err) + } + if err := atdb.Migrate(conn); err != nil { + t.Fatalf("migrate: %v", err) + } + t.Cleanup(func() { _ = conn.Close() }) + return context.Background(), conn +} + +const ( + testUserDID = "did:plc:userabc" + testOrgDID = "did:plc:organizer" +) + +// seedEvent inserts a row directly into the events table for test fixtures. +// We bypass the `event` package to avoid an import cycle (event imports +// nothing from checkin, and checkin tests don't need event's validation — +// they just need a row to JOIN against). +func seedEvent(t *testing.T, db *sql.DB, uri string, start, end time.Time) { + t.Helper() + _, err := db.Exec(` + INSERT INTO events (uri, name, start_time, end_time, location, organizer_did, cached_at) + VALUES (?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) + `, uri, "Test Event", start.UTC(), end.UTC(), "Somewhere", testOrgDID) + if err != nil { + t.Fatalf("seed event %q: %v", uri, err) + } +} + +// seedCheckin inserts a checkin row directly. +func seedCheckin(t *testing.T, db *sql.DB, recordURI, did, eventURI string, at time.Time) { + t.Helper() + _, err := db.Exec(` + INSERT INTO checkins (record_uri, did, event_uri, checked_in_at, cached_at) + VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP) + `, recordURI, did, eventURI, at.UTC()) + if err != nil { + t.Fatalf("seed checkin: %v", err) + } +} + +func TestPut_RejectsNilSession(t *testing.T) { + ctx, db := newTestDB(t) + _, err := Put(ctx, nil, db, "at://x/y/z", time.Now()) + if err == nil { + t.Errorf("Put(nil session) succeeded; want error") + } +} + +func TestPut_RejectsMissingEventURI(t *testing.T) { + ctx, db := newTestDB(t) + // A non-nil but empty session is fine here: `Put` validates the + // event URI before touching `sess.Data`, so a zero-value session + // pointer never gets dereferenced. + _, err := Put(ctx, &oauth.ClientSession{}, db, "", time.Now()) + if err == nil { + t.Errorf("Put(empty event URI) succeeded; want error") + } +} + +func TestCurrent_NoneReturnsFalse(t *testing.T) { + ctx, db := newTestDB(t) + uri, ok, err := Current(ctx, db, syntax.DID(testUserDID)) + if err != nil { + t.Fatalf("Current: %v", err) + } + if ok || uri != "" { + t.Errorf("Current = (%q, %v); want ('', false)", uri, ok) + } +} + +func TestCurrent_ReturnsOngoing(t *testing.T) { + ctx, db := newTestDB(t) + now := time.Now().UTC() + evURI := "at://did:plc:org/quest.atmo.event/ongoing" + seedEvent(t, db, evURI, now.Add(-2*time.Hour), now.Add(6*time.Hour)) + seedCheckin(t, db, "at://did:plc:user/quest.atmo.checkin/1", testUserDID, evURI, now.Add(-time.Hour)) + + got, ok, err := Current(ctx, db, syntax.DID(testUserDID)) + if err != nil { + t.Fatalf("Current: %v", err) + } + if !ok || got != evURI { + t.Errorf("Current = (%q, %v); want (%q, true)", got, ok, evURI) + } +} + +func TestCurrent_FiltersOutEndedEvent(t *testing.T) { + ctx, db := newTestDB(t) + now := time.Now().UTC() + evURI := "at://did:plc:org/quest.atmo.event/yesterday" + // Event window is fully in the past. + seedEvent(t, db, evURI, now.Add(-48*time.Hour), now.Add(-24*time.Hour)) + seedCheckin(t, db, "at://did:plc:user/quest.atmo.checkin/old", testUserDID, evURI, now.Add(-30*time.Hour)) + + _, ok, err := Current(ctx, db, syntax.DID(testUserDID)) + if err != nil { + t.Fatalf("Current: %v", err) + } + if ok { + t.Errorf("Current returned ok=true for ended event") + } +} + +func TestCurrent_FiltersOutFutureEvent(t *testing.T) { + ctx, db := newTestDB(t) + now := time.Now().UTC() + evURI := "at://did:plc:org/quest.atmo.event/tomorrow" + seedEvent(t, db, evURI, now.Add(24*time.Hour), now.Add(48*time.Hour)) + seedCheckin(t, db, "at://did:plc:user/quest.atmo.checkin/early", testUserDID, evURI, now) + + _, ok, err := Current(ctx, db, syntax.DID(testUserDID)) + if err != nil { + t.Fatalf("Current: %v", err) + } + if ok { + t.Errorf("Current returned ok=true for future event") + } +} + +func TestCurrent_PicksMostRecentWhenMultiple(t *testing.T) { + ctx, db := newTestDB(t) + now := time.Now().UTC() + evA := "at://did:plc:org/quest.atmo.event/A" + evB := "at://did:plc:org/quest.atmo.event/B" + // Both ongoing — checked in to A first, then B. Current should pick B. + seedEvent(t, db, evA, now.Add(-3*time.Hour), now.Add(3*time.Hour)) + seedEvent(t, db, evB, now.Add(-1*time.Hour), now.Add(5*time.Hour)) + seedCheckin(t, db, "at://did:plc:user/quest.atmo.checkin/a", testUserDID, evA, now.Add(-2*time.Hour)) + seedCheckin(t, db, "at://did:plc:user/quest.atmo.checkin/b", testUserDID, evB, now.Add(-30*time.Minute)) + + got, ok, err := Current(ctx, db, syntax.DID(testUserDID)) + if err != nil { + t.Fatalf("Current: %v", err) + } + if !ok || got != evB { + t.Errorf("Current = (%q, %v); want (%q, true)", got, ok, evB) + } +} + +func TestCurrent_IgnoresOtherUsers(t *testing.T) { + ctx, db := newTestDB(t) + now := time.Now().UTC() + evURI := "at://did:plc:org/quest.atmo.event/shared" + seedEvent(t, db, evURI, now.Add(-time.Hour), now.Add(time.Hour)) + seedCheckin(t, db, "at://did:plc:otheruser/quest.atmo.checkin/x", "did:plc:otheruser", evURI, now) + + _, ok, err := Current(ctx, db, syntax.DID(testUserDID)) + if err != nil { + t.Fatalf("Current: %v", err) + } + if ok { + t.Errorf("Current returned ok=true for a different user's checkin") + } +} diff --git a/internal/connection/connection.go b/internal/connection/connection.go new file mode 100644 index 0000000..d549f43 --- /dev/null +++ b/internal/connection/connection.go @@ -0,0 +1,102 @@ +// Package connection implements writing quest.atmo.connection records and +// queueing reciprocal writes for users who aren't currently logged in. +// +// The lexicon (lexicons/quest/atmo/connection.json) defines a record with: +// +// { with: did, connectedAt: datetime, event?: at-uri } +// +// One record per (viewer, target [, event]) tuple, keyed by a TID rkey. There +// can be multiple connection records to the same target across different +// events. +// +// Writes go through the user's OAuth session and require the +// `repo:quest.atmo.connection` scope. +// +// Async reciprocity: +// +// - When user A (logged in) scans user B's QR, we write A's record +// synchronously to A's PDS. +// - We *also* try to write B's reciprocal record. If B has a usable OAuth +// session in our store we do it inline; otherwise we Enqueue a row in +// pending_connections and drain it the next time B logs in. +// +// The package is intentionally small and side-effect-free — handlers compose +// it with the OAuth + DB layers. +package connection + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/bluesky-social/indigo/atproto/auth/oauth" + "github.com/bluesky-social/indigo/atproto/syntax" +) + +const ( + // NSID of the connection record lexicon. + NSID = "quest.atmo.connection" + + // createRecord is the XRPC procedure we POST to. + nsidCreateRecord = "com.atproto.repo.createRecord" +) + +// Record models a connection record we're about to write. Author is the DID +// whose PDS we're writing to (not part of the record value — derived from +// the session). +type Record struct { + With syntax.DID + ConnectedAt time.Time + // EventURI is an optional at-uri linking this connection to an event. + // Not surfaced yet; reserved for the events feature. + EventURI string +} + +// Put writes a new connection record to the authenticated user's PDS via +// com.atproto.repo.createRecord. Each call produces a separate record (TID +// rkey), so calling Put twice for the same `with` legitimately creates two +// records — that's by design per the spec (e.g. across multiple events). +// +// Caller must have the `repo:quest.atmo.connection` scope on the session. +func Put(ctx context.Context, sess *oauth.ClientSession, rec Record) (uri, cid string, err error) { + if sess == nil { + return "", "", errors.New("connection: nil oauth session") + } + if rec.With == "" { + return "", "", errors.New("connection: missing target DID") + } + if sess.Data.AccountDID == rec.With { + return "", "", errors.New("connection: cannot connect to yourself") + } + if rec.ConnectedAt.IsZero() { + rec.ConnectedAt = time.Now().UTC() + } + + value := map[string]any{ + "$type": NSID, + "with": rec.With.String(), + "connectedAt": rec.ConnectedAt.UTC().Format(time.RFC3339), + } + if rec.EventURI != "" { + value["event"] = rec.EventURI + } + + input := map[string]any{ + "repo": sess.Data.AccountDID.String(), + "collection": NSID, + // `validate` is intentionally omitted; PDSes that don't yet know + // the quest.atmo.connection lexicon reject `validate: true` with + // `InvalidRequest: Unknown lexicon type`. Leaving it unset asks + // the PDS to validate only against lexicons it already knows. + "record": value, + } + var out struct { + URI string `json:"uri"` + CID string `json:"cid"` + } + if err := sess.APIClient().Post(ctx, syntax.NSID(nsidCreateRecord), input, &out); err != nil { + return "", "", fmt.Errorf("createRecord %s: %w", NSID, err) + } + return out.URI, out.CID, nil +} diff --git a/internal/connection/connection_test.go b/internal/connection/connection_test.go new file mode 100644 index 0000000..bcd5cdc --- /dev/null +++ b/internal/connection/connection_test.go @@ -0,0 +1,42 @@ +package connection + +import ( + "context" + "strings" + "testing" + + "github.com/bluesky-social/indigo/atproto/auth/oauth" + "github.com/bluesky-social/indigo/atproto/syntax" +) + +func TestPut_RejectsNilSession(t *testing.T) { + _, _, err := Put(context.Background(), nil, Record{With: syntax.DID(didA)}) + if err == nil { + t.Fatal("expected error for nil session") + } + if !strings.Contains(err.Error(), "nil oauth session") { + t.Errorf("unexpected error: %v", err) + } +} + +func TestPut_RejectsEmptyWith(t *testing.T) { + sess := &oauth.ClientSession{Data: &oauth.ClientSessionData{AccountDID: syntax.DID(didA)}} + _, _, err := Put(context.Background(), sess, Record{With: ""}) + if err == nil { + t.Fatal("expected error for missing target DID") + } + if !strings.Contains(err.Error(), "missing target DID") { + t.Errorf("unexpected error: %v", err) + } +} + +func TestPut_RejectsSelfConnect(t *testing.T) { + sess := &oauth.ClientSession{Data: &oauth.ClientSessionData{AccountDID: syntax.DID(didA)}} + _, _, err := Put(context.Background(), sess, Record{With: syntax.DID(didA)}) + if err == nil { + t.Fatal("expected error for self-connect") + } + if !strings.Contains(err.Error(), "cannot connect to yourself") { + t.Errorf("unexpected error: %v", err) + } +} diff --git a/internal/connection/drain.go b/internal/connection/drain.go new file mode 100644 index 0000000..667f845 --- /dev/null +++ b/internal/connection/drain.go @@ -0,0 +1,77 @@ +package connection + +import ( + "context" + "log/slog" + + "github.com/bluesky-social/indigo/atproto/auth/oauth" +) + +// DrainResult summarizes what happened during a queue drain. +type DrainResult struct { + // Written is the count of pending items successfully flushed to the + // authenticated user's PDS. + Written int + // Skipped is the count of pending items that hit a write error and were + // left in the queue (so a future drain can retry). + Skipped int +} + +// Drain flushes every pending_connection row for sess.AccountDID by calling +// Put for each, then deleting the row on success. Errors on individual rows +// are logged (if logger is non-nil) and the row is left in the queue for a +// future retry. +// +// Returns a DrainResult summarizing the operation; never returns an error +// from the per-row writes — the goal is best-effort flush on login, not +// blocking the user's redirect to /profile. +// +// A wrapper-level error (e.g. queue lookup failure) is returned as-is. +func Drain(ctx context.Context, q *Queue, sess *oauth.ClientSession, logger *slog.Logger) (DrainResult, error) { + res := DrainResult{} + if sess == nil { + return res, errNoSession + } + target := sess.Data.AccountDID + items, err := q.List(ctx, target, 0) + if err != nil { + return res, err + } + for _, item := range items { + _, _, err := Put(ctx, sess, Record{With: item.InitiatorDID}) + if err != nil { + if logger != nil { + logger.Warn("connection drain: write failed", + "target", target.String(), + "initiator", item.InitiatorDID.String(), + "err", err, + ) + } + res.Skipped++ + continue + } + if err := q.Delete(ctx, item.ID); err != nil { + // Wrote the record successfully but couldn't delete the queue + // row — next drain will retry and create a duplicate record. Log + // loudly so we can spot it. + if logger != nil { + logger.Error("connection drain: row delete failed; will duplicate on retry", + "id", item.ID, + "err", err, + ) + } + res.Skipped++ + continue + } + res.Written++ + } + return res, nil +} + +// errNoSession is returned by Drain when invoked without a session. Kept +// package-private — callers should ensure they have a session before calling. +var errNoSession = errSentinel("connection drain: nil session") + +type errSentinel string + +func (e errSentinel) Error() string { return string(e) } diff --git a/internal/connection/queue.go b/internal/connection/queue.go new file mode 100644 index 0000000..3ccc938 --- /dev/null +++ b/internal/connection/queue.go @@ -0,0 +1,127 @@ +package connection + +import ( + "context" + "database/sql" + "errors" + "fmt" + + "github.com/bluesky-social/indigo/atproto/syntax" +) + +// PendingItem is a queued reciprocal connection that needs to be written to +// target_did's PDS the next time they log in. +type PendingItem struct { + ID int64 + TargetDID syntax.DID + InitiatorDID syntax.DID +} + +// Queue is a SQLite-backed FIFO of reciprocal connection writes. Methods are +// safe for concurrent callers (SQLite's own write lock serializes). +type Queue struct { + db *sql.DB +} + +// NewQueue constructs a Queue against the provided database. The caller is +// responsible for running migrations (003_pending_connections.sql). +func NewQueue(db *sql.DB) *Queue { + return &Queue{db: db} +} + +// Enqueue records that `target` owes a connection record listing `initiator`. +// Idempotent: re-enqueueing the same (target, initiator) is a no-op thanks to +// the unique index on (target_did, initiator_did). +func (q *Queue) Enqueue(ctx context.Context, target, initiator syntax.DID) error { + if target == "" || initiator == "" { + return errors.New("connection queue: empty DID") + } + if target == initiator { + return errors.New("connection queue: self-connection") + } + _, err := q.db.ExecContext(ctx, ` + INSERT INTO pending_connections (target_did, initiator_did) + VALUES (?, ?) + ON CONFLICT(target_did, initiator_did) DO NOTHING + `, target.String(), initiator.String()) + if err != nil { + return fmt.Errorf("enqueue: %w", err) + } + return nil +} + +// List returns up to `limit` pending items for the given target. If limit is +// <= 0, all pending items are returned. +func (q *Queue) List(ctx context.Context, target syntax.DID, limit int) ([]PendingItem, error) { + var ( + rows *sql.Rows + err error + ) + if limit <= 0 { + rows, err = q.db.QueryContext(ctx, ` + SELECT id, target_did, initiator_did + FROM pending_connections + WHERE target_did = ? + ORDER BY id + `, target.String()) + } else { + rows, err = q.db.QueryContext(ctx, ` + SELECT id, target_did, initiator_did + FROM pending_connections + WHERE target_did = ? + ORDER BY id + LIMIT ? + `, target.String(), limit) + } + if err != nil { + return nil, fmt.Errorf("list pending: %w", err) + } + defer rows.Close() + + var out []PendingItem + for rows.Next() { + var ( + id int64 + targetStr string + initiatorStr string + ) + if err := rows.Scan(&id, &targetStr, &initiatorStr); err != nil { + return nil, fmt.Errorf("scan pending: %w", err) + } + tDID, err := syntax.ParseDID(targetStr) + if err != nil { + return nil, fmt.Errorf("pending target DID parse: %w", err) + } + iDID, err := syntax.ParseDID(initiatorStr) + if err != nil { + return nil, fmt.Errorf("pending initiator DID parse: %w", err) + } + out = append(out, PendingItem{ID: id, TargetDID: tDID, InitiatorDID: iDID}) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterate pending: %w", err) + } + return out, nil +} + +// Delete removes a single pending row by its primary key. Used after a +// successful PDS write during Drain. +func (q *Queue) Delete(ctx context.Context, id int64) error { + _, err := q.db.ExecContext(ctx, `DELETE FROM pending_connections WHERE id = ?`, id) + if err != nil { + return fmt.Errorf("delete pending %d: %w", id, err) + } + return nil +} + +// Count returns the number of pending items for the given target. +func (q *Queue) Count(ctx context.Context, target syntax.DID) (int, error) { + var n int + err := q.db.QueryRowContext(ctx, ` + SELECT COUNT(*) FROM pending_connections WHERE target_did = ? + `, target.String()).Scan(&n) + if err != nil { + return 0, fmt.Errorf("count pending: %w", err) + } + return n, nil +} diff --git a/internal/connection/queue_test.go b/internal/connection/queue_test.go new file mode 100644 index 0000000..7b4809b --- /dev/null +++ b/internal/connection/queue_test.go @@ -0,0 +1,187 @@ +package connection + +import ( + "context" + "path/filepath" + "testing" + + "github.com/bluesky-social/indigo/atproto/syntax" + + atdb "atmoquest/internal/db" +) + +const ( + didA = "did:plc:aaaaaaaaaaaaaaaaaaaaaaaa" + didB = "did:plc:bbbbbbbbbbbbbbbbbbbbbbbb" + didC = "did:plc:cccccccccccccccccccccccc" +) + +func newTestQueue(t *testing.T) *Queue { + t.Helper() + dir := t.TempDir() + dsn := "file:" + filepath.Join(dir, "test.db") + "?_pragma=journal_mode(WAL)&_pragma=foreign_keys(ON)" + conn, err := atdb.Open(dsn) + if err != nil { + t.Fatalf("open db: %v", err) + } + t.Cleanup(func() { _ = conn.Close() }) + if err := atdb.Migrate(conn); err != nil { + t.Fatalf("migrate: %v", err) + } + return NewQueue(conn) +} + +func did(t *testing.T, s string) syntax.DID { + t.Helper() + d, err := syntax.ParseDID(s) + if err != nil { + t.Fatalf("parse DID %s: %v", s, err) + } + return d +} + +func TestQueue_EnqueueAndList(t *testing.T) { + q := newTestQueue(t) + ctx := context.Background() + target := did(t, didA) + initiator := did(t, didB) + + if err := q.Enqueue(ctx, target, initiator); err != nil { + t.Fatalf("enqueue: %v", err) + } + items, err := q.List(ctx, target, 0) + if err != nil { + t.Fatalf("list: %v", err) + } + if len(items) != 1 { + t.Fatalf("len(items) = %d, want 1", len(items)) + } + if items[0].TargetDID != target { + t.Errorf("TargetDID = %s, want %s", items[0].TargetDID, target) + } + if items[0].InitiatorDID != initiator { + t.Errorf("InitiatorDID = %s, want %s", items[0].InitiatorDID, initiator) + } +} + +func TestQueue_EnqueueIdempotent(t *testing.T) { + q := newTestQueue(t) + ctx := context.Background() + target := did(t, didA) + initiator := did(t, didB) + + for i := 0; i < 3; i++ { + if err := q.Enqueue(ctx, target, initiator); err != nil { + t.Fatalf("enqueue #%d: %v", i, err) + } + } + count, err := q.Count(ctx, target) + if err != nil { + t.Fatalf("count: %v", err) + } + if count != 1 { + t.Errorf("count = %d, want 1 (unique index should suppress dupes)", count) + } +} + +func TestQueue_RejectsSelfConnect(t *testing.T) { + q := newTestQueue(t) + target := did(t, didA) + err := q.Enqueue(context.Background(), target, target) + if err == nil { + t.Fatal("expected error for self-connect") + } +} + +func TestQueue_RejectsEmptyDID(t *testing.T) { + q := newTestQueue(t) + if err := q.Enqueue(context.Background(), "", did(t, didA)); err == nil { + t.Error("expected error for empty target") + } + if err := q.Enqueue(context.Background(), did(t, didA), ""); err == nil { + t.Error("expected error for empty initiator") + } +} + +func TestQueue_MultipleInitiatorsForSameTarget(t *testing.T) { + q := newTestQueue(t) + ctx := context.Background() + target := did(t, didA) + + for _, init := range []syntax.DID{did(t, didB), did(t, didC)} { + if err := q.Enqueue(ctx, target, init); err != nil { + t.Fatalf("enqueue: %v", err) + } + } + items, err := q.List(ctx, target, 0) + if err != nil { + t.Fatalf("list: %v", err) + } + if len(items) != 2 { + t.Fatalf("len = %d, want 2", len(items)) + } +} + +func TestQueue_ListLimit(t *testing.T) { + q := newTestQueue(t) + ctx := context.Background() + target := did(t, didA) + _ = q.Enqueue(ctx, target, did(t, didB)) + _ = q.Enqueue(ctx, target, did(t, didC)) + + items, err := q.List(ctx, target, 1) + if err != nil { + t.Fatalf("list: %v", err) + } + if len(items) != 1 { + t.Errorf("len with limit=1 = %d, want 1", len(items)) + } +} + +func TestQueue_Delete(t *testing.T) { + q := newTestQueue(t) + ctx := context.Background() + target := did(t, didA) + if err := q.Enqueue(ctx, target, did(t, didB)); err != nil { + t.Fatalf("enqueue: %v", err) + } + items, _ := q.List(ctx, target, 0) + if err := q.Delete(ctx, items[0].ID); err != nil { + t.Fatalf("delete: %v", err) + } + count, _ := q.Count(ctx, target) + if count != 0 { + t.Errorf("count after delete = %d, want 0", count) + } +} + +func TestQueue_ListPreservesInsertionOrder(t *testing.T) { + q := newTestQueue(t) + ctx := context.Background() + target := did(t, didA) + order := []syntax.DID{did(t, didB), did(t, didC)} + for _, init := range order { + if err := q.Enqueue(ctx, target, init); err != nil { + t.Fatalf("enqueue: %v", err) + } + } + items, _ := q.List(ctx, target, 0) + for i, item := range items { + if item.InitiatorDID != order[i] { + t.Errorf("items[%d].InitiatorDID = %s, want %s", i, item.InitiatorDID, order[i]) + } + } +} + +func TestQueue_CountIsolatedByTarget(t *testing.T) { + q := newTestQueue(t) + ctx := context.Background() + _ = q.Enqueue(ctx, did(t, didA), did(t, didB)) + _ = q.Enqueue(ctx, did(t, didC), did(t, didB)) + if c, _ := q.Count(ctx, did(t, didA)); c != 1 { + t.Errorf("count(A) = %d, want 1", c) + } + if c, _ := q.Count(ctx, did(t, didC)); c != 1 { + t.Errorf("count(C) = %d, want 1", c) + } +} diff --git a/internal/db/db.go b/internal/db/db.go new file mode 100644 index 0000000..d0a3d0a --- /dev/null +++ b/internal/db/db.go @@ -0,0 +1,98 @@ +// Package db handles SQLite connection and schema migrations. +package db + +import ( + "database/sql" + "embed" + "fmt" + "io/fs" + "path/filepath" + "sort" + "strings" + + _ "modernc.org/sqlite" +) + +//go:embed migrations/*.sql +var migrationsFS embed.FS + +// Open opens (and pings) a SQLite database at the given DSN. +// Example DSN: file:data/atmoquest.db?_pragma=journal_mode(WAL) +func Open(dsn string) (*sql.DB, error) { + // modernc.org/sqlite registers the "sqlite" driver name. + if err := ensureDataDir(dsn); err != nil { + return nil, err + } + conn, err := sql.Open("sqlite", dsn) + if err != nil { + return nil, fmt.Errorf("open: %w", err) + } + if err := conn.Ping(); err != nil { + return nil, fmt.Errorf("ping: %w", err) + } + return conn, nil +} + +// Migrate applies all embedded migrations in lexical order. It tracks applied +// migrations in a `schema_migrations` table so each file runs at most once. +func Migrate(conn *sql.DB) error { + if _, err := conn.Exec(`CREATE TABLE IF NOT EXISTS schema_migrations ( + name TEXT PRIMARY KEY, + applied_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP + )`); err != nil { + return fmt.Errorf("create schema_migrations: %w", err) + } + + entries, err := fs.ReadDir(migrationsFS, "migrations") + if err != nil { + return fmt.Errorf("read migrations dir: %w", err) + } + names := make([]string, 0, len(entries)) + for _, e := range entries { + if e.IsDir() || !strings.HasSuffix(e.Name(), ".sql") { + continue + } + names = append(names, e.Name()) + } + sort.Strings(names) + + for _, name := range names { + var exists string + err := conn.QueryRow("SELECT name FROM schema_migrations WHERE name = ?", name).Scan(&exists) + if err == nil { + continue + } + if err != sql.ErrNoRows { + return fmt.Errorf("check migration %s: %w", name, err) + } + + body, err := fs.ReadFile(migrationsFS, "migrations/"+name) + if err != nil { + return fmt.Errorf("read migration %s: %w", name, err) + } + if _, err := conn.Exec(string(body)); err != nil { + return fmt.Errorf("apply migration %s: %w", name, err) + } + if _, err := conn.Exec("INSERT INTO schema_migrations (name) VALUES (?)", name); err != nil { + return fmt.Errorf("record migration %s: %w", name, err) + } + } + return nil +} + +// ensureDataDir creates the parent directory for a `file:` SQLite DSN if needed. +func ensureDataDir(dsn string) error { + const prefix = "file:" + if !strings.HasPrefix(dsn, prefix) { + return nil + } + path := strings.TrimPrefix(dsn, prefix) + if i := strings.IndexByte(path, '?'); i >= 0 { + path = path[:i] + } + dir := filepath.Dir(path) + if dir == "" || dir == "." { + return nil + } + return mkdirAll(dir) +} diff --git a/internal/db/db_test.go b/internal/db/db_test.go new file mode 100644 index 0000000..9ad2a47 --- /dev/null +++ b/internal/db/db_test.go @@ -0,0 +1,50 @@ +package db + +import ( + "path/filepath" + "testing" +) + +func TestOpenAndMigrate(t *testing.T) { + dir := t.TempDir() + dsn := "file:" + filepath.Join(dir, "test.db") + "?_pragma=journal_mode(WAL)&_pragma=foreign_keys(ON)" + + conn, err := Open(dsn) + if err != nil { + t.Fatalf("Open: %v", err) + } + t.Cleanup(func() { _ = conn.Close() }) + + if err := Migrate(conn); err != nil { + t.Fatalf("Migrate: %v", err) + } + + // Idempotency: running again must be a no-op. + if err := Migrate(conn); err != nil { + t.Fatalf("Migrate (second run): %v", err) + } + + // Spot-check the OAuth tables created by 002_oauth.sql exist. + for _, table := range []string{"schema_migrations", "oauth_sessions", "oauth_auth_requests"} { + var got string + err := conn.QueryRow( + `SELECT name FROM sqlite_master WHERE type='table' AND name=?`, + table, + ).Scan(&got) + if err != nil { + t.Errorf("table %q missing after Migrate: %v", table, err) + } + } +} + +func TestOpen_CreatesParentDirectory(t *testing.T) { + dir := t.TempDir() + // Use a path several levels deep that doesn't exist yet. + dsn := "file:" + filepath.Join(dir, "deep", "nested", "data.db") + + conn, err := Open(dsn) + if err != nil { + t.Fatalf("Open should auto-create parent dirs: %v", err) + } + _ = conn.Close() +} diff --git a/internal/db/fs.go b/internal/db/fs.go new file mode 100644 index 0000000..e5397c9 --- /dev/null +++ b/internal/db/fs.go @@ -0,0 +1,9 @@ +package db + +import "os" + +// mkdirAll wraps os.MkdirAll so the rest of the package depends on a single +// filesystem helper (easier to swap in tests later). +func mkdirAll(dir string) error { + return os.MkdirAll(dir, 0o755) +} diff --git a/internal/db/migrations/001_init.sql b/internal/db/migrations/001_init.sql new file mode 100644 index 0000000..c938aac --- /dev/null +++ b/internal/db/migrations/001_init.sql @@ -0,0 +1,21 @@ +-- Initial schema. SQLite is the v1 store for app-side state that doesn't belong +-- in user PDS records: admin flags, event caches, and aggregate stats. + +CREATE TABLE users ( + did TEXT PRIMARY KEY, + handle TEXT NOT NULL, + is_admin INTEGER NOT NULL DEFAULT 0, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX users_handle_idx ON users (handle); + +-- Cached aggregate stats per event (refreshed by background workers). +-- Keeps the WebSocket progress bar fast without scanning PDSes on every tick. +CREATE TABLE event_stats ( + event_uri TEXT PRIMARY KEY, + unique_connectors INTEGER NOT NULL DEFAULT 0, + total_checkins INTEGER NOT NULL DEFAULT 0, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); diff --git a/internal/db/migrations/002_oauth.sql b/internal/db/migrations/002_oauth.sql new file mode 100644 index 0000000..55862a1 --- /dev/null +++ b/internal/db/migrations/002_oauth.sql @@ -0,0 +1,31 @@ +-- OAuth state for the indigo ClientAuthStore interface. +-- +-- Two tables: +-- * oauth_sessions — long-lived post-flow sessions (the access/refresh +-- tokens + DPoP key). Composite key (did, session_id) +-- so a single account can have multiple concurrent +-- sessions (different devices/browsers). +-- * oauth_auth_requests — short-lived pre-flow state, 10-minute TTL, +-- single-use; deleted after the callback handler +-- consumes it. +-- +-- `data` is the JSON-encoded ClientSessionData / AuthRequestInfo. v1 stores it +-- in plaintext; a future migration should encrypt at rest (chacha20poly1305 or +-- AES-GCM) because a leaked row is account takeover. + +CREATE TABLE oauth_sessions ( + did TEXT NOT NULL, + session_id TEXT NOT NULL, + data BLOB NOT NULL, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (did, session_id) +); + +CREATE TABLE oauth_auth_requests ( + state TEXT PRIMARY KEY, + data BLOB NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + expires_at TIMESTAMP NOT NULL +); + +CREATE INDEX oauth_auth_requests_expires_at_idx ON oauth_auth_requests (expires_at); diff --git a/internal/db/migrations/003_pending_connections.sql b/internal/db/migrations/003_pending_connections.sql new file mode 100644 index 0000000..e225f60 --- /dev/null +++ b/internal/db/migrations/003_pending_connections.sql @@ -0,0 +1,30 @@ +-- Pending connections queue. +-- +-- Holds reciprocal connection writes for users who weren't logged in at the +-- moment another user scanned their QR. When the target_did next logs in, the +-- callback handler drains these and writes one quest.atmo.connection record +-- per row to the target's PDS, then deletes the rows. +-- +-- target_did — the user we need to write a connection record FOR (the one +-- whose PDS the row will be flushed to) +-- initiator_did — the user that *scanner* — i.e. the value of the `with` +-- field on the record we'll write +-- created_at — when the scanner first hit /c/{did}/confirm +-- +-- Uniqueness on (target, initiator) prevents the same scanner spamming the +-- queue. If a user wants multiple connections to the same person (e.g. across +-- multiple events), that's modeled by writing additional records directly via +-- the live-session path — the queue is just the offline catch-up rail. + +CREATE TABLE pending_connections ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + target_did TEXT NOT NULL, + initiator_did TEXT NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX pending_connections_target_idx + ON pending_connections (target_did); + +CREATE UNIQUE INDEX pending_connections_unique_idx + ON pending_connections (target_did, initiator_did); diff --git a/internal/db/migrations/004_events_checkins.sql b/internal/db/migrations/004_events_checkins.sql new file mode 100644 index 0000000..a212f6e --- /dev/null +++ b/internal/db/migrations/004_events_checkins.sql @@ -0,0 +1,48 @@ +-- Events + check-ins (v0.1 scaffolding). +-- +-- `events` is a local cache of quest.atmo.event records. The canonical record +-- lives in the organizer's PDS; we mirror it locally so that +-- (a) the home page's "what am I currently checked into" query stays a +-- single SQL hit (no PDS roundtrip per render), and +-- (b) the events search page can query by name / location / time-window +-- without fanning out across the network. +-- +-- Records are cached on first sight (an admin creating an event, a user +-- check-in resolving the at-uri, etc.). Cached rows are best-effort — when +-- they fall out of sync with the source PDS, a fresh fetch overwrites them. +CREATE TABLE events ( + uri TEXT PRIMARY KEY, -- at-uri of the record + name TEXT NOT NULL, + start_time TIMESTAMP NOT NULL, + end_time TIMESTAMP NOT NULL, + location TEXT NOT NULL DEFAULT '', -- human-readable, e.g. "Seattle, WA" + geofence_lat REAL, -- NULL = no geofence + geofence_lng REAL, + geofence_radius INTEGER, -- meters + organizer_did TEXT NOT NULL DEFAULT '', + cached_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +-- Index helps "what events are currently ongoing" queries used by the home +-- page (CURRENT_TIMESTAMP BETWEEN start_time AND end_time). +CREATE INDEX events_time_window_idx ON events (start_time, end_time); + +-- `checkins` mirrors quest.atmo.checkin records. The canonical record lives +-- in the user's PDS; we cache so the home page can answer "is this user +-- currently checked into anything?" in one query. +-- +-- Multiple check-ins to the same event are allowed (a user re-checking in on +-- day 2, etc.); each is a distinct row keyed by record_uri. +CREATE TABLE checkins ( + record_uri TEXT PRIMARY KEY, -- at-uri of the checkin record + did TEXT NOT NULL, -- whose PDS holds it + event_uri TEXT NOT NULL, -- at-uri of the event record + checked_in_at TIMESTAMP NOT NULL, + cached_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (event_uri) REFERENCES events (uri) ON DELETE CASCADE +); + +-- "Find the user's most recent ongoing checkin" is the home-page hot path; +-- this index makes it an O(log N) lookup per user. +CREATE INDEX checkins_did_time_idx ON checkins (did, checked_in_at DESC); +CREATE INDEX checkins_event_idx ON checkins (event_uri); diff --git a/internal/db/migrations/005_admin_users_badges.sql b/internal/db/migrations/005_admin_users_badges.sql new file mode 100644 index 0000000..90ab800 --- /dev/null +++ b/internal/db/migrations/005_admin_users_badges.sql @@ -0,0 +1,60 @@ +-- Admins, users, and event extensions (v0.2). +-- +-- Three concerns in one migration because they're tightly coupled: +-- +-- 1. Extend the existing `users` table (defined in 001_init.sql) with +-- the columns the admin UI needs. We *don't* recreate the table — +-- 001 already created it with `did`, `handle`, `is_admin`, +-- `created_at`, `updated_at`. We add display_name, auth_count, and +-- is_banned. The existing `created_at` / `updated_at` columns +-- serve as first-seen / last-seen for our purposes. +-- +-- 2. New columns on `events` so admins can capture +-- `expected_attendees` and so we know which admin created the event +-- (for audit + filtering). A separate `qr_token` column gives each +-- event a short URL-safe slug for the /e/{token} scan-to-checkin +-- flow — we don't expose the full at-uri in QR codes both for +-- privacy and because they'd be unwieldy in print. +-- +-- 3. An `event_badges` table holding the admin-designed badge config +-- plus a server-signed signature over the canonical JSON of that +-- config. The signature lets us prove a badge design wasn't +-- tampered with after creation; later work (badge issuance) will +-- add a separate table binding (event, recipient_did) → signed +-- credential. +-- +-- All three are additive — none of the existing tables are modified +-- destructively, so rollback is a matter of DROPping in reverse order. + +ALTER TABLE users ADD COLUMN display_name TEXT NOT NULL DEFAULT ''; +ALTER TABLE users ADD COLUMN auth_count INTEGER NOT NULL DEFAULT 1; +ALTER TABLE users ADD COLUMN is_banned INTEGER NOT NULL DEFAULT 0; + +-- "Most recently active" listing for the admin UI. +CREATE INDEX users_last_seen_idx ON users (updated_at DESC); +-- Partial index: cheap "is this user an admin" check from middleware. +CREATE INDEX users_admins_idx ON users (did) WHERE is_admin = 1; + +-- Event extensions. SQLite supports ALTER TABLE ADD COLUMN cleanly. +ALTER TABLE events ADD COLUMN expected_attendees INTEGER NOT NULL DEFAULT 0; +ALTER TABLE events ADD COLUMN created_by_did TEXT NOT NULL DEFAULT ''; +ALTER TABLE events ADD COLUMN qr_token TEXT NOT NULL DEFAULT ''; + +-- qr_token must be unique among non-empty values so that /e/{token} +-- resolves to exactly one event. The unique partial index allows empty +-- strings on historical rows that pre-date this column. +CREATE UNIQUE INDEX events_qr_token_idx ON events (qr_token) WHERE qr_token != ''; + +CREATE TABLE event_badges ( + event_uri TEXT PRIMARY KEY, + shape TEXT NOT NULL, -- 'circle' | 'shield' | 'star' | 'hexagon' + primary_color TEXT NOT NULL, -- hex e.g. '#fab387' + accent_color TEXT NOT NULL, -- hex + ribbon_color TEXT NOT NULL, -- hex + label TEXT NOT NULL, -- short text on the badge face + signature TEXT NOT NULL, -- base64(ed25519(canonical_json)) + signing_key_id TEXT NOT NULL DEFAULT 'v1', -- which key signed; lets us rotate later + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (event_uri) REFERENCES events (uri) ON DELETE CASCADE +); diff --git a/internal/event/event.go b/internal/event/event.go new file mode 100644 index 0000000..28266b2 --- /dev/null +++ b/internal/event/event.go @@ -0,0 +1,149 @@ +// Package event reads and caches quest.atmo.event records. +// +// Events are records in an organizer's PDS, but the app needs to query them +// by time-window and (eventually) location, which is awkward to do over +// the network for every page render. So we keep a small local cache in +// SQLite — see `internal/db/migrations/004_events_checkins.sql`. +// +// Writes (record creation) are not implemented here — those live behind the +// admin UI in a future PR. This v1 only handles reading + caching what +// other code (a checkin handler, an admin import job, etc.) hands us. +package event + +import ( + "context" + "database/sql" + "errors" + "time" + + "github.com/bluesky-social/indigo/atproto/syntax" +) + +// NSID is the lexicon namespace. +const NSID = "quest.atmo.event" + +// Geofence captures the optional lat/lng/radius from the lexicon. A nil +// pointer on Record means the event has no geofence and the soft-check +// can't run. +type Geofence struct { + Lat float64 + Lng float64 + RadiusMeters int +} + +// Record models a single event for the application. Mirrors the lexicon but +// flattens the geofence into a pointer for ergonomic Go use. +type Record struct { + URI string // at-uri of the record + Name string + StartTime time.Time + EndTime time.Time + Location string + Geofence *Geofence + OrganizerDID syntax.DID +} + +// ErrNotFound is returned by Get when no cached event matches. +var ErrNotFound = errors.New("event: not found") + +// Cache upserts an event into the local cache. Idempotent — re-caching the +// same URI overwrites the prior row (so an admin updating the canonical +// record refreshes our view). +func Cache(ctx context.Context, db *sql.DB, r Record) error { + if r.URI == "" { + return errors.New("event: empty URI") + } + if r.Name == "" { + return errors.New("event: empty name") + } + if r.EndTime.Before(r.StartTime) { + return errors.New("event: end_time before start_time") + } + + var lat, lng *float64 + var radius *int + if r.Geofence != nil { + lat = &r.Geofence.Lat + lng = &r.Geofence.Lng + radius = &r.Geofence.RadiusMeters + } + + _, err := db.ExecContext(ctx, ` + INSERT INTO events ( + uri, name, start_time, end_time, location, + geofence_lat, geofence_lng, geofence_radius, + organizer_did, cached_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) + ON CONFLICT(uri) DO UPDATE SET + name = excluded.name, + start_time = excluded.start_time, + end_time = excluded.end_time, + location = excluded.location, + geofence_lat = excluded.geofence_lat, + geofence_lng = excluded.geofence_lng, + geofence_radius = excluded.geofence_radius, + organizer_did = excluded.organizer_did, + cached_at = CURRENT_TIMESTAMP + `, + r.URI, r.Name, r.StartTime.UTC(), r.EndTime.UTC(), r.Location, + lat, lng, radius, + r.OrganizerDID.String(), + ) + return err +} + +// Get returns the cached event for uri, or ErrNotFound if it isn't cached +// (yet). Future-work: fall back to a PDS fetch + cache write here. +func Get(ctx context.Context, db *sql.DB, uri string) (Record, error) { + row := db.QueryRowContext(ctx, ` + SELECT uri, name, start_time, end_time, location, + geofence_lat, geofence_lng, geofence_radius, organizer_did + FROM events + WHERE uri = ? + `, uri) + return scanRow(row) +} + +// IsOngoing returns true if the event's time window contains `at`. +func (r Record) IsOngoing(at time.Time) bool { + return !at.Before(r.StartTime) && !at.After(r.EndTime) +} + +// scanRow reads a single events row into a Record. Tolerant of NULL +// geofence columns. +func scanRow(s scanner) (Record, error) { + var r Record + var organizer string + var lat, lng sql.NullFloat64 + var radius sql.NullInt64 + err := s.Scan( + &r.URI, &r.Name, &r.StartTime, &r.EndTime, &r.Location, + &lat, &lng, &radius, &organizer, + ) + if err == sql.ErrNoRows { + return Record{}, ErrNotFound + } + if err != nil { + return Record{}, err + } + if organizer != "" { + did, parseErr := syntax.ParseDID(organizer) + if parseErr == nil { + r.OrganizerDID = did + } + } + if lat.Valid && lng.Valid && radius.Valid { + r.Geofence = &Geofence{ + Lat: lat.Float64, + Lng: lng.Float64, + RadiusMeters: int(radius.Int64), + } + } + return r, nil +} + +// scanner abstracts *sql.Row and *sql.Rows so scanRow can serve both Get +// and any future List helpers. +type scanner interface { + Scan(dest ...any) error +} diff --git a/internal/event/event_test.go b/internal/event/event_test.go new file mode 100644 index 0000000..fd5b8de --- /dev/null +++ b/internal/event/event_test.go @@ -0,0 +1,171 @@ +package event + +import ( + "context" + "database/sql" + "errors" + "path/filepath" + "testing" + "time" + + "github.com/bluesky-social/indigo/atproto/syntax" + + atdb "atmoquest/internal/db" +) + +// newTestDB returns a freshly-migrated SQLite DB scoped to the test. +func newTestDB(t *testing.T) (context.Context, *sql.DB) { + t.Helper() + dir := t.TempDir() + dsn := "file:" + filepath.Join(dir, "event.db") + "?_pragma=foreign_keys(ON)" + conn, err := atdb.Open(dsn) + if err != nil { + t.Fatalf("db open: %v", err) + } + if err := atdb.Migrate(conn); err != nil { + t.Fatalf("migrate: %v", err) + } + t.Cleanup(func() { _ = conn.Close() }) + return context.Background(), conn +} + +const ( + testEventURI = "at://did:plc:eventorganizer/quest.atmo.event/3lkabc" + testOrgDID = "did:plc:eventorganizer" +) + +func sampleEvent() Record { + return Record{ + URI: testEventURI, + Name: "CascadiaJS 2026", + StartTime: time.Now().Add(-1 * time.Hour).UTC(), + EndTime: time.Now().Add(8 * time.Hour).UTC(), + Location: "Portland, OR", + OrganizerDID: syntax.DID(testOrgDID), + } +} + +func TestCache_InsertAndGet(t *testing.T) { + ctx, db := newTestDB(t) + rec := sampleEvent() + + if err := Cache(ctx, db, rec); err != nil { + t.Fatalf("Cache: %v", err) + } + + got, err := Get(ctx, db, rec.URI) + if err != nil { + t.Fatalf("Get: %v", err) + } + if got.Name != rec.Name { + t.Errorf("Name = %q, want %q", got.Name, rec.Name) + } + if got.Location != rec.Location { + t.Errorf("Location = %q, want %q", got.Location, rec.Location) + } + if got.OrganizerDID.String() != rec.OrganizerDID.String() { + t.Errorf("OrganizerDID = %q, want %q", got.OrganizerDID, rec.OrganizerDID) + } + if got.Geofence != nil { + t.Errorf("Geofence = %+v, want nil", got.Geofence) + } +} + +func TestCache_UpsertOverwrites(t *testing.T) { + ctx, db := newTestDB(t) + rec := sampleEvent() + if err := Cache(ctx, db, rec); err != nil { + t.Fatalf("Cache initial: %v", err) + } + rec.Name = "CascadiaJS 2026 — Day 2" + rec.Location = "Portland Convention Center" + if err := Cache(ctx, db, rec); err != nil { + t.Fatalf("Cache update: %v", err) + } + got, err := Get(ctx, db, rec.URI) + if err != nil { + t.Fatalf("Get: %v", err) + } + if got.Name != "CascadiaJS 2026 — Day 2" { + t.Errorf("Name after upsert = %q", got.Name) + } + if got.Location != "Portland Convention Center" { + t.Errorf("Location after upsert = %q", got.Location) + } +} + +func TestCache_RoundTripsGeofence(t *testing.T) { + ctx, db := newTestDB(t) + rec := sampleEvent() + rec.Geofence = &Geofence{Lat: 45.5152, Lng: -122.6784, RadiusMeters: 250} + if err := Cache(ctx, db, rec); err != nil { + t.Fatalf("Cache: %v", err) + } + got, err := Get(ctx, db, rec.URI) + if err != nil { + t.Fatalf("Get: %v", err) + } + if got.Geofence == nil { + t.Fatalf("Geofence is nil; want %+v", rec.Geofence) + } + if got.Geofence.Lat != 45.5152 || got.Geofence.Lng != -122.6784 || got.Geofence.RadiusMeters != 250 { + t.Errorf("Geofence = %+v, want %+v", got.Geofence, rec.Geofence) + } +} + +func TestCache_RejectsBadInput(t *testing.T) { + ctx, db := newTestDB(t) + cases := []struct { + name string + mut func(*Record) + }{ + {"empty URI", func(r *Record) { r.URI = "" }}, + {"empty name", func(r *Record) { r.Name = "" }}, + {"end before start", func(r *Record) { + r.StartTime = time.Now() + r.EndTime = r.StartTime.Add(-time.Hour) + }}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + rec := sampleEvent() + tc.mut(&rec) + if err := Cache(ctx, db, rec); err == nil { + t.Errorf("Cache succeeded; want validation error") + } + }) + } +} + +func TestGet_NotFoundReturnsSentinel(t *testing.T) { + ctx, db := newTestDB(t) + _, err := Get(ctx, db, "at://did:plc:nope/quest.atmo.event/none") + if !errors.Is(err, ErrNotFound) { + t.Errorf("err = %v, want ErrNotFound", err) + } +} + +func TestIsOngoing(t *testing.T) { + start := time.Date(2026, 6, 1, 9, 0, 0, 0, time.UTC) + end := time.Date(2026, 6, 1, 18, 0, 0, 0, time.UTC) + rec := Record{StartTime: start, EndTime: end} + + cases := []struct { + name string + at time.Time + want bool + }{ + {"before", start.Add(-time.Hour), false}, + {"at start", start, true}, + {"middle", start.Add(4 * time.Hour), true}, + {"at end", end, true}, + {"after", end.Add(time.Hour), false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := rec.IsOngoing(tc.at); got != tc.want { + t.Errorf("IsOngoing(%v) = %v, want %v", tc.at, got, tc.want) + } + }) + } +} diff --git a/internal/event/put.go b/internal/event/put.go new file mode 100644 index 0000000..acdc2ab --- /dev/null +++ b/internal/event/put.go @@ -0,0 +1,268 @@ +package event + +import ( + "context" + "crypto/rand" + "database/sql" + "encoding/base32" + "errors" + "fmt" + "time" + + "github.com/bluesky-social/indigo/atproto/auth/oauth" + "github.com/bluesky-social/indigo/atproto/syntax" +) + +const nsidCreateRecord = "com.atproto.repo.createRecord" + +// CreateInput is the payload an admin form supplies when creating a new +// event. We keep this separate from `Record` so the handler can pass +// raw form data without having to construct an at-uri (the at-uri is +// derived after the PDS write). +type CreateInput struct { + Name string + StartTime time.Time + EndTime time.Time + Location string + ExpectedAttendees int + Geofence *Geofence +} + +// Put writes a new quest.atmo.event record to the authenticated admin's +// PDS, then caches the resulting record locally with extra admin-only +// columns (expected_attendees, created_by_did, qr_token). +// +// Returns the cached Record plus the QR token (a short URL-safe slug +// that the scan-to-checkin route /e/{token} resolves to this event). +// +// Caller must have the `repo:quest.atmo.event` scope on the session and +// must already be confirmed as an admin (the handler layer enforces +// this — Put doesn't re-check, so it can be reused from a CLI tool). +func Put(ctx context.Context, sess *oauth.ClientSession, db *sql.DB, in CreateInput) (Record, string, error) { + if sess == nil { + return Record{}, "", errors.New("event: nil oauth session") + } + if err := validateCreate(in); err != nil { + return Record{}, "", err + } + + // The record value as the PDS sees it. Mirrors the + // quest.atmo.event lexicon. We store times as RFC 3339 strings; + // the PDS doesn't have a richer time type. + value := map[string]any{ + "$type": NSID, + "name": in.Name, + "startTime": in.StartTime.UTC().Format(time.RFC3339), + "endTime": in.EndTime.UTC().Format(time.RFC3339), + "location": in.Location, + "expectedAttendees": in.ExpectedAttendees, + } + if in.Geofence != nil { + value["geofence"] = map[string]any{ + "lat": in.Geofence.Lat, + "lng": in.Geofence.Lng, + "radiusMeters": in.Geofence.RadiusMeters, + } + } + + input := map[string]any{ + "repo": sess.Data.AccountDID.String(), + "collection": NSID, + // `validate` omitted — PDSes that don't know the + // quest.atmo.event lexicon would reject `validate: true`. + "record": value, + } + var out struct { + URI string `json:"uri"` + CID string `json:"cid"` + } + if err := sess.APIClient().Post(ctx, syntax.NSID(nsidCreateRecord), input, &out); err != nil { + return Record{}, "", fmt.Errorf("createRecord %s: %w", NSID, err) + } + + rec := Record{ + URI: out.URI, + Name: in.Name, + StartTime: in.StartTime.UTC(), + EndTime: in.EndTime.UTC(), + Location: in.Location, + Geofence: in.Geofence, + OrganizerDID: sess.Data.AccountDID, + } + + // Cache locally, with admin-only columns. We generate a short + // random qr_token so the /e/{token} route stays compact in print. + qr, err := newQRToken() + if err != nil { + return Record{}, "", fmt.Errorf("event: qr token: %w", err) + } + + var lat, lng *float64 + var radius *int + if rec.Geofence != nil { + lat = &rec.Geofence.Lat + lng = &rec.Geofence.Lng + radius = &rec.Geofence.RadiusMeters + } + + _, err = db.ExecContext(ctx, ` + INSERT INTO events ( + uri, name, start_time, end_time, location, + geofence_lat, geofence_lng, geofence_radius, + organizer_did, cached_at, + expected_attendees, created_by_did, qr_token + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP, ?, ?, ?) + ON CONFLICT(uri) DO UPDATE SET + name = excluded.name, + start_time = excluded.start_time, + end_time = excluded.end_time, + location = excluded.location, + geofence_lat = excluded.geofence_lat, + geofence_lng = excluded.geofence_lng, + geofence_radius = excluded.geofence_radius, + organizer_did = excluded.organizer_did, + expected_attendees = excluded.expected_attendees, + -- Preserve created_by_did + qr_token on update so the + -- public scan-to-checkin URL stays stable across edits. + cached_at = CURRENT_TIMESTAMP + `, + rec.URI, rec.Name, rec.StartTime, rec.EndTime, rec.Location, + lat, lng, radius, + rec.OrganizerDID.String(), + in.ExpectedAttendees, sess.Data.AccountDID.String(), qr, + ) + if err != nil { + return Record{}, "", fmt.Errorf("event: cache: %w", err) + } + + // If the row already existed (admin edited an event), the ON + // CONFLICT branch above doesn't touch qr_token — read whatever's + // there so the caller gets the persisted value, not the freshly- + // generated one. + var persistedQR string + _ = db.QueryRowContext(ctx, `SELECT qr_token FROM events WHERE uri = ?`, rec.URI).Scan(&persistedQR) + if persistedQR != "" { + qr = persistedQR + } + + return rec, qr, nil +} + +// AdminMeta is the row data the admin event list cares about beyond the +// core Record. +type AdminMeta struct { + ExpectedAttendees int + CreatedByDID string + QRToken string +} + +// ListAdmin returns events with their admin-only metadata, ordered by +// start_time DESC (upcoming + recent events first). Limit caps the +// number of rows; pass 0 for the default of 100. +func ListAdmin(ctx context.Context, db *sql.DB, limit int) ([]AdminEvent, error) { + if limit <= 0 { + limit = 100 + } + rows, err := db.QueryContext(ctx, ` + SELECT uri, name, start_time, end_time, location, + geofence_lat, geofence_lng, geofence_radius, organizer_did, + expected_attendees, created_by_did, qr_token + FROM events + ORDER BY start_time DESC + LIMIT ? + `, limit) + if err != nil { + return nil, err + } + defer rows.Close() + + var out []AdminEvent + for rows.Next() { + var ae AdminEvent + var organizer string + var lat, lng sql.NullFloat64 + var radius sql.NullInt64 + if err := rows.Scan( + &ae.URI, &ae.Name, &ae.StartTime, &ae.EndTime, &ae.Location, + &lat, &lng, &radius, &organizer, + &ae.ExpectedAttendees, &ae.CreatedByDID, &ae.QRToken, + ); err != nil { + return nil, err + } + if organizer != "" { + if d, perr := syntax.ParseDID(organizer); perr == nil { + ae.OrganizerDID = d + } + } + if lat.Valid && lng.Valid && radius.Valid { + ae.Geofence = &Geofence{Lat: lat.Float64, Lng: lng.Float64, RadiusMeters: int(radius.Int64)} + } + out = append(out, ae) + } + return out, rows.Err() +} + +// AdminEvent is a Record plus admin-only columns. Returned by ListAdmin. +type AdminEvent struct { + Record + ExpectedAttendees int + CreatedByDID string + QRToken string +} + +// LookupByQRToken resolves a /e/{token} URL to the underlying event. +// Returns ErrNotFound for unknown tokens. +func LookupByQRToken(ctx context.Context, db *sql.DB, token string) (Record, error) { + if token == "" { + return Record{}, ErrNotFound + } + row := db.QueryRowContext(ctx, ` + SELECT uri, name, start_time, end_time, location, + geofence_lat, geofence_lng, geofence_radius, organizer_did + FROM events + WHERE qr_token = ? + `, token) + return scanRow(row) +} + +// validateCreate guards Put's inputs. +func validateCreate(in CreateInput) error { + if in.Name == "" { + return errors.New("event: empty name") + } + if in.StartTime.IsZero() || in.EndTime.IsZero() { + return errors.New("event: missing start/end time") + } + if in.EndTime.Before(in.StartTime) { + return errors.New("event: end_time before start_time") + } + if in.ExpectedAttendees < 0 { + return errors.New("event: expected_attendees cannot be negative") + } + return nil +} + +// newQRToken returns a short, URL-safe slug (base32, lowercase). We use +// 10 bytes of entropy → 16 chars of base32, which is plenty for a +// per-event public token and short enough to print on signage. +func newQRToken() (string, error) { + buf := make([]byte, 10) + if _, err := rand.Read(buf); err != nil { + return "", err + } + // Standard base32 without padding, lowercased. We strip '=' so the + // token slots cleanly into URLs. + s := base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(buf) + // Lowercase for friendlier QRs (case-insensitive scanners read both + // cases, but the URL itself ends up case-sensitive in the route + // param). Stick with lower so users typing it in by hand work too. + out := make([]byte, len(s)) + for i := 0; i < len(s); i++ { + c := s[i] + if c >= 'A' && c <= 'Z' { + c += 'a' - 'A' + } + out[i] = c + } + return string(out), nil +} diff --git a/internal/oauthclient/oauthclient.go b/internal/oauthclient/oauthclient.go new file mode 100644 index 0000000..6be514d --- /dev/null +++ b/internal/oauthclient/oauthclient.go @@ -0,0 +1,79 @@ +// Package oauthclient builds the indigo oauth.ClientApp for atmoquest. +// +// Two transport modes: +// +// - Localhost dev (PublicURL is http://localhost or http://127.0.0.1): +// uses oauth.NewLocalhostConfig — a public client with no metadata fetch, +// no signing key. This is the AS-supported escape hatch for loopback dev. +// +// - Production (PublicURL is https://…): public client backed by URL-based +// client metadata. v1 ships as a public client (no confidential signing +// key); upgrading to a confidential client requires loading an +// atcrypto.PrivateKey (P-256) and calling cfg.SetClientSecret. +// +// Scopes are deliberately narrow: `atproto` (mandatory identity) plus one +// `repo:quest.atmo.*` per app-owned lexicon. Reads of public records (e.g. +// `app.bsky.actor.profile`) don't need a scope. We do NOT request +// `transition:generic` — see DefaultScopes. +package oauthclient + +import ( + "fmt" + + "github.com/bluesky-social/indigo/atproto/auth/oauth" + + "atmoquest/config" +) + +// DefaultScopes is the scope set requested for every login. +// +// Design: principle of least privilege. We ask only for the `atproto` +// identity scope plus full CRUD on the records this app owns in the user's +// repo. We do NOT request `transition:generic` (which would grant write +// access to every record type, including all Bluesky posts/likes/follows) — +// that's the legacy blanket scope, and a quest-tracking app has no business +// holding it. +// +// Reading records (including `app.bsky.actor.profile` for display name / +// avatar) is unauthenticated — public records on a PDS need no scope. If we +// ever need to call AppView XRPCs (e.g. `app.bsky.actor.getProfile` for a +// hydrated profile), add the corresponding `rpc:?aud=` +// scope at that point. +// +// Each `repo:` here omits `action=`, which per spec grants +// create + update + delete on that collection. +var DefaultScopes = []string{ + "atproto", + "repo:quest.atmo.profile", + "repo:quest.atmo.event", + "repo:quest.atmo.checkin", + "repo:quest.atmo.connection", + "repo:quest.atmo.badge", +} + +// Build constructs an *oauth.ClientApp wired against the provided store. +// Returns the app plus the resolved client_id URL (for surfacing in logs / +// debug pages). +func Build(cfg *config.Config, store oauth.ClientAuthStore) (*oauth.ClientApp, string, error) { + if cfg.IsLocalhost() { + callbackURL := cfg.PublicURL + "/oauth/callback" + ocfg := oauth.NewLocalhostConfig(callbackURL, DefaultScopes) + app := oauth.NewClientApp(&ocfg, store) + return app, ocfg.ClientID, nil + } + + // Production / non-loopback. Public client (no confidential key) for v1; + // upgrade-to-confidential is a follow-up that needs key loading via + // atcrypto.ParsePrivateMultibase or generation + persistence. + clientID := cfg.PublicURL + "/oauth/client-metadata.json" + callbackURL := cfg.PublicURL + "/oauth/callback" + ocfg := oauth.NewPublicConfig(clientID, callbackURL, DefaultScopes) + app := oauth.NewClientApp(&ocfg, store) + + // Sanity check: NewPublicConfig should always set ClientID, but we depend + // on its bytewise equality with the metadata URL the AS will fetch. + if ocfg.ClientID != clientID { + return nil, "", fmt.Errorf("oauth client id mismatch: cfg=%q want=%q", ocfg.ClientID, clientID) + } + return app, ocfg.ClientID, nil +} diff --git a/internal/oauthclient/oauthclient_test.go b/internal/oauthclient/oauthclient_test.go new file mode 100644 index 0000000..9954b36 --- /dev/null +++ b/internal/oauthclient/oauthclient_test.go @@ -0,0 +1,111 @@ +package oauthclient + +import ( + "strings" + "testing" + + "atmoquest/config" + "atmoquest/internal/oauthstore" +) + +// nilStore is a typed nil that satisfies oauth.ClientAuthStore. Build only +// stashes the store on the ClientApp; it doesn't dereference it. We use this +// instead of spinning up SQLite for what's a pure-construction test. +var nilStore = (*oauthstore.Store)(nil) + +func TestBuild_Localhost(t *testing.T) { + cfg := &config.Config{PublicURL: "http://localhost:3000"} + app, clientID, err := Build(cfg, nilStore) + if err != nil { + t.Fatalf("Build: %v", err) + } + if app == nil { + t.Fatal("Build returned nil app") + } + // indigo's localhost client_id is a synthetic URL that begins with + // http://localhost and embeds the redirect URI as a query param. + if !strings.HasPrefix(clientID, "http://localhost") { + t.Errorf("localhost client_id should start with http://localhost; got %q", clientID) + } + if !strings.Contains(clientID, "redirect_uri=") { + t.Errorf("localhost client_id should contain redirect_uri param; got %q", clientID) + } + if app.Config.IsConfidential() { + t.Error("localhost client should not be confidential") + } +} + +func TestBuild_Localhost127001(t *testing.T) { + cfg := &config.Config{PublicURL: "http://127.0.0.1:3000"} + _, clientID, err := Build(cfg, nilStore) + if err != nil { + t.Fatalf("Build: %v", err) + } + if !strings.HasPrefix(clientID, "http://localhost") { + t.Errorf("127.0.0.1 should still produce a loopback localhost client_id; got %q", clientID) + } +} + +func TestBuild_HTTPSProduction(t *testing.T) { + cfg := &config.Config{PublicURL: "https://atmoquest"} + app, clientID, err := Build(cfg, nilStore) + if err != nil { + t.Fatalf("Build: %v", err) + } + want := "https://atmoquest/oauth/client-metadata.json" + if clientID != want { + t.Errorf("client_id = %q, want %q", clientID, want) + } + if app.Config.IsConfidential() { + t.Error("v1 production client should be a public client (not confidential) until a signing key is wired") + } +} + +func TestDefaultScopes(t *testing.T) { + if len(DefaultScopes) < 2 { + t.Fatalf("DefaultScopes should request at least atproto + a write scope; got %v", DefaultScopes) + } + + set := make(map[string]bool, len(DefaultScopes)) + for _, s := range DefaultScopes { + set[s] = true + } + + // `atproto` is mandatory per the atproto OAuth profile. + if !set["atproto"] { + t.Errorf("DefaultScopes must include 'atproto'; got %v", DefaultScopes) + } + + // Principle of least privilege: we should NOT request the legacy blanket + // scope. Anything write-related must be a granular `repo:` scope. + if set["transition:generic"] { + t.Errorf("DefaultScopes must NOT include 'transition:generic' (legacy blanket scope); got %v", DefaultScopes) + } + if set["transition:chat.bsky"] || set["transition:email"] { + t.Errorf("DefaultScopes should not request transition:* scopes; got %v", DefaultScopes) + } + + // Every app-owned lexicon under quest.atmo.* must have a corresponding + // repo: write scope, otherwise the feature that needs it will silently + // fail at write time. Update this list when adding a new lexicon. + wantRepoScopes := []string{ + "repo:quest.atmo.profile", + "repo:quest.atmo.event", + "repo:quest.atmo.checkin", + "repo:quest.atmo.connection", + "repo:quest.atmo.badge", + } + for _, want := range wantRepoScopes { + if !set[want] { + t.Errorf("DefaultScopes missing required scope %q; got %v", want, DefaultScopes) + } + } + + // Catch over-broad wildcards that would defeat the point of the granular + // list above. + for _, s := range DefaultScopes { + if s == "repo:*" || s == "rpc:*" { + t.Errorf("DefaultScopes should not include broad wildcard %q", s) + } + } +} diff --git a/internal/oauthstore/store.go b/internal/oauthstore/store.go new file mode 100644 index 0000000..22f2c94 --- /dev/null +++ b/internal/oauthstore/store.go @@ -0,0 +1,149 @@ +// Package oauthstore implements indigo's oauth.ClientAuthStore backed by +// SQLite. It persists two kinds of records: +// +// - oauth_sessions — long-lived session data (access/refresh tokens, +// DPoP key) keyed by (DID, sessionID). +// - oauth_auth_requests — short-lived pre-flow state, 10-minute TTL. +// +// v1 stores the payloads as plaintext JSON in BLOB columns. A future migration +// should encrypt at rest (chacha20poly1305 or AES-GCM) because a leaked row is +// equivalent to account takeover. +// +// Garbage collection of expired auth requests is done opportunistically on +// every SaveAuthRequestInfo call. At higher volumes this should move to a +// background ticker. +package oauthstore + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "fmt" + "time" + + "github.com/bluesky-social/indigo/atproto/auth/oauth" + "github.com/bluesky-social/indigo/atproto/syntax" +) + +// authRequestTTL is the indigo-recommended lifetime for pre-flow request state. +const authRequestTTL = 10 * time.Minute + +// Store implements oauth.ClientAuthStore against a database/sql connection. +type Store struct { + db *sql.DB +} + +// New constructs a SQLite-backed ClientAuthStore. The caller is responsible +// for running migrations beforehand (see internal/db/migrations/002_oauth.sql). +func New(db *sql.DB) *Store { + return &Store{db: db} +} + +// Compile-time assertion that we satisfy the interface. +var _ oauth.ClientAuthStore = (*Store)(nil) + +// --- Sessions --------------------------------------------------------------- + +func (s *Store) GetSession(ctx context.Context, did syntax.DID, sessionID string) (*oauth.ClientSessionData, error) { + var blob []byte + err := s.db.QueryRowContext(ctx, ` + SELECT data FROM oauth_sessions WHERE did = ? AND session_id = ? + `, did.String(), sessionID).Scan(&blob) + if errors.Is(err, sql.ErrNoRows) { + return nil, fmt.Errorf("oauth session not found for did=%s session=%s", did, sessionID) + } + if err != nil { + return nil, fmt.Errorf("query oauth session: %w", err) + } + + var data oauth.ClientSessionData + if err := json.Unmarshal(blob, &data); err != nil { + return nil, fmt.Errorf("decode oauth session: %w", err) + } + return &data, nil +} + +func (s *Store) SaveSession(ctx context.Context, sess oauth.ClientSessionData) error { + blob, err := json.Marshal(sess) + if err != nil { + return fmt.Errorf("encode oauth session: %w", err) + } + _, err = s.db.ExecContext(ctx, ` + INSERT INTO oauth_sessions (did, session_id, data, updated_at) + VALUES (?, ?, ?, CURRENT_TIMESTAMP) + ON CONFLICT(did, session_id) DO UPDATE SET + data = excluded.data, + updated_at = CURRENT_TIMESTAMP + `, sess.AccountDID.String(), sess.SessionID, blob) + if err != nil { + return fmt.Errorf("upsert oauth session: %w", err) + } + return nil +} + +func (s *Store) DeleteSession(ctx context.Context, did syntax.DID, sessionID string) error { + _, err := s.db.ExecContext(ctx, ` + DELETE FROM oauth_sessions WHERE did = ? AND session_id = ? + `, did.String(), sessionID) + if err != nil { + return fmt.Errorf("delete oauth session: %w", err) + } + return nil +} + +// --- Auth requests (pre-flow state) ----------------------------------------- + +func (s *Store) GetAuthRequestInfo(ctx context.Context, state string) (*oauth.AuthRequestData, error) { + var blob []byte + var expiresAt time.Time + err := s.db.QueryRowContext(ctx, ` + SELECT data, expires_at FROM oauth_auth_requests WHERE state = ? + `, state).Scan(&blob, &expiresAt) + if errors.Is(err, sql.ErrNoRows) { + return nil, fmt.Errorf("oauth auth request not found for state=%s", state) + } + if err != nil { + return nil, fmt.Errorf("query oauth auth request: %w", err) + } + if time.Now().After(expiresAt) { + // Treat expired entries as absent; surface a clear error and clean up. + _, _ = s.db.ExecContext(ctx, `DELETE FROM oauth_auth_requests WHERE state = ?`, state) + return nil, fmt.Errorf("oauth auth request expired for state=%s", state) + } + + var data oauth.AuthRequestData + if err := json.Unmarshal(blob, &data); err != nil { + return nil, fmt.Errorf("decode oauth auth request: %w", err) + } + return &data, nil +} + +func (s *Store) SaveAuthRequestInfo(ctx context.Context, info oauth.AuthRequestData) error { + blob, err := json.Marshal(info) + if err != nil { + return fmt.Errorf("encode oauth auth request: %w", err) + } + + // Opportunistic GC of expired entries. Fine at low volumes; promote to a + // cron when usage justifies it. + _, _ = s.db.ExecContext(ctx, `DELETE FROM oauth_auth_requests WHERE expires_at < CURRENT_TIMESTAMP`) + + now := time.Now() + _, err = s.db.ExecContext(ctx, ` + INSERT INTO oauth_auth_requests (state, data, created_at, expires_at) + VALUES (?, ?, ?, ?) + `, info.State, blob, now, now.Add(authRequestTTL)) + if err != nil { + return fmt.Errorf("insert oauth auth request: %w", err) + } + return nil +} + +func (s *Store) DeleteAuthRequestInfo(ctx context.Context, state string) error { + _, err := s.db.ExecContext(ctx, `DELETE FROM oauth_auth_requests WHERE state = ?`, state) + if err != nil { + return fmt.Errorf("delete oauth auth request: %w", err) + } + return nil +} diff --git a/internal/oauthstore/store_test.go b/internal/oauthstore/store_test.go new file mode 100644 index 0000000..3930d24 --- /dev/null +++ b/internal/oauthstore/store_test.go @@ -0,0 +1,272 @@ +package oauthstore + +import ( + "context" + "database/sql" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/bluesky-social/indigo/atproto/auth/oauth" + "github.com/bluesky-social/indigo/atproto/syntax" + _ "modernc.org/sqlite" + + atdb "atmoquest/internal/db" +) + +// newTestStore opens a fresh SQLite database in a temp directory, runs the +// app's migrations, and returns a ready-to-use Store. The connection is +// closed automatically when the test ends. +func newTestStore(t *testing.T) (*Store, *sql.DB) { + t.Helper() + dir := t.TempDir() + dsn := "file:" + filepath.Join(dir, "store.db") + "?_pragma=journal_mode(WAL)&_pragma=foreign_keys(ON)" + conn, err := atdb.Open(dsn) + if err != nil { + t.Fatalf("db open: %v", err) + } + t.Cleanup(func() { _ = conn.Close() }) + if err := atdb.Migrate(conn); err != nil { + t.Fatalf("migrate: %v", err) + } + return New(conn), conn +} + +func fakeSession(t *testing.T) oauth.ClientSessionData { + t.Helper() + did, err := syntax.ParseDID("did:plc:abcde12345abcde12345abcd") + if err != nil { + t.Fatalf("parse did: %v", err) + } + return oauth.ClientSessionData{ + AccountDID: did, + SessionID: "session-state-token-001", + HostURL: "https://pds.example.com", + AuthServerURL: "https://pds.example.com", + AuthServerTokenEndpoint: "https://pds.example.com/oauth/token", + Scopes: []string{"atproto", "transition:generic"}, + AccessToken: "access.tok.123", + RefreshToken: "refresh.tok.456", + DPoPPrivateKeyMultibase: "zXyz", + } +} + +func TestStore_SessionRoundTrip(t *testing.T) { + store, _ := newTestStore(t) + ctx := context.Background() + sess := fakeSession(t) + + if err := store.SaveSession(ctx, sess); err != nil { + t.Fatalf("SaveSession: %v", err) + } + + got, err := store.GetSession(ctx, sess.AccountDID, sess.SessionID) + if err != nil { + t.Fatalf("GetSession: %v", err) + } + if got.AccountDID != sess.AccountDID { + t.Errorf("AccountDID = %s, want %s", got.AccountDID, sess.AccountDID) + } + if got.AccessToken != sess.AccessToken { + t.Errorf("AccessToken = %q, want %q", got.AccessToken, sess.AccessToken) + } + if got.HostURL != sess.HostURL { + t.Errorf("HostURL = %q, want %q", got.HostURL, sess.HostURL) + } + if len(got.Scopes) != 2 { + t.Errorf("Scopes len = %d, want 2", len(got.Scopes)) + } +} + +func TestStore_SessionUpsert(t *testing.T) { + store, _ := newTestStore(t) + ctx := context.Background() + sess := fakeSession(t) + + if err := store.SaveSession(ctx, sess); err != nil { + t.Fatalf("SaveSession (initial): %v", err) + } + + // Mutate and re-save; should overwrite, not error. + sess.AccessToken = "access.tok.NEW" + if err := store.SaveSession(ctx, sess); err != nil { + t.Fatalf("SaveSession (update): %v", err) + } + + got, err := store.GetSession(ctx, sess.AccountDID, sess.SessionID) + if err != nil { + t.Fatalf("GetSession: %v", err) + } + if got.AccessToken != "access.tok.NEW" { + t.Errorf("AccessToken = %q, want updated value", got.AccessToken) + } +} + +func TestStore_GetSessionNotFound(t *testing.T) { + store, _ := newTestStore(t) + ctx := context.Background() + did, _ := syntax.ParseDID("did:plc:missingmissingmissingmiss") + + _, err := store.GetSession(ctx, did, "nope") + if err == nil { + t.Fatal("expected not-found error") + } + if !strings.Contains(err.Error(), "not found") { + t.Errorf("error %q should mention 'not found'", err) + } +} + +func TestStore_DeleteSession(t *testing.T) { + store, _ := newTestStore(t) + ctx := context.Background() + sess := fakeSession(t) + + if err := store.SaveSession(ctx, sess); err != nil { + t.Fatalf("SaveSession: %v", err) + } + if err := store.DeleteSession(ctx, sess.AccountDID, sess.SessionID); err != nil { + t.Fatalf("DeleteSession: %v", err) + } + if _, err := store.GetSession(ctx, sess.AccountDID, sess.SessionID); err == nil { + t.Fatal("expected not-found after delete") + } + + // Delete-of-nothing is a no-op, not an error. + if err := store.DeleteSession(ctx, sess.AccountDID, sess.SessionID); err != nil { + t.Errorf("DeleteSession (already gone) returned error: %v", err) + } +} + +func TestStore_AuthRequestRoundTrip(t *testing.T) { + store, _ := newTestStore(t) + ctx := context.Background() + did, _ := syntax.ParseDID("did:plc:abcde12345abcde12345abcd") + + req := oauth.AuthRequestData{ + State: "state-abc-123", + AuthServerURL: "https://pds.example.com", + AccountDID: &did, + Scopes: []string{"atproto"}, + RequestURI: "urn:ietf:params:oauth:request_uri:abc", + AuthServerTokenEndpoint: "https://pds.example.com/oauth/token", + PKCEVerifier: "verifier-123", + DPoPPrivateKeyMultibase: "zPriv", + } + + if err := store.SaveAuthRequestInfo(ctx, req); err != nil { + t.Fatalf("SaveAuthRequestInfo: %v", err) + } + + got, err := store.GetAuthRequestInfo(ctx, req.State) + if err != nil { + t.Fatalf("GetAuthRequestInfo: %v", err) + } + if got.State != req.State { + t.Errorf("State = %q, want %q", got.State, req.State) + } + if got.PKCEVerifier != req.PKCEVerifier { + t.Errorf("PKCEVerifier = %q, want %q", got.PKCEVerifier, req.PKCEVerifier) + } + if got.AccountDID == nil || got.AccountDID.String() != did.String() { + t.Errorf("AccountDID round-trip failed: %+v", got.AccountDID) + } +} + +func TestStore_AuthRequestNotFound(t *testing.T) { + store, _ := newTestStore(t) + ctx := context.Background() + if _, err := store.GetAuthRequestInfo(ctx, "no-such-state"); err == nil { + t.Fatal("expected not-found error") + } +} + +func TestStore_AuthRequestExpiry(t *testing.T) { + store, conn := newTestStore(t) + ctx := context.Background() + + req := oauth.AuthRequestData{ + State: "state-expired", + AuthServerURL: "https://pds.example.com", + Scopes: []string{"atproto"}, + } + if err := store.SaveAuthRequestInfo(ctx, req); err != nil { + t.Fatalf("SaveAuthRequestInfo: %v", err) + } + + // Force the expires_at into the past to simulate TTL elapse without + // sleeping. The store reads expires_at directly. + if _, err := conn.ExecContext(ctx, + `UPDATE oauth_auth_requests SET expires_at = ? WHERE state = ?`, + time.Now().Add(-time.Hour), req.State, + ); err != nil { + t.Fatalf("force expiry: %v", err) + } + + _, err := store.GetAuthRequestInfo(ctx, req.State) + if err == nil { + t.Fatal("expected expired-error") + } + if !strings.Contains(err.Error(), "expired") { + t.Errorf("error %q should mention 'expired'", err) + } + + // And the expired row should have been cleaned up. + var count int + _ = conn.QueryRowContext(ctx, `SELECT COUNT(*) FROM oauth_auth_requests WHERE state = ?`, req.State).Scan(&count) + if count != 0 { + t.Errorf("expired row not cleaned up; count = %d", count) + } +} + +func TestStore_AuthRequestGCOnSave(t *testing.T) { + store, conn := newTestStore(t) + ctx := context.Background() + + // Insert a row already-expired via raw SQL so we can verify SaveAuthRequestInfo + // cleans it up opportunistically. + if _, err := conn.ExecContext(ctx, + `INSERT INTO oauth_auth_requests (state, data, created_at, expires_at) VALUES (?, ?, ?, ?)`, + "old-state", []byte(`{}`), time.Now().Add(-2*time.Hour), time.Now().Add(-time.Hour), + ); err != nil { + t.Fatalf("seed expired row: %v", err) + } + + if err := store.SaveAuthRequestInfo(ctx, oauth.AuthRequestData{State: "fresh-state"}); err != nil { + t.Fatalf("SaveAuthRequestInfo: %v", err) + } + + var oldCount int + _ = conn.QueryRowContext(ctx, `SELECT COUNT(*) FROM oauth_auth_requests WHERE state = ?`, "old-state").Scan(&oldCount) + if oldCount != 0 { + t.Errorf("expired row not GC'd on save; count = %d", oldCount) + } + + var freshCount int + _ = conn.QueryRowContext(ctx, `SELECT COUNT(*) FROM oauth_auth_requests WHERE state = ?`, "fresh-state").Scan(&freshCount) + if freshCount != 1 { + t.Errorf("fresh row missing; count = %d", freshCount) + } +} + +func TestStore_DeleteAuthRequest(t *testing.T) { + store, _ := newTestStore(t) + ctx := context.Background() + + if err := store.SaveAuthRequestInfo(ctx, oauth.AuthRequestData{State: "doomed"}); err != nil { + t.Fatalf("SaveAuthRequestInfo: %v", err) + } + if err := store.DeleteAuthRequestInfo(ctx, "doomed"); err != nil { + t.Fatalf("DeleteAuthRequestInfo: %v", err) + } + if _, err := store.GetAuthRequestInfo(ctx, "doomed"); err == nil { + t.Fatal("expected not-found after delete") + } +} + +// Compile-time assertion lives in store.go (`var _ oauth.ClientAuthStore = +// (*Store)(nil)`), but we restate it here so a future package split or rename +// trips the test build instead of just the production build. +func TestStore_SatisfiesInterface(t *testing.T) { + var _ oauth.ClientAuthStore = (*Store)(nil) +} diff --git a/internal/profile/profile.go b/internal/profile/profile.go new file mode 100644 index 0000000..3f6e34d --- /dev/null +++ b/internal/profile/profile.go @@ -0,0 +1,302 @@ +// Package profile reads and writes the user's atmoquest profile and the +// underlying app.bsky.actor.profile record on their PDS. +// +// Design: +// +// - Reads of public records (both app.bsky.actor.profile and +// quest.atmo.profile) go through an unauthenticated atclient pointed at +// the user's PDS. No scope required. +// - Writes go through the user's OAuth session via sess.APIClient(), which +// handles DPoP signing + access-token + auto-refresh. Requires the +// `repo:quest.atmo.profile` scope (see oauthclient.DefaultScopes). +// - Avatars are served as blobs from the PDS via com.atproto.sync.getBlob, +// a public endpoint. This works for any ATProto provider, not just bsky's +// CDN. +package profile + +import ( + "context" + "errors" + "fmt" + "net/http" + "net/url" + "strings" + "time" + + "github.com/bluesky-social/indigo/atproto/atclient" + "github.com/bluesky-social/indigo/atproto/auth/oauth" + "github.com/bluesky-social/indigo/atproto/syntax" +) + +const ( + // NSIDs we read and write. + bskyProfileNSID = "app.bsky.actor.profile" + questProfileNSID = "quest.atmo.profile" + + // getRecord / putRecord live in the com.atproto.repo namespace. + nsidGetRecord = "com.atproto.repo.getRecord" + nsidPutRecord = "com.atproto.repo.putRecord" + + // MaxLinks mirrors lexicons/quest/atmo/profile.json#links.maxLength. + // Form parsing trims to this cap defensively. + MaxLinks = 5 + + // MaxInterests mirrors lexicons/quest/atmo/profile.json#interests.maxLength. + MaxInterests = 30 + + // MaxBioRunes mirrors profile.json#bio.maxGraphemes (close enough — we + // count runes, not graphemes; this is a UI-side belt-and-braces check + // before the lexicon validator runs on the PDS). + MaxBioRunes = 256 +) + +// ErrNotFound is returned by Fetch* when the record doesn't exist yet. +// Callers should treat this as "no profile yet, render defaults". +var ErrNotFound = errors.New("profile: record not found") + +// Link mirrors quest.atmo.profile#link. +type Link struct { + Label string `json:"label"` + URL string `json:"url"` +} + +// QuestRecord is the decoded value of a quest.atmo.profile record. +// Fields not yet surfaced in the UI are still parsed so a future read/write +// cycle round-trips them. +type QuestRecord struct { + Bio string `json:"bio,omitempty"` + Links []Link `json:"links,omitempty"` + Interests []string `json:"interests,omitempty"` + Location string `json:"location,omitempty"` + WorksAt string `json:"worksAt,omitempty"` + ContactMethod string `json:"contactMethod,omitempty"` + Hiring *bool `json:"hiring,omitempty"` + Looking *bool `json:"looking,omitempty"` + UpdatedAt time.Time `json:"updatedAt,omitempty"` +} + +// BlueskyRecord is the subset of app.bsky.actor.profile we read. +// We deliberately ignore banner, labels, joinedViaStarterPack, etc — they +// aren't shown on the atmoquest profile. +type BlueskyRecord struct { + DisplayName string `json:"displayName,omitempty"` + Description string `json:"description,omitempty"` + Avatar *BlobRef `json:"avatar,omitempty"` +} + +// BlobRef is the wire shape of an atproto blob reference. The Ref.Link field +// holds the CID; MimeType is the original upload mime. +// +// We only need the CID and mime for rendering, so we don't model the full +// CBOR/JSON blob ref struct (which carries size, $type=blob, etc). +type BlobRef struct { + Ref BlobRefLink `json:"ref"` + MimeType string `json:"mimeType,omitempty"` +} + +// BlobRefLink is the {"$link": ""} sub-object inside a blob ref. +type BlobRefLink struct { + Link string `json:"$link"` +} + +// CID returns the avatar blob's CID string, or empty if missing. +func (b *BlobRef) CID() string { + if b == nil { + return "" + } + return b.Ref.Link +} + +// getRecordResponse is the wire shape of com.atproto.repo.getRecord output. +// The "value" field shape depends on the collection — we unmarshal it into +// the caller-provided target via a second decode pass. +type getRecordResponse struct { + URI string `json:"uri"` + CID string `json:"cid"` + Value interface{} `json:"value"` +} + +// FetchBluesky reads app.bsky.actor.profile/self from the given PDS for the +// given DID. Public, unauthenticated. Returns ErrNotFound if the user has no +// Bluesky profile record (rare but valid for non-bsky-onboarded accounts). +func FetchBluesky(ctx context.Context, pdsHost string, did syntax.DID) (*BlueskyRecord, error) { + var out BlueskyRecord + if err := fetchRecord(ctx, pdsHost, did, bskyProfileNSID, "self", &out); err != nil { + return nil, err + } + return &out, nil +} + +// FetchQuest reads quest.atmo.profile/self from the given PDS for the given +// DID. Public, unauthenticated. Returns ErrNotFound if no record exists yet +// (typical for a freshly-signed-in user). +func FetchQuest(ctx context.Context, pdsHost string, did syntax.DID) (*QuestRecord, error) { + var out QuestRecord + if err := fetchRecord(ctx, pdsHost, did, questProfileNSID, "self", &out); err != nil { + return nil, err + } + return &out, nil +} + +func fetchRecord(ctx context.Context, pdsHost string, did syntax.DID, collection, rkey string, value any) error { + c := atclient.NewAPIClient(pdsHost) + params := map[string]any{ + "repo": did.String(), + "collection": collection, + "rkey": rkey, + } + // Two-pass: first decode the envelope, then re-decode the inner value. + // atclient.Get does one JSON pass into the target, but we need to map the + // envelope's `value` field onto our struct, so use a generic envelope + // shape and re-marshal. + var env struct { + URI string `json:"uri"` + CID string `json:"cid"` + Value any `json:"value"` + } + err := c.Get(ctx, syntax.NSID(nsidGetRecord), params, &env) + if err != nil { + // atclient surfaces 4xx via atclient.APIError; treat + // RecordNotFound + 400 InvalidRequest both as ErrNotFound to be + // resilient across PDS implementations. + if isRecordMissing(err) { + return ErrNotFound + } + return fmt.Errorf("getRecord %s: %w", collection, err) + } + if env.Value == nil { + return ErrNotFound + } + // Re-marshal the generic value into the typed target. This is cheap (a + // single small record) and keeps us decoupled from any JSON tag magic. + return remarshal(env.Value, value) +} + +func isRecordMissing(err error) bool { + if err == nil { + return false + } + var apiErr *atclient.APIError + if errors.As(err, &apiErr) { + switch apiErr.StatusCode { + case http.StatusNotFound: + return true + case http.StatusBadRequest: + // PDSes surface a missing record as 400 InvalidRequest with a + // name like "RecordNotFound" — match on either. + n := strings.ToLower(apiErr.Name) + if strings.Contains(n, "notfound") || strings.Contains(n, "not_found") { + return true + } + } + } + // Fallback: some clients flatten the error to its message. + msg := strings.ToLower(err.Error()) + return strings.Contains(msg, "could not locate record") || + strings.Contains(msg, "recordnotfound") || + strings.Contains(msg, "record not found") +} + +// PutQuest writes (creates or overwrites) the user's quest.atmo.profile/self +// record via the authenticated OAuth session. Sets UpdatedAt to now if it's +// zero. Returns the new record CID. +// +// Caller must have the `repo:quest.atmo.profile` scope on the session. +func PutQuest(ctx context.Context, sess *oauth.ClientSession, did syntax.DID, rec QuestRecord) (string, error) { + if sess == nil { + return "", errors.New("profile: nil oauth session") + } + if rec.UpdatedAt.IsZero() { + rec.UpdatedAt = time.Now().UTC() + } + + // Build the record value with $type set so the PDS can validate it + // against the right lexicon. + value := map[string]any{ + "$type": questProfileNSID, + "updatedAt": rec.UpdatedAt.UTC().Format(time.RFC3339), + } + if rec.Bio != "" { + value["bio"] = rec.Bio + } + if len(rec.Interests) > 0 { + value["interests"] = rec.Interests + } + if len(rec.Links) > 0 { + links := make([]map[string]any, 0, len(rec.Links)) + for _, l := range rec.Links { + links = append(links, map[string]any{ + "label": l.Label, + "url": l.URL, + }) + } + value["links"] = links + } + if rec.Location != "" { + value["location"] = rec.Location + } + if rec.WorksAt != "" { + value["worksAt"] = rec.WorksAt + } + if rec.ContactMethod != "" { + value["contactMethod"] = rec.ContactMethod + } + if rec.Hiring != nil { + value["hiring"] = *rec.Hiring + } + if rec.Looking != nil { + value["looking"] = *rec.Looking + } + + input := map[string]any{ + "repo": did.String(), + "collection": questProfileNSID, + "rkey": "self", + // `validate` is intentionally omitted — when set to `true` PDSes + // that haven't cached our (custom) quest.atmo.profile lexicon + // reject the write with `InvalidRequest: Unknown lexicon type`. + // Leaving it unset asks the PDS to validate only against lexicons + // it already knows, which is what we want. + "record": value, + } + var out struct { + URI string `json:"uri"` + CID string `json:"cid"` + } + if err := sess.APIClient().Post(ctx, syntax.NSID(nsidPutRecord), input, &out); err != nil { + return "", fmt.Errorf("putRecord %s: %w", questProfileNSID, err) + } + return out.CID, nil +} + +// AvatarURL builds the public blob URL on the user's PDS for the avatar CID. +// Returns empty string if either input is missing. +// +// We use com.atproto.sync.getBlob — a public, unauthenticated endpoint on +// every PDS. This works for any provider (bsky.social, custom PDSes, etc.) +// and never depends on bsky's CDN. +func AvatarURL(pdsHost string, did syntax.DID, cid string) string { + if pdsHost == "" || did == "" || cid == "" { + return "" + } + host := strings.TrimRight(pdsHost, "/") + q := url.Values{ + "did": []string{did.String()}, + "cid": []string{cid}, + } + return host + "/xrpc/com.atproto.sync.getBlob?" + q.Encode() +} + +// EffectiveBio picks the bio to show: the atmoquest override if set, else +// the Bluesky description, else empty. Whitespace-only counts as unset. +func EffectiveBio(quest *QuestRecord, bsky *BlueskyRecord) string { + if quest != nil { + if b := strings.TrimSpace(quest.Bio); b != "" { + return b + } + } + if bsky != nil { + return strings.TrimSpace(bsky.Description) + } + return "" +} diff --git a/internal/profile/profile_test.go b/internal/profile/profile_test.go new file mode 100644 index 0000000..47869f4 --- /dev/null +++ b/internal/profile/profile_test.go @@ -0,0 +1,252 @@ +package profile + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + + "github.com/bluesky-social/indigo/atproto/syntax" +) + +const testDID = "did:plc:abcdefghij1234567890abcd" + +// newPDS returns a minimal PDS-like test server that serves +// com.atproto.repo.getRecord. The handler is supplied by each test. +func newPDS(t *testing.T, handler func(http.ResponseWriter, *http.Request)) *httptest.Server { + t.Helper() + mux := http.NewServeMux() + mux.HandleFunc("/xrpc/com.atproto.repo.getRecord", handler) + return httptest.NewServer(mux) +} + +func TestFetchBluesky_OK(t *testing.T) { + srv := newPDS(t, func(w http.ResponseWriter, r *http.Request) { + q := r.URL.Query() + if q.Get("repo") != testDID { + t.Errorf("repo = %q, want %q", q.Get("repo"), testDID) + } + if q.Get("collection") != "app.bsky.actor.profile" { + t.Errorf("collection = %q", q.Get("collection")) + } + if q.Get("rkey") != "self" { + t.Errorf("rkey = %q", q.Get("rkey")) + } + _ = json.NewEncoder(w).Encode(map[string]any{ + "uri": "at://" + testDID + "/app.bsky.actor.profile/self", + "cid": "bafyreigh2akiscaildc", + "value": map[string]any{ + "$type": "app.bsky.actor.profile", + "displayName": "Brittany", + "description": "frontend nerd · atproto enthusiast", + "avatar": map[string]any{ + "$type": "blob", + "ref": map[string]any{"$link": "bafkreiavatar1234"}, + "mimeType": "image/jpeg", + "size": 123456, + }, + }, + }) + }) + defer srv.Close() + + did, _ := syntax.ParseDID(testDID) + rec, err := FetchBluesky(context.Background(), srv.URL, did) + if err != nil { + t.Fatalf("FetchBluesky: %v", err) + } + if rec.DisplayName != "Brittany" { + t.Errorf("DisplayName = %q", rec.DisplayName) + } + if !strings.Contains(rec.Description, "atproto enthusiast") { + t.Errorf("Description = %q", rec.Description) + } + if rec.Avatar == nil || rec.Avatar.CID() != "bafkreiavatar1234" { + t.Errorf("Avatar.CID = %q", rec.Avatar.CID()) + } + if rec.Avatar.MimeType != "image/jpeg" { + t.Errorf("Avatar.MimeType = %q", rec.Avatar.MimeType) + } +} + +func TestFetchBluesky_NotFound(t *testing.T) { + // PDSes return 400 InvalidRequest with name=RecordNotFound when a record + // doesn't exist on a known repo. + srv := newPDS(t, func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusBadRequest) + _ = json.NewEncoder(w).Encode(map[string]any{ + "error": "RecordNotFound", + "message": "Could not locate record", + }) + }) + defer srv.Close() + + did, _ := syntax.ParseDID(testDID) + _, err := FetchBluesky(context.Background(), srv.URL, did) + if !errors.Is(err, ErrNotFound) { + t.Fatalf("err = %v, want ErrNotFound", err) + } +} + +func TestFetchBluesky_NotFound404(t *testing.T) { + // Some implementations might surface as plain 404. + srv := newPDS(t, func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + _ = json.NewEncoder(w).Encode(map[string]any{"error": "NotFound"}) + }) + defer srv.Close() + did, _ := syntax.ParseDID(testDID) + _, err := FetchBluesky(context.Background(), srv.URL, did) + if !errors.Is(err, ErrNotFound) { + t.Fatalf("err = %v, want ErrNotFound", err) + } +} + +func TestFetchBluesky_OtherError(t *testing.T) { + srv := newPDS(t, func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + _ = json.NewEncoder(w).Encode(map[string]any{"error": "Boom"}) + }) + defer srv.Close() + did, _ := syntax.ParseDID(testDID) + _, err := FetchBluesky(context.Background(), srv.URL, did) + if err == nil { + t.Fatal("expected error") + } + if errors.Is(err, ErrNotFound) { + t.Fatalf("err should not be ErrNotFound: %v", err) + } +} + +func TestFetchQuest_OK(t *testing.T) { + srv := newPDS(t, func(w http.ResponseWriter, r *http.Request) { + if r.URL.Query().Get("collection") != "quest.atmo.profile" { + t.Errorf("collection = %q", r.URL.Query().Get("collection")) + } + _ = json.NewEncoder(w).Encode(map[string]any{ + "uri": "at://" + testDID + "/quest.atmo.profile/self", + "cid": "bafyreiquest1", + "value": map[string]any{ + "$type": "quest.atmo.profile", + "bio": "my atmo override bio", + "interests": []string{"coffee", "atproto", "hiking"}, + "links": []map[string]any{ + {"label": "My Site", "url": "https://example.com"}, + {"label": "GitHub", "url": "https://github.com/example"}, + }, + "updatedAt": "2026-05-14T12:34:56Z", + }, + }) + }) + defer srv.Close() + + did, _ := syntax.ParseDID(testDID) + rec, err := FetchQuest(context.Background(), srv.URL, did) + if err != nil { + t.Fatalf("FetchQuest: %v", err) + } + if rec.Bio != "my atmo override bio" { + t.Errorf("Bio = %q", rec.Bio) + } + if len(rec.Interests) != 3 || rec.Interests[0] != "coffee" { + t.Errorf("Interests = %v", rec.Interests) + } + if len(rec.Links) != 2 || rec.Links[1].URL != "https://github.com/example" { + t.Errorf("Links = %+v", rec.Links) + } + if rec.UpdatedAt.IsZero() { + t.Errorf("UpdatedAt should parse, got zero") + } +} + +func TestFetchQuest_NotFound(t *testing.T) { + srv := newPDS(t, func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusBadRequest) + _ = json.NewEncoder(w).Encode(map[string]any{ + "error": "RecordNotFound", + "message": "Could not locate record", + }) + }) + defer srv.Close() + did, _ := syntax.ParseDID(testDID) + _, err := FetchQuest(context.Background(), srv.URL, did) + if !errors.Is(err, ErrNotFound) { + t.Fatalf("err = %v, want ErrNotFound", err) + } +} + +func TestAvatarURL(t *testing.T) { + did, _ := syntax.ParseDID(testDID) + got := AvatarURL("https://example-pds.test", did, "bafkreiavatar") + u, err := url.Parse(got) + if err != nil { + t.Fatalf("not a URL: %v", err) + } + if u.Host != "example-pds.test" { + t.Errorf("host = %q", u.Host) + } + if u.Path != "/xrpc/com.atproto.sync.getBlob" { + t.Errorf("path = %q", u.Path) + } + q := u.Query() + if q.Get("did") != testDID { + t.Errorf("did param = %q", q.Get("did")) + } + if q.Get("cid") != "bafkreiavatar" { + t.Errorf("cid param = %q", q.Get("cid")) + } +} + +func TestAvatarURL_TrailingSlashStripped(t *testing.T) { + did, _ := syntax.ParseDID(testDID) + got := AvatarURL("https://pds.test/", did, "bafkreiX") + if strings.Contains(got, "//xrpc") { + t.Errorf("trailing slash not stripped: %s", got) + } +} + +func TestAvatarURL_EmptyInputs(t *testing.T) { + did, _ := syntax.ParseDID(testDID) + if AvatarURL("", did, "cid") != "" { + t.Error("empty PDS should return empty string") + } + if AvatarURL("https://x", did, "") != "" { + t.Error("empty CID should return empty string") + } + if AvatarURL("https://x", "", "cid") != "" { + t.Error("empty DID should return empty string") + } +} + +func TestBlobRef_CID_Nil(t *testing.T) { + var b *BlobRef + if b.CID() != "" { + t.Error("nil BlobRef.CID should be empty") + } +} + +func TestEffectiveBio(t *testing.T) { + cases := []struct { + name string + quest *QuestRecord + bsky *BlueskyRecord + want string + }{ + {"override beats bsky", &QuestRecord{Bio: "atmo"}, &BlueskyRecord{Description: "bsky"}, "atmo"}, + {"empty override falls back", &QuestRecord{Bio: " "}, &BlueskyRecord{Description: "bsky"}, "bsky"}, + {"no quest record uses bsky", nil, &BlueskyRecord{Description: "bsky"}, "bsky"}, + {"both empty", nil, nil, ""}, + {"both whitespace", &QuestRecord{Bio: " "}, &BlueskyRecord{Description: " "}, ""}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := EffectiveBio(tc.quest, tc.bsky); got != tc.want { + t.Errorf("got %q, want %q", got, tc.want) + } + }) + } +} diff --git a/internal/profile/remarshal.go b/internal/profile/remarshal.go new file mode 100644 index 0000000..91f7181 --- /dev/null +++ b/internal/profile/remarshal.go @@ -0,0 +1,14 @@ +package profile + +import "encoding/json" + +// remarshal serializes `in` to JSON and decodes back into `out`. Used to +// re-shape the generic envelope value from com.atproto.repo.getRecord into a +// concrete typed struct. +func remarshal(in, out any) error { + b, err := json.Marshal(in) + if err != nil { + return err + } + return json.Unmarshal(b, out) +} diff --git a/internal/qrcode/qrcode.go b/internal/qrcode/qrcode.go new file mode 100644 index 0000000..23daf46 --- /dev/null +++ b/internal/qrcode/qrcode.go @@ -0,0 +1,104 @@ +// Package qrcode wraps github.com/skip2/go-qrcode to emit small, crisp SVGs +// instead of PNGs. SVGs scale perfectly at any size (laptop screen, phone, +// printed conference badge) without re-encoding, and they compress well over +// gzip. +package qrcode + +import ( + "bytes" + "fmt" + "io" + + qr "github.com/skip2/go-qrcode" +) + +// SVGOptions controls the rendered SVG. +type SVGOptions struct { + // ModulePx is the side length of each QR "module" (cell) in SVG user + // units. The output viewBox is sized to (size * ModulePx). 8 is a good + // default for ~256-300 px renders. + ModulePx int + // Margin is the white quiet zone around the code, measured in modules. + // QR spec mandates 4-module minimum for reliable scanning; some scanners + // tolerate less but 4 is the safe default. + Margin int + // Foreground / Background are CSS color strings. Default: "#11111b" / + // "#cdd6f4" to match the app's terminal palette. + Foreground string + Background string + // Level is the error-correction level (Low/Medium/High/Highest). Higher + // = more robust to scratches/glare but larger code. Medium is the + // industry default; we use High so the QR survives being shown on a + // half-occluded phone screen at a noisy conference. + Level qr.RecoveryLevel +} + +// DefaultOptions returns reasonable defaults for displaying on a profile. +func DefaultOptions() SVGOptions { + return SVGOptions{ + ModulePx: 8, + Margin: 4, + Foreground: "#11111b", + Background: "#cdd6f4", + Level: qr.High, + } +} + +// EncodeSVG generates an SVG string for the given content (e.g. a URL) using +// the provided options. Returns ("", err) on encode failure. +// +// The output is a self-contained SVG document with explicit viewBox and +// width/height in modules so it can be sized via CSS without distortion. +func EncodeSVG(content string, opts SVGOptions) ([]byte, error) { + if opts.ModulePx <= 0 { + opts.ModulePx = 8 + } + if opts.Margin < 0 { + opts.Margin = 4 + } + if opts.Foreground == "" { + opts.Foreground = "#11111b" + } + if opts.Background == "" { + opts.Background = "#cdd6f4" + } + + q, err := qr.New(content, opts.Level) + if err != nil { + return nil, fmt.Errorf("qrcode: encode: %w", err) + } + // Disable the library's built-in border — we render our own quiet zone + // using the opts.Margin parameter so the SVG dimensions are predictable. + q.DisableBorder = true + + bm := q.Bitmap() + size := len(bm) // matrix is square + totalModules := size + opts.Margin*2 + totalPx := totalModules * opts.ModulePx + + var buf bytes.Buffer + writeSVG(&buf, bm, size, opts, totalModules, totalPx) + return buf.Bytes(), nil +} + +// writeSVG emits the SVG document. Split out for testability. +func writeSVG(w io.Writer, bm [][]bool, size int, opts SVGOptions, totalModules, totalPx int) { + fmt.Fprintf(w, + ``, + totalModules, totalModules, totalPx, totalPx) + // Background fills the whole viewBox (modules + quiet zone). + fmt.Fprintf(w, ``, totalModules, totalModules, opts.Background) + + // Foreground modules. We emit a single built from "M x y h1 v1 h-1 z" + // rectangles — fewer DOM nodes than one per module, and renders + // identically. + var path bytes.Buffer + for y := 0; y < size; y++ { + for x := 0; x < size; x++ { + if bm[y][x] { + fmt.Fprintf(&path, "M%d %dh1v1h-1z", x+opts.Margin, y+opts.Margin) + } + } + } + fmt.Fprintf(w, ``, path.String(), opts.Foreground) +} diff --git a/internal/qrcode/qrcode_test.go b/internal/qrcode/qrcode_test.go new file mode 100644 index 0000000..ba74308 --- /dev/null +++ b/internal/qrcode/qrcode_test.go @@ -0,0 +1,83 @@ +package qrcode + +import ( + "bytes" + "strings" + "testing" +) + +func TestEncodeSVG_Basics(t *testing.T) { + svg, err := EncodeSVG("https://atmoquest/c/did:plc:abc", DefaultOptions()) + if err != nil { + t.Fatalf("EncodeSVG: %v", err) + } + s := string(svg) + + if !strings.HasPrefix(s, "") { + t.Error("missing closing tag") + } +} + +func TestEncodeSVG_DeterministicForSameInput(t *testing.T) { + a, err := EncodeSVG("https://atmoquest/c/did:plc:abc", DefaultOptions()) + if err != nil { + t.Fatalf("first encode: %v", err) + } + b, err := EncodeSVG("https://atmoquest/c/did:plc:abc", DefaultOptions()) + if err != nil { + t.Fatalf("second encode: %v", err) + } + if !bytes.Equal(a, b) { + t.Error("same input produced different SVG bytes") + } +} + +func TestEncodeSVG_ContentChangesShape(t *testing.T) { + a, _ := EncodeSVG("aaaa", DefaultOptions()) + b, _ := EncodeSVG("bbbb", DefaultOptions()) + if bytes.Equal(a, b) { + t.Error("different content produced identical SVG") + } +} + +func TestEncodeSVG_CustomColors(t *testing.T) { + opts := DefaultOptions() + opts.Foreground = "#ff00ff" + opts.Background = "#00ff00" + svg, err := EncodeSVG("test", opts) + if err != nil { + t.Fatalf("EncodeSVG: %v", err) + } + s := string(svg) + if !strings.Contains(s, "#ff00ff") || !strings.Contains(s, "#00ff00") { + t.Errorf("custom colors not present: %s", s[:200]) + } +} + +func TestEncodeSVG_DefaultsApplied(t *testing.T) { + // Pass an empty options struct; defaults should be filled in and no + // error. + svg, err := EncodeSVG("hello", SVGOptions{}) + if err != nil { + t.Fatalf("EncodeSVG with empty opts: %v", err) + } + s := string(svg) + if !strings.Contains(s, "#11111b") { + t.Errorf("expected default foreground #11111b in SVG; got: %.200s", s) + } + if !strings.Contains(s, "#cdd6f4") { + t.Errorf("expected default background #cdd6f4 in SVG; got: %.200s", s) + } +} diff --git a/internal/session/session.go b/internal/session/session.go new file mode 100644 index 0000000..e33ead9 --- /dev/null +++ b/internal/session/session.go @@ -0,0 +1,96 @@ +// Package session manages the user-facing session cookie that links a browser +// to a stored OAuth session (keyed by DID + indigo session ID). +// +// The cookie is signed (and optionally encrypted, when a 32-byte key is +// provided) using gorilla/sessions. +// +// - SameSite=Lax — required: the OAuth callback is a cross-origin top-level +// redirect, and `Strict` would drop the cookie and break ProcessCallback. +// - HttpOnly=true — keep tokens out of JS reach. +// - Secure=true when PublicURL is https. +// - MaxAge=30 days — refresh of indigo tokens is automatic on ResumeSession. +// +// In development with an unset SESSION_SECRET we generate a random key in-memory +// so the app boots; this means cookies are invalidated on every restart, which +// is fine for local dev. +package session + +import ( + "crypto/rand" + "errors" + "net/http" + + "github.com/gorilla/sessions" + + "atmoquest/config" +) + +const cookieName = "atmoquest_session" + +// Manager wraps gorilla/sessions for our single-cookie use case. +type Manager struct { + store *sessions.CookieStore + secure bool +} + +// New constructs a Manager. If cfg.SessionSecret is empty and we're in dev, +// a random key is generated for this process lifetime. +func New(cfg *config.Config) (*Manager, error) { + var keyBytes []byte + switch { + case cfg.SessionSecret != "": + keyBytes = []byte(cfg.SessionSecret) + case cfg.Environment == config.Dev: + keyBytes = make([]byte, 32) + if _, err := rand.Read(keyBytes); err != nil { + return nil, err + } + default: + return nil, errors.New("SESSION_SECRET is required outside development") + } + + store := sessions.NewCookieStore(keyBytes) + store.Options = &sessions.Options{ + Path: "/", + MaxAge: 30 * 24 * 60 * 60, + HttpOnly: true, + Secure: cfg.IsSecure(), + SameSite: http.SameSiteLaxMode, + } + return &Manager{store: store, secure: cfg.IsSecure()}, nil +} + +// Set writes the DID + OAuth session ID into the session cookie. +func (m *Manager) Set(w http.ResponseWriter, r *http.Request, did, oauthSessionID string) error { + s, _ := m.store.Get(r, cookieName) + s.Values["did"] = did + s.Values["sid"] = oauthSessionID + return s.Save(r, w) +} + +// Get returns the DID + OAuth session ID stored on the cookie, or empty +// strings if no valid session is present. Never returns an error — a malformed +// cookie is treated as "not logged in". +func (m *Manager) Get(r *http.Request) (did, sid string) { + s, err := m.store.Get(r, cookieName) + if err != nil { + return "", "" + } + did, _ = s.Values["did"].(string) + sid, _ = s.Values["sid"].(string) + return did, sid +} + +// Clear deletes the session cookie. Best-effort; we always issue a fresh +// Set-Cookie even if the existing one is malformed. +func (m *Manager) Clear(w http.ResponseWriter, r *http.Request) { + s, _ := m.store.Get(r, cookieName) + s.Options = &sessions.Options{ + Path: "/", + MaxAge: -1, + HttpOnly: true, + Secure: m.secure, + SameSite: http.SameSiteLaxMode, + } + _ = s.Save(r, w) +} diff --git a/internal/session/session_test.go b/internal/session/session_test.go new file mode 100644 index 0000000..0991197 --- /dev/null +++ b/internal/session/session_test.go @@ -0,0 +1,170 @@ +package session + +import ( + "net/http" + "net/http/httptest" + "testing" + + "atmoquest/config" +) + +func newManager(t *testing.T) *Manager { + t.Helper() + cfg := &config.Config{ + Environment: config.Dev, + PublicURL: "http://localhost:3000", + SessionSecret: "test-cookie-secret-32-bytes-long", + } + m, err := New(cfg) + if err != nil { + t.Fatalf("New: %v", err) + } + return m +} + +func TestNew_DevAutoGeneratesSecret(t *testing.T) { + cfg := &config.Config{Environment: config.Dev, PublicURL: "http://localhost:3000"} + m, err := New(cfg) + if err != nil { + t.Fatalf("New: %v", err) + } + if m == nil { + t.Fatal("Manager should not be nil in dev with empty secret") + } +} + +func TestNew_RequiresSecretOutsideDev(t *testing.T) { + cfg := &config.Config{Environment: config.Prod, PublicURL: "https://atmoquest"} + if _, err := New(cfg); err == nil { + t.Fatal("expected error when SESSION_SECRET is missing in production") + } +} + +func TestManager_SetGetRoundTrip(t *testing.T) { + m := newManager(t) + + // First request: Set the cookie. + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/", nil) + if err := m.Set(rec, req, "did:plc:abc123", "session-id-001"); err != nil { + t.Fatalf("Set: %v", err) + } + resp := rec.Result() + if len(resp.Cookies()) == 0 { + t.Fatal("Set produced no Set-Cookie header") + } + + // Second request: read the cookie back. + req2 := httptest.NewRequest(http.MethodGet, "/", nil) + for _, c := range resp.Cookies() { + req2.AddCookie(c) + } + gotDID, gotSID := m.Get(req2) + if gotDID != "did:plc:abc123" { + t.Errorf("did = %q, want did:plc:abc123", gotDID) + } + if gotSID != "session-id-001" { + t.Errorf("sid = %q, want session-id-001", gotSID) + } +} + +func TestManager_GetNoCookie(t *testing.T) { + m := newManager(t) + req := httptest.NewRequest(http.MethodGet, "/", nil) + did, sid := m.Get(req) + if did != "" || sid != "" { + t.Errorf("Get without cookie = (%q, %q); want both empty", did, sid) + } +} + +func TestManager_GetGarbageCookie(t *testing.T) { + m := newManager(t) + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.AddCookie(&http.Cookie{Name: cookieName, Value: "not-a-valid-signed-cookie"}) + + // Must not panic, must not return an error path that surfaces upstream. + did, sid := m.Get(req) + if did != "" || sid != "" { + t.Errorf("Get with garbage cookie = (%q, %q); want both empty", did, sid) + } +} + +func TestManager_Clear(t *testing.T) { + m := newManager(t) + + // Seed a cookie. + rec1 := httptest.NewRecorder() + req1 := httptest.NewRequest(http.MethodGet, "/", nil) + if err := m.Set(rec1, req1, "did:plc:abc", "sid-1"); err != nil { + t.Fatalf("Set: %v", err) + } + + // Clear should issue a Set-Cookie with MaxAge < 0. + rec2 := httptest.NewRecorder() + req2 := httptest.NewRequest(http.MethodGet, "/", nil) + for _, c := range rec1.Result().Cookies() { + req2.AddCookie(c) + } + m.Clear(rec2, req2) + + cookies := rec2.Result().Cookies() + if len(cookies) == 0 { + t.Fatal("Clear produced no Set-Cookie header") + } + var found bool + for _, c := range cookies { + if c.Name == cookieName { + found = true + if c.MaxAge >= 0 { + t.Errorf("Clear cookie MaxAge = %d; want negative (delete)", c.MaxAge) + } + } + } + if !found { + t.Errorf("Clear didn't emit a Set-Cookie for %q", cookieName) + } +} + +func TestManager_CookieFlags(t *testing.T) { + // Secure flag should reflect IsSecure on the publishing URL. + cases := []struct { + name string + publicURL string + wantSec bool + }{ + {"http loopback", "http://localhost:3000", false}, + {"https prod", "https://atmoquest", true}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + cfg := &config.Config{ + Environment: config.Dev, + PublicURL: c.publicURL, + SessionSecret: "test-cookie-secret-32-bytes-long", + } + m, err := New(cfg) + if err != nil { + t.Fatalf("New: %v", err) + } + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/", nil) + if err := m.Set(rec, req, "did:plc:x", "sid"); err != nil { + t.Fatalf("Set: %v", err) + } + cookies := rec.Result().Cookies() + if len(cookies) == 0 { + t.Fatal("no cookie") + } + ck := cookies[0] + if !ck.HttpOnly { + t.Error("cookie should be HttpOnly") + } + if ck.SameSite != http.SameSiteLaxMode { + t.Errorf("cookie SameSite = %v; want Lax (Strict breaks OAuth callback)", ck.SameSite) + } + if ck.Secure != c.wantSec { + t.Errorf("cookie Secure = %v; want %v", ck.Secure, c.wantSec) + } + }) + } +} diff --git a/internal/users/users.go b/internal/users/users.go new file mode 100644 index 0000000..be6ae77 --- /dev/null +++ b/internal/users/users.go @@ -0,0 +1,246 @@ +// Package users tracks the durable per-DID user record. Distinct from +// oauth_sessions (which holds active sessions and expires rows) — the +// users table is the long-lived list the admin UI searches and where +// the is_admin flag lives. +// +// The package intentionally exposes a small surface: +// +// - Touch is called on every successful OAuth callback to upsert a row +// and bump the last_seen_at / auth_count counters. +// - IsAdmin is a cheap "is this DID an admin" check used by middleware. +// - List returns a paginated, optionally filtered list for the admin UI. +// - SetAdmin promotes/demotes a user (only callable from admin handlers). +// - Count returns aggregate stats for the dashboard. +// +// All functions take a *sql.DB so the package is testable in isolation +// and doesn't depend on the broader handlers package. +package users + +import ( + "context" + "database/sql" + "errors" + "strings" + "time" + + "github.com/bluesky-social/indigo/atproto/syntax" +) + +// ErrNotFound is returned by Get when the DID has never been seen. +var ErrNotFound = errors.New("users: not found") + +// User mirrors a row in the `users` table. +type User struct { + DID syntax.DID + Handle string + DisplayName string + FirstSeenAt time.Time + LastSeenAt time.Time + AuthCount int + IsAdmin bool + IsBanned bool +} + +// Touch upserts a row for did and refreshes the denormalized handle / +// display_name fields (which the admin search relies on). auth_count is +// bumped by one on every call so we get a free login counter. +// +// Pass the values you have at hand — empty strings are fine and will be +// preserved across calls (we COALESCE the new value with the prior one +// so a re-auth that failed to fetch a fresh handle doesn't wipe the +// previous handle from the row). +func Touch(ctx context.Context, db *sql.DB, did syntax.DID, handle, displayName string) error { + if did == "" { + return errors.New("users: empty DID") + } + // We map FirstSeenAt → created_at, LastSeenAt → updated_at (the + // existing columns from 001_init.sql). The Touch flow always writes + // `handle` because it's NOT NULL in the legacy schema — empty + // string is fine. + _, err := db.ExecContext(ctx, ` + INSERT INTO users (did, handle, display_name) + VALUES (?, ?, ?) + ON CONFLICT(did) DO UPDATE SET + -- Preserve a previously-known handle if the new touch didn't + -- supply one. (handle resolution can fail intermittently.) + handle = CASE WHEN excluded.handle = '' THEN users.handle ELSE excluded.handle END, + display_name = CASE WHEN excluded.display_name = '' THEN users.display_name ELSE excluded.display_name END, + updated_at = CURRENT_TIMESTAMP, + auth_count = users.auth_count + 1 + `, did.String(), handle, displayName) + return err +} + +// IsAdmin returns true when the DID is flagged is_admin and not banned. +// A banned admin cannot act as admin — `is_banned` overrides `is_admin`. +// Returns (false, nil) for unknown DIDs (no error). +func IsAdmin(ctx context.Context, db *sql.DB, did syntax.DID) (bool, error) { + if did == "" { + return false, nil + } + var isAdmin, isBanned int + err := db.QueryRowContext(ctx, ` + SELECT is_admin, is_banned FROM users WHERE did = ? + `, did.String()).Scan(&isAdmin, &isBanned) + if errors.Is(err, sql.ErrNoRows) { + return false, nil + } + if err != nil { + return false, err + } + return isAdmin == 1 && isBanned == 0, nil +} + +// Get returns a single user record, or ErrNotFound. +func Get(ctx context.Context, db *sql.DB, did syntax.DID) (User, error) { + row := db.QueryRowContext(ctx, ` + SELECT did, handle, display_name, created_at, updated_at, + auth_count, is_admin, is_banned + FROM users WHERE did = ? + `, did.String()) + return scanUser(row) +} + +// ListOptions controls pagination + filtering on List. +// +// Query is a case-insensitive substring match over handle + display_name + +// DID. Empty Query returns all users. Limit defaults to 50 when zero; +// Offset defaults to 0. AdminsOnly filters to is_admin = 1. +type ListOptions struct { + Query string + AdminsOnly bool + Limit int + Offset int +} + +// List returns users sorted by most-recently-seen, applying ListOptions. +func List(ctx context.Context, db *sql.DB, opts ListOptions) ([]User, error) { + if opts.Limit <= 0 || opts.Limit > 500 { + opts.Limit = 50 + } + if opts.Offset < 0 { + opts.Offset = 0 + } + + // Build the WHERE clause incrementally. Using parameterized LIKE with + // explicit lowercasing keeps the search case-insensitive without + // depending on SQLite's COLLATE NOCASE being set on the column. + where := "1=1" + args := []any{} + if q := strings.TrimSpace(opts.Query); q != "" { + where += " AND (LOWER(handle) LIKE ? OR LOWER(display_name) LIKE ? OR LOWER(did) LIKE ?)" + pat := "%" + strings.ToLower(q) + "%" + args = append(args, pat, pat, pat) + } + if opts.AdminsOnly { + where += " AND is_admin = 1" + } + args = append(args, opts.Limit, opts.Offset) + + rows, err := db.QueryContext(ctx, ` + SELECT did, handle, display_name, created_at, updated_at, + auth_count, is_admin, is_banned + FROM users + WHERE `+where+` + ORDER BY updated_at DESC + LIMIT ? OFFSET ? + `, args...) + if err != nil { + return nil, err + } + defer rows.Close() + + var out []User + for rows.Next() { + u, err := scanUser(rows) + if err != nil { + return nil, err + } + out = append(out, u) + } + return out, rows.Err() +} + +// SetAdmin flips the is_admin bit on a user. Returns ErrNotFound if the +// user doesn't exist (callers should ensure the user has logged in at +// least once before being promoted). +func SetAdmin(ctx context.Context, db *sql.DB, did syntax.DID, admin bool) error { + v := 0 + if admin { + v = 1 + } + res, err := db.ExecContext(ctx, `UPDATE users SET is_admin = ? WHERE did = ?`, v, did.String()) + if err != nil { + return err + } + n, err := res.RowsAffected() + if err != nil { + return err + } + if n == 0 { + return ErrNotFound + } + return nil +} + +// Stats is the aggregate row the admin dashboard renders. +type Stats struct { + TotalUsers int + AdminCount int + BannedCount int + // ActiveLast7d is the count of users whose last_seen_at is within 7 + // days. A small "is the app being used" indicator. + ActiveLast7d int +} + +// Counts returns the dashboard aggregate. +func Counts(ctx context.Context, db *sql.DB) (Stats, error) { + var s Stats + err := db.QueryRowContext(ctx, ` + SELECT + COUNT(*), + SUM(CASE WHEN is_admin = 1 THEN 1 ELSE 0 END), + SUM(CASE WHEN is_banned = 1 THEN 1 ELSE 0 END), + SUM(CASE WHEN updated_at >= datetime('now', '-7 days') THEN 1 ELSE 0 END) + FROM users + `).Scan(&s.TotalUsers, &s.AdminCount, &s.BannedCount, &s.ActiveLast7d) + if errors.Is(err, sql.ErrNoRows) { + return Stats{}, nil + } + return s, err +} + +// scanner abstracts *sql.Row and *sql.Rows for shared decoding. +type scanner interface { + Scan(dest ...any) error +} + +func scanUser(s scanner) (User, error) { + var u User + var didStr string + var isAdmin, isBanned int + err := s.Scan( + &didStr, + &u.Handle, + &u.DisplayName, + &u.FirstSeenAt, + &u.LastSeenAt, + &u.AuthCount, + &isAdmin, + &isBanned, + ) + if errors.Is(err, sql.ErrNoRows) { + return User{}, ErrNotFound + } + if err != nil { + return User{}, err + } + if d, perr := syntax.ParseDID(didStr); perr == nil { + u.DID = d + } else { + u.DID = syntax.DID(didStr) + } + u.IsAdmin = isAdmin == 1 + u.IsBanned = isBanned == 1 + return u, nil +} diff --git a/router/router.go b/router/router.go index 99c3f96..6702e2b 100644 --- a/router/router.go +++ b/router/router.go @@ -2,20 +2,39 @@ package router import ( "context" + "database/sql" "net/http" "sync" - "atmoquest/config" - "atmoquest/features/index" - "atmoquest/web/resources" - + "github.com/bluesky-social/indigo/atproto/auth/oauth" "github.com/delaneyj/toolbelt/embeddednats" "github.com/go-chi/chi/v5" - "github.com/gorilla/sessions" "github.com/starfederation/datastar-go/datastar" + + "atmoquest/config" + "atmoquest/features/auth" + "atmoquest/features/connect" + "atmoquest/features/event" + "atmoquest/features/index" + "atmoquest/features/profile" + "atmoquest/internal/connection" + "atmoquest/internal/session" + "atmoquest/web/resources" ) -func SetupRoutes(ctx context.Context, router chi.Router, sessionStore *sessions.CookieStore, ns *embeddednats.Server) (err error) { +// SetupRoutes registers all top-level routes and feature subrouters. +// +// Shared dependencies are passed in explicitly rather than read from package +// globals so tests can wire their own. As more features come online we may +// promote this into a dedicated Deps struct. +func SetupRoutes( + ctx context.Context, + router chi.Router, + sess *session.Manager, + oauthApp *oauth.ClientApp, + ns *embeddednats.Server, + conn *sql.DB, +) (err error) { if config.Global.Environment == config.Dev { setupReload(router) @@ -23,7 +42,16 @@ func SetupRoutes(ctx context.Context, router chi.Router, sessionStore *sessions. router.Handle("/static/*", resources.Handler()) - index.SetupRoutes(router) + // Build the auth handlers once; other features depend on them for + // RequireSession / ResumeSession. + connQueue := connection.NewQueue(conn) + authH := auth.NewHandlers(conn, oauthApp, sess) + authH.ConnQueue = connQueue + auth.SetupRoutes(router, authH) + profile.SetupRoutes(router, conn, authH) + connect.SetupRoutes(router, conn, authH, connQueue) + event.SetupRoutes(router, conn, authH) + index.SetupRoutes(router, conn) return nil } diff --git a/web/resources/static/css/terminal.css b/web/resources/static/css/terminal.css new file mode 100644 index 0000000..35df9be --- /dev/null +++ b/web/resources/static/css/terminal.css @@ -0,0 +1,1383 @@ +/* atmo.quest — Terminal Quest aesthetic. + Catppuccin-adjacent palette + JetBrains Mono + Instrument Serif italic accents. + Lifted from /atmoquest/design-3-terminal.html in the spec folder. */ + +:root { + --base: #1e1e2e; + --mantle: #181825; + --crust: #11111b; + --text: #cdd6f4; + --subtext: #a6adc8; + --muted: #7f849c; + --surface: #313244; + --overlay: #45475a; + --blue: #89b4fa; + --lavender: #b4befe; + --green: #a6e3a1; + --yellow: #f9e2af; + --peach: #fab387; + --pink: #f5c2e7; + --red: #f38ba8; +} + +* { box-sizing: border-box; margin: 0; padding: 0; } + +body { + font-family: 'JetBrains Mono', monospace; + background: var(--base); + color: var(--text); + min-height: 100vh; + overflow-x: hidden; + position: relative; +} + +body::before { + content: ''; + position: fixed; + inset: 0; + background-image: + linear-gradient(rgba(180, 190, 254, 0.04) 1px, transparent 1px), + linear-gradient(90deg, rgba(180, 190, 254, 0.04) 1px, transparent 1px); + background-size: 32px 32px; + pointer-events: none; + z-index: 0; +} + +body::after { + content: ''; + position: fixed; + inset: 0; + background: + radial-gradient(ellipse at top right, rgba(137, 180, 250, 0.08) 0%, transparent 60%), + radial-gradient(ellipse at bottom left, rgba(245, 194, 231, 0.06) 0%, transparent 60%); + pointer-events: none; + z-index: 0; +} + +.container { + position: relative; + z-index: 1; + max-width: 760px; + margin: 0 auto; + padding: 20px; +} + +/* terminal window chrome */ +.window { + background: var(--mantle); + border: 1px solid var(--overlay); + border-radius: 12px; + overflow: hidden; + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.4); + margin-bottom: 24px; +} +.window-bar { + background: var(--crust); + padding: 10px 14px; + display: flex; + align-items: center; + gap: 8px; + border-bottom: 1px solid var(--overlay); +} +.dot { width: 12px; height: 12px; border-radius: 50%; } +.dot-r { background: var(--red); } +.dot-y { background: var(--yellow); } +.dot-g { background: var(--green); } +.window-title { + flex: 1; + text-align: center; + font-size: 12px; + color: var(--muted); + letter-spacing: 0.02em; +} +.window-body { + padding: 28px 24px; + font-size: 14px; + line-height: 1.7; +} + +/* prompt lines */ +.prompt-line { + display: flex; + align-items: baseline; + gap: 10px; + margin-bottom: 8px; +} +.prompt-line .user { color: var(--green); } +.prompt-line .at { color: var(--muted); } +.prompt-line .path { color: var(--blue); } +.prompt-line .sep { color: var(--muted); } +.prompt-line .cmd { color: var(--text); } +.output { + color: var(--subtext); + margin: 8px 0 22px 0; +} +.output.indent { padding-left: 14px; } + +/* hero */ +.hero-block { margin: 28px 0 12px; } +.hero-tag { + display: inline-block; + font-size: 11px; + color: var(--lavender); + background: rgba(180, 190, 254, 0.08); + border: 1px solid rgba(180, 190, 254, 0.2); + padding: 4px 10px; + border-radius: 4px; + margin-bottom: 18px; + letter-spacing: 0.05em; +} +.hero-tag::before { + content: '●'; + color: var(--green); + margin-right: 8px; + font-size: 8px; + vertical-align: middle; +} +h1 { + font-family: 'Instrument Serif', serif; + font-weight: 400; + font-size: clamp(40px, 8vw, 64px); + line-height: 1.05; + color: var(--text); + letter-spacing: -0.01em; + margin-bottom: 18px; + white-space: nowrap; +} +h1 .quest { font-style: italic; color: var(--peach); } +h1 .cursor { + display: inline-block; + width: 0.5ch; + background: var(--peach); + margin-left: 4px; +} +.lede { + font-size: 15px; + line-height: 1.65; + color: var(--subtext); + max-width: 520px; + margin-bottom: 28px; +} +.lede .kw { color: var(--green); } +.lede .kw2 { color: var(--peach); } + +/* CTA row */ +.ctas { + display: flex; + gap: 12px; + flex-wrap: wrap; + margin-bottom: 8px; +} +.btn { + font-family: inherit; + font-size: 13px; + padding: 12px 18px; + border-radius: 6px; + text-decoration: none; + border: 1px solid transparent; + cursor: pointer; + transition: all 0.15s ease; + display: inline-flex; + align-items: center; + gap: 8px; +} +.btn-primary { + background: var(--peach); + color: var(--crust); + font-weight: 500; +} +.btn-primary:hover { + background: var(--yellow); + transform: translateY(-1px); +} +.btn-ghost { + background: transparent; + color: var(--text); + border-color: var(--overlay); +} +.btn-ghost:hover { + border-color: var(--lavender); + color: var(--lavender); +} +.btn .shortcut { + font-size: 10px; + opacity: 0.6; + padding: 2px 5px; + border: 1px solid currentColor; + border-radius: 3px; +} + +/* status bar */ +.statusbar { + background: var(--crust); + padding: 8px 14px; + font-size: 11px; + color: var(--muted); + display: flex; + justify-content: space-between; + border-top: 1px solid var(--overlay); + flex-wrap: wrap; + gap: 8px; +} +.statusbar .ok { color: var(--green); } +.statusbar .clock { color: var(--lavender); } +.status-handle { + color: var(--lavender); + text-decoration: none; + font-weight: 600; + margin-left: 2px; + border-bottom: 1px dashed transparent; + transition: border-color 120ms; +} +.status-handle:hover, +.status-handle:focus-visible { + border-bottom-color: var(--lavender); + outline: none; +} + +/* quest log */ +.quest-log h2 { + font-family: 'JetBrains Mono', monospace; + font-size: 12px; + color: var(--lavender); + letter-spacing: 0.1em; + text-transform: uppercase; + margin-bottom: 16px; + padding-bottom: 10px; + border-bottom: 1px dashed var(--overlay); +} +.quest-log h2 .count { color: var(--muted); font-weight: 400; } +.quest-log .quest { + display: grid; + grid-template-columns: 24px 1fr; + gap: 12px; + padding: 12px 0; + border-bottom: 1px solid rgba(69, 71, 90, 0.4); +} +.quest-log .quest:last-child { border-bottom: none; } +.quest-icon { color: var(--peach); font-size: 16px; line-height: 1.4; } +.quest-title { font-size: 14px; color: var(--text); margin-bottom: 4px; font-weight: 500; } +.quest-desc { font-size: 12px; color: var(--subtext); line-height: 1.5; } +.quest-desc code { + background: var(--surface); + padding: 1px 5px; + border-radius: 3px; + color: var(--peach); + font-size: 11px; +} +.quest-meta { margin-top: 6px; font-size: 10px; color: var(--muted); letter-spacing: 0.05em; } +.quest-meta .badge { + background: rgba(166, 227, 161, 0.15); + color: var(--green); + padding: 2px 6px; + border-radius: 3px; + margin-right: 6px; +} +.quest-meta .badge.locked { background: rgba(127, 132, 156, 0.15); color: var(--muted); } + +/* auth: chooser + form */ +.auth-choices { + display: grid; + gap: 14px; + margin-top: 12px; +} +.auth-card { + display: block; + padding: 18px; + border: 1px solid var(--overlay); + border-radius: 6px; + text-decoration: none; + color: inherit; + background: rgba(180, 190, 254, 0.04); + transition: all 0.15s ease; +} +.auth-card:hover { + border-color: var(--peach); + background: rgba(250, 179, 135, 0.06); + transform: translateY(-1px); +} +.auth-card-tag { + font-size: 10px; + color: var(--lavender); + letter-spacing: 0.1em; + text-transform: uppercase; + margin-bottom: 8px; +} +.auth-card-title { + font-family: 'Instrument Serif', serif; + font-size: 22px; + color: var(--text); + margin-bottom: 6px; + line-height: 1.2; +} +.auth-card-desc { + font-size: 13px; + color: var(--subtext); + line-height: 1.5; + margin-bottom: 12px; +} +.auth-card-desc code { + background: var(--surface); + padding: 1px 5px; + border-radius: 3px; + color: var(--peach); + font-size: 11px; +} +.auth-card-cta { + font-size: 13px; + color: var(--peach); + display: inline-flex; + align-items: center; + gap: 8px; +} +.auth-card-disabled { + opacity: 0.55; + cursor: not-allowed; +} +.auth-card-disabled:hover { + border-color: var(--overlay); + background: rgba(180, 190, 254, 0.04); + transform: none; +} + +.auth-form { + margin: 18px 0 6px; + display: flex; + flex-direction: column; + gap: 14px; +} +.field { display: flex; flex-direction: column; gap: 6px; } +.field-label { + font-size: 11px; + color: var(--lavender); + letter-spacing: 0.1em; + text-transform: uppercase; +} +.field-input-wrap { + display: flex; + align-items: stretch; + border: 1px solid var(--overlay); + border-radius: 6px; + background: var(--surface); + overflow: hidden; + transition: border-color 0.15s ease; +} +.field-input-wrap:focus-within { border-color: var(--peach); } +.field-prefix { + display: inline-flex; + align-items: center; + padding: 0 12px; + color: var(--peach); + background: rgba(250, 179, 135, 0.08); + border-right: 1px solid var(--overlay); + font-size: 14px; +} +.field-input { + flex: 1; + background: transparent; + border: 0; + outline: 0; + color: var(--text); + font: inherit; + font-size: 14px; + padding: 12px 14px; +} +.field-input::placeholder { color: var(--muted); } +.field-hint { + font-size: 11px; + color: var(--muted); +} +.field-hint code { + background: var(--surface); + padding: 1px 4px; + border-radius: 3px; + color: var(--peach); + font-size: 10px; +} + +.auth-error { + font-size: 12px; + color: var(--red, #f38ba8); + background: rgba(243, 139, 168, 0.08); + border: 1px solid rgba(243, 139, 168, 0.3); + padding: 8px 12px; + border-radius: 4px; +} +.auth-error-prefix { + color: var(--red, #f38ba8); + font-weight: 500; + margin-right: 6px; + text-transform: uppercase; + font-size: 10px; + letter-spacing: 0.1em; +} + +.output.muted { color: var(--muted); font-size: 12px; } +.muted-link { color: var(--muted); text-decoration: none; } +.muted-link:hover { color: var(--lavender); } + +/* profile: definition list */ +.kv { + display: grid; + grid-template-columns: 100px 1fr; + gap: 10px 18px; + margin: 14px 0 22px; + font-size: 13px; +} +.kv dt { + color: var(--lavender); + font-size: 11px; + letter-spacing: 0.1em; + text-transform: uppercase; + padding-top: 2px; +} +.kv dd { + margin: 0; + color: var(--text); +} +.kv code.break { + background: var(--surface); + padding: 2px 6px; + border-radius: 3px; + color: var(--peach); + font-size: 12px; + word-break: break-all; +} +.badge-scope { + display: inline-block; + background: rgba(166, 227, 161, 0.15); + color: var(--green); + padding: 2px 8px; + border-radius: 3px; + margin-right: 6px; + margin-bottom: 4px; + font-size: 11px; + letter-spacing: 0.03em; +} +.muted { color: var(--muted); } + +/* profile: card + avatar + bio + pills */ +.profile-card { + display: flex; + flex-direction: column; + align-items: center; + text-align: center; + margin: 22px 0 18px; + padding: 22px 16px 18px; + border: 1px solid var(--overlay); + border-radius: 8px; + background: linear-gradient(180deg, rgba(180, 190, 254, 0.04), transparent 60%); +} + +/* "✓ connected with did:…" status banner shown after a successful confirm. */ +.connected-banner { + display: flex; + gap: 8px; + align-items: center; + padding: 8px 14px; + margin-bottom: 16px; + border-radius: 6px; + background: rgba(166, 227, 161, 0.12); + border: 1px solid rgba(166, 227, 161, 0.35); + color: var(--green); + font-size: 12px; + width: 100%; + justify-content: center; +} +.connected-banner .connected-tick { + font-weight: 700; + font-size: 14px; +} +.connected-banner code.break { + color: var(--green); + background: transparent; + padding: 0; +} + +/* avatar flip card */ +.profile-flip { + perspective: 1000px; + width: 144px; + margin: 0 auto 14px; +} +.profile-flip-inner { + position: relative; + width: 144px; + height: 144px; + background: transparent; + border: none; + padding: 0; + cursor: pointer; + transform-style: preserve-3d; + transition: transform 520ms cubic-bezier(0.2, 0.85, 0.3, 1); +} +.profile-flip[data-flip="1"] .profile-flip-inner { + transform: rotateY(180deg); +} +.profile-flip-inner:focus-visible { + outline: 2px solid var(--peach); + outline-offset: 6px; + border-radius: 50%; +} +.profile-flip-face { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + border-radius: 50%; + backface-visibility: hidden; + -webkit-backface-visibility: hidden; + display: flex; + align-items: center; + justify-content: center; + padding: 3px; + background: linear-gradient(135deg, var(--peach), var(--lavender) 60%, var(--blue)); + overflow: hidden; +} +.profile-flip-front .profile-avatar, +.profile-flip-front .profile-avatar-empty { + width: 100%; + height: 100%; + margin: 0; +} +.profile-flip-back { + transform: rotateY(180deg); + background: var(--text); + padding: 6px; +} +.profile-qr { + width: 100%; + height: 100%; + object-fit: contain; + display: block; + border-radius: 50%; + background: var(--text); +} +.profile-flip-hint { + margin-top: 10px; + font-size: 10px; + letter-spacing: 0.08em; + color: var(--muted); + text-transform: lowercase; +} +@media (prefers-reduced-motion: reduce) { + .profile-flip-inner { transition: none; } +} + +/* legacy wrap (used by /c/{did} target preview & edit page header) */ +.profile-avatar-wrap { + width: 128px; + height: 128px; + border-radius: 50%; + padding: 3px; + background: linear-gradient(135deg, var(--peach), var(--lavender) 60%, var(--blue)); + margin-bottom: 14px; + flex-shrink: 0; +} +.profile-avatar { + width: 100%; + height: 100%; + border-radius: 50%; + object-fit: cover; + display: block; + background: var(--surface); +} +.profile-avatar-empty { + display: flex; + align-items: center; + justify-content: center; + color: var(--muted); + font-size: 48px; + font-family: 'Instrument Serif', serif; + font-style: italic; +} +.profile-name { + font-family: 'Instrument Serif', serif; + font-style: italic; + font-size: 32px; + font-weight: 400; + color: var(--text); + line-height: 1.1; + margin: 0 0 8px; +} +.profile-name-muted { color: var(--muted); } +.profile-bio { + max-width: 52ch; + margin: 6px auto 4px; + font-size: 14px; + line-height: 1.55; + color: var(--subtext); + white-space: pre-wrap; +} +.profile-bio-empty { color: var(--muted); font-style: italic; } +.profile-bio-source { + font-size: 11px; + color: var(--muted); + letter-spacing: 0.05em; + margin-top: 6px; +} + +/* boxed details card — bio / works at / based in / contact (rows hidden when empty) */ +.profile-details { + width: 100%; + margin: 10px 0 4px; + padding: 14px 18px; + background: var(--base); + border: 1px solid var(--overlay); + border-radius: 8px; + text-align: left; +} +.detail-row { + display: grid; + grid-template-columns: 84px 1fr; + gap: 14px; + padding: 10px 0; + border-bottom: 1px solid rgba(69, 71, 90, 0.4); + align-items: baseline; + font-size: 13px; +} +.detail-row:first-child { padding-top: 4px; } +.detail-row:last-child { border-bottom: none; padding-bottom: 4px; } +.detail-k { + color: var(--muted); + font-size: 10px; + letter-spacing: 0.1em; + text-transform: uppercase; +} +.detail-v { + color: var(--text); + line-height: 1.55; + word-wrap: break-word; + overflow-wrap: anywhere; +} +.detail-v-bio { + color: var(--subtext); + white-space: pre-wrap; +} +@media (max-width: 480px) { + .detail-row { + grid-template-columns: 1fr; + gap: 2px; + padding: 8px 0; + } +} + +/* optional secondary label suffix on edit-form labels, e.g. "(optional)" */ +.field-label-aux { + color: var(--muted); + font-weight: 400; + font-size: 11px; + letter-spacing: 0.04em; + margin-left: 4px; +} +.profile-section { + width: 100%; + margin-top: 18px; + text-align: left; +} +.profile-section-label { + font-size: 10px; + letter-spacing: 0.12em; + text-transform: uppercase; + color: var(--lavender); + margin-bottom: 8px; +} +.profile-pills, +.profile-links { + display: flex; + flex-wrap: wrap; + gap: 6px; +} +.pill { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 4px 10px; + border-radius: 999px; + font-size: 12px; + background: var(--surface); + color: var(--subtext); + border: 1px solid var(--overlay); + line-height: 1.4; +} +.pill-interest { + color: var(--green); + background: rgba(166, 227, 161, 0.08); + border-color: rgba(166, 227, 161, 0.25); +} +.pill-link { + color: var(--blue); + background: rgba(137, 180, 250, 0.08); + border-color: rgba(137, 180, 250, 0.25); + text-decoration: none; + transition: background 120ms, border-color 120ms, color 120ms; +} +.pill-link:hover { + color: var(--lavender); + background: rgba(180, 190, 254, 0.12); + border-color: rgba(180, 190, 254, 0.5); +} +.pill-link-arrow { opacity: 0.7; font-size: 11px; } + +/* hiring / looking-for-work status pills (above the bio) */ +.profile-status-pills { + display: flex; + flex-wrap: wrap; + gap: 8px; + justify-content: center; + margin: 6px 0 12px; +} +.pill-status-dot { + font-size: 9px; + line-height: 1; +} +.pill-hiring { + color: var(--peach); + background: rgba(250, 179, 135, 0.1); + border-color: rgba(250, 179, 135, 0.35); + font-weight: 600; + letter-spacing: 0.04em; + text-transform: lowercase; +} +.pill-hiring .pill-status-dot { + color: var(--peach); +} +.pill-looking { + color: var(--blue); + background: rgba(137, 180, 250, 0.1); + border-color: rgba(137, 180, 250, 0.35); + font-weight: 600; + letter-spacing: 0.04em; + text-transform: lowercase; +} +.pill-looking .pill-status-dot { color: var(--blue); } + +/* edit form: status checkbox -> pill toggles */ +.profile-status-field { + border: 1px solid var(--overlay); + border-radius: 6px; + padding: 12px 14px; + background: var(--surface); +} +.profile-status-toggles { + display: flex; + flex-wrap: wrap; + gap: 10px; + margin: 4px 0 6px; +} +.profile-status-toggle { + position: relative; + cursor: pointer; + user-select: none; +} +.profile-status-toggle input[type="checkbox"] { + position: absolute; + opacity: 0; + width: 100%; + height: 100%; + inset: 0; + margin: 0; + cursor: pointer; +} +.profile-status-toggle-pill { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 5px 12px; + border-radius: 999px; + font-size: 12px; + background: var(--surface); + color: var(--muted); + border: 1px solid var(--overlay); + letter-spacing: 0.04em; + text-transform: lowercase; + transition: color 120ms, background-color 120ms, border-color 120ms; +} +.profile-status-toggle:hover .profile-status-toggle-pill { + color: var(--text); + border-color: var(--lavender); +} +.profile-status-toggle input[type="checkbox"]:focus-visible + .profile-status-toggle-pill { + outline: 2px solid var(--peach); + outline-offset: 2px; +} +.profile-status-toggle input[type="checkbox"]:checked + .profile-status-toggle-pill { + color: var(--peach); + background: rgba(250, 179, 135, 0.1); + border-color: rgba(250, 179, 135, 0.4); + font-weight: 600; +} +.profile-status-toggle:has(input[name="looking"]:checked) .profile-status-toggle-pill { + color: var(--blue); + background: rgba(137, 180, 250, 0.1); + border-color: rgba(137, 180, 250, 0.4); +} + +/* collapsible session debug panel below the card */ +.profile-meta { + margin: 8px 0 16px; + border: 1px solid var(--overlay); + border-radius: 6px; + padding: 0 14px; + background: var(--surface); +} +.profile-meta summary { + cursor: pointer; + padding: 10px 0; + font-size: 11px; + letter-spacing: 0.1em; + text-transform: uppercase; + color: var(--muted); + list-style: none; +} +.profile-meta summary::marker, +.profile-meta summary::-webkit-details-marker { display: none; } +.profile-meta summary::before { content: "▸ "; color: var(--peach); } +.profile-meta[open] summary::before { content: "▾ "; } +.profile-meta .kv { margin: 4px 0 14px; } + +/* edit form */ +.profile-edit-note { + font-size: 11px; + color: var(--muted); + margin-top: 8px; +} +.profile-edit-form { margin: 16px 0 18px; } +.field-textarea { + resize: vertical; + min-height: 80px; + font-family: inherit; + line-height: 1.5; + padding: 8px 12px; +} + +/* tag combobox (interests editor) */ +.tag-combobox { + /* Promote the wrap to flex-wrap so chips + input share rows. */ + flex-wrap: wrap; + align-items: center; + padding: 6px 8px; + gap: 6px; + cursor: text; +} +.tag-chips { + display: contents; +} +.tag-chip { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 3px 4px 3px 10px; + border-radius: 999px; + font-size: 12px; + line-height: 1.4; + color: var(--green); + background: rgba(166, 227, 161, 0.08); + border: 1px solid rgba(166, 227, 161, 0.3); + max-width: 100%; +} +.tag-chip-label { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.tag-chip-remove { + appearance: none; + background: transparent; + border: 0; + color: var(--green); + opacity: 0.65; + cursor: pointer; + font-size: 14px; + line-height: 1; + padding: 2px 6px; + border-radius: 999px; + transition: opacity 0.12s, background-color 0.12s; +} +.tag-chip-remove:hover, +.tag-chip-remove:focus-visible { + opacity: 1; + background: rgba(166, 227, 161, 0.16); + outline: none; +} +.tag-input { + flex: 1 1 160px; + min-width: 140px; + border: 0; + outline: 0; + background: transparent; + color: var(--text); + font-family: inherit; + font-size: 13px; + padding: 4px 6px; +} +.tag-input::placeholder { color: var(--muted); } +.tag-combobox:focus-within { border-color: var(--peach); } + +.profile-links-field { + border: 1px solid var(--overlay); + border-radius: 6px; + padding: 12px 14px 4px; + background: var(--surface); +} +.profile-links-field legend { + padding: 0 6px; + background: var(--base); + border-radius: 3px; +} +.profile-link-row { + display: flex; + align-items: stretch; + gap: 10px; + margin-bottom: 10px; +} +.profile-link-num { + font-size: 11px; + color: var(--muted); + letter-spacing: 0.05em; + padding-top: 10px; + min-width: 24px; + flex-shrink: 0; +} +.profile-link-fields { + flex: 1; + display: grid; + grid-template-columns: minmax(120px, 1fr) minmax(180px, 2fr); + gap: 8px; +} +@media (max-width: 560px) { + .profile-link-fields { grid-template-columns: 1fr; } + .profile-link-num { padding-top: 0; } +} + +footer { + text-align: center; + padding: 24px; + font-size: 11px; + color: var(--muted); +} +footer a { color: var(--blue); text-decoration: none; } +footer a:hover { color: var(--lavender); } + +@media (max-width: 480px) { + .ctas { flex-direction: column; align-items: stretch; } + .btn { justify-content: center; } +} + +/* ─── home dashboard (signed-in) ───────────────────────────── */ +.home-dashboard { + display: flex; + flex-direction: column; + gap: 28px; + margin-top: 8px; +} +.home-identity { + display: flex; + align-items: center; + gap: 24px; + flex-wrap: wrap; +} +.home-identity .profile-flip { + width: 160px; + height: 160px; + flex-shrink: 0; +} +.home-identity-text { + display: flex; + flex-direction: column; + gap: 6px; + min-width: 0; + flex: 1 1 200px; +} +.home-name { + font-size: 22px; + font-weight: 600; + color: var(--text); + margin: 0; + line-height: 1.2; +} +.home-name-muted { color: var(--subtext); font-style: italic; } +.home-handle { + font-size: 13px; + color: var(--lavender); + margin: 0; +} +.home-identity-ctas { + display: flex; + gap: 8px; + flex-wrap: wrap; + margin-top: 10px; +} +.btn-sm { + font-size: 12px; + padding: 8px 12px; +} + +/* event card on the home dashboard */ +.home-event-card { + border: 1px solid var(--overlay); + border-left: 3px solid var(--peach); + border-radius: 6px; + padding: 18px 20px; + background: rgba(250, 179, 135, 0.04); + display: flex; + flex-direction: column; + gap: 8px; +} +.home-event-card.is-empty { + border-left-color: var(--overlay); + background: transparent; + text-align: center; +} +.home-event-label { + font-size: 10px; + letter-spacing: 0.12em; + text-transform: uppercase; + color: var(--lavender); +} +.home-event-name { + margin: 0; + font-size: 18px; + font-weight: 600; + color: var(--text); +} +.home-event-meta { + font-size: 12px; + color: var(--subtext); + display: flex; + flex-wrap: wrap; + gap: 4px 12px; +} +.home-event-meta-sep { color: var(--overlay); } +.home-event-ctas { + display: flex; + gap: 8px; + flex-wrap: wrap; + margin-top: 6px; +} +.home-event-empty { + color: var(--subtext); + font-size: 13px; + margin: 0; +} +.home-event-empty-hint { + color: var(--overlay); + font-size: 11px; + margin: 0; +} + +@media (max-width: 520px) { + .home-identity { flex-direction: column; align-items: flex-start; gap: 16px; } + .home-identity .profile-flip { width: 140px; height: 140px; } +} + +/* ─── connect: also-check-in toggle ────────────────────────── */ +.connect-checkin-toggle { + display: flex; + align-items: center; + gap: 10px; + padding: 10px 12px; + margin: 12px 0; + border: 1px solid var(--overlay); + border-radius: 6px; + cursor: pointer; + background: rgba(166, 227, 161, 0.04); + transition: border-color 0.15s ease, background 0.15s ease; +} +.connect-checkin-toggle:hover { border-color: var(--green); } +.connect-checkin-toggle input[type="checkbox"] { accent-color: var(--green); } +.connect-checkin-pill { + font-size: 13px; + color: var(--text); + display: inline-flex; + align-items: center; + gap: 6px; + flex-wrap: wrap; +} +.connect-checkin-pill .pill-status-dot { color: var(--green); } + + +/* ----------------------------------------------------------------- */ +/* admin console */ +/* ----------------------------------------------------------------- */ + +.admin-nav { + display: flex; + gap: 14px; + flex-wrap: wrap; + align-items: center; + margin: 6px 0 16px 0; + padding: 6px 8px; + border: 1px dashed var(--surface1); + border-radius: 6px; + background: rgba(49, 50, 68, 0.18); +} +.admin-nav-link { + color: var(--subtext1); + text-decoration: none; + padding: 4px 6px; + font-size: 14px; + border-radius: 4px; + transition: color 0.12s ease, background 0.12s ease; +} +.admin-nav-link:hover { color: var(--text); background: rgba(166, 227, 161, 0.06); } +.admin-nav-link.is-active { color: var(--green); } +.admin-nav-tail { margin-left: auto; color: var(--overlay2); text-decoration: none; font-size: 13px; } +.admin-nav-tail:hover { color: var(--subtext1); } + +.admin-section-title { + font-size: 17px; + margin: 4px 0 14px 0; + color: var(--text); +} + +.admin-empty { color: var(--subtext0); padding: 18px 4px; } + +.admin-stats-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); + gap: 12px; + margin: 8px 0 18px 0; +} +.admin-stat-tile { + border: 1px solid var(--surface1); + border-radius: 8px; + padding: 14px 16px; + background: rgba(49, 50, 68, 0.22); + display: flex; + flex-direction: column; + gap: 4px; +} +.admin-stat-label { + font-size: 12px; + letter-spacing: 0.04em; + text-transform: lowercase; + color: var(--subtext0); +} +.admin-stat-value { + font-size: 26px; + color: var(--text); + font-feature-settings: "tnum" 1; +} +.admin-stat-sub { font-size: 11px; color: var(--overlay2); } + +.admin-signer-card { + margin-top: 18px; + border: 1px solid var(--surface1); + border-radius: 8px; + padding: 12px 14px; + background: rgba(180, 190, 254, 0.06); +} +.admin-signer-label { + font-size: 12px; + letter-spacing: 0.05em; + text-transform: lowercase; + color: var(--subtext0); + margin-bottom: 4px; +} +.admin-signer-id { font-size: 13px; color: var(--text); margin-bottom: 4px; } +.admin-signer-id code { color: var(--lavender); } +.admin-signer-pub { + display: block; + font-size: 12px; + color: var(--subtext1); + word-break: break-all; + margin-top: 6px; +} + +.admin-search { + display: flex; + gap: 10px; + align-items: center; + flex-wrap: wrap; + margin-bottom: 14px; +} +.admin-search-input { + flex: 1 1 280px; + background: var(--mantle); + border: 1px solid var(--surface1); + color: var(--text); + border-radius: 6px; + padding: 8px 10px; + font: inherit; +} +.admin-search-input:focus { + outline: none; + border-color: var(--green); + box-shadow: 0 0 0 2px rgba(166, 227, 161, 0.15); +} +.admin-search-checkbox { + display: inline-flex; + align-items: center; + gap: 6px; + color: var(--subtext1); + font-size: 13px; +} +.admin-search-checkbox input { accent-color: var(--green); } + +.admin-events-toolbar { + display: flex; + gap: 10px; + margin-bottom: 12px; +} + +.admin-table-wrap { + overflow-x: auto; + border: 1px solid var(--surface1); + border-radius: 8px; +} +.admin-table { + width: 100%; + border-collapse: collapse; + font-size: 13px; +} +.admin-table thead th { + text-align: left; + background: rgba(49, 50, 68, 0.45); + color: var(--subtext0); + font-weight: 500; + letter-spacing: 0.04em; + text-transform: lowercase; + padding: 8px 12px; + border-bottom: 1px solid var(--surface1); +} +.admin-table tbody td { + padding: 8px 12px; + border-bottom: 1px solid var(--surface0); + color: var(--text); + vertical-align: middle; +} +.admin-table tbody tr:last-child td { border-bottom: none; } +.admin-table tbody tr:hover { background: rgba(166, 227, 161, 0.03); } +.admin-table .num { text-align: right; font-feature-settings: "tnum" 1; } +.admin-table .nowrap { white-space: nowrap; } +.admin-table .handle { color: var(--green); } +.admin-table .muted { color: var(--overlay2); } +.admin-inline-form { display: inline-flex; gap: 0; margin: 0; padding: 0; } + +.role-pill { + display: inline-block; + font-size: 11px; + padding: 2px 8px; + border-radius: 999px; + letter-spacing: 0.04em; + text-transform: lowercase; + border: 1px solid var(--surface1); +} +.role-pill.role-admin { color: var(--peach); border-color: rgba(250, 179, 135, 0.45); background: rgba(250, 179, 135, 0.07); } +.role-pill.role-banned { color: var(--red); border-color: rgba(243, 139, 168, 0.45); background: rgba(243, 139, 168, 0.07); } +.role-pill.role-user { color: var(--subtext1); } + +.admin-pagination { + display: flex; + gap: 12px; + align-items: center; + justify-content: center; + margin: 14px 0 6px 0; +} + +.admin-form { + display: flex; + flex-direction: column; + gap: 12px; + max-width: 540px; +} +.admin-form-row { display: flex; flex-direction: column; gap: 6px; } +.admin-form-row label { + font-size: 12px; + color: var(--subtext0); + letter-spacing: 0.04em; + text-transform: lowercase; +} +.admin-form-row input, +.admin-form-row select { + background: var(--mantle); + border: 1px solid var(--surface1); + color: var(--text); + border-radius: 6px; + padding: 8px 10px; + font: inherit; +} +.admin-form-row input:focus, +.admin-form-row select:focus { + outline: none; + border-color: var(--green); + box-shadow: 0 0 0 2px rgba(166, 227, 161, 0.15); +} +.admin-form-row-pair { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 12px; +} +@media (max-width: 560px) { + .admin-form-row-pair { grid-template-columns: 1fr; } +} + +.admin-badge-layout { + display: grid; + grid-template-columns: 240px 1fr; + gap: 24px; + align-items: start; +} +@media (max-width: 720px) { + .admin-badge-layout { grid-template-columns: 1fr; } +} +.admin-badge-preview { + border: 1px solid var(--surface1); + border-radius: 8px; + padding: 18px; + background: rgba(49, 50, 68, 0.18); + display: flex; + flex-direction: column; + align-items: center; + gap: 10px; +} +.admin-badge-preview svg { display: block; max-width: 100%; height: auto; } +.admin-badge-uri { font-size: 11px; text-align: center; margin: 0; word-break: break-all; } +.admin-badge-signature { font-size: 12px; color: var(--green); margin: 0; } +.admin-badge-form { max-width: 100%; } + +.badge-swatches { + display: flex; + flex-wrap: wrap; + gap: 8px; +} +.badge-swatch { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 4px 8px; + border: 1px solid var(--surface1); + border-radius: 999px; + cursor: pointer; + font-size: 11px; + color: var(--subtext1); + transition: border-color 0.12s ease, color 0.12s ease; +} +.badge-swatch:hover { border-color: var(--green); } +.badge-swatch input { accent-color: var(--green); margin: 0; } +.badge-swatch input:checked + .badge-swatch-chip { + box-shadow: 0 0 0 2px var(--green); +} +.badge-swatch-chip { + display: inline-block; + width: 18px; + height: 18px; + border-radius: 50%; + border: 1px solid var(--surface2); +} +.badge-swatch-hex { font-feature-settings: "tnum" 1; } + +.event-card { + border: 1px solid var(--surface1); + border-radius: 10px; + padding: 22px; + background: rgba(49, 50, 68, 0.22); + margin: 6px 0 14px 0; +} +.event-name { font-size: 22px; margin: 0 0 8px 0; color: var(--text); } +.event-meta { color: var(--subtext1); margin: 4px 0; font-size: 14px; } + +.status-handle { color: var(--green); text-decoration: none; } +.status-handle:hover { text-decoration: underline; } +.break { word-break: break-all; } diff --git a/web/resources/static/js/interests.js b/web/resources/static/js/interests.js new file mode 100644 index 0000000..94aec43 --- /dev/null +++ b/web/resources/static/js/interests.js @@ -0,0 +1,170 @@ +// interests.js — wires the tag-combobox on the profile edit form. +// +// Server-rendered structure (see profile_edit.templ): +// +//
+//
+// +// coffee +// +// +// … +//
+// +// +//
+// +// JS owns the in-memory list, re-renders chips, and keeps the hidden input +// in sync (comma-joined) so the existing server-side parseInterests handler +// keeps working unchanged. +(function () { + "use strict"; + + function init(root) { + var chipsEl = root.querySelector("[data-tag-chips]"); + var input = root.querySelector("[data-tag-input]"); + var hidden = root.querySelector("[data-tag-hidden]"); + if (!chipsEl || !input || !hidden) return; + + var maxTags = parseInt(root.getAttribute("data-tag-max"), 10) || 30; + var maxLen = parseInt(root.getAttribute("data-tag-maxlen"), 10) || 64; + + // Seed from server-rendered chips so a JS-failed initial render still + // matches what the user sees. + var tags = []; + var seen = Object.create(null); + Array.prototype.forEach.call( + chipsEl.querySelectorAll("[data-tag]"), + function (el) { + var v = (el.getAttribute("data-tag") || "").trim(); + if (!v) return; + var key = v.toLowerCase(); + if (seen[key]) return; + seen[key] = true; + tags.push(v); + } + ); + + function render() { + // Rebuild chips from the array — cheap, list is tiny. + var frag = document.createDocumentFragment(); + tags.forEach(function (t) { + var chip = document.createElement("span"); + chip.className = "tag-chip"; + chip.setAttribute("data-tag", t); + + var label = document.createElement("span"); + label.className = "tag-chip-label"; + label.textContent = t; + chip.appendChild(label); + + var btn = document.createElement("button"); + btn.type = "button"; + btn.className = "tag-chip-remove"; + btn.setAttribute("aria-label", "remove " + t); + btn.setAttribute("data-tag-remove", ""); + btn.textContent = "\u00d7"; // × + chip.appendChild(btn); + + frag.appendChild(chip); + }); + chipsEl.replaceChildren(frag); + hidden.value = tags.join(", "); + } + + function addTag(raw) { + if (typeof raw !== "string") return false; + var v = raw.trim(); + if (!v) return false; + // Reject embedded commas — splitting would be unexpected here. + // The user can paste multiple values; we handle that in onInput. + if (v.indexOf(",") !== -1) return false; + if (v.length > maxLen) v = v.slice(0, maxLen); + var key = v.toLowerCase(); + if (tags.some(function (t) { return t.toLowerCase() === key; })) { + return false; + } + if (tags.length >= maxTags) return false; + tags.push(v); + return true; + } + + function commitFromInput() { + var raw = input.value; + if (!raw) return; + // Allow paste-style "foo, bar, baz". + var parts = raw.split(","); + var any = false; + parts.forEach(function (p) { + if (addTag(p)) any = true; + }); + if (any) render(); + input.value = ""; + } + + input.addEventListener("keydown", function (e) { + if (e.key === "Enter" || e.key === ",") { + e.preventDefault(); + commitFromInput(); + return; + } + if (e.key === "Backspace" && input.value === "" && tags.length > 0) { + // Pop the last chip for fast correction. + tags.pop(); + render(); + } + }); + + // The `change` event fires when the user picks a value from the + // dropdown (Safari/Firefox/Chrome). Treat it as a commit. + input.addEventListener("change", function () { + commitFromInput(); + }); + + input.addEventListener("blur", function () { + commitFromInput(); + }); + + chipsEl.addEventListener("click", function (e) { + var btn = e.target.closest("[data-tag-remove]"); + if (!btn) return; + var chip = btn.closest(".tag-chip"); + if (!chip) return; + var v = chip.getAttribute("data-tag"); + tags = tags.filter(function (t) { return t !== v; }); + render(); + input.focus(); + }); + + // If the user clicks anywhere in the combobox shell, focus the input — + // makes the whole field feel like one big input. + root.addEventListener("click", function (e) { + if (e.target === root || e.target === chipsEl) { + input.focus(); + } + }); + + // Form submit: flush any pending text first so a half-typed tag isn't + // silently dropped. + var form = root.closest("form"); + if (form) { + form.addEventListener("submit", function () { + commitFromInput(); + }); + } + + // Initial sync (hidden input may have been server-rendered from a + // stale list — repaint to be safe). + render(); + } + + function boot() { + document.querySelectorAll("[data-tag-combobox]").forEach(init); + } + + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", boot); + } else { + boot(); + } +})(); diff --git a/web/resources/static/js/profile.js b/web/resources/static/js/profile.js new file mode 100644 index 0000000..8918a70 --- /dev/null +++ b/web/resources/static/js/profile.js @@ -0,0 +1,116 @@ +// atmo.quest — profile page client behaviors. +// +// Three small features: +// +// 1) Flip card: tap the avatar/QR button to flip between photo and QR. CSS +// handles the actual rotation; we just toggle data-flip="1" / aria. +// +// 2) Pending connections: when an unauthenticated visitor lands on +// /c/{did}, the connect template pushes that DID into localStorage. On +// the next page load (post-login), we POST the queue to +// /connect/flush-local and clear it on success. Idempotent — repeated +// visits with an empty queue are a no-op. +// +// 3) Pending event check-ins: same idea but for /e/{token} scans. Stashed +// via [data-queue-event="token"] and flushed via /event/flush-local. +(function () { + "use strict"; + + // ---- Flip card ----------------------------------------------------------- + document.querySelectorAll("[data-flip-toggle]").forEach(function (btn) { + var card = btn.closest("[data-flip]"); + if (!card) return; + btn.addEventListener("click", function () { + var flipped = card.getAttribute("data-flip") === "1"; + var next = flipped ? "0" : "1"; + card.setAttribute("data-flip", next); + btn.setAttribute("aria-pressed", next === "1" ? "true" : "false"); + }); + }); + + // ---- Generic localStorage queue helpers --------------------------------- + function readQueue(key) { + try { + var raw = localStorage.getItem(key); + if (!raw) return []; + var arr = JSON.parse(raw); + return Array.isArray(arr) ? arr : []; + } catch (e) { + return []; + } + } + function pushUnique(key, value, max) { + var arr = readQueue(key); + if (arr.indexOf(value) !== -1) return; + arr.push(value); + if (arr.length > max) arr = arr.slice(arr.length - max); + try { localStorage.setItem(key, JSON.stringify(arr)); } catch (e) {} + } + + var CONN_KEY = "atmoquest.pending_connections"; + var EVT_KEY = "atmoquest.pending_events"; + + // ---- Public queueing helpers (kept on window for template inline use) --- + window.atmoquest = window.atmoquest || {}; + window.atmoquest.queueConnection = function (targetDID) { + if (typeof targetDID !== "string" || targetDID.indexOf("did:") !== 0) return; + pushUnique(CONN_KEY, targetDID, 50); + }; + window.atmoquest.queueEvent = function (token) { + if (typeof token !== "string" || token.length === 0 || token.length > 64) return; + pushUnique(EVT_KEY, token, 20); + }; + + // Auto-queue from declarative markers placed by /c/{did} and /e/{token} + // templates for anonymous visitors. + document.querySelectorAll("[data-queue-did]").forEach(function (el) { + window.atmoquest.queueConnection(el.getAttribute("data-queue-did")); + }); + document.querySelectorAll("[data-queue-event]").forEach(function (el) { + window.atmoquest.queueEvent(el.getAttribute("data-queue-event")); + }); + + // ---- Flush queues ------------------------------------------------------- + // + // We attempt to flush both queues on every page load when they're + // non-empty. The server returns 401 if the user isn't logged in and + // we leave the queue intact for the next page (after sign-in). On a + // 2xx response we clear the queue. + // + // The sessionStorage sentinel prevents an infinite reload loop if a + // stale queue item keeps the server rejecting. + function flush(url, body, queueKey) { + return fetch(url, { + method: "POST", + credentials: "same-origin", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }) + .then(function (resp) { + if (resp.ok) { + try { localStorage.removeItem(queueKey); } catch (e) {} + return true; + } + return false; + }) + .catch(function () { return false; }); + } + + var pendingConn = readQueue(CONN_KEY); + var pendingEvt = readQueue(EVT_KEY); + if (pendingConn.length === 0 && pendingEvt.length === 0) return; + + var pConn = pendingConn.length > 0 + ? flush("/connect/flush-local", { targets: pendingConn }, CONN_KEY) + : Promise.resolve(false); + var pEvt = pendingEvt.length > 0 + ? flush("/event/flush-local", { tokens: pendingEvt }, EVT_KEY) + : Promise.resolve(false); + + Promise.all([pConn, pEvt]).then(function (results) { + if (results.indexOf(true) === -1) return; + if (sessionStorage.getItem("atmoquest.flushed")) return; + sessionStorage.setItem("atmoquest.flushed", "1"); + window.location.reload(); + }); +})(); -- 2.51.2