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

263 lines
8.0 KiB
Go

package handlers
import (
"encoding/json"
"html/template"
"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/atproto"
)
// 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"`
}
// 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 for dropdown
var ownedHolds, crewHolds, eligibleHolds []HoldDisplay
holdDataMap := make(map[string]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 {
// Group holds by membership type
for _, hold := range availableHolds {
display := HoldDisplay{
DID: hold.HoldDID,
DisplayName: deriveDisplayName(hold.HoldDID),
Region: hold.Region,
Membership: hold.Membership,
}
// 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)
}
}
// Add to data map for JavaScript
holdDataMap[hold.HoldDID] = display
// Group by membership type
switch hold.Membership {
case "owner":
ownedHolds = append(ownedHolds, display)
case "crew":
crewHolds = append(crewHolds, display)
case "eligible":
eligibleHolds = append(eligibleHolds, display)
}
}
}
}
// Serialize hold data for JavaScript
holdDataJSON, _ := json.Marshal(holdDataMap)
// Check if current hold needs to be shown separately (not in discovered holds)
_, currentHoldDiscovered := holdDataMap[profile.DefaultHold]
showCurrentHold := profile.DefaultHold != "" && !currentHoldDiscovered
// Look up AppView default hold details from database
appViewDefaultDisplay := deriveDisplayName(h.DefaultHoldDID)
var appViewDefaultRegion string
if h.DefaultHoldDID != "" && h.DB != nil {
if captain, err := db.GetCaptainRecord(h.DB, h.DefaultHoldDID); err == nil && captain != nil {
appViewDefaultRegion = captain.Region
}
}
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
}
CurrentHoldDID string
CurrentHoldDisplay string
ShowCurrentHold bool
AppViewDefaultHoldDID string
AppViewDefaultHoldDisplay string
AppViewDefaultRegion string
OwnedHolds []HoldDisplay
CrewHolds []HoldDisplay
EligibleHolds []HoldDisplay
HoldDataJSON template.JS
}{
PageData: NewPageData(r, &h.BaseUIHandler),
Meta: meta,
CurrentHoldDID: profile.DefaultHold,
CurrentHoldDisplay: deriveDisplayName(profile.DefaultHold),
ShowCurrentHold: showCurrentHold,
AppViewDefaultHoldDID: h.DefaultHoldDID,
AppViewDefaultHoldDisplay: appViewDefaultDisplay,
AppViewDefaultRegion: appViewDefaultRegion,
OwnedHolds: ownedHolds,
CrewHolds: crewHolds,
EligibleHolds: eligibleHolds,
HoldDataJSON: template.JS(holdDataJSON),
}
data.Profile.Handle = user.Handle
data.Profile.DID = user.DID
data.Profile.PDSEndpoint = user.PDSEndpoint
data.Profile.DefaultHold = profile.DefaultHold
if err := h.Templates.ExecuteTemplate(w, "settings", data); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
}
// deriveDisplayName derives a human-readable name from a hold DID
func deriveDisplayName(did string) string {
// For did:web, extract the domain
if strings.HasPrefix(did, "did:web:") {
domain := strings.TrimPrefix(did, "did:web:")
// URL-decode the domain (did:web encodes : as %3A)
decoded, err := url.QueryUnescape(domain)
if err == nil {
return decoded
}
return domain
}
// For did:plc, truncate for display
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")
}
// 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
}
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)
}
}