Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ internal/
# hydrateMessages() — user+unfurl+reactions per message
# sendPushNotifications() — async Web Push after save
notifications.go # Push subscribe/unsubscribe, mute, VAPID key, room members
profile.go # GET/PATCH/DELETE /user/profile; POST /user/identities/{provider}/disconnect
reactions.go # POST /rooms/{id}/messages/{msgID}/reactions — toggle, SSE broadcast
upload.go # GET /rooms/{id}/upload-url — presigned S3 PUT (optional)
sse.go # GET /rooms/{id}/events — Redis Pub/Sub → SSE fan-out
Expand Down Expand Up @@ -73,6 +74,7 @@ web/
reactions.html # Reactions bar partial (used standalone for SSE reaction events)
history.html # Infinite-scroll history partial (sentinel + messages)
unfurl.html # Link preview card partial
profile.html # Profile section partial (lazy-loaded in settings dialog via HTMX)
login.html # Login page (GitHub button; optional password form via PasswordAuthEnabled)
error.html # Error page
static/
Expand Down Expand Up @@ -120,6 +122,10 @@ POST /settings/mute — set mute duration (1h/8h/24h/168h
DELETE /settings/mute — clear mute

GET /user/events — user-level SSE stream (unread badges, future cross-room events)
GET /user/profile — profile section partial (HTMX, lazy-loaded in settings dialog)
PATCH /user/profile — update display name (unique; re-renders profile partial)
POST /user/profile/delete — delete account (requires confirmation="DELETE"); HX-Redirect → /login
POST /user/identities/{provider}/disconnect — unlink OAuth provider (refused if last auth method)

GET /sw.js — Service Worker (root scope; no-cache)
GET /static/* — embedded static files (immutable cache)
Expand All @@ -137,7 +143,9 @@ users:{uuid} Hash id, name, avatar_url, email, cre
users:{uuid}:identities Set "{provider}:{providerUserID}" members (no TTL)
users:{uuid}:push_subscriptions Hash endpoint → subscriptionJSON (no TTL)
users:{uuid}:mute_until String unix ms timestamp or "forever"; TTL = mute duration (or none)
users:{uuid}:sessions Set session tokens for this user (no TTL; for cascade delete)
identities:{provider}:{providerUserID} String canonical uuid (no TTL)
name_index:{lowercase_name} String canonical uuid (no TTL; for display name uniqueness)
rooms ZSet room IDs scored by creation time (unix seconds)
rooms:{id} Hash id, name
rooms:{id}:messages ZSet message IDs scored by created_at (unix ms); cleaned on write
Expand Down
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ test-go:
# Browser E2E tests using go-rod + headless Chromium.
# Requires Chromium to be installed (rod auto-downloads if not found).
test-e2e:
go test ./internal/browser/... -v -timeout 120s -parallel 4
go test ./internal/browser/... -v -timeout 120s

# JS lint via Biome.
lint:
Expand Down
34 changes: 31 additions & 3 deletions internal/auth/oauth.go
Original file line number Diff line number Diff line change
Expand Up @@ -210,13 +210,42 @@ func (h *Handler) HandleCallback(w http.ResponseWriter, r *http.Request) {
return
}

// If the user is already logged in, this is a "connect provider" flow.
if existingUser := h.resolveExistingSession(r); existingUser != nil {
identityOwner, _ := h.Redis.GetUserByIdentity(r.Context(), identity.Provider, identity.ProviderUserID)
if identityOwner != nil && identityOwner.ID != existingUser.ID {
http.Redirect(w, r, "/?error=identity_taken", http.StatusFound)
return
}
if identityOwner == nil {
if err := h.Redis.LinkIdentity(r.Context(), existingUser.ID, identity.Provider, identity.ProviderUserID); err != nil {
http.Error(w, "failed to link identity", http.StatusInternalServerError)
return
}
}
http.Redirect(w, r, "/?settings=profile", http.StatusFound)
return
}

if err := h.createSession(r.Context(), w, identity); err != nil {
http.Error(w, "failed to create session", http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/rooms/bemro", http.StatusFound)
}

// resolveExistingSession checks if the request has a valid session cookie.
// Returns the user if logged in, nil otherwise. Used to detect the "connect
// provider" flow in HandleCallback.
func (h *Handler) resolveExistingSession(r *http.Request) *model.User {
token, err := TokenFromRequest(r, h.SessionSecret)
if err != nil {
return nil
}
user, _ := h.Redis.GetSession(r.Context(), token)
return user
}

// HandleLogout deletes the session and clears the cookie.
func (h *Handler) HandleLogout(w http.ResponseWriter, r *http.Request) {
token, err := TokenFromRequest(r, h.SessionSecret)
Expand Down Expand Up @@ -252,9 +281,8 @@ func (h *Handler) createSession(ctx context.Context, w http.ResponseWriter, iden
return fmt.Errorf("link identity: %w", err)
}
} else {
// Known identity — refresh display name and avatar from the provider
// in case the user has updated their profile since last login.
user.Name = identity.Name
// Known identity — refresh avatar from the provider but preserve the
// user-chosen display name (editable via profile settings).
user.AvatarURL = identity.AvatarURL
if err := h.Redis.UpsertUser(ctx, *user); err != nil {
return fmt.Errorf("upsert user: %w", err)
Expand Down
Loading
Loading