mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-08-30 12:46:57 +00:00
263 lines
8.0 KiB
Go
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)
|
|
}
|
|
}
|