mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-09-01 15:56:58 +00:00
501 lines
15 KiB
Go
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)
|
|
}
|