From bcbfe3a7892963d50597824888f010bcd3ae0dbe Mon Sep 17 00:00:00 2001 From: Will Garrison Date: Mon, 25 May 2026 13:02:44 -0700 Subject: [PATCH] Add local account support --- .gitignore | 1 + features/about/pages/about_templ.go | 14 +- features/auth/e2e_test.go | 105 ++++++ features/auth/handlers.go | 117 ++++++- features/auth/link.go | 272 +++++++++++++++ features/auth/pages/signin.templ | 3 + features/auth/pages/signin_templ.go | 2 +- features/auth/routes.go | 8 + features/auth/session.go | 23 ++ features/connect/handlers.go | 142 +++++++- features/connect/pages/connect_local.templ | 98 ++++++ features/connect/pages/connect_local_templ.go | 273 +++++++++++++++ features/connect/pages/connect_templ.go | 10 +- features/connect/routes.go | 2 + features/connections/handlers.go | 174 +++++++++- .../connections/pages/connections_templ.go | 9 +- features/event/handlers.go | 116 +++++-- features/events/handlers.go | 208 +++++++++-- features/events/pages/events_templ.go | 9 +- features/index/handlers.go | 16 +- features/index/pages/index_templ.go | 14 +- features/profile/handlers.go | 324 ++++++++++++++++-- features/profile/routes.go | 8 +- features/settings/handlers.go | 318 +++++++++++++---- features/settings/pages/settings.templ | 76 ++-- features/settings/pages/settings_templ.go | 234 ++++++++----- go.mod | 1 + go.sum | 2 + internal/checkin/checkin.go | 90 +++++ internal/checkin/list.go | 7 +- internal/checkin/list_test.go | 14 +- internal/connection/local.go | 49 +++ .../db/migrations/015_users_user_type.sql | 5 + .../db/migrations/016_local_connections.sql | 29 ++ internal/db/migrations/017_account_links.sql | 9 + internal/db/migrations/018_user_profile.sql | 11 + internal/session/session.go | 80 +++++ web/resources/static/js/profile.js | 8 +- 38 files changed, 2546 insertions(+), 335 deletions(-) create mode 100644 features/auth/e2e_test.go create mode 100644 features/auth/link.go create mode 100644 features/connect/pages/connect_local.templ create mode 100644 features/connect/pages/connect_local_templ.go create mode 100644 internal/connection/local.go create mode 100644 internal/db/migrations/015_users_user_type.sql create mode 100644 internal/db/migrations/016_local_connections.sql create mode 100644 internal/db/migrations/017_account_links.sql create mode 100644 internal/db/migrations/018_user_profile.sql diff --git a/.gitignore b/.gitignore index 9ece8f7..b080e00 100644 --- a/.gitignore +++ b/.gitignore @@ -28,6 +28,7 @@ !web/**/* web/resources/static/libs/* +plans/* # !Makefile diff --git a/features/about/pages/about_templ.go b/features/about/pages/about_templ.go index 1db88e2..40a333b 100644 --- a/features/about/pages/about_templ.go +++ b/features/about/pages/about_templ.go @@ -5,12 +5,10 @@ package pages //lint:file-ignore SA4006 This context is only used if a nested component is present. -import ( - "atmoquest/features/common/layouts" +import "github.com/a-h/templ" +import templruntime "github.com/a-h/templ/runtime" - "github.com/a-h/templ" - templruntime "github.com/a-h/templ/runtime" -) +import "atmoquest/features/common/layouts" // AboutATProto is a short explainer page linked from the guest landing and // settings. Intentionally tiny — its job is to give a curious visitor enough @@ -49,7 +47,7 @@ func AboutATProto(isAuthed bool) templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "
about the Atmosphere
you") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "
about atproto
you") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -62,7 +60,7 @@ func AboutATProto(isAuthed bool) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "atmo.quest:~$ cat /about-atmosphere.md
atprotocol · open social

about the Atmosphere

The Atmosphere, also called ATProtocol, is an open social protocol. Instead of accounts living inside one company's database, your data lives in your repo — a personal data store you can take with you.

atmo.quest writes connection, check-in, and badge records to your repo. If atmo.quest disappears tomorrow, those records still exist in your repo and can be read by any other Atmosphere app. If atmo.quest starts doing something that you don't like, you can take your data and your connections anywhere else you'd like.

You don't have to start over on a new social media app every few years now. Let's make social media social again.

← back read the protocol docs ↗ ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "atmo.quest:~$ cat /etc/atproto/about.md
protocol · open · yours

your data

ATProto is an open social protocol. Instead of accounts living inside one company's database, your records live in your repo — a personal data store you can take with you.

atmo.quest writes connection, check-in, and badge records to your PDS. If atmo.quest disappears tomorrow, those records still exist in your repo and can be read by any other ATProto-aware app.

← back read the protocol docs ↗ ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -78,7 +76,7 @@ func AboutATProto(isAuthed bool) templ.Component { } return nil }) - templ_7745c5c3_Err = layouts.Base("What's the Atmosphere?", "The Atmosphere, also called ATProtocol, is an open social protocol. Your records, connections, and data are owned by you, and you decide what happens with them.").Render(templ.WithChildren(ctx, templ_7745c5c3_Var2), templ_7745c5c3_Buffer) + templ_7745c5c3_Err = layouts.Base("What's ATProto?", "ATProto is an open social protocol — your records live in 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/auth/e2e_test.go b/features/auth/e2e_test.go new file mode 100644 index 0000000..1f37cae --- /dev/null +++ b/features/auth/e2e_test.go @@ -0,0 +1,105 @@ +package auth + +import ( + "net/http" + "net/http/httptest" + "testing" +) + +// TestContinueWithoutAccountRedirect verifies the redirect goes to /profile +func TestContinueWithoutAccountRedirect(t *testing.T) { + // Create a simple handler that simulates ContinueWithoutAccount + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Simulate setting the local cookie + http.SetCookie(w, &http.Cookie{ + Name: "atmoquest_local", + Value: "local_test_12345", + Path: "/", + MaxAge: 365 * 24 * 60 * 60, + HttpOnly: false, + SameSite: http.SameSiteLaxMode, + }) + + // Redirect to /profile (not /) + http.Redirect(w, r, "/profile", http.StatusFound) + }) + + // Create test server + ts := httptest.NewServer(handler) + defer ts.Close() + + // Create client that doesn't follow redirects + client := &http.Client{ + CheckRedirect: func(req *http.Request, via []*http.Request) error { + return http.ErrUseLastResponse + }, + } + + // Make request + resp, err := client.Get(ts.URL + "/auth/local/continue") + if err != nil { + t.Fatalf("request failed: %v", err) + } + defer resp.Body.Close() + + // Check that we got a redirect to /profile + if resp.StatusCode != http.StatusFound { + t.Errorf("expected status 302, got %d", resp.StatusCode) + } + location := resp.Header.Get("Location") + if location != "/profile" { + t.Errorf("expected redirect to /profile, got %s", location) + } + + t.Log("Redirect test passed!") +} + +// TestLocalCookieAttributes verifies the cookie is set with correct attributes +func TestLocalCookieAttributes(t *testing.T) { + // Create a simple handler that sets the cookie + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Simulate ContinueWithoutAccount setting the cookie + http.SetCookie(w, &http.Cookie{ + Name: "atmoquest_local", + Value: "local_test_12345", + Path: "/", + MaxAge: 365 * 24 * 60 * 60, + HttpOnly: false, // NOT HttpOnly so JS can read + SameSite: http.SameSiteLaxMode, + }) + + w.WriteHeader(http.StatusOK) + }) + + // Create test server + ts := httptest.NewServer(handler) + defer ts.Close() + + // Make request + resp, err := http.Get(ts.URL + "/test") + if err != nil { + t.Fatalf("request failed: %v", err) + } + defer resp.Body.Close() + + // Check cookies + cookies := resp.Cookies() + found := false + for _, c := range cookies { + if c.Name == "atmoquest_local" { + found = true + if c.HttpOnly { + t.Error("cookie should NOT be HttpOnly") + } + if c.Value == "" { + t.Error("cookie value is empty") + } + t.Logf("Cookie: %s=%s, HttpOnly=%v", c.Name, c.Value, c.HttpOnly) + } + } + if !found { + t.Error("atmoquest_local cookie not found") + } + + t.Log("Cookie attributes test passed!") +} diff --git a/features/auth/handlers.go b/features/auth/handlers.go index cced8f5..84d0e11 100644 --- a/features/auth/handlers.go +++ b/features/auth/handlers.go @@ -15,6 +15,7 @@ import ( "github.com/bluesky-social/indigo/atproto/auth/oauth" "github.com/bluesky-social/indigo/atproto/syntax" + "github.com/google/uuid" "atmoquest/features/auth/pages" "atmoquest/internal/connection" @@ -125,11 +126,11 @@ func (h *Handlers) OAuthCallback(w http.ResponseWriter, r *http.Request) { // without requiring admin intervention. go h.SyncPDSHistory(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. + // 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 { @@ -144,6 +145,15 @@ func (h *Handlers) OAuthCallback(w http.ResponseWriter, r *http.Request) { } } + // Check if there's a local account to link + // We need to do this after the cookie is set, but we can't call + // LinkLocalToATProto directly because it writes headers. + // Instead, we'll set a flag and redirect to a link endpoint. + if h.Sessions.GetLocal(r) != "" { + http.Redirect(w, r, "/auth/link", http.StatusFound) + return + } + http.Redirect(w, r, "/profile", http.StatusFound) } @@ -184,15 +194,14 @@ func (h *Handlers) OAuthJWKS(w http.ResponseWriter, _ *http.Request) { } } -// recordUserLogin upserts a row in the users table for this DID and tries +// 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. +// the admin step; the users.Touch COALESCEs. func (h *Handlers) RecordUserLogin(sessData *oauth.ClientSessionData) { if h.DB == nil || sessData == nil { return @@ -224,7 +233,7 @@ func (h *Handlers) RecordUserLogin(sessData *oauth.ClientSessionData) { func sanitizeAuthError(err error) string { s := err.Error() if len(s) > 200 { - s = s[:200] + "…" + return s[:200] + "…" } return s } @@ -248,3 +257,93 @@ func sanitizeASCode(code string) string { } return string(out) } + +// ContinueWithoutAccount handles the "continue without atmosphere account" flow. +// Generates a local_id server-side, sets the cookie, creates user row. +// On subsequent visits (after sign-out), auto-recovers the previous local account +// via the known-accounts cookie. +func (h *Handlers) ContinueWithoutAccount(w http.ResponseWriter, r *http.Request) { + // Check if already authenticated (ATProto or local) + if _, _, err := h.ResumeSession(r); err == nil { + http.Redirect(w, r, "/profile", http.StatusFound) + return + } + + // Check current local cookie first. + existingLocalID := h.Sessions.GetLocal(r) + if existingLocalID != "" { + var exists string + err := h.DB.QueryRowContext(r.Context(), ` + SELECT did FROM users WHERE did = ? + `, existingLocalID).Scan(&exists) + if err == nil { + http.Redirect(w, r, "/profile", http.StatusFound) + return + } + } + + // Check known-accounts cookie for recovery. + known := h.Sessions.GetKnownLocals(r) + for _, id := range known { + if id == "" { + continue + } + var exists string + err := h.DB.QueryRowContext(r.Context(), ` + SELECT did FROM users WHERE did = ? + `, id).Scan(&exists) + if err == nil { + // Found a valid known account — resume it. + slog.Info("continue without account: recovering known local account", "local_id", id) + if err := h.Sessions.SetLocal(w, id); err != nil { + slog.Error("continue without account: set local cookie", "err", err) + http.Error(w, "failed to set cookie", http.StatusInternalServerError) + return + } + http.Redirect(w, r, "/profile", http.StatusFound) + return + } + } + + // No recoverable account — create a new one. + localID := "local_" + uuid.New().String() + + _, err := h.DB.ExecContext(r.Context(), ` + INSERT OR IGNORE INTO users (did, handle, user_type) VALUES (?, '', 'local') + `, localID) + if err != nil { + slog.Warn("continue without account: create user", "err", err) + } + + var exists string + err = h.DB.QueryRowContext(r.Context(), ` + SELECT did FROM users WHERE did = ? + `, localID).Scan(&exists) + if err != nil { + slog.Error("continue without account: verify user", "err", err) + http.Error(w, "user not found after creation", http.StatusInternalServerError) + return + } + + if err := h.Sessions.SetLocal(w, localID); err != nil { + slog.Error("continue without account: set local cookie", "err", err) + http.Error(w, "failed to set cookie", http.StatusInternalServerError) + return + } + h.Sessions.RememberLocal(w, localID) + + http.Redirect(w, r, "/profile", http.StatusFound) +} + +// LocalLogout clears the local session cookie and redirects home. +// Before clearing, the local ID is saved to the known-accounts cookie so the +// user can recover their account on the next visit. +func (h *Handlers) LocalLogout(w http.ResponseWriter, r *http.Request) { + if id := h.Sessions.GetLocal(r); id != "" { + h.Sessions.RememberLocal(w, id) + } + h.Sessions.ClearLocal(w) + http.Redirect(w, r, "/", http.StatusFound) +} + + diff --git a/features/auth/link.go b/features/auth/link.go new file mode 100644 index 0000000..72b2ae7 --- /dev/null +++ b/features/auth/link.go @@ -0,0 +1,272 @@ +package auth + +import ( + "encoding/json" + "log/slog" + "net/http" + "time" + + "github.com/bluesky-social/indigo/atproto/syntax" + + "atmoquest/internal/badge" + "atmoquest/internal/checkin" + "atmoquest/internal/connection" + "atmoquest/internal/profile" +) + +// LinkLocalToATProto migrates a local account to an ATProto account. +// Writes all local data (connections, checkins, profile, badges) to the user's PDS. +func (h *Handlers) LinkLocalToATProto(w http.ResponseWriter, r *http.Request) { + localID := h.Sessions.GetLocal(r) + if localID == "" { + http.Error(w, "no local account to link", http.StatusBadRequest) + return + } + + did, sess, err := h.ResumeSession(r) + if err != nil { + http.Redirect(w, r, "/signin", http.StatusFound) + return + } + + // Start transaction for local table updates + tx, err := h.DB.BeginTx(r.Context(), nil) + if err != nil { + slog.Error("link account: begin tx", "err", err) + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + defer tx.Rollback() + + ctx := r.Context() + + // 1. Migrate connections from local_connections to PDS + // Only migrate where target is ATProto (target_did != '') + rows, err := tx.QueryContext(ctx, ` + SELECT target_did, event_uri FROM local_connections + WHERE viewer_local_id = ? AND target_did != '' + `, localID) + if err == nil { + for rows.Next() { + var targetDID, eventURI string + if err := rows.Scan(&targetDID, &eventURI); err != nil { + continue + } + target, err := syntax.ParseDID(targetDID) + if err != nil { + continue + } + // Write connection to PDS + rec := connection.Record{ + With: target, + EventURI: eventURI, + ConnectedAt: time.Now().UTC(), + } + if _, _, err := connection.Put(ctx, sess, rec); err != nil { + slog.Warn("link: migrate connection", "target", targetDID, "err", err) + } + } + rows.Close() + } + + // 2. Migrate checkins to PDS + checkinRows, err := tx.QueryContext(ctx, ` + SELECT event_uri, checked_in_at FROM checkins WHERE did = ? + `, localID) + if err == nil { + for checkinRows.Next() { + var eventURI string + var checkedInAt time.Time + if err := checkinRows.Scan(&eventURI, &checkedInAt); err != nil { + continue + } + // Write checkin to PDS + if _, err := checkin.Put(ctx, sess, h.DB, eventURI, checkedInAt); err != nil { + slog.Warn("link: migrate checkin", "event", eventURI, "err", err) + } + } + checkinRows.Close() + } + + // 3. Migrate profile to PDS (quest.atmo.profile) + var displayName, bio, location, worksAt, contactMethod string + var hiring, looking, hideFromLeaderboard, hideBadges bool + var interestsJSON, linksJSON string + err = tx.QueryRowContext(ctx, ` + SELECT display_name, bio, location, works_at, contact_method, + hiring, "looking", hide_from_leaderboard, hide_badges, interests, links + FROM users WHERE did = ? + `, localID).Scan(&displayName, &bio, &location, &worksAt, &contactMethod, + &hiring, &looking, &hideFromLeaderboard, &hideBadges, &interestsJSON, &linksJSON) + if err == nil { + // Build QuestRecord + rec := profile.QuestRecord{ + Bio: bio, + Location: location, + WorksAt: worksAt, + ContactMethod: contactMethod, + UpdatedAt: time.Now().UTC(), + } + if hiring { + t := true + rec.Hiring = &t + } + if looking { + t := true + rec.Looking = &t + } + // Parse interests from JSON + if interestsJSON != "" { + var interests []string + if err := json.Unmarshal([]byte(interestsJSON), &interests); err == nil { + rec.Interests = interests + } + } + // Parse links from JSON + if linksJSON != "" { + var links []profile.Link + if err := json.Unmarshal([]byte(linksJSON), &links); err == nil { + rec.Links = links + } + } + // Write to PDS + if _, err := profile.PutQuest(ctx, sess, did, rec); err != nil { + slog.Warn("link: migrate profile", "err", err) + } + } + + // 4. Migrate badges to PDS + badgeRows, err := tx.QueryContext(ctx, ` + SELECT d.trigger FROM earned_badges e + JOIN badge_definitions d ON e.badge_id = d.id + WHERE e.did = ? + `, localID) + if err == nil { + for badgeRows.Next() { + var trigger string + if err := badgeRows.Scan(&trigger); err != nil { + continue + } + // Award badge to PDS + if _, err := badge.Award(ctx, sess, h.DB, badge.AwardType(trigger), ""); err != nil { + slog.Warn("link: migrate badge", "trigger", trigger, "err", err) + } + } + badgeRows.Close() + } + + // 5. Update local tables: change did from localID to new DID. + // Note: connection_notes uses viewer_did, not did. + tables := []struct { + name string + col string + }{ + {"checkins", "did"}, + {"connection_notes", "viewer_did"}, + {"earned_badges", "did"}, + } + for _, t := range tables { + q := "UPDATE " + t.name + " SET " + t.col + " = ? WHERE " + t.col + " = ?" + if _, e := tx.ExecContext(ctx, q, did.String(), localID); e != nil { + slog.Warn("link: update table", "table", t.name, "err", e) + } + } + + // 6. Update users table: delete existing ATProto user row (created by + // RecordUserLogin on first OAuth sign-in), then relabel the local row. + // We use two statements inside the same transaction so they're atomic. + _, _ = tx.ExecContext(ctx, `DELETE FROM users WHERE did = ?`, did.String()) + _, err = tx.ExecContext(ctx, ` + UPDATE users SET did = ?, user_type = 'atproto' WHERE did = ? + `, did.String(), localID) + if err != nil { + slog.Error("link: update users", "err", err) + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + + // 7. Update local_connections: change viewer_local_id to viewer_did + _, err = tx.ExecContext(ctx, ` + UPDATE local_connections SET viewer_did = ?, viewer_local_id = NULL + WHERE viewer_local_id = ? + `, did.String(), localID) + if err != nil { + slog.Warn("link: update local_connections", "err", err) + } + + // 8. Record the link in account_links table + _, err = tx.ExecContext(ctx, ` + INSERT OR IGNORE INTO account_links (local_did, did) VALUES (?, ?) + `, localID, did.String()) + if err != nil { + slog.Error("link: record link", "err", err) + } + + // 9. Clear local cookie + h.Sessions.ClearLocal(w) + + if err := tx.Commit(); err != nil { + slog.Error("link: commit", "err", err) + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + + http.Redirect(w, r, "/profile", http.StatusFound) +} + +// ExportLocalData returns all local user data as JSON for manual export. +func (h *Handlers) ExportLocalData(w http.ResponseWriter, r *http.Request) { + localID := h.Sessions.GetLocal(r) + if localID == "" { + http.Error(w, "no local account", http.StatusBadRequest) + return + } + + data := map[string]any{} + + // Get user info + userRow := h.DB.QueryRowContext(r.Context(), + "SELECT did, handle, display_name, user_type FROM users WHERE did = ?", localID) + var did, handle, displayName, userType string + if err := userRow.Scan(&did, &handle, &displayName, &userType); err == nil { + data["user"] = map[string]string{ + "did": did, + "handle": handle, + "display_name": displayName, + "user_type": userType, + } + } + + // Get connections from local_connections + connRows, err := h.DB.QueryContext(r.Context(), ` + SELECT id, viewer_did, viewer_local_id, target_did, target_local_id, event_uri, connected_at + FROM local_connections + WHERE viewer_local_id = ? OR target_local_id = ? + `, localID, localID) + if err == nil { + defer connRows.Close() + conns := []map[string]any{} + for connRows.Next() { + var id int + var viewerDID, viewerLocalID, targetDID, targetLocalID, eventURI, connectedAt string + if err := connRows.Scan(&id, &viewerDID, &viewerLocalID, &targetDID, &targetLocalID, &eventURI, &connectedAt); err == nil { + conns = append(conns, map[string]any{ + "id": id, + "viewer_did": viewerDID, + "viewer_local": viewerLocalID, + "target_did": targetDID, + "target_local": targetLocalID, + "event_uri": eventURI, + "connected_at": connectedAt, + }) + } + } + data["connections"] = conns + } + + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Content-Disposition", "attachment; filename=\"atmoquest-export.json\"") + if err := json.NewEncoder(w).Encode(data); err != nil { + slog.Error("export: encode json", "err", err) + } +} diff --git a/features/auth/pages/signin.templ b/features/auth/pages/signin.templ index d02b9e5..e99f09d 100644 --- a/features/auth/pages/signin.templ +++ b/features/auth/pages/signin.templ @@ -40,6 +40,9 @@ templ Signin() {
▸ continue ↗
+
atmo.quest diff --git a/features/auth/pages/signin_templ.go b/features/auth/pages/signin_templ.go index 2d81e54..ebe290f 100644 --- a/features/auth/pages/signin_templ.go +++ b/features/auth/pages/signin_templ.go @@ -58,7 +58,7 @@ func Signin() templ.Component { 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.

") + 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.

atmo.quest
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } diff --git a/features/auth/routes.go b/features/auth/routes.go index 7049c5c..2680819 100644 --- a/features/auth/routes.go +++ b/features/auth/routes.go @@ -16,6 +16,9 @@ import ( // - 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) +// - GET /auth/local/continue — continue without Atmosphere account +// - POST /auth/link — link local account to ATProto account +// - GET /export/local — export local account data as JSON func SetupRoutes(router chi.Router, h *Handlers) { router.Get("/signin", h.Signin) router.Get("/signin/atproto", h.SigninATProto) @@ -24,4 +27,9 @@ func SetupRoutes(router chi.Router, h *Handlers) { router.Post("/oauth/logout", h.OAuthLogout) router.Get("/oauth/client-metadata.json", h.OAuthClientMetadata) router.Get("/oauth/jwks.json", h.OAuthJWKS) + router.Get("/auth/local/continue", h.ContinueWithoutAccount) + router.Get("/auth/link", h.LinkLocalToATProto) + router.Post("/auth/link", h.LinkLocalToATProto) + router.Post("/auth/local/logout", h.LocalLogout) + router.Get("/export/local", h.ExportLocalData) } diff --git a/features/auth/session.go b/features/auth/session.go index 6d9b617..a81da22 100644 --- a/features/auth/session.go +++ b/features/auth/session.go @@ -12,6 +12,29 @@ import ( // Other features should check for this with errors.Is and redirect to /signin. var ErrNoSession = errors.New("auth: no session") +// IdentityInfo holds the resolved viewer identity. +// Use ResolveIdentity in handlers that support both ATProto and local users. +type IdentityInfo struct { + DID string // ATProto DID (did:plc:...) or local ID (local_xxx) + IsLocal bool // true when the user is a local-only account + IsAuth bool // true when the user is authenticated (either type) +} + +// ResolveIdentity checks the ATProto session cookie first, then the local +// cookie. Returns the identity without writing any redirect headers. +// Use this in handlers that can serve both ATProto and local users. +func (h *Handlers) ResolveIdentity(r *http.Request) IdentityInfo { + didStr, sid := h.Sessions.Get(r) + if didStr != "" && sid != "" { + return IdentityInfo{DID: didStr, IsLocal: false, IsAuth: true} + } + localID := h.Sessions.GetLocal(r) + if localID != "" { + return IdentityInfo{DID: localID, IsLocal: true, IsAuth: true} + } + return IdentityInfo{} +} + // 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". diff --git a/features/connect/handlers.go b/features/connect/handlers.go index 387e34f..fb8b4dd 100644 --- a/features/connect/handlers.go +++ b/features/connect/handlers.go @@ -10,6 +10,7 @@ package connect import ( + "context" "database/sql" "encoding/json" "log/slog" @@ -22,6 +23,7 @@ import ( "atmoquest/config" "atmoquest/features/auth" + "atmoquest/features/connect/pages" "atmoquest/internal/badge" "atmoquest/internal/checkin" "atmoquest/internal/connection" @@ -43,24 +45,40 @@ func NewHandlers(conn *sql.DB, authH *auth.Handlers, queue *connection.Queue) *H } // 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. +// The QR encodes /c/ (ATProto) or /c/l/ (local) +// so any standard QR scanner opens the connect page in a browser. func (h *Handlers) ProfileQR(w http.ResponseWriter, r *http.Request) { - did, _, ok := h.Auth.RequireSession(w, r) - if !ok { + // Try local session first (doesn't write redirect headers on miss). + localID := h.Auth.Sessions.GetLocal(r) + if localID != "" { + svg, err := qrcode.EncodeSVG(connectURLLocal(localID), qrcode.DefaultOptions()) + if err != nil { + slog.Error("qr encode local", "local_id", localID, "err", err) + http.Error(w, "qr encode failed", http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "image/svg+xml") + w.Header().Set("Cache-Control", "private, max-age=60") + _, _ = w.Write(svg) 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) + + // Try ATProto session (RequireSession may write redirect on miss). + did, _, ok := h.Auth.RequireSession(w, r) + if ok { + 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") + w.Header().Set("Cache-Control", "private, max-age=60") + _, _ = w.Write(svg) 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) + + http.Error(w, "unauthorized", http.StatusUnauthorized) } // Connect handles GET /c/{did} — the URL encoded in QR codes. @@ -159,6 +177,84 @@ func (h *Handlers) Connect(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/connections/"+target.String(), http.StatusSeeOther) } +// ConnectLocal handles GET /c/l/{local_id} — QR code scan for local users. +func (h *Handlers) ConnectLocal(w http.ResponseWriter, r *http.Request) { + targetLocalID := chi.URLParam(r, "local_id") + if targetLocalID == "" || !strings.HasPrefix(targetLocalID, "local_") { + http.NotFound(w, r) + return + } + + // Verify target exists in users table with user_type = 'local' + var exists string + err := h.DB.QueryRowContext(r.Context(), + "SELECT did FROM users WHERE did = ? AND user_type = 'local'", targetLocalID).Scan(&exists) + if err != nil { + http.NotFound(w, r) + return + } + + // Check if viewer is authenticated (ATProto or local) + viewerDID, _, ok := h.Auth.RequireSession(w, r) + viewerLocalID := h.Auth.Sessions.GetLocal(r) + + if !ok && viewerLocalID == "" { + // Not logged in — render page with data-queue-local-id attribute + h.renderConnectLocalPage(w, r, targetLocalID, false) + return + } + + // Self-connect check + if viewerLocalID == targetLocalID { + http.Redirect(w, r, "/profile", http.StatusSeeOther) + return + } + + // Write connection record to local_connections table + viewerDIDStr := "" + if ok { + viewerDIDStr = viewerDID.String() + } + + err = h.writeLocalConnection(r.Context(), viewerDIDStr, viewerLocalID, "", targetLocalID, "") + if err != nil { + slog.Warn("connect local: write connection", "err", err) + } + + // Redirect to show connection + if viewerLocalID != "" { + http.Redirect(w, r, "/connections/l/"+targetLocalID, http.StatusSeeOther) + } else { + http.Redirect(w, r, "/connections/"+viewerDID.String(), http.StatusSeeOther) + } +} + +// writeLocalConnection writes a connection involving at least one local user. +func (h *Handlers) writeLocalConnection(ctx context.Context, viewerDID, viewerLocalID, targetDID, targetLocalID, eventURI string) error { + return connection.WriteLocal(ctx, h.DB, viewerDID, viewerLocalID, targetDID, targetLocalID, eventURI) +} + +// renderConnectLocalPage renders the connect page for local user targets. +func (h *Handlers) renderConnectLocalPage(w http.ResponseWriter, r *http.Request, targetLocalID string, viewerLoggedIn bool) { + // Fetch target profile from users table + var displayName string + err := h.DB.QueryRowContext(r.Context(), + "SELECT COALESCE(NULLIF(handle, ''), NULLIF(did, ''), 'Unknown') FROM users WHERE did = ?", targetLocalID).Scan(&displayName) + if err != nil { + displayName = targetLocalID + } + + w.Header().Set("Content-Type", "text/html; charset=utf-8") + if err := pages.ConnectLocal(pages.ConnectLocalView{ + TargetLocalID: targetLocalID, + TargetDisplayName: displayName, + ViewerLoggedIn: viewerLoggedIn, + SelfConnect: false, + }).Render(r.Context(), w); err != nil { + slog.Error("render connect local", "err", err) + } +} + // 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 @@ -192,6 +288,21 @@ func (h *Handlers) ConnectFlushLocal(w http.ResponseWriter, r *http.Request) { if raw == "" { continue } + + // Handle local_ IDs — write to local_connections table. + if strings.HasPrefix(raw, "local_") { + viewerDIDStr := viewerDID.String() + err := connection.WriteLocal(r.Context(), h.DB, viewerDIDStr, "", "", raw, "") + if err != nil { + slog.Warn("flush-local: write local connection", "target", raw, "err", err) + skipped++ + errs = append(errs, "write failed for "+raw) + continue + } + written++ + continue + } + target, err := syntax.ParseDID(raw) if err != nil { skipped++ @@ -236,6 +347,11 @@ func connectURL(did syntax.DID) string { return strings.TrimRight(config.Global.PublicURL, "/") + "/c/" + did.String() } +// connectURLLocal builds the URL for local user QR codes. +func connectURLLocal(localID string) string { + return strings.TrimRight(config.Global.PublicURL, "/") + "/c/l/" + localID +} + // 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. diff --git a/features/connect/pages/connect_local.templ b/features/connect/pages/connect_local.templ new file mode 100644 index 0000000..76caada --- /dev/null +++ b/features/connect/pages/connect_local.templ @@ -0,0 +1,98 @@ +package pages + +import "atmoquest/features/common/layouts" + +// ConnectLocalView is the data for the local user connect page. +type ConnectLocalView struct { + TargetLocalID string + TargetDisplayName string + TargetAvatarURL string + TargetBio string + ViewerLoggedIn bool + SelfConnect bool +} + +templ ConnectLocal(v ConnectLocalView) { + @layouts.Base("connect", "Connect with another atmo.quest profile.") { +
+
+
+
+
+
~/atmoquest — connect
+ @layouts.TopNav("connections") +
+
+
+ you{ "@" }atmoquest:~$ + connect --with { shortID(v.TargetLocalID) } +
+ +
+
+ if v.TargetAvatarURL != "" { + { + } else { + + } +
+ if v.TargetDisplayName != "" { +

{ v.TargetDisplayName }

+ } else { +

(no display name)

+ } +

{ v.TargetLocalID }

+ if v.TargetBio != "" { +

{ v.TargetBio }

+ } + + if v.SelfConnect { + + + } else if v.ViewerLoggedIn { +
+
+ + cancel +
+
+

+ this connection is stored locally and will be visible to both users. +

+ } 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. +
+ + } +
+
+ +
+ if !v.ViewerLoggedIn && !v.SelfConnect { + + + } + @layouts.BottomNav("connections") + } +} + +// shortID renders a short version of a DID or local ID. +func shortID(id string) string { + if len(id) <= 18 { + return id + } + return id[:18] + "…" +} diff --git a/features/connect/pages/connect_local_templ.go b/features/connect/pages/connect_local_templ.go new file mode 100644 index 0000000..8c653aa --- /dev/null +++ b/features/connect/pages/connect_local_templ.go @@ -0,0 +1,273 @@ +// 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" + +// ConnectLocalView is the data for the local user connect page. +type ConnectLocalView struct { + TargetLocalID string + TargetDisplayName string + TargetAvatarURL string + TargetBio string + ViewerLoggedIn bool + SelfConnect bool +} + +func ConnectLocal(v ConnectLocalView) 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
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = layouts.TopNav("connections").Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "
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_local.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, 3, "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(shortID(v.TargetLocalID)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/connect/pages/connect_local.templ`, Line: 28, Col: 64} + } + _, 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, 4, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if v.TargetAvatarURL != "" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "\"")") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "
?
") + 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.TargetDisplayName != "" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "

") + 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_local.templ`, Line: 40, 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, 11, "

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

(no display name)

") + 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 + } + var templ_7745c5c3_Var8 string + templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(v.TargetLocalID) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/connect/pages/connect_local.templ`, Line: 44, Col: 71} + } + _, 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, 14, "

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

") + 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_local.templ`, Line: 46, 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, 16, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + if v.SelfConnect { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "
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, 18, "
cancel

this connection is stored locally and will be visible to both users.

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "
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, 21, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if !v.ViewerLoggedIn && !v.SelfConnect { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, " ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = layouts.BottomNav("connections").Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) + templ_7745c5c3_Err = layouts.Base("connect", "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 + }) +} + +// shortID renders a short version of a DID or local ID. +func shortID(id string) string { + if len(id) <= 18 { + return id + } + return id[:18] + "…" +} + +var _ = templruntime.GeneratedTemplate diff --git a/features/connect/pages/connect_templ.go b/features/connect/pages/connect_templ.go index df3f16c..c344501 100644 --- a/features/connect/pages/connect_templ.go +++ b/features/connect/pages/connect_templ.go @@ -5,12 +5,10 @@ package pages //lint:file-ignore SA4006 This context is only used if a nested component is present. -import ( - "atmoquest/features/common/layouts" +import "github.com/a-h/templ" +import templruntime "github.com/a-h/templ/runtime" - "github.com/a-h/templ" - templruntime "github.com/a-h/templ/runtime" -) +import "atmoquest/features/common/layouts" // ConnectView is the data the Connect handler renders. type ConnectView struct { @@ -72,7 +70,7 @@ func Connect(v ConnectView) templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "
connect
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "
~/atmoquest — connect
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } diff --git a/features/connect/routes.go b/features/connect/routes.go index 2523b67..29508cd 100644 --- a/features/connect/routes.go +++ b/features/connect/routes.go @@ -13,11 +13,13 @@ import ( // // - GET /profile/qr.svg — SVG QR for the signed-in user's connect URL // - GET /c/{did} — auto-connect: writes records + redirects +// - GET /c/l/{local_id} — auto-connect for local users // - 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.Get("/c/l/{local_id}", h.ConnectLocal) router.Post("/connect/flush-local", h.ConnectFlushLocal) } diff --git a/features/connections/handlers.go b/features/connections/handlers.go index 74d9dd0..7a00432 100644 --- a/features/connections/handlers.go +++ b/features/connections/handlers.go @@ -3,6 +3,7 @@ package connections import ( + "context" "database/sql" "encoding/json" "errors" @@ -40,11 +41,19 @@ func NewHandlers(conn *sql.DB, authH *auth.Handlers) *Handlers { // List renders the full connections list page. func (h *Handlers) List(w http.ResponseWriter, r *http.Request) { - did, _, ok := h.Auth.RequireSession(w, r) - if !ok { + identity := h.Auth.ResolveIdentity(r) + if !identity.IsAuth { + http.Redirect(w, r, "/signin", http.StatusFound) return } + if identity.IsLocal { + h.listLocal(r.Context(), w, r, identity.DID) + return + } + + did, err := syntax.ParseDID(identity.DID) + pds := h.lookupPDSForDID(r, did) entries, err := connection.List(r.Context(), pds, did) @@ -54,9 +63,15 @@ func (h *Handlers) List(w http.ResponseWriter, r *http.Request) { entries = nil } + // Also fetch local connections from local_connections table + localItems, err := h.fetchLocalConnections(r.Context(), did.String()) + if err != nil { + slog.Warn("connections: list local", "err", err) + } + // Load events the user has checked into for the filter chips. now := time.Now() - userEvents, err := checkin.ListForUser(r.Context(), h.DB, did, now) + userEvents, err := checkin.ListForUser(r.Context(), h.DB, did.String(), now) if err != nil { slog.Warn("connections: list user events", "err", err) } @@ -149,6 +164,9 @@ func (h *Handlers) List(w http.ResponseWriter, r *http.Request) { items = append(items, item) } + // Append local connections + items = append(items, localItems...) + // Sort. switch sortBy { case "alpha": @@ -195,6 +213,60 @@ func (h *Handlers) List(w http.ResponseWriter, r *http.Request) { } } +// listLocal renders the connections page for a local user. +// Reads only from the local_connections table (no PDS lookups). +func (h *Handlers) listLocal(ctx context.Context, w http.ResponseWriter, r *http.Request, localID string) { + items, err := h.fetchLocalConnections(ctx, localID) + if err != nil { + slog.Warn("connections: list local", "err", err) + } + + sortBy := strings.TrimSpace(r.URL.Query().Get("sort")) + if sortBy == "" { + sortBy = "recent" + } + query := strings.TrimSpace(r.URL.Query().Get("q")) + + if query != "" { + q := strings.ToLower(query) + var filtered []pages.ConnectionItem + for _, item := range items { + if strings.Contains(strings.ToLower(item.DisplayName), q) || + strings.Contains(strings.ToLower(item.Handle), q) || + strings.Contains(strings.ToLower(item.DID), q) { + filtered = append(filtered, item) + } + } + items = filtered + } + + switch sortBy { + case "alpha": + sort.Slice(items, func(i, j int) bool { + ni := strings.ToLower(displayOrDID(items[i])) + nj := strings.ToLower(displayOrDID(items[j])) + return ni < nj + }) + default: + sort.Slice(items, func(i, j int) bool { + return items[i].SortTime.After(items[j].SortTime) + }) + } + + view := pages.ConnectionsView{ + Items: items, + SortBy: sortBy, + Query: query, + Total: len(items), + Showing: len(items), + } + + w.Header().Set("Content-Type", "text/html; charset=utf-8") + if err := pages.Connections(view).Render(ctx, w); err != nil { + slog.Error("render connections", "err", err) + } +} + func displayOrDID(item pages.ConnectionItem) string { if item.DisplayName != "" { return item.DisplayName @@ -223,7 +295,23 @@ func (h *Handlers) lookupPDSForDID(r *http.Request, did syntax.DID) string { // View renders the detailed profile page for a connected person, including // private notes and follow-up flag (backed by localStorage on the client). func (h *Handlers) View(w http.ResponseWriter, r *http.Request) { - viewerDID, _, ok := h.Auth.RequireSession(w, r) + identity := h.Auth.ResolveIdentity(r) + if !identity.IsAuth { + http.Redirect(w, r, "/signin", http.StatusFound) + return + } + if identity.IsLocal { + http.Redirect(w, r, "/profile", http.StatusFound) + return + } + + viewerDID, err := syntax.ParseDID(identity.DID) + if err != nil { + http.Redirect(w, r, "/signin", http.StatusFound) + return + } + + _, _, ok := h.Auth.RequireSession(w, r) if !ok { return } @@ -368,3 +456,81 @@ func (h *Handlers) SaveNotes(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(map[string]string{"status": "ok"}) } + +// fetchLocalConnections fetches connections from the local_connections table +// for the given viewer (ATProto DID or local ID). +func (h *Handlers) fetchLocalConnections(ctx context.Context, viewerID string) ([]pages.ConnectionItem, error) { + // Determine if this is a local ID or ATProto DID + isLocal := strings.HasPrefix(viewerID, "local_") + + var rows *sql.Rows + var err error + + if isLocal { + rows, err = h.DB.QueryContext(ctx, ` + SELECT id, target_did, target_local_id, event_uri, connected_at + FROM local_connections + WHERE viewer_local_id = ? + `, viewerID) + } else { + rows, err = h.DB.QueryContext(ctx, ` + SELECT id, target_did, target_local_id, event_uri, connected_at + FROM local_connections + WHERE viewer_did = ? + `, viewerID) + } + + if err != nil { + return nil, err + } + defer rows.Close() + + var items []pages.ConnectionItem + for rows.Next() { + var ( + id int + targetDID sql.NullString + targetLocalID sql.NullString + eventURI sql.NullString + connectedAt time.Time + ) + if err := rows.Scan(&id, &targetDID, &targetLocalID, &eventURI, &connectedAt); err != nil { + slog.Warn("fetch local connections: scan", "err", err) + continue + } + + // Determine the target ID (use target_did if present, else target_local_id) + targetID := targetDID.String + if targetID == "" { + targetID = targetLocalID.String + } + if targetID == "" { + continue + } + + item := pages.ConnectionItem{ + DID: targetID, + ConnectedAt: connectedAt.Format("Jan 2, 2006"), + EventURI: eventURI.String, + SortTime: connectedAt, + } + + // Enrich with profile info from users table + var handle, displayName string + err := h.DB.QueryRowContext(ctx, `SELECT handle, display_name FROM users WHERE did = ?`, targetID).Scan(&handle, &displayName) + if err == nil { + item.DisplayName = displayName + item.Handle = handle + } + + // Load follow-up flag from SQLite + note, err := notes.Get(ctx, h.DB, viewerID, targetID) + if err == nil { + item.FollowUp = note.FollowUp + } + + items = append(items, item) + } + + return items, rows.Err() +} diff --git a/features/connections/pages/connections_templ.go b/features/connections/pages/connections_templ.go index 413da25..92c3b42 100644 --- a/features/connections/pages/connections_templ.go +++ b/features/connections/pages/connections_templ.go @@ -5,13 +5,14 @@ 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" "fmt" "time" - "github.com/a-h/templ" - templruntime "github.com/a-h/templ/runtime" + "atmoquest/features/common/layouts" ) // ConnectionsView is the data passed to the connections list template. @@ -270,7 +271,7 @@ func Connections(v ConnectionsView) templ.Component { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "
connections live in your repo · notes stay private in this app
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "
connections live in your repo · notes stay on this device
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } diff --git a/features/event/handlers.go b/features/event/handlers.go index 5ff5654..ebd83e1 100644 --- a/features/event/handlers.go +++ b/features/event/handlers.go @@ -16,6 +16,7 @@ import ( "strings" "time" + "github.com/bluesky-social/indigo/atproto/auth/oauth" "github.com/go-chi/chi/v5" "atmoquest/config" @@ -73,7 +74,7 @@ func (h *Handlers) EventScan(w http.ResponseWriter, r *http.Request) { return } - didStr, sid := h.Auth.Sessions.Get(r) + identity := h.Auth.ResolveIdentity(r) view := pages.EventScanView{ Token: token, EventName: ev.Name, @@ -81,7 +82,7 @@ func (h *Handlers) EventScan(w http.ResponseWriter, r *http.Request) { 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 != "", + ViewerLoggedIn: identity.IsAuth, } if ev.Geofence != nil { view.GeofenceLat = ev.Geofence.Lat @@ -95,27 +96,43 @@ func (h *Handlers) EventScan(w http.ResponseWriter, r *http.Request) { } // EventScanCheckin handles POST /e/{token}/checkin from a logged-in visitor -// who confirmed the check-in CTA. +// who confirmed the check-in CTA. Supports both ATProto and local users. func (h *Handlers) EventScanCheckin(w http.ResponseWriter, r *http.Request) { - _, viewerSess, ok := h.Auth.RequireSession(w, r) - if !ok { + identity := h.Auth.ResolveIdentity(r) + if !identity.IsAuth { + http.Redirect(w, r, "/signin", http.StatusFound) 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 - } - // Award event-attendee badge (idempotent). - if _, err := badge.Award(r.Context(), viewerSess, h.DB, badge.AwardEventAttendee, ev.URI); err != nil { - slog.Info("event scan: event-attendee badge", "err", err) + + if identity.IsLocal { + if err := checkin.PutLocal(r.Context(), h.DB, identity.DID, ev.URI, time.Time{}); err != nil { + slog.Warn("event scan checkin (local)", "event_uri", ev.URI, "err", err) + http.Error(w, "failed to check in", http.StatusInternalServerError) + return + } + } else { + _, viewerSess, ok := h.Auth.RequireSession(w, r) + if !ok { + 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 + } + // Award event-attendee badge (idempotent). + if _, err := badge.Award(r.Context(), viewerSess, h.DB, badge.AwardEventAttendee, ev.URI); err != nil { + slog.Info("event scan: event-attendee badge", "err", err) + } } + // Update event stats. if err := event.IncrementCheckins(r.Context(), h.DB, ev.URI); err != nil { slog.Info("event scan: increment checkins", "err", err) @@ -133,10 +150,12 @@ func (h *Handlers) EventScanCheckin(w http.ResponseWriter, r *http.Request) { // 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 { + identity := h.Auth.ResolveIdentity(r) + if !identity.IsAuth { + http.Redirect(w, r, "/signin", http.StatusFound) return } + var body struct { Tokens []string `json:"tokens"` } @@ -146,41 +165,74 @@ func (h *Handlers) EventFlushLocal(w http.ResponseWriter, r *http.Request) { } written, skipped := 0, 0 - errs := make([]string, 0) + var viewerSess *oauth.ClientSession + if !identity.IsLocal { + _, s, ok := h.Auth.RequireSession(w, r) + if !ok { + return + } + viewerSess = s + } + + // Collect valid event URIs first. + var eventURIs []string + tokenToURI := make(map[string]string) for _, t := range body.Tokens { t = strings.TrimSpace(t) if t == "" { + skipped++ 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 } - // Award event-attendee badge (idempotent). - if _, err := badge.Award(r.Context(), viewerSess, h.DB, badge.AwardEventAttendee, ev.URI); err != nil { - slog.Info("event flush-local: event-attendee badge", "err", err) + eventURIs = append(eventURIs, ev.URI) + tokenToURI[t] = ev.URI + } + + if identity.IsLocal { + wLocal, sLocal, err := checkin.PutLocalBatch(r.Context(), h.DB, identity.DID, eventURIs, time.Time{}) + if err != nil { + slog.Warn("event flush-local: batch checkin (local)", "err", err) } - // Update event stats. - if err := event.IncrementCheckins(r.Context(), h.DB, ev.URI); err != nil { - slog.Info("event flush-local: increment checkins", "err", err) + written = wLocal + skipped += sLocal + } else { + errs := make([]string, 0) + for _, evURI := range eventURIs { + if _, err := checkin.Put(r.Context(), viewerSess, h.DB, evURI, time.Time{}); err != nil { + slog.Warn("event flush-local: checkin", "event_uri", evURI, "err", err) + skipped++ + errs = append(errs, "checkin failed for "+truncate(evURI, 20)) + continue + } + // Award event-attendee badge (idempotent). + if _, err := badge.Award(r.Context(), viewerSess, h.DB, badge.AwardEventAttendee, evURI); err != nil { + slog.Info("event flush-local: event-attendee badge", "err", err) + } + // Update event stats. + if err := event.IncrementCheckins(r.Context(), h.DB, evURI); err != nil { + slog.Info("event flush-local: increment checkins", "err", err) + } + written++ } - written++ + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "written": written, + "skipped": skipped, + "errors": errs, + }) + return } w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(map[string]any{ "written": written, "skipped": skipped, - "errors": errs, + "errors": []string{}, }) } diff --git a/features/events/handlers.go b/features/events/handlers.go index 1a883e6..ca92221 100644 --- a/features/events/handlers.go +++ b/features/events/handlers.go @@ -39,27 +39,35 @@ func NewHandlers(conn *sql.DB, authH *auth.Handlers) *Handlers { // List renders the /events page showing all events the user has checked into. func (h *Handlers) List(w http.ResponseWriter, r *http.Request) { - viewerDID, _, ok := h.Auth.RequireSession(w, r) - if !ok { + identity := h.Auth.ResolveIdentity(r) + if !identity.IsAuth { + http.Redirect(w, r, "/signin", http.StatusFound) return } + viewerIDStr := identity.DID now := time.Now() - userEvents, err := checkin.ListForUser(r.Context(), h.DB, viewerDID, now) - if err != nil { - slog.Error("events: list for user", "did", viewerDID.String(), "err", err) - } - // Load the viewer's connections once from their PDS, then count per event. - viewerPDS := h.lookupPDSForDID(r, viewerDID) - viewerConns, err := connection.List(r.Context(), viewerPDS, viewerDID) + userEvents, err := checkin.ListForUser(r.Context(), h.DB, viewerIDStr, now) if err != nil { - slog.Debug("events: list viewer connections", "err", err) + slog.Error("events: list for user", "did", viewerIDStr, "err", err) } + + // Load the viewer's connections for per-event connector counts. + // Works for local users too — the PDS lookup soft-fails if no session. connsByEvent := make(map[string]int) - for _, c := range viewerConns { - if c.EventURI != "" { - connsByEvent[c.EventURI]++ + if !identity.IsLocal { + viewerDID, parseErr := syntax.ParseDID(viewerIDStr) + if parseErr == nil { + viewerPDS := h.lookupPDSForDID(r, viewerDID) + viewerConns, listErr := connection.List(r.Context(), viewerPDS, viewerDID) + if listErr == nil { + for _, c := range viewerConns { + if c.EventURI != "" { + connsByEvent[c.EventURI]++ + } + } + } } } @@ -126,8 +134,9 @@ func formatDateRange(start, end time.Time) string { // Detail renders the event detail screen with attendees grid and leaderboard. func (h *Handlers) Detail(w http.ResponseWriter, r *http.Request) { - viewerDID, _, ok := h.Auth.RequireSession(w, r) - if !ok { + identity := h.Auth.ResolveIdentity(r) + if !identity.IsAuth { + http.Redirect(w, r, "/signin", http.StatusFound) return } @@ -140,6 +149,13 @@ func (h *Handlers) Detail(w http.ResponseWriter, r *http.Request) { now := time.Now() stats, _ := event.GetStats(r.Context(), h.DB, ev.URI) + + // Get all DIDs who checked into this event. + attendeeDIDs, err := checkin.ListAttendeesForEvent(r.Context(), h.DB, ev.URI) + if err != nil { + slog.Error("event detail: attendees", "err", err) + } + filter := r.URL.Query().Get("filter") page := 1 if p := r.URL.Query().Get("page"); p != "" { @@ -148,10 +164,25 @@ func (h *Handlers) Detail(w http.ResponseWriter, r *http.Request) { } } - // Get all DIDs who checked into this event. - attendeeDIDs, err := checkin.ListAttendeesForEvent(r.Context(), h.DB, ev.URI) + milestoneBase := len(attendeeDIDs) + if ev.ExpectedAttendees > 0 { + milestoneBase = ev.ExpectedAttendees + } + + if identity.IsLocal { + h.detailLocal(w, r, &ev, token, attendeeDIDs, stats, filter, page, milestoneBase, now) + return + } + + viewerDID, err := syntax.ParseDID(identity.DID) if err != nil { - slog.Error("event detail: attendees", "err", err) + http.Redirect(w, r, "/signin", http.StatusFound) + return + } + + _, _, ok := h.Auth.RequireSession(w, r) + if !ok { + return } // Get the viewer's connections to determine connected/not-connected. @@ -262,10 +293,6 @@ func (h *Handlers) Detail(w http.ResponseWriter, r *http.Request) { // Build leaderboard (top 10). var leaderboard []pages.LeaderboardRow - type ranked struct { - cc *connCount - rank int - } // Simple sort: collect, sort by count desc, take top 10. var sorted []*connCount for _, cc := range connCounts { @@ -305,11 +332,6 @@ func (h *Handlers) Detail(w http.ResponseWriter, r *http.Request) { } // Milestone thresholds based on expected attendees (or checked-in count as fallback). - totalAttendees := len(attendeeDIDs) - milestoneBase := totalAttendees - if ev.ExpectedAttendees > 0 { - milestoneBase = ev.ExpectedAttendees - } leaderboardThreshold := milestoneBase * 20 / 100 interestThreshold := milestoneBase * 40 / 100 statsThreshold := milestoneBase * 80 / 100 @@ -414,8 +436,6 @@ func (h *Handlers) Detail(w http.ResponseWriter, r *http.Request) { } } - // Use the event's expected attendees for the total if set, otherwise - // fall back to the number of checked-in attendees. view := pages.EventDetailView{ Token: token, Name: ev.Name, @@ -491,6 +511,136 @@ func (h *Handlers) Detail(w http.ResponseWriter, r *http.Request) { } } +// detailLocal renders a simplified event detail page for local users, +// skipping PDS-dependent features (leaderboard, interest matching, +// connections, Bluesky enrichment). +func (h *Handlers) detailLocal(w http.ResponseWriter, r *http.Request, + ev *event.Record, token string, + attendeeDIDs []string, stats event.Stats, + filter string, page int, milestoneBase int, now time.Time) { + + // Build basic attendee list without PDS enrichment. + var allAttendees []pages.Attendee + for _, didStr := range attendeeDIDs { + a := pages.Attendee{ + DID: didStr, + Connected: false, + Handle: "", + } + allAttendees = append(allAttendees, a) + } + + // Apply filter. + var attendees []pages.Attendee + for _, a := range allAttendees { + switch filter { + case "mine": + if !a.Connected { + continue + } + case "unmet": + if a.Connected { + continue + } + } + attendees = append(attendees, a) + } + + // Paginate attendees (20 per page). + const perPage = 20 + totalFiltered := len(attendees) + totalPages := (totalFiltered + perPage - 1) / perPage + if totalPages < 1 { + totalPages = 1 + } + if page > totalPages { + page = totalPages + } + start := (page - 1) * perPage + end := start + perPage + if end > totalFiltered { + end = totalFiltered + } + attendees = attendees[start:end] + + // Compute event status. + var status string + isOngoing := ev.IsOngoing(now) + if isOngoing { + status = "in progress" + } else if now.Before(ev.StartTime) { + status = "starting soon" + } else { + status = "complete" + } + + dayNumber := 0 + totalDays := 0 + eventDuration := ev.EndTime.Sub(ev.StartTime) + if eventDuration.Hours() > 24 { + totalDays = int(eventDuration.Hours()/24) + 1 + if now.Before(ev.StartTime) { + dayNumber = 0 + } else { + dayNumber = int(now.Sub(ev.StartTime).Hours()/24) + 1 + if dayNumber > totalDays { + dayNumber = totalDays + } + } + } + + view := pages.EventDetailView{ + Token: token, + Name: ev.Name, + Location: ev.Location, + StartTime: ev.StartTime.Format("Mon Jan 2 · 3:04 PM"), + EndTime: ev.EndTime.Format("Mon Jan 2 · 3:04 PM"), + IsOngoing: isOngoing, + Status: status, + DayNumber: dayNumber, + TotalDays: totalDays, + UniqueConnectors: stats.UniqueConnectors, + TotalCheckins: stats.TotalCheckins, + ActualCheckins: len(attendeeDIDs), + TotalAttendees: milestoneBase, + Attendees: attendees, + Filter: filter, + Page: page, + TotalPages: totalPages, + // PDS-dependent features are disabled for local users. + LeaderboardUnlocked: false, + InterestUnlocked: false, + StatsUnlocked: false, + } + + // For past events, load follow-ups from notes. + if status == "complete" { + if allNotes, err := notes.ListAll(r.Context(), h.DB, ""); err == nil { + for _, n := range allNotes { + if n.FollowUp { + view.FollowUps = append(view.FollowUps, pages.FollowUpItem{ + DID: n.TargetDID, + Notes: n.Notes, + }) + } + } + } + if summary, err := event.GetSummary(r.Context(), h.DB, ev.URI); err == nil && summary != nil { + view.Summary = &pages.EventSummary{ + TotalConnections: summary.TotalConnections, + TotalCheckins: summary.TotalCheckins, + NewSignups: summary.NewSignups, + PeakHour: summary.PeakHour, + } + } + } + + w.Header().Set("Content-Type", "text/html; charset=utf-8") + if err := pages.EventDetail(view).Render(r.Context(), w); err != nil { + slog.Error("render event detail (local)", "err", err) + } +} + // lookupPDSForDID returns the PDS host for a DID. Falls back to bsky.social. func (h *Handlers) lookupPDSForDID(r *http.Request, did syntax.DID) string { if h.DB == nil { diff --git a/features/events/pages/events_templ.go b/features/events/pages/events_templ.go index 33646ff..a24b693 100644 --- a/features/events/pages/events_templ.go +++ b/features/events/pages/events_templ.go @@ -5,12 +5,13 @@ 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" "fmt" - "github.com/a-h/templ" - templruntime "github.com/a-h/templ/runtime" + "atmoquest/features/common/layouts" ) // EventsView is the data for the /events page. @@ -111,7 +112,7 @@ func Events(v EventsView) templ.Component { return templ_7745c5c3_Err } if !v.HasAny { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "

you haven't checked into any events yet.

scan an event QR code or connect at an event to get started.

← back home
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "

you haven't checked into any events yet.

scan an event QR code to get started.

← back home
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } diff --git a/features/index/handlers.go b/features/index/handlers.go index e58496c..59dcef9 100644 --- a/features/index/handlers.go +++ b/features/index/handlers.go @@ -74,14 +74,18 @@ func (h *Handlers) IndexPage(w http.ResponseWriter, r *http.Request) { // purposes. If something downstream needs a fresh OAuth session // it'll force a re-sign on its own. if h.Auth != nil && h.Auth.Sessions != nil { - didStr, sid := h.Auth.Sessions.Get(r) - if didStr != "" && sid != "" { + identity := h.Auth.ResolveIdentity(r) + if identity.IsAuth { view.LoggedIn = true - view.Handle = h.resolveHandleForDisplay(r, didStr) + view.Handle = identity.DID + view.QRURL = "/profile/qr.svg" - if did, err := syntax.ParseDID(didStr); err == nil { - h.fillAuthedView(r, did, &view) - h.fillRecentConnections(r, did, &view) + if !identity.IsLocal { + did, err := syntax.ParseDID(identity.DID) + if err == nil { + h.fillAuthedView(r, did, &view) + h.fillRecentConnections(r, did, &view) + } } } } diff --git a/features/index/pages/index_templ.go b/features/index/pages/index_templ.go index dc5de63..3f5bae0 100644 --- a/features/index/pages/index_templ.go +++ b/features/index/pages/index_templ.go @@ -5,11 +5,11 @@ 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" - - "github.com/a-h/templ" - templruntime "github.com/a-h/templ/runtime" ) // IndexView passes session-aware values into the landing page. When a user @@ -165,7 +165,7 @@ func indexAuthed(v IndexView) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "atmo.quest:~$ lets connect
quest_log.md

Side Quests ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, " 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.

quest_log.md

Side Quests ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -689,7 +689,7 @@ func indexGuest() templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "

◆
Make your first connection
Scan someone's QR code. A quest.atmo.connection record gets written to your repo. They get one too. That's the protocol bit.
+1 connection first connect
◆
Check in to an event
Drops a checkin record for the event. You'll be on the event's attendee list.
+1 checkin add new attendee
◆
Take a note while it's fresh
Private notes, attached to each connection. Get a follow-up list after the event to remember who you connected with.
unlocks with first connection
◆
Help unlock event rewards
As more people join, new things appear. Leaderboards, suggested connections, end-of-event stats. Earned together.
share with attendees invite other attendees
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "
◆
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 an event
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
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } diff --git a/features/profile/handlers.go b/features/profile/handlers.go index e3d2d31..bb61f52 100644 --- a/features/profile/handlers.go +++ b/features/profile/handlers.go @@ -8,11 +8,14 @@ package profile import ( "database/sql" + "encoding/json" "errors" "log/slog" "net/http" "strings" + "github.com/go-chi/chi/v5" + "atmoquest/features/auth" "atmoquest/features/profile/pages" "atmoquest/internal/badge" @@ -34,30 +37,122 @@ func NewHandlers(conn *sql.DB, authH *auth.Handlers) *Handlers { // 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. +// Also handles local users (reads from users table). func (h *Handlers) Profile(w http.ResponseWriter, r *http.Request) { - did, sess, ok := h.Auth.RequireSession(w, r) - if !ok { + // Check for ATProto session first + did, sess, err := h.Auth.ResumeSession(r) + if err == nil { + // ATProto user - proceed with normal flow + pds := sess.Data.HostURL + + bsky, bskyErr := profile.FetchBluesky(r.Context(), pds, did) + if bskyErr != nil && !errors.Is(bskyErr, profile.ErrNotFound) { + slog.Warn("profile: fetch bsky", "did", did.String(), "err", bskyErr) + bsky = nil + } + quest, questErr := profile.FetchQuest(r.Context(), pds, did) + if questErr != nil && !errors.Is(questErr, profile.ErrNotFound) { + slog.Warn("profile: fetch quest", "did", did.String(), "err", questErr) + 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")) + + // Load earned badges from local cache. + if earned, err := badge.ListEarned(r.Context(), h.DB, did.String()); err == nil { + for _, eb := range earned { + svg := badge.RenderSVG(badge.Design{ + Shape: eb.Shape, + PrimaryColor: eb.PrimaryColor, + AccentColor: eb.AccentColor, + RibbonColor: eb.RibbonColor, + Label: eb.Label, + }, 160) + view.Badges = append(view.Badges, pages.ProfileBadge{ + Name: eb.Name, + Description: eb.Description, + Shape: eb.Shape, + PrimaryColor: eb.PrimaryColor, + AccentColor: eb.AccentColor, + RibbonColor: eb.RibbonColor, + Label: eb.Label, + EarnedAt: eb.EarnedAt.Format("Jan 2, 2006"), + Message: eb.Message, + SVGPreview: svg, + }) + } + } + + 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) + } 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 + // Check for local session + localID := h.Auth.Sessions.GetLocal(r) + if localID == "" { + http.Redirect(w, r, "/signin", http.StatusFound) + return } - 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 + // Local user - read from users table + h.renderLocalProfile(w, r, localID) +} + +// renderLocalProfile renders the profile for a local user (reads from users table). +func (h *Handlers) renderLocalProfile(w http.ResponseWriter, r *http.Request, localID string) { + ctx := r.Context() + + // Fetch user data from users table + var displayName, handle, bio, location, worksAt, contactMethod string + var hiring, looking, hideFromLeaderboard, hideBadges bool + var interestsJSON, linksJSON string + err := h.DB.QueryRowContext(ctx, ` + SELECT display_name, handle, bio, location, works_at, contact_method, + hiring, "looking", hide_from_leaderboard, hide_badges, interests, links + FROM users WHERE did = ? + `, localID).Scan(&displayName, &handle, &bio, &location, &worksAt, &contactMethod, + &hiring, &looking, &hideFromLeaderboard, &hideBadges, &interestsJSON, &linksJSON) + if err != nil { + slog.Error("local profile: fetch user", "err", err) + http.Error(w, "user not found", http.StatusNotFound) + return } - view := buildProfileView(did, pds, sess.Data.Scopes, bsky, quest) - view.QRURL = "/profile/qr.svg" - view.ConnectedDID = strings.TrimSpace(r.URL.Query().Get("connected")) + // Build profile view + view := pages.ProfileView{ + QRURL: "/profile/qr.svg", + ConnectedDID: strings.TrimSpace(r.URL.Query().Get("connected")), + DisplayName: displayName, + Bio: bio, + Location: location, + WorksAt: worksAt, + ContactMethod: contactMethod, + Hiring: hiring, + Looking: looking, + } + + // Parse interests and links from JSON + if interestsJSON != "" { + var interests []string + if err := json.Unmarshal([]byte(interestsJSON), &interests); err == nil { + view.Interests = interests + } + } + if linksJSON != "" { + var links []profile.Link + if err := json.Unmarshal([]byte(linksJSON), &links); err == nil { + for _, l := range links { + view.Links = append(view.Links, pages.ProfileLink{Label: l.Label, URL: l.URL}) + } + } + } - // Load earned badges from local cache. - if earned, err := badge.ListEarned(r.Context(), h.DB, did.String()); err == nil { + // Load earned badges from local cache + if earned, err := badge.ListEarned(ctx, h.DB, localID); err == nil { for _, eb := range earned { svg := badge.RenderSVG(badge.Design{ Shape: eb.Shape, @@ -82,17 +177,82 @@ func (h *Handlers) Profile(w http.ResponseWriter, r *http.Request) { } 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) + if err := pages.Profile(view).Render(ctx, w); err != nil { + slog.Error("render local profile", "err", err) + } +} + +// renderLocalProfileEdit renders the edit form for a local user (reads from users table). +func (h *Handlers) renderLocalProfileEdit(w http.ResponseWriter, r *http.Request, localID string) { + ctx := r.Context() + + // Fetch user data from users table + var displayName, handle, bio, location, worksAt, contactMethod string + var hiring, looking, hideFromLeaderboard, hideBadges bool + var interestsJSON, linksJSON string + err := h.DB.QueryRowContext(ctx, ` + SELECT display_name, handle, bio, location, works_at, contact_method, + hiring, "looking", hide_from_leaderboard, hide_badges, interests, links + FROM users WHERE did = ? + `, localID).Scan(&displayName, &handle, &bio, &location, &worksAt, &contactMethod, + &hiring, &looking, &hideFromLeaderboard, &hideBadges, &interestsJSON, &linksJSON) + if err != nil { + slog.Error("local profile edit: fetch user", "err", err) + http.Error(w, "user not found", http.StatusNotFound) + return + } + + // Build profile view for edit form + view := pages.ProfileEditView{ + DID: localID, + DisplayName: displayName, + Bio: bio, + Location: location, + WorksAt: worksAt, + ContactMethod: contactMethod, + Hiring: hiring, + Looking: looking, + } + + // Parse interests and links from JSON + if interestsJSON != "" { + var interests []string + if err := json.Unmarshal([]byte(interestsJSON), &interests); err == nil { + view.Interests = interests + } + } + if linksJSON != "" { + var links []profile.Link + if err := json.Unmarshal([]byte(linksJSON), &links); err == nil { + for _, l := range links { + view.Links = append(view.Links, pages.ProfileLink{Label: l.Label, URL: l.URL}) + } + } + } + + w.Header().Set("Content-Type", "text/html; charset=utf-8") + if err := pages.ProfileEdit(view).Render(ctx, w); err != nil { + slog.Error("render local profile edit", "err", err) } } // ProfileEdit renders the form to edit the quest.atmo.profile record. +// Also handles local users (reads from users table). func (h *Handlers) ProfileEdit(w http.ResponseWriter, r *http.Request) { - did, sess, ok := h.Auth.RequireSession(w, r) - if !ok { + // Check for ATProto session first + did, sess, err := h.Auth.ResumeSession(r) + if err != nil { + // Check for local session + localID := h.Auth.Sessions.GetLocal(r) + if localID == "" { + http.Redirect(w, r, "/signin", http.StatusFound) + return + } + // Local user - render edit form from users table + h.renderLocalProfileEdit(w, r, localID) return } + pds := sess.Data.HostURL bsky, err := profile.FetchBluesky(r.Context(), pds, did) @@ -116,11 +276,22 @@ func (h *Handlers) ProfileEdit(w http.ResponseWriter, r *http.Request) { // 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. +// Also handles local users (saves to users table). func (h *Handlers) ProfileSave(w http.ResponseWriter, r *http.Request) { - did, sess, ok := h.Auth.RequireSession(w, r) - if !ok { + // Check for ATProto session first + did, sess, err := h.Auth.ResumeSession(r) + if err != nil { + // Check for local session + localID := h.Auth.Sessions.GetLocal(r) + if localID == "" { + http.Redirect(w, r, "/signin", http.StatusFound) + return + } + // Local user - save to users table + h.saveLocalProfile(w, r, localID) return } + if err := r.ParseForm(); err != nil { http.Error(w, "invalid form", http.StatusBadRequest) return @@ -150,6 +321,55 @@ func (h *Handlers) ProfileSave(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/profile", http.StatusSeeOther) } +// saveLocalProfile saves profile data to users table for local users. +func (h *Handlers) saveLocalProfile(w http.ResponseWriter, r *http.Request, localID string) { + ctx := r.Context() + + if err := r.ParseForm(); err != nil { + http.Error(w, "invalid form", http.StatusBadRequest) + return + } + + // Parse form data + bio := r.FormValue("bio") + interestsStr := r.FormValue("interests") + location := r.FormValue("location") + worksAt := r.FormValue("works_at") + contactMethod := r.FormValue("contact_method") + hiring := r.FormValue("hiring") == "1" + looking := r.FormValue("looking") == "1" + hideBadges := r.FormValue("hide_badges") == "1" + + // Parse interests into JSON + interests := parseInterests(interestsStr) + interestsJSON, _ := json.Marshal(interests) + + // Parse links into JSON + links := []profile.Link{} + 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 != "" { + links = append(links, profile.Link{Label: label, URL: raw}) + } + } + linksJSON, _ := json.Marshal(links) + + // Update users table + _, err := h.DB.ExecContext(ctx, ` + UPDATE users SET bio = ?, location = ?, works_at = ?, contact_method = ?, + hiring = ?, "looking" = ?, hide_badges = ?, interests = ?, links = ? + WHERE did = ? + `, bio, location, worksAt, contactMethod, hiring, looking, hideBadges, string(interestsJSON), string(linksJSON), localID) + if err != nil { + slog.Error("save local profile", "err", err) + http.Error(w, "failed to save", http.StatusInternalServerError) + return + } + + 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. // @@ -274,3 +494,63 @@ func validateLinkURL(raw string) (string, error) { } return u.String(), nil } + +// ProfileLocalView renders a public profile view for a local user. +// This is used when someone visits /profile/l/{local_id}. +func (h *Handlers) ProfileLocalView(w http.ResponseWriter, r *http.Request) { + localID := chi.URLParam(r, "local_id") + if localID == "" || !strings.HasPrefix(localID, "local_") { + http.NotFound(w, r) + return + } + + ctx := r.Context() + + // Fetch user data from users table + var displayName, handle, bio, location, worksAt, contactMethod string + var hiring, looking bool + var interestsJSON, linksJSON string + err := h.DB.QueryRowContext(ctx, ` + SELECT display_name, handle, bio, location, works_at, contact_method, + hiring, "looking", interests, links + FROM users WHERE did = ? + `, localID).Scan(&displayName, &handle, &bio, &location, &worksAt, &contactMethod, + &hiring, &looking, &interestsJSON, &linksJSON) + if err != nil { + slog.Error("local profile view: fetch user", "err", err) + http.NotFound(w, r) + return + } + + // Build profile view + view := pages.ProfileView{ + DisplayName: displayName, + Bio: bio, + Location: location, + WorksAt: worksAt, + ContactMethod: contactMethod, + Hiring: hiring, + Looking: looking, + } + + // Parse interests and links from JSON + if interestsJSON != "" { + var interests []string + if err := json.Unmarshal([]byte(interestsJSON), &interests); err == nil { + view.Interests = interests + } + } + if linksJSON != "" { + var links []profile.Link + if err := json.Unmarshal([]byte(linksJSON), &links); err == nil { + for _, l := range links { + view.Links = append(view.Links, pages.ProfileLink{Label: l.Label, URL: l.URL}) + } + } + } + + w.Header().Set("Content-Type", "text/html; charset=utf-8") + if err := pages.Profile(view).Render(ctx, w); err != nil { + slog.Error("render local profile view", "err", err) + } +} diff --git a/features/profile/routes.go b/features/profile/routes.go index 6a3a112..434c2da 100644 --- a/features/profile/routes.go +++ b/features/profile/routes.go @@ -11,13 +11,15 @@ import ( // 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 +// - GET /profile — read-only profile view (ATProto or local) +// - GET /profile/edit — edit form (ATProto or local) +// - POST /profile/edit — save quest.atmo.profile to PDS (ATProto) or users table (local) +// - GET /profile/l/{local_id} — view local user profile (public) 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) + router.Get("/profile/l/{local_id}", h.ProfileLocalView) } diff --git a/features/settings/handlers.go b/features/settings/handlers.go index 9a5f064..57aca05 100644 --- a/features/settings/handlers.go +++ b/features/settings/handlers.go @@ -40,26 +40,81 @@ func NewHandlers(conn *sql.DB, authH *auth.Handlers) *Handlers { } } -// Page renders the settings page. +// Page renders the settings page. Supports both ATProto and local users. func (h *Handlers) Page(w http.ResponseWriter, r *http.Request) { - did, _, ok := h.Auth.RequireSession(w, r) - if !ok { + identity := h.Auth.ResolveIdentity(r) + if !identity.IsAuth { + http.Redirect(w, r, "/signin", http.StatusFound) return } - handle := h.resolveHandle(r, did.String()) + didStr := identity.DID deleted := r.URL.Query().Get("deleted") == "1" - isAdmin, _ := users.IsAdmin(r.Context(), h.DB, did) - // Count notes for the "local notes" row. - allNotes, _ := notes.ListAll(r.Context(), h.DB, did.String()) + allNotes, _ := notes.ListAll(r.Context(), h.DB, didStr) noteCount := len(allNotes) - // Display name initial for avatar. avatarInitial := "?" displayName := "" - avatarURL := "" + + if identity.IsLocal { + // Local user: show local ID and basic info. + id := didStr + if len(id) > 18 { + avatarInitial = string([]rune(id)[len(id)-8:])[:1] + } else { + avatarInitial = string([]rune(id)[0]) + } + + // Load display name from users table. + var dbDisplayName string + _ = h.DB.QueryRowContext(r.Context(), + "SELECT display_name FROM users WHERE did = ?", didStr).Scan(&dbDisplayName) + if dbDisplayName != "" { + displayName = dbDisplayName + avatarInitial = string([]rune(displayName)[0]) + } + + view := pages.SettingsView{ + Handle: id, + DisplayName: displayName, + AvatarInitial: avatarInitial, + NoteCount: noteCount, + Deleted: deleted, + ShowAttendees: true, + ShowLeaderboard: true, + IsLocal: true, + } + + // Load privacy settings via raw query (local IDs can't use GetPrivacy which needs syntax.DID). + var hideAtt, hideLb int + _ = h.DB.QueryRowContext(r.Context(), + "SELECT hide_from_attendees, hide_from_leaderboard FROM users WHERE did = ?", didStr).Scan(&hideAtt, &hideLb) + view.ShowAttendees = hideAtt != 1 + view.ShowLeaderboard = hideLb != 1 + + w.Header().Set("Content-Type", "text/html; charset=utf-8") + if err := pages.Settings(view).Render(r.Context(), w); err != nil { + slog.Error("render settings (local)", "err", err) + } + return + } + + did, err := syntax.ParseDID(identity.DID) + if err != nil { + http.Redirect(w, r, "/signin", http.StatusFound) + return + } + + _, _, ok := h.Auth.RequireSession(w, r) + if !ok { + return + } + + handle := h.resolveHandle(r, did.String()) + isAdmin, _ := users.IsAdmin(r.Context(), h.DB, did) + if handle != "" && handle != "handle.invalid" { avatarInitial = string([]rune(handle)[0]) } @@ -67,6 +122,7 @@ func (h *Handlers) Page(w http.ResponseWriter, r *http.Request) { // Extract PDS host from session. pds := "" pdsHost := "" + avatarURL := "" if h.DB != nil { var host string err := h.DB.QueryRowContext(r.Context(), ` @@ -75,7 +131,6 @@ func (h *Handlers) Page(w http.ResponseWriter, r *http.Request) { `, did.String()).Scan(&host) if err == nil && host != "" { pdsHost = host - // Extract hostname from URL. pds = strings.TrimPrefix(strings.TrimPrefix(host, "https://"), "http://") } } @@ -119,65 +174,114 @@ func (h *Handlers) Page(w http.ResponseWriter, r *http.Request) { } // ExportCSV streams the viewer's connections and notes as a CSV download. -// Includes all connections from the user's PDS, merged with any local notes. +// Includes all connections from the user's PDS (or local_connections for local users), +// merged with any local notes. func (h *Handlers) ExportCSV(w http.ResponseWriter, r *http.Request) { - did, _, ok := h.Auth.RequireSession(w, r) - if !ok { + identity := h.Auth.ResolveIdentity(r) + if !identity.IsAuth { + http.Redirect(w, r, "/signin", http.StatusFound) return } + didStr := identity.DID + // Load all notes into a map keyed by target DID. - noteRows, err := notes.ListAll(r.Context(), h.DB, did.String()) + noteRows, err := notes.ListAll(r.Context(), h.DB, didStr) if err != nil { - slog.Warn("settings: export notes", "did", did.String(), "err", err) + slog.Warn("settings: export notes", "did", didStr, "err", err) } noteMap := make(map[string]notes.ExportRow, len(noteRows)) for _, nr := range noteRows { noteMap[nr.TargetDID] = nr } - // Load all connections from the user's PDS. - pdsHost := h.lookupPDS(r, did) - conns, err := connection.List(r.Context(), pdsHost, did) - if err != nil { - slog.Warn("settings: export connections", "did", did.String(), "err", err) - } - w.Header().Set("Content-Type", "text/csv; charset=utf-8") w.Header().Set("Content-Disposition", "attachment; filename=atmoquest-connections.csv") cw := csv.NewWriter(w) _ = cw.Write([]string{"target_did", "handle", "connected_at", "event", "notes", "follow_up"}) - // Track which DIDs we've already written (connections may have dupes across events). written := make(map[string]bool) - for _, c := range conns { - targetDID := c.With.String() - handle := h.resolveHandle(r, targetDID) - connectedAt := "" - if !c.ConnectedAt.IsZero() { - connectedAt = c.ConnectedAt.Format("2006-01-02T15:04:05Z") + if identity.IsLocal { + // For local users, export from local_connections. + rows, err := h.DB.QueryContext(r.Context(), ` + SELECT target_did, target_local_id, event_uri, connected_at + FROM local_connections + WHERE viewer_local_id = ? + ORDER BY connected_at DESC + `, didStr) + if err == nil { + defer rows.Close() + for rows.Next() { + var targetDID, targetLocalID, eventURI, connectedAt string + if err := rows.Scan(&targetDID, &targetLocalID, &eventURI, &connectedAt); err != nil { + continue + } + target := targetDID + if target == "" { + target = targetLocalID + } + noteText := "" + followUp := "no" + if nr, ok := noteMap[target]; ok { + noteText = nr.Notes + if nr.FollowUp { + followUp = "yes" + } + written[target] = true + } + _ = cw.Write([]string{target, "", connectedAt, eventURI, noteText, followUp}) + } } - noteText := "" - followUp := "no" - if nr, ok := noteMap[targetDID]; ok { - noteText = nr.Notes - if nr.FollowUp { - followUp = "yes" + } else { + did, parseErr := syntax.ParseDID(didStr) + if parseErr != nil { + http.Redirect(w, r, "/signin", http.StatusFound) + return + } + + _, _, ok := h.Auth.RequireSession(w, r) + if !ok { + return + } + + // Load all connections from the user's PDS. + pdsHost := h.lookupPDS(r, did) + conns, err := connection.List(r.Context(), pdsHost, did) + if err != nil { + slog.Warn("settings: export connections", "did", didStr, "err", err) + } + + for _, c := range conns { + targetDID := c.With.String() + handle := h.resolveHandle(r, targetDID) + connectedAt := "" + if !c.ConnectedAt.IsZero() { + connectedAt = c.ConnectedAt.Format("2006-01-02T15:04:05Z") } - written[targetDID] = true + noteText := "" + followUp := "no" + if nr, ok := noteMap[targetDID]; ok { + noteText = nr.Notes + if nr.FollowUp { + followUp = "yes" + } + written[targetDID] = true + } + _ = cw.Write([]string{targetDID, handle, connectedAt, c.EventURI, noteText, followUp}) } - _ = cw.Write([]string{targetDID, handle, connectedAt, c.EventURI, noteText, followUp}) } - // Write any notes for DIDs that didn't appear in the connection list - // (e.g., notes left over after a connection was deleted). + // Write any notes for DIDs that didn't appear in the connection list. for _, nr := range noteRows { if written[nr.TargetDID] { continue } - handle := h.resolveHandle(r, nr.TargetDID) + handle := "" + if !identity.IsLocal { + handle = h.resolveHandle(r, nr.TargetDID) + } followUp := "no" if nr.FollowUp { followUp = "yes" @@ -205,8 +309,9 @@ func (h *Handlers) lookupPDS(r *http.Request, did syntax.DID) string { // TogglePrivacy handles POST /settings/privacy — toggles a privacy setting. func (h *Handlers) TogglePrivacy(w http.ResponseWriter, r *http.Request) { - did, _, ok := h.Auth.RequireSession(w, r) - if !ok { + identity := h.Auth.ResolveIdentity(r) + if !identity.IsAuth { + http.Redirect(w, r, "/signin", http.StatusFound) return } @@ -217,7 +322,7 @@ func (h *Handlers) TogglePrivacy(w http.ResponseWriter, r *http.Request) { field := r.FormValue("field") // value="1" means "toggle ON (hide)", value="0" means "toggle OFF (show)" - value := r.FormValue("value") == "1" + val := r.FormValue("value") == "1" var column string switch field { @@ -230,8 +335,14 @@ func (h *Handlers) TogglePrivacy(w http.ResponseWriter, r *http.Request) { return } - if err := users.SetPrivacy(r.Context(), h.DB, did, column, value); err != nil { - slog.Warn("settings: toggle privacy", "did", did.String(), "field", field, "err", err) + boolVal := 0 + if val { + boolVal = 1 + } + _, err := h.DB.ExecContext(r.Context(), + "UPDATE users SET "+column+" = ? WHERE did = ?", boolVal, identity.DID) + if err != nil { + slog.Warn("settings: toggle privacy", "did", identity.DID, "field", field, "err", err) http.Error(w, "failed to update", http.StatusInternalServerError) return } @@ -244,8 +355,9 @@ func (h *Handlers) TogglePrivacy(w http.ResponseWriter, r *http.Request) { // checkbox is checked, also deletes all quest.atmo.* records from the user's // PDS and clears local cache tables. func (h *Handlers) DeleteData(w http.ResponseWriter, r *http.Request) { - did, sess, ok := h.Auth.RequireSession(w, r) - if !ok { + identity := h.Auth.ResolveIdentity(r) + if !identity.IsAuth { + http.Redirect(w, r, "/signin", http.StatusFound) return } @@ -254,15 +366,23 @@ func (h *Handlers) DeleteData(w http.ResponseWriter, r *http.Request) { return } - handle := h.resolveHandle(r, did.String()) + didStr := identity.DID confirm := strings.TrimSpace(r.FormValue("confirm_handle")) - purgePDS := r.FormValue("purge_pds") == "1" - if confirm != handle { - // Re-render with error. + // For ATProto users, confirmation is by handle. For local users, by local ID. + expected := didStr + if !identity.IsLocal { + expected = h.resolveHandle(r, didStr) + } + if confirm != expected { + handle := didStr + if !identity.IsLocal { + handle = h.resolveHandle(r, didStr) + } view := pages.SettingsView{ - Handle: handle, - DeleteError: fmt.Sprintf("You typed %q but your handle is %q. Please type your exact handle to confirm.", confirm, handle), + Handle: handle, + DeleteError: fmt.Sprintf("You typed %q but we expected %q. Please type your exact identifier to confirm.", confirm, expected), + IsLocal: identity.IsLocal, } w.Header().Set("Content-Type", "text/html; charset=utf-8") w.WriteHeader(http.StatusUnprocessableEntity) @@ -272,16 +392,30 @@ func (h *Handlers) DeleteData(w http.ResponseWriter, r *http.Request) { return } - count, err := notes.DeleteAll(r.Context(), h.DB, did.String()) + count, err := notes.DeleteAll(r.Context(), h.DB, didStr) if err != nil { - slog.Warn("settings: delete notes", "did", did.String(), "err", err) + slog.Warn("settings: delete notes", "did", didStr, "err", err) http.Error(w, "failed to delete", http.StatusInternalServerError) return } - slog.Info("settings: deleted notes", "did", did.String(), "count", count) + slog.Info("settings: deleted notes", "did", didStr, "count", count) - if purgePDS { - h.purgePDSRecords(r, did, sess) + if !identity.IsLocal { + did, parseErr := syntax.ParseDID(identity.DID) + if parseErr == nil { + _, sess, ok := h.Auth.RequireSession(w, r) + if ok { + purgePDS := r.FormValue("purge_pds") == "1" + if purgePDS { + h.purgePDSRecords(r, did, sess) + } + } + } + } + + // For local users, clear the cookie so they're fully signed out after delete. + if identity.IsLocal { + h.Auth.Sessions.ClearLocal(w) } http.Redirect(w, r, "/settings?deleted=1", http.StatusSeeOther) @@ -363,24 +497,34 @@ func (h *Handlers) purgePDSRecords(r *http.Request, did syntax.DID, sess *oauth. // NotesList renders /settings/notes — a table of all local notes and follow-up flags. func (h *Handlers) NotesList(w http.ResponseWriter, r *http.Request) { - did, sess, ok := h.Auth.RequireSession(w, r) - if !ok { + identity := h.Auth.ResolveIdentity(r) + if !identity.IsAuth { + http.Redirect(w, r, "/signin", http.StatusFound) return } - // Award "Extra Curious" badge for exploring data pages. - if sess != nil { - _, _ = badge.Award(r.Context(), sess, h.DB, badge.AwardExtraCurious, "") + if !identity.IsLocal { + _, sess, ok := h.Auth.RequireSession(w, r) + if !ok { + return + } + // Award "Extra Curious" badge for exploring data pages. + if sess != nil { + _, _ = badge.Award(r.Context(), sess, h.DB, badge.AwardExtraCurious, "") + } } - rows, err := notes.ListAll(r.Context(), h.DB, did.String()) + rows, err := notes.ListAll(r.Context(), h.DB, identity.DID) if err != nil { slog.Warn("settings: list notes", "err", err) } var view pages.NotesListView for _, nr := range rows { - handle := h.resolveHandle(r, nr.TargetDID) + handle := "" + if !identity.IsLocal { + handle = h.resolveHandle(r, nr.TargetDID) + } view.Notes = append(view.Notes, pages.NoteRow{ TargetDID: nr.TargetDID, Handle: handle, @@ -397,7 +541,19 @@ func (h *Handlers) NotesList(w http.ResponseWriter, r *http.Request) { } // PDSData renders /settings/pds — all quest.atmo.* records from the user's PDS. +// For local users, shows a summary of their local data instead. func (h *Handlers) PDSData(w http.ResponseWriter, r *http.Request) { + identity := h.Auth.ResolveIdentity(r) + if !identity.IsAuth { + http.Redirect(w, r, "/signin", http.StatusFound) + return + } + + if identity.IsLocal { + h.localDataView(w, r, identity.DID) + return + } + did, sess, ok := h.Auth.RequireSession(w, r) if !ok { return @@ -470,6 +626,38 @@ func (h *Handlers) PDSData(w http.ResponseWriter, r *http.Request) { } } +// localDataView renders a summary of local user data. +func (h *Handlers) localDataView(w http.ResponseWriter, r *http.Request, localID string) { + // Count checkins. + var checkinCount int + _ = h.DB.QueryRowContext(r.Context(), + "SELECT COUNT(*) FROM checkins WHERE did = ?", localID).Scan(&checkinCount) + + // Count local connections. + var connCount int + _ = h.DB.QueryRowContext(r.Context(), + "SELECT COUNT(*) FROM local_connections WHERE viewer_local_id = ?", localID).Scan(&connCount) + + // Count notes. + var noteCount int + _ = h.DB.QueryRowContext(r.Context(), + "SELECT COUNT(*) FROM connection_notes WHERE did = ?", localID).Scan(¬eCount) + + view := pages.PDSDataView{ + PDS: "local", + Collections: []pages.PDSCollection{ + {Name: "checkins", Records: []pages.PDSRecord{{URI: fmt.Sprintf("%d check-ins", checkinCount)}}}, + {Name: "local_connections", Records: []pages.PDSRecord{{URI: fmt.Sprintf("%d connections", connCount)}}}, + {Name: "connection_notes", Records: []pages.PDSRecord{{URI: fmt.Sprintf("%d notes", noteCount)}}}, + }, + } + + w.Header().Set("Content-Type", "text/html; charset=utf-8") + if err := pages.PDSData(view).Render(r.Context(), w); err != nil { + slog.Error("render local data view", "err", err) + } +} + // rkeyFromURI extracts the record key (last path segment) from an at:// URI. func rkeyFromURI(uri string) string { parts := strings.Split(uri, "/") diff --git a/features/settings/pages/settings.templ b/features/settings/pages/settings.templ index fdb947a..f9487b0 100644 --- a/features/settings/pages/settings.templ +++ b/features/settings/pages/settings.templ @@ -8,17 +8,18 @@ import ( // SettingsView is the data for the settings page. type SettingsView struct { - Handle string // user's atproto handle + Handle string // user's atproto handle (or local ID for local users) DisplayName string // user's display name AvatarInitial string // first letter for avatar placeholder AvatarURL string // user's avatar URL from Bluesky profile - PDS string // PDS hostname (e.g. "bsky.social") + PDS string // PDS hostname (e.g. "bsky.social"); empty for local users NoteCount int // number of local notes Deleted bool // true after a successful delete DeleteError string // non-empty when confirmation failed IsAdmin bool // show admin link when true ShowAttendees bool // true = visible on attendee lists (default) ShowLeaderboard bool // true = visible on leaderboard (default) + IsLocal bool // true for local-only accounts (no ATProto PDS) } func settingsItoa(n int) string { @@ -69,9 +70,13 @@ templ Settings(v SettingsView) { { v.Handle } } - - if v.PDS != "" { - + if v.IsLocal { + + } else { + + if v.PDS != "" { + + } } @@ -143,14 +148,26 @@ templ Settings(v SettingsView) {
{ settingsItoa(v.NoteCount) } notes ›
- -
◆
-
-
PDS data
-
stored in your ATProto PDS
-
-
›
-
+ if !v.IsLocal { + +
◆
+
+
PDS data
+
stored in your ATProto PDS
+
+
›
+
+ } + if v.IsLocal { + +
◆
+
+
Link to Atmosphere account
+
migrate your data to an ATProto account
+
+
›
+
+ } @@ -189,16 +206,29 @@ templ Settings(v SettingsView) {
Danger zone
-
- -
+ if v.IsLocal { +
+ +
+ } else { +
+ +
+ } @deleteDataRow(v)
diff --git a/features/settings/pages/settings_templ.go b/features/settings/pages/settings_templ.go index af369f6..82aacbc 100644 --- a/features/settings/pages/settings_templ.go +++ b/features/settings/pages/settings_templ.go @@ -5,27 +5,29 @@ 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" "fmt" - "github.com/a-h/templ" - templruntime "github.com/a-h/templ/runtime" + "atmoquest/features/common/layouts" ) // SettingsView is the data for the settings page. type SettingsView struct { - Handle string // user's atproto handle + Handle string // user's atproto handle (or local ID for local users) DisplayName string // user's display name AvatarInitial string // first letter for avatar placeholder AvatarURL string // user's avatar URL from Bluesky profile - PDS string // PDS hostname (e.g. "bsky.social") + PDS string // PDS hostname (e.g. "bsky.social"); empty for local users NoteCount int // number of local notes Deleted bool // true after a successful delete DeleteError string // non-empty when confirmation failed IsAdmin bool // show admin link when true ShowAttendees bool // true = visible on attendee lists (default) ShowLeaderboard bool // true = visible on leaderboard (default) + IsLocal bool // true for local-only accounts (no ATProto PDS) } func settingsItoa(n int) string { @@ -102,7 +104,7 @@ func Settings(v SettingsView) templ.Component { var templ_7745c5c3_Var3 string templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.ResolveAttributeValue(v.AvatarURL) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/settings/pages/settings.templ`, Line: 60, Col: 65} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/settings/pages/settings.templ`, Line: 61, Col: 65} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var3) if templ_7745c5c3_Err != nil { @@ -115,7 +117,7 @@ func Settings(v SettingsView) templ.Component { var templ_7745c5c3_Var4 string templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.ResolveAttributeValue(v.DisplayName) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/settings/pages/settings.templ`, Line: 60, Col: 87} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/settings/pages/settings.templ`, Line: 61, Col: 87} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var4) if templ_7745c5c3_Err != nil { @@ -133,7 +135,7 @@ func Settings(v SettingsView) templ.Component { var templ_7745c5c3_Var5 string templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(v.AvatarInitial) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/settings/pages/settings.templ`, Line: 62, Col: 61} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/settings/pages/settings.templ`, Line: 63, Col: 61} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5)) if templ_7745c5c3_Err != nil { @@ -152,7 +154,7 @@ func Settings(v SettingsView) templ.Component { 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/settings/pages/settings.templ`, Line: 67, Col: 24} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/settings/pages/settings.templ`, Line: 68, Col: 24} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6)) if templ_7745c5c3_Err != nil { @@ -162,154 +164,218 @@ func Settings(v SettingsView) templ.Component { var templ_7745c5c3_Var7 string templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(v.Handle) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/settings/pages/settings.templ`, Line: 69, Col: 19} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/settings/pages/settings.templ`, Line: 70, Col: 19} } _, 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, 11, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - if v.PDS != "" { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } + if v.PDS != "" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "
Privacy
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "
Privacy
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if v.ShowAttendees { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, " ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, " ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } else { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, " ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, " ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if v.ShowLeaderboard { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, " ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, " ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } else { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, " ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, " ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "
Your data
Danger zone
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, " notes ›
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } + if !v.IsLocal { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "
◆
PDS data
stored in your ATProto PDS
›
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + if v.IsLocal { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "
◆
Link to Atmosphere account
migrate your data to an ATProto account
›
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "
Danger zone
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if v.IsLocal { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } templ_7745c5c3_Err = deleteDataRow(v).Render(ctx, templ_7745c5c3_Buffer) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if v.IsAdmin { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "") + 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, 32, "
built on ATProto · open source · made for connecting
atmo.quest
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "
built on ATProto · open source · made for connecting
atmo.quest
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -343,61 +409,61 @@ func deleteDataRow(v SettingsView) templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var12 := templ.GetChildren(ctx) - if templ_7745c5c3_Var12 == nil { - templ_7745c5c3_Var12 = templ.NopComponent + templ_7745c5c3_Var14 := templ.GetChildren(ctx) + if templ_7745c5c3_Var14 == nil { + templ_7745c5c3_Var14 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "
◆
Delete atmo.quest data
removes local notes and flags
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "
◆
Delete atmo.quest data
removes local notes and flags
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if v.DeleteError != "" { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var13 string - templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(v.DeleteError) + var templ_7745c5c3_Var15 string + templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(v.DeleteError) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/settings/pages/settings.templ`, Line: 248, Col: 20} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/settings/pages/settings.templ`, Line: 278, Col: 20} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13)) + _, 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, 35, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "\" class=\"settings-confirm-input\" autocomplete=\"off\" required aria-label=\"Type your handle to confirm deletion\">
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } diff --git a/go.mod b/go.mod index 5fe92e1..1fb0660 100644 --- a/go.mod +++ b/go.mod @@ -86,6 +86,7 @@ require ( github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-runewidth v0.0.16 // indirect + github.com/mattn/go-sqlite3 v1.14.44 // 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 diff --git a/go.sum b/go.sum index 3391a2d..d8e2d2b 100644 --- a/go.sum +++ b/go.sum @@ -285,6 +285,8 @@ github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D github.com/mattn/go-runewidth v0.0.3/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU= github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/mattn/go-sqlite3 v1.14.44 h1:3VSe+xafpbzsLbdr2AWlAZk9yRHiBhTBakioXaCKTF8= +github.com/mattn/go-sqlite3 v1.14.44/go.mod h1:pjEuOr8IwzLJP2MfGeTb0A35jauH+C2kbHKBr7yXKVQ= 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= diff --git a/internal/checkin/checkin.go b/internal/checkin/checkin.go index e337cfd..4b7d558 100644 --- a/internal/checkin/checkin.go +++ b/internal/checkin/checkin.go @@ -5,6 +5,9 @@ // `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". +// +// Local users (those without an ATProto account) use PutLocal instead of +// Put — it writes directly to the checkins table without needing a PDS session. package checkin import ( @@ -18,6 +21,93 @@ import ( "github.com/bluesky-social/indigo/atproto/syntax" ) +// PutLocal writes a check-in record directly to the local checkins table for +// users without an ATProto PDS (local_xxx accounts). Unlike Put, it does not +// write to any PDS — the local row is the source of truth. +func PutLocal(ctx context.Context, db *sql.DB, did string, eventURI string, at time.Time) error { + if did == "" { + return errors.New("checkin: empty did") + } + if eventURI == "" { + return errors.New("checkin: missing event URI") + } + if at.IsZero() { + at = time.Now().UTC() + } + + // Dedup: check if this user already has a check-in for this event. + if db != nil { + var existing string + err := db.QueryRowContext(ctx, ` + SELECT record_uri FROM checkins + WHERE did = ? AND event_uri = ? + LIMIT 1 + `, did, eventURI).Scan(&existing) + if err == nil && existing != "" { + return nil + } + } + + // Generate a local record_uri (not a real at-uri, just a unique key). + recordURI := fmt.Sprintf("local://%s/checkin/%d", did, at.UnixNano()) + + _, err := 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 + `, recordURI, did, eventURI, at.UTC()) + return err +} + +// PutLocalBatch writes multiple check-in records for the same user in a +// single transaction. Skips events the user already checked into. +func PutLocalBatch(ctx context.Context, db *sql.DB, did string, events []string, at time.Time) (written, skipped int, err error) { + if len(events) == 0 { + return 0, 0, nil + } + if at.IsZero() { + at = time.Now().UTC() + } + + tx, err := db.BeginTx(ctx, nil) + if err != nil { + return 0, 0, err + } + defer tx.Rollback() + + for _, eventURI := range events { + if eventURI == "" { + skipped++ + continue + } + var existing string + err := tx.QueryRowContext(ctx, ` + SELECT record_uri FROM checkins + WHERE did = ? AND event_uri = ? + LIMIT 1 + `, did, eventURI).Scan(&existing) + if err == nil && existing != "" { + skipped++ + continue + } + recordURI := fmt.Sprintf("local://%s/checkin/%d", did, at.UnixNano()+int64(written)) + _, err = tx.ExecContext(ctx, ` + INSERT INTO checkins (record_uri, did, event_uri, checked_in_at, cached_at) + VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP) + `, recordURI, did, eventURI, at.UTC()) + if err != nil { + skipped++ + continue + } + written++ + } + + if err := tx.Commit(); err != nil { + return written, skipped, err + } + return written, skipped, nil +} + const ( // NSID of the check-in record lexicon. NSID = "quest.atmo.checkin" diff --git a/internal/checkin/list.go b/internal/checkin/list.go index 6ddf470..c65b285 100644 --- a/internal/checkin/list.go +++ b/internal/checkin/list.go @@ -4,8 +4,6 @@ import ( "context" "database/sql" "time" - - "github.com/bluesky-social/indigo/atproto/syntax" ) // UserEvent is an event a user has checked into, enriched with event metadata. @@ -21,7 +19,8 @@ type UserEvent struct { // ListForUser returns all events a user has checked into, most recent first. // Each event appears once (even if the user checked in multiple times). -func ListForUser(ctx context.Context, db *sql.DB, did syntax.DID, now time.Time) ([]UserEvent, error) { +// did can be an ATProto DID ("did:plc:...") or a local ID ("local_..."). +func ListForUser(ctx context.Context, db *sql.DB, did string, now time.Time) ([]UserEvent, error) { rows, err := db.QueryContext(ctx, ` SELECT e.uri, e.name, e.location, e.start_time, e.end_time, MAX(c.checked_in_at) AS last_checkin @@ -30,7 +29,7 @@ func ListForUser(ctx context.Context, db *sql.DB, did syntax.DID, now time.Time) WHERE c.did = ? GROUP BY e.uri ORDER BY last_checkin DESC - `, did.String()) + `, did) if err != nil { return nil, err } diff --git a/internal/checkin/list_test.go b/internal/checkin/list_test.go index 16530f3..6b04670 100644 --- a/internal/checkin/list_test.go +++ b/internal/checkin/list_test.go @@ -7,8 +7,6 @@ import ( "testing" "time" - "github.com/bluesky-social/indigo/atproto/syntax" - atdb "atmoquest/internal/db" ) @@ -58,7 +56,7 @@ func seedListCheckin(t *testing.T, db *sql.DB, recordURI, did, eventURI string, func TestListForUser_Empty(t *testing.T) { ctx, db := newListTestDB(t) - events, err := ListForUser(ctx, db, syntax.DID(listUserDID), time.Now()) + events, err := ListForUser(ctx, db, listUserDID, time.Now()) if err != nil { t.Fatalf("ListForUser: %v", err) } @@ -74,7 +72,7 @@ func TestListForUser_ReturnsEventsWithMetadata(t *testing.T) { seedListEvent(t, db, evURI, "CascadiaJS 2026", now.Add(-1*time.Hour), now.Add(8*time.Hour)) seedListCheckin(t, db, "at://did:plc:user/quest.atmo.checkin/1", listUserDID, evURI, now) - events, err := ListForUser(ctx, db, syntax.DID(listUserDID), now) + events, err := ListForUser(ctx, db, listUserDID, now) if err != nil { t.Fatalf("ListForUser: %v", err) } @@ -104,7 +102,7 @@ func TestListForUser_OngoingFlag(t *testing.T) { seedListCheckin(t, db, "at://u/c/1", listUserDID, ongoingURI, now) seedListCheckin(t, db, "at://u/c/2", listUserDID, endedURI, now.Add(-30*time.Hour)) - events, err := ListForUser(ctx, db, syntax.DID(listUserDID), now) + events, err := ListForUser(ctx, db, listUserDID, now) if err != nil { t.Fatalf("ListForUser: %v", err) } @@ -133,7 +131,7 @@ func TestListForUser_DeduplicatesMultipleCheckins(t *testing.T) { seedListCheckin(t, db, "at://u/c/a", listUserDID, evURI, now.Add(-1*time.Hour)) seedListCheckin(t, db, "at://u/c/b", listUserDID, evURI, now) - events, err := ListForUser(ctx, db, syntax.DID(listUserDID), now) + events, err := ListForUser(ctx, db, listUserDID, now) if err != nil { t.Fatalf("ListForUser: %v", err) } @@ -150,7 +148,7 @@ func TestListForUser_IsolatedByUser(t *testing.T) { seedListEvent(t, db, evURI, "Shared Event", now.Add(-1*time.Hour), now.Add(8*time.Hour)) seedListCheckin(t, db, "at://other/c/1", "did:plc:otheruser12345678abcde", evURI, now) - events, _ := ListForUser(ctx, db, syntax.DID(listUserDID), now) + events, _ := ListForUser(ctx, db, listUserDID, now) if len(events) != 0 { t.Errorf("should not see other user's checkins; got %d events", len(events)) } @@ -167,7 +165,7 @@ func TestListForUser_OrderedMostRecentFirst(t *testing.T) { seedListCheckin(t, db, "at://u/c/a", listUserDID, evA, now.Add(-8*time.Hour)) seedListCheckin(t, db, "at://u/c/b", listUserDID, evB, now) - events, _ := ListForUser(ctx, db, syntax.DID(listUserDID), now) + events, _ := ListForUser(ctx, db, listUserDID, now) if len(events) != 2 { t.Fatalf("expected 2; got %d", len(events)) } diff --git a/internal/connection/local.go b/internal/connection/local.go new file mode 100644 index 0000000..21d42ce --- /dev/null +++ b/internal/connection/local.go @@ -0,0 +1,49 @@ +// Package connection provides helpers for managing quest.atmo.connection +// records on ATProto PDSs, plus a SQLite queue for reciprocal writes. +// +// This file handles local connections that can't be stored on PDS. +package connection + +import ( + "context" + "database/sql" + "errors" +) + +// WriteLocal writes a connection involving at least one local user. +// For connections between two ATProto users, use Put() (writes to PDS). +func WriteLocal(ctx context.Context, db *sql.DB, + viewerDID, viewerLocalID string, + targetDID, targetLocalID string, + eventURI string) error { + + if (viewerDID == "" && viewerLocalID == "") || (viewerDID != "" && viewerLocalID != "") { + return errors.New("exactly one of viewerDID, viewerLocalID must be set") + } + if (targetDID == "" && targetLocalID == "") || (targetDID != "" && targetLocalID != "") { + return errors.New("exactly one of targetDID, targetLocalID must be set") + } + + // Map empty strings to SQL NULL; the CHECK constraints require NULL, + // and Go's database/sql treats nil (any typed nil interface) as SQL NULL. + var vDID, vLocalID, tDID, tLocalID interface{} + if viewerDID != "" { + vDID = viewerDID + } + if viewerLocalID != "" { + vLocalID = viewerLocalID + } + if targetDID != "" { + tDID = targetDID + } + if targetLocalID != "" { + tLocalID = targetLocalID + } + + _, err := db.ExecContext(ctx, ` + INSERT INTO local_connections + (viewer_did, viewer_local_id, target_did, target_local_id, event_uri, connected_at) + VALUES (?, ?, ?, ?, ?, CURRENT_TIMESTAMP) + `, vDID, vLocalID, tDID, tLocalID, eventURI) + return err +} diff --git a/internal/db/migrations/015_users_user_type.sql b/internal/db/migrations/015_users_user_type.sql new file mode 100644 index 0000000..85b8e7e --- /dev/null +++ b/internal/db/migrations/015_users_user_type.sql @@ -0,0 +1,5 @@ +-- Add user_type column to users table to distinguish ATProto vs local users +ALTER TABLE users ADD COLUMN user_type TEXT NOT NULL DEFAULT 'atproto'; + +-- Index for querying local users +CREATE INDEX IF NOT EXISTS users_type_idx ON users (user_type) WHERE user_type = 'local'; diff --git a/internal/db/migrations/016_local_connections.sql b/internal/db/migrations/016_local_connections.sql new file mode 100644 index 0000000..87b71b6 --- /dev/null +++ b/internal/db/migrations/016_local_connections.sql @@ -0,0 +1,29 @@ +-- Local connections table for connections involving local users +-- Connections between two ATProto users go to PDS; all others go here +CREATE TABLE IF NOT EXISTS local_connections ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + viewer_did TEXT, -- NULL if viewer is local + viewer_local_id TEXT, -- NULL if viewer is ATProto + target_did TEXT, -- NULL if target is local + target_local_id TEXT, -- NULL if target is ATProto + event_uri TEXT NOT NULL DEFAULT '', + connected_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CHECK ( + (viewer_did IS NOT NULL AND viewer_local_id IS NULL) OR + (viewer_did IS NULL AND viewer_local_id IS NOT NULL) + ), + CHECK ( + (target_did IS NOT NULL AND target_local_id IS NULL) OR + (target_did IS NULL AND target_local_id IS NOT NULL) + ), + + FOREIGN KEY (viewer_did) REFERENCES users(did) ON DELETE CASCADE, + FOREIGN KEY (target_did) REFERENCES users(did) ON DELETE CASCADE, + FOREIGN KEY (event_uri) REFERENCES events(uri) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS local_connections_viewer_did_idx ON local_connections (viewer_did); +CREATE INDEX IF NOT EXISTS local_connections_viewer_local_idx ON local_connections (viewer_local_id) WHERE viewer_local_id IS NOT NULL; +CREATE INDEX IF NOT EXISTS local_connections_target_did_idx ON local_connections (target_did); +CREATE INDEX IF NOT EXISTS local_connections_target_local_idx ON local_connections (target_local_id) WHERE target_local_id IS NOT NULL; diff --git a/internal/db/migrations/017_account_links.sql b/internal/db/migrations/017_account_links.sql new file mode 100644 index 0000000..e0442ee --- /dev/null +++ b/internal/db/migrations/017_account_links.sql @@ -0,0 +1,9 @@ +-- Account links table to track when local accounts are linked to ATProto accounts +CREATE TABLE IF NOT EXISTS account_links ( + local_did TEXT PRIMARY KEY, -- the local_xxx ID + did TEXT NOT NULL, -- the ATProto DID + linked_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (did) REFERENCES users(did) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS account_links_did_idx ON account_links (did); diff --git a/internal/db/migrations/018_user_profile.sql b/internal/db/migrations/018_user_profile.sql new file mode 100644 index 0000000..0d63f43 --- /dev/null +++ b/internal/db/migrations/018_user_profile.sql @@ -0,0 +1,11 @@ +-- Add profile columns to users table for local users +-- These columns store quest.atmo.profile data for local accounts +ALTER TABLE users ADD COLUMN bio TEXT DEFAULT ''; +ALTER TABLE users ADD COLUMN location TEXT DEFAULT ''; +ALTER TABLE users ADD COLUMN works_at TEXT DEFAULT ''; +ALTER TABLE users ADD COLUMN contact_method TEXT DEFAULT ''; +ALTER TABLE users ADD COLUMN hiring INTEGER NOT NULL DEFAULT 0; +ALTER TABLE users ADD COLUMN looking INTEGER NOT NULL DEFAULT 0; +ALTER TABLE users ADD COLUMN hide_badges INTEGER NOT NULL DEFAULT 0; +ALTER TABLE users ADD COLUMN interests TEXT DEFAULT '[]'; +ALTER TABLE users ADD COLUMN links TEXT DEFAULT '[]'; diff --git a/internal/session/session.go b/internal/session/session.go index e33ead9..8715e52 100644 --- a/internal/session/session.go +++ b/internal/session/session.go @@ -19,6 +19,7 @@ import ( "crypto/rand" "errors" "net/http" + "strings" "github.com/gorilla/sessions" @@ -94,3 +95,82 @@ func (m *Manager) Clear(w http.ResponseWriter, r *http.Request) { } _ = s.Save(r, w) } + +const localCookieName = "atmoquest_local" +const knownLocalsCookieName = "atmoquest_known_locals" + +// SetLocal writes the local_id into a long-lived cookie (NOT HttpOnly for JS access). +// MaxAge = 365 days. +func (m *Manager) SetLocal(w http.ResponseWriter, localID string) error { + http.SetCookie(w, &http.Cookie{ + Name: localCookieName, + Value: localID, + Path: "/", + MaxAge: 365 * 24 * 60 * 60, + HttpOnly: false, + Secure: m.secure, + SameSite: http.SameSiteLaxMode, + }) + return nil +} + +// GetLocal reads the local_id from the cookie. +// Returns empty string if not present. +func (m *Manager) GetLocal(r *http.Request) string { + c, err := r.Cookie(localCookieName) + if err != nil { + return "" + } + return c.Value +} + +// ClearLocal deletes the local cookie. +func (m *Manager) ClearLocal(w http.ResponseWriter) { + http.SetCookie(w, &http.Cookie{ + Name: localCookieName, + Value: "", + Path: "/", + MaxAge: -1, + }) +} + +// RememberLocal saves a local ID to the known-accounts cookie so the user +// can recover it after signing out. Multiple IDs are comma-separated. +func (m *Manager) RememberLocal(w http.ResponseWriter, localID string) { + // We can't read existing cookie while writing, but we re-set it on every + // login so the latest ID is always included. + http.SetCookie(w, &http.Cookie{ + Name: knownLocalsCookieName, + Value: localID, + Path: "/", + MaxAge: 365 * 24 * 60 * 60, + // NOT HttpOnly so JS can read it for account-switching UI. + Secure: m.secure, + SameSite: http.SameSiteLaxMode, + }) +} + +// GetKnownLocals returns the list of local IDs previously used on this browser. +func (m *Manager) GetKnownLocals(r *http.Request) []string { + c, err := r.Cookie(knownLocalsCookieName) + if err != nil { + return nil + } + if c.Value == "" { + return nil + } + parts := strings.Split(c.Value, ",") + out := make([]string, 0, len(parts)) + for _, p := range parts { + p = strings.TrimSpace(p) + if p != "" { + out = append(out, p) + } + } + return out +} + +// IsLocalAuthenticated checks if the request has a valid local session. +func (m *Manager) IsLocalAuthenticated(r *http.Request) bool { + return m.GetLocal(r) != "" +} diff --git a/web/resources/static/js/profile.js b/web/resources/static/js/profile.js index cf18e2e..7e69b1c 100644 --- a/web/resources/static/js/profile.js +++ b/web/resources/static/js/profile.js @@ -68,9 +68,11 @@ // ---- 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.queueConnection = function (target) { + if (typeof target !== "string") return; + if (target.indexOf("did:") === 0 || target.indexOf("local_") === 0) { + pushUnique(CONN_KEY, target, 50); + } }; window.atmoquest.queueEvent = function (token) { if (typeof token !== "string" || token.length === 0 || token.length > 64) return; -- 2.51.2