Files
at-container-registry/pkg/appview/handlers/settings.go
T

501 lines
15 KiB
Go

package handlers
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"log/slog"
"net/http"
"net/url"
"strings"
"time"
"atcr.io/pkg/appview/db"
"atcr.io/pkg/appview/middleware"
"atcr.io/pkg/appview/storage"
"atcr.io/pkg/appview/webhooks"
"atcr.io/pkg/atproto"
"github.com/bluesky-social/indigo/atproto/syntax"
)
// HoldDisplay represents a hold for display in the UI
type HoldDisplay struct {
DID string `json:"did"`
DisplayName string `json:"displayName"`
Region string `json:"region"`
Membership string `json:"membership"`
Permissions []string `json:"permissions,omitempty"`
Status string `json:"status"` // "" = unknown, "online", "offline"
IsActive bool `json:"isActive"`
}
// SettingsHandler handles the settings page
type SettingsHandler struct {
BaseUIHandler
}
func (h *SettingsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
user := middleware.GetUser(r)
if user == nil {
http.Redirect(w, r, "/auth/oauth/login?return_to=/settings", http.StatusFound)
return
}
// Create ATProto client with session provider (uses DoWithSession for DPoP nonce safety)
client := atproto.NewClientWithSessionProvider(user.PDSEndpoint, user.DID, h.Refresher)
// Fetch sailor profile
profile, err := storage.GetProfile(r.Context(), client)
if err != nil {
// Error fetching profile - log out user
slog.Warn("Failed to fetch profile, logging out", "component", "settings", "did", user.DID, "error", err)
http.Redirect(w, r, "/auth/logout", http.StatusFound)
return
}
if profile == nil {
// Profile doesn't exist yet (404) - user needs to log out and back in to create it
slog.Warn("Profile doesn't exist, logging out", "component", "settings", "did", user.DID)
http.Redirect(w, r, "/auth/logout", http.StatusFound)
return
}
slog.Debug("Fetched profile", "component", "settings", "did", user.DID, "default_hold", profile.DefaultHold)
// Get available holds
var activeHold *HoldDisplay
var otherHolds, allHolds []HoldDisplay
if h.DB != nil {
availableHolds, err := db.GetAvailableHolds(h.DB, user.DID)
if err != nil {
slog.Warn("Failed to get available holds", "component", "settings", "did", user.DID, "error", err)
} else {
for _, hold := range availableHolds {
display := HoldDisplay{
DID: hold.HoldDID,
DisplayName: resolveHoldDisplayName(r.Context(), &h.BaseUIHandler, hold.HoldDID),
Region: hold.Region,
Membership: hold.Membership,
IsActive: hold.HoldDID == profile.DefaultHold,
}
// Parse permissions JSON if present
if hold.Permissions != "" {
if err := json.Unmarshal([]byte(hold.Permissions), &display.Permissions); err != nil {
slog.Warn("Failed to parse permissions JSON", "component", "settings", "did", user.DID, "hold_did", hold.HoldDID, "error", err)
}
}
// Check health status (uses cache if available, otherwise pings on-demand)
if h.HealthChecker != nil {
if status := h.HealthChecker.GetStatus(r.Context(), hold.HoldDID); status != nil {
if status.Reachable {
display.Status = "online"
} else {
display.Status = "offline"
}
}
}
// All holds go in dropdown list
allHolds = append(allHolds, display)
// Separate active from other member holds (skip eligible)
if hold.Membership != "eligible" {
if display.IsActive {
holdCopy := display
activeHold = &holdCopy
} else {
otherHolds = append(otherHolds, display)
}
}
}
}
}
// Fetch webhooks (local DB read)
webhooksData := h.buildWebhooksData(user.DID)
// Fetch subscription info (Stripe with in-memory cache)
subscriptionData := h.buildSubscriptionDisplay(user.DID)
meta := NewPageMeta(
"Settings - "+h.ClientShortName,
"Manage your "+h.ClientShortName+" account settings, authorized devices, and storage preferences",
).WithRobots("noindex").
WithSiteName(h.ClientShortName)
data := struct {
PageData
Meta *PageMeta
Profile struct {
Handle string
DID string
PDSEndpoint string
DefaultHold string
AutoRemoveUntagged bool
}
ActiveHold *HoldDisplay
OtherHolds []HoldDisplay
AllHolds []HoldDisplay
WebhooksData webhooksTemplateData
Subscription SubscriptionDisplay
}{
PageData: NewPageData(r, &h.BaseUIHandler),
Meta: meta,
ActiveHold: activeHold,
OtherHolds: otherHolds,
AllHolds: allHolds,
WebhooksData: webhooksData,
Subscription: subscriptionData,
}
data.Profile.Handle = user.Handle
data.Profile.DID = user.DID
data.Profile.PDSEndpoint = user.PDSEndpoint
data.Profile.DefaultHold = profile.DefaultHold
data.Profile.AutoRemoveUntagged = profile.AutoRemoveUntagged
if err := h.Templates.ExecuteTemplate(w, "settings", data); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
}
// webhooksTemplateData is the data passed to the webhooks_list template.
type webhooksTemplateData struct {
Webhooks []webhookEntry
Limits webhookLimits
ContainerID string
TriggerInfo []triggerInfo
}
// buildWebhooksData fetches webhook data for SSR in the settings page.
func (h *SettingsHandler) buildWebhooksData(userDID string) webhooksTemplateData {
data := webhooksTemplateData{
ContainerID: "webhooks-content",
TriggerInfo: webhookTriggerInfo(),
}
data.Limits = h.getWebhookLimits(userDID)
webhookList, err := db.ListWebhooks(h.ReadOnlyDB, userDID)
if err != nil {
slog.Warn("Failed to list webhooks for settings SSR", "error", err)
return data
}
data.Webhooks = make([]webhookEntry, len(webhookList))
for i, wh := range webhookList {
data.Webhooks[i] = webhookEntry{
ID: wh.ID,
Triggers: wh.Triggers,
URL: wh.URL,
HasSecret: wh.HasSecret,
CreatedAt: wh.CreatedAt.Format(time.RFC3339),
HasFirst: wh.Triggers&webhooks.TriggerFirst != 0,
HasAll: wh.Triggers&webhooks.TriggerAll != 0,
HasChanged: wh.Triggers&webhooks.TriggerChanged != 0,
}
}
return data
}
// buildSubscriptionDisplay fetches subscription info for SSR in the settings page.
func (h *SettingsHandler) buildSubscriptionDisplay(userDID string) SubscriptionDisplay {
if h.BillingManager == nil || !h.BillingManager.Enabled() {
return SubscriptionDisplay{HideBilling: true}
}
info, err := h.BillingManager.GetSubscriptionInfo(userDID)
if err != nil {
slog.Warn("Failed to get subscription info for settings SSR", "did", userDID, "error", err)
return SubscriptionDisplay{HideBilling: true}
}
if !info.PaymentsEnabled {
return SubscriptionDisplay{HideBilling: true}
}
display := SubscriptionDisplay{
UserDID: info.UserDID,
CurrentTier: info.CurrentTier,
PaymentsEnabled: info.PaymentsEnabled,
SubscriptionID: info.SubscriptionID,
BillingInterval: info.BillingInterval,
}
for _, tier := range info.Tiers {
td := TierDisplay{
ID: tier.ID,
Name: tier.Name,
Description: tier.Description,
Features: tier.Features,
PriceCentsMonthly: tier.PriceCentsMonthly,
PriceCentsYearly: tier.PriceCentsYearly,
IsCurrent: tier.IsCurrent,
}
if tier.PriceCentsMonthly > 0 {
td.PriceMonthly = fmt.Sprintf("$%d/mo", tier.PriceCentsMonthly/100)
}
if tier.PriceCentsYearly > 0 {
td.PriceYearly = fmt.Sprintf("$%d/yr", tier.PriceCentsYearly/100)
}
display.Tiers = append(display.Tiers, td)
}
return display
}
// resolveHoldDisplayName resolves a hold DID to a human-readable handle via the
// identity directory. Falls back to domain extraction (did:web) or truncation (did:plc).
func resolveHoldDisplayName(ctx context.Context, h *BaseUIHandler, did string) string {
if did == "" {
return ""
}
// Try resolving via identity directory
if h.Directory != nil {
parsed, err := syntax.ParseDID(did)
if err == nil {
ident, err := h.Directory.LookupDID(ctx, parsed)
if err == nil && ident.Handle.String() != "handle.invalid" && ident.Handle.String() != "" {
return ident.Handle.String()
}
}
}
// Fallback: extract domain from did:web
if strings.HasPrefix(did, "did:web:") {
domain := strings.TrimPrefix(did, "did:web:")
if decoded, err := url.QueryUnescape(domain); err == nil {
return decoded
}
return domain
}
// Fallback: truncate did:plc
if len(did) > 24 {
return did[:24] + "..."
}
return did
}
// UpdateDefaultHoldHandler handles updating the default hold
type UpdateDefaultHoldHandler struct {
BaseUIHandler
}
func (h *UpdateDefaultHoldHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
user := middleware.GetUser(r)
if user == nil {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
// Accept hold_did (new dropdown) or hold_endpoint (legacy text input)
holdDID := r.FormValue("hold_did")
if holdDID == "" {
holdDID = r.FormValue("hold_endpoint")
}
// Normalize did:web encoding (form URL-decoding can strip %3A → colon)
holdDID = atproto.NormalizeDID(holdDID)
// Validate hold DID if provided and database is available
if holdDID != "" && h.DB != nil {
// Check if user has access to this hold
availableHolds, err := db.GetAvailableHolds(h.DB, user.DID)
if err != nil {
slog.Warn("Failed to validate hold access", "component", "settings", "did", user.DID, "error", err)
// Don't block - fall through to allow the update
} else {
hasAccess := false
for _, hold := range availableHolds {
if hold.HoldDID == holdDID {
hasAccess = true
break
}
}
if !hasAccess {
w.Header().Set("Content-Type", "text/html")
if err := h.Templates.ExecuteTemplate(w, "alert", map[string]string{
"Type": "error",
"Message": "You don't have access to this hold",
}); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
return
}
}
}
// Create ATProto client with session provider (uses DoWithSession for DPoP nonce safety)
client := atproto.NewClientWithSessionProvider(user.PDSEndpoint, user.DID, h.Refresher)
// Fetch existing profile or create new one
profile, err := storage.GetProfile(r.Context(), client)
if err != nil || profile == nil {
// Profile doesn't exist, create new one
profile = atproto.NewSailorProfileRecord(holdDID)
} else {
// Update existing profile
profile.DefaultHold = holdDID
profile.UpdatedAt = time.Now()
}
// Save profile
if err := storage.UpdateProfile(r.Context(), client, profile); err != nil {
http.Error(w, "Failed to update profile: "+err.Error(), http.StatusInternalServerError)
return
}
// Cache default hold DID locally (don't wait for Jetstream roundtrip)
if h.DB != nil {
_ = db.UpdateUserDefaultHold(h.DB, user.DID, holdDID)
// Ensure crew membership on the new hold (auto-registers on open holds)
// and refresh captain/crew cache so badge tiers are available immediately
if holdDID != "" {
go func() {
storage.EnsureCrewMembership(
context.Background(), client, h.Refresher,
holdDID, middleware.GetGlobalAuthorizer(),
)
refreshCaptainRecord(holdDID, h.DB)
refreshCrewMembership(holdDID, user.DID, h.DB)
}()
}
}
w.Header().Set("HX-Refresh", "true")
w.Header().Set("Content-Type", "text/html")
if err := h.Templates.ExecuteTemplate(w, "alert", map[string]string{
"Type": "success",
"Message": "Default hold updated successfully!",
}); err != nil {
slog.Warn("Failed to render alert", "error", err)
}
}
// UpdateAutoRemoveUntaggedHandler handles toggling the auto-remove-untagged setting
type UpdateAutoRemoveUntaggedHandler struct {
BaseUIHandler
}
func (h *UpdateAutoRemoveUntaggedHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
user := middleware.GetUser(r)
if user == nil {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
// Create ATProto client with session provider
client := atproto.NewClientWithSessionProvider(user.PDSEndpoint, user.DID, h.Refresher)
// Fetch existing profile
profile, err := storage.GetProfile(r.Context(), client)
if err != nil || profile == nil {
http.Error(w, "Failed to fetch profile", http.StatusInternalServerError)
return
}
// Toggle the setting (checkbox sends value when checked, absent when unchecked)
profile.AutoRemoveUntagged = !profile.AutoRemoveUntagged
profile.UpdatedAt = time.Now()
if err := storage.UpdateProfile(r.Context(), client, profile); err != nil {
http.Error(w, "Failed to update profile: "+err.Error(), http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNoContent)
}
// refreshCaptainRecord fetches a hold's captain record via XRPC and caches it locally.
// This ensures badge tiers and other captain metadata are available immediately
// without waiting for Jetstream or the next backfill cycle.
func refreshCaptainRecord(holdDID string, dbConn *sql.DB) {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
holdURL, err := atproto.ResolveHoldURL(ctx, holdDID)
if err != nil {
slog.Debug("Failed to resolve hold URL for captain refresh", "hold_did", holdDID, "error", err)
return
}
holdClient := atproto.NewClient(holdURL, holdDID, "")
record, err := holdClient.GetRecord(ctx, "io.atcr.hold.captain", "self")
if err != nil {
slog.Debug("Failed to fetch captain record for refresh", "hold_did", holdDID, "error", err)
return
}
var captainRecord db.HoldCaptainRecord
if err := json.Unmarshal(record.Value, &captainRecord); err != nil {
slog.Debug("Failed to parse captain record for refresh", "hold_did", holdDID, "error", err)
return
}
captainRecord.HoldDID = holdDID
captainRecord.UpdatedAt = time.Now()
if err := db.UpsertCaptainRecord(dbConn, &captainRecord); err != nil {
slog.Debug("Failed to cache captain record on refresh", "hold_did", holdDID, "error", err)
return
}
slog.Info("Refreshed captain record for hold", "hold_did", holdDID)
}
// refreshCrewMembership fetches a user's crew record from a hold and caches it locally.
// Uses the deterministic rkey to do a direct O(1) lookup.
func refreshCrewMembership(holdDID, userDID string, dbConn *sql.DB) {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
holdURL, err := atproto.ResolveHoldURL(ctx, holdDID)
if err != nil {
slog.Debug("Failed to resolve hold URL for crew refresh", "hold_did", holdDID, "error", err)
return
}
rkey := atproto.CrewRecordKey(userDID)
holdClient := atproto.NewClient(holdURL, holdDID, "")
record, err := holdClient.GetRecord(ctx, atproto.CrewCollection, rkey)
if err != nil {
slog.Debug("No crew record found for user on hold", "hold_did", holdDID, "user_did", userDID, "error", err)
return
}
var crewRecord atproto.CrewRecord
if err := json.Unmarshal(record.Value, &crewRecord); err != nil {
slog.Debug("Failed to parse crew record for refresh", "hold_did", holdDID, "error", err)
return
}
permJSON, _ := json.Marshal(crewRecord.Permissions)
member := &db.CrewMember{
HoldDID: holdDID,
MemberDID: crewRecord.Member,
Rkey: rkey,
Role: crewRecord.Role,
Permissions: string(permJSON),
Tier: crewRecord.Tier,
AddedAt: crewRecord.AddedAt,
}
if err := db.UpsertCrewMember(dbConn, member); err != nil {
slog.Debug("Failed to cache crew membership on refresh", "hold_did", holdDID, "user_did", userDID, "error", err)
return
}
slog.Info("Refreshed crew membership for user on hold", "hold_did", holdDID, "user_did", userDID, "tier", crewRecord.Tier)
}