From 2b9ea997acf529b7d72828dfcb3064a32a3102f7 Mon Sep 17 00:00:00 2001 From: Evan Jarrett Date: Sun, 22 Feb 2026 11:16:55 -0600 Subject: [PATCH] fix tier and supporter badge assignments. normalize did:web adresses with ports. various minor fixes --- config-hold.example.yaml | 73 +++++++------- deploy/upcloud/configs/hold.yaml.tmpl | 30 +++--- docker-compose.yml | 3 +- pkg/appview/db/hold_store.go | 49 ++++++++-- pkg/appview/handlers/settings.go | 72 +++++++++++++- pkg/appview/handlers/user.go | 11 ++- pkg/appview/handlers/webhooks.go | 1 - pkg/appview/src/css/main.css | 6 +- pkg/appview/templates/pages/settings.html | 24 +++-- .../templates/partials/webhooks_list.html | 2 +- pkg/atproto/lexicon.go | 12 +-- pkg/atproto/relays.go | 1 + pkg/atproto/resolver.go | 15 +++ pkg/auth/holdlocal/holdlocal_test.go | 8 +- pkg/hold/admin/handlers_crew.go | 34 ++----- pkg/hold/admin/handlers_crew_io.go | 16 ++-- pkg/hold/config.go | 8 +- pkg/hold/pds/auth_test.go | 20 ++-- pkg/hold/pds/crew.go | 3 +- pkg/hold/pds/crew_test.go | 16 ++-- pkg/hold/pds/layer_test.go | 14 +-- pkg/hold/pds/server.go | 2 +- pkg/hold/pds/server_test.go | 4 +- pkg/hold/pds/webhooks.go | 61 ++++++++---- pkg/hold/pds/xrpc.go | 11 ++- pkg/hold/pds/xrpc_test.go | 18 ++-- pkg/hold/quota/config.go | 66 ++++++++----- pkg/hold/quota/config_test.go | 96 ++++++++++++++----- 28 files changed, 446 insertions(+), 230 deletions(-) diff --git a/config-hold.example.yaml b/config-hold.example.yaml index 759b043..857fbb7 100644 --- a/config-hold.example.yaml +++ b/config-hold.example.yaml @@ -97,47 +97,50 @@ gc: enabled: false # Storage quota tiers. Empty disables quota enforcement. quota: - # Quota tiers keyed by rank name. Each tier has a human-readable quota limit. + # Quota tiers ordered by rank (lowest to highest). Position determines rank. tiers: - bosun: - # Storage quota limit (e.g. "5GB", "50GB", "1TB"). - quota: 50GB - # Trigger vulnerability scan immediately on push. When false, images are still scanned by background scheduling. - scan_on_push: true - # Maximum webhook URLs (0=none, -1=unlimited). Default: 1. - max_webhooks: 5 - # Allow all webhook trigger types. Free tiers only get scan:first. - webhook_all_triggers: true - # Show supporter badge on user profiles for members at this tier. - supporter_badge: true - deckhand: - # Storage quota limit (e.g. "5GB", "50GB", "1TB"). - quota: 5GB - # Trigger vulnerability scan immediately on push. When false, images are still scanned by background scheduling. - scan_on_push: false - # Maximum webhook URLs (0=none, -1=unlimited). Default: 1. - max_webhooks: 1 - # Allow all webhook trigger types. Free tiers only get scan:first. - webhook_all_triggers: false - # Show supporter badge on user profiles for members at this tier. - supporter_badge: true - quartermaster: - # Storage quota limit (e.g. "5GB", "50GB", "1TB"). - quota: 100GB - # Trigger vulnerability scan immediately on push. When false, images are still scanned by background scheduling. - scan_on_push: true - # Maximum webhook URLs (0=none, -1=unlimited). Default: 1. - max_webhooks: -1 - # Allow all webhook trigger types. Free tiers only get scan:first. - webhook_all_triggers: true - # Show supporter badge on user profiles for members at this tier. - supporter_badge: true + - # Tier name used as the key for crew assignments. + name: deckhand + # Storage quota limit (e.g. "5GB", "50GB", "1TB"). + quota: 5GB + # Trigger vulnerability scan immediately on push. When false, images are still scanned by background scheduling. + scan_on_push: false + # Maximum webhook URLs (0=none, -1=unlimited). Default: 1. + max_webhooks: 1 + # Allow all webhook trigger types. Free tiers only get scan:first. + webhook_all_triggers: false + # Show supporter badge on user profiles for members at this tier. + supporter_badge: true + - # Tier name used as the key for crew assignments. + name: bosun + # Storage quota limit (e.g. "5GB", "50GB", "1TB"). + quota: 50GB + # Trigger vulnerability scan immediately on push. When false, images are still scanned by background scheduling. + scan_on_push: true + # Maximum webhook URLs (0=none, -1=unlimited). Default: 1. + max_webhooks: 5 + # Allow all webhook trigger types. Free tiers only get scan:first. + webhook_all_triggers: true + # Show supporter badge on user profiles for members at this tier. + supporter_badge: true + - # Tier name used as the key for crew assignments. + name: quartermaster + # Storage quota limit (e.g. "5GB", "50GB", "1TB"). + quota: 100GB + # Trigger vulnerability scan immediately on push. When false, images are still scanned by background scheduling. + scan_on_push: true + # Maximum webhook URLs (0=none, -1=unlimited). Default: 1. + max_webhooks: -1 + # Allow all webhook trigger types. Free tiers only get scan:first. + webhook_all_triggers: true + # Show supporter badge on user profiles for members at this tier. + supporter_badge: true # Default tier assignment for new crew members. defaults: # Tier assigned to new crew members who don't have an explicit tier. new_crew_tier: deckhand # Show supporter badge on the hold owner's profile. - owner_badge: false + owner_badge: true # Vulnerability scanner settings. Empty disables scanning. scanner: # Shared secret for scanner WebSocket auth. Empty disables scanning. diff --git a/deploy/upcloud/configs/hold.yaml.tmpl b/deploy/upcloud/configs/hold.yaml.tmpl index 136f8da..2457469 100644 --- a/deploy/upcloud/configs/hold.yaml.tmpl +++ b/deploy/upcloud/configs/hold.yaml.tmpl @@ -47,21 +47,21 @@ gc: enabled: false quota: tiers: - deckhand: - quota: 5GB - max_webhooks: 1 - bosun: - quota: 50GB - scan_on_push: true - max_webhooks: 5 - webhook_all_triggers: true - supporter_badge: true - quartermaster: - quota: 100GB - scan_on_push: true - max_webhooks: -1 - webhook_all_triggers: true - supporter_badge: true + - name: deckhand + quota: 5GB + max_webhooks: 1 + - name: bosun + quota: 50GB + scan_on_push: true + max_webhooks: 5 + webhook_all_triggers: true + supporter_badge: true + - name: quartermaster + quota: 100GB + scan_on_push: true + max_webhooks: -1 + webhook_all_triggers: true + supporter_badge: true defaults: new_crew_tier: deckhand owner_badge: true diff --git a/docker-compose.yml b/docker-compose.yml index 7b3d3a3..93d9607 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -48,10 +48,11 @@ services: atcr-hold: env_file: - - ../atcr-secrets.env # Load S3/Storj credentials from external file + - ../atcr-secrets.env # Load S3/Storj credentials from external file # Base config: config-hold.example.yaml (passed via Air entrypoint) # Env vars below override config file values for local dev environment: + HOLD_SCANNER_SECRET: dev-secret HOLD_SERVER_PUBLIC_URL: http://172.28.0.3:8080 HOLD_REGISTRATION_OWNER_DID: did:plc:pddp4xt5lgnv2qsegbzzs4xg HOLD_REGISTRATION_ALLOW_ALL_CREW: true diff --git a/pkg/appview/db/hold_store.go b/pkg/appview/db/hold_store.go index be245b3..9b5e8ff 100644 --- a/pkg/appview/db/hold_store.go +++ b/pkg/appview/db/hold_store.go @@ -26,9 +26,9 @@ type HoldCaptainRecord struct { AllowAllCrew bool `json:"allowAllCrew"` DeployedAt string `json:"deployedAt"` Region string `json:"region"` - Successor string `json:"successor"` // DID of successor hold (migration redirect) - SupporterBadgeTiers string `json:"-"` // JSON array of tier names, e.g. '["bosun","quartermaster"]' - UpdatedAt time.Time `json:"-"` // Set manually, not from JSON + Successor string `json:"successor"` // DID of successor hold (migration redirect) + SupporterBadgeTiers string `json:"-"` // JSON array of tier names, e.g. '["bosun","quartermaster"]' + UpdatedAt time.Time `json:"-"` // Set manually, not from JSON } // GetCaptainRecord retrieves a captain record from the cache @@ -135,6 +135,19 @@ func (r *HoldCaptainRecord) HasSupporterBadge(tier string) bool { return false } +// normalizeDidWeb ensures did:web DIDs use %3A encoding for port separators. +// This is a local copy to avoid importing atproto (prevents circular dependencies). +func normalizeDidWeb(did string) string { + if !strings.HasPrefix(did, "did:web:") { + return did + } + host := strings.TrimPrefix(did, "did:web:") + if !strings.Contains(host, "%3A") && strings.Contains(host, ":") { + host = strings.Replace(host, ":", "%3A", 1) + } + return "did:web:" + host +} + // GetSupporterBadge returns the supporter badge tier name for a user on a specific hold. // Returns empty string if the hold doesn't have badges, the user's tier isn't badge-eligible, // or the user isn't a member of the hold. @@ -143,17 +156,17 @@ func GetSupporterBadge(dbConn DBTX, userDID, holdDID string) string { return "" } + // Normalize did:web encoding for consistent comparison + holdDID = normalizeDidWeb(holdDID) + captain, err := GetCaptainRecord(dbConn, holdDID) if err != nil || captain == nil || captain.SupporterBadgeTiers == "" { return "" } - // Check if user is the captain (owner) - if captain.OwnerDID == userDID { - if captain.HasSupporterBadge("owner") { - return "owner" - } - return "" + // If user is the owner and "owner" badge is enabled, show it + if captain.OwnerDID == userDID && captain.HasSupporterBadge("owner") { + return "owner" } // Look up crew membership for this user on this hold @@ -163,7 +176,7 @@ func GetSupporterBadge(dbConn DBTX, userDID, holdDID string) string { } for _, m := range memberships { - if m.HoldDID == holdDID && m.Tier != "" { + if normalizeDidWeb(m.HoldDID) == holdDID && m.Tier != "" { if captain.HasSupporterBadge(m.Tier) { return m.Tier } @@ -174,6 +187,22 @@ func GetSupporterBadge(dbConn DBTX, userDID, holdDID string) string { return "" } +// GetCrewHoldDID returns the hold DID from the user's most recent crew membership. +// Used as a fallback when the user's DefaultHoldDID is not cached. +func GetCrewHoldDID(db DBTX, memberDID string) string { + var holdDID string + err := db.QueryRow(` + SELECT hold_did FROM hold_crew_members + WHERE member_did = ? + ORDER BY updated_at DESC + LIMIT 1 + `, memberDID).Scan(&holdDID) + if err != nil { + return "" + } + return holdDID +} + // ListHoldDIDs returns all known hold DIDs from the cache func ListHoldDIDs(db DBTX) ([]string, error) { query := ` diff --git a/pkg/appview/handlers/settings.go b/pkg/appview/handlers/settings.go index 994adab..d5ebe26 100644 --- a/pkg/appview/handlers/settings.go +++ b/pkg/appview/handlers/settings.go @@ -25,6 +25,7 @@ type HoldDisplay struct { Region string `json:"region"` Membership string `json:"membership"` Permissions []string `json:"permissions,omitempty"` + Status string `json:"status"` // "" = unknown, "online", "offline" } // SettingsHandler handles the settings page @@ -85,6 +86,17 @@ func (h *SettingsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { } } + // Check cached health status (non-blocking, nil = no data yet) + if h.HealthChecker != nil { + if status := h.HealthChecker.GetCachedStatus(hold.HoldDID); status != nil { + if status.Reachable { + display.Status = "online" + } else { + display.Status = "offline" + } + } + } + // Add to data map for JavaScript holdDataMap[hold.HoldDID] = display @@ -220,6 +232,9 @@ func (h *UpdateDefaultHoldHandler) ServeHTTP(w http.ResponseWriter, r *http.Requ 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 @@ -273,9 +288,17 @@ func (h *UpdateDefaultHoldHandler) ServeHTTP(w http.ResponseWriter, r *http.Requ if h.DB != nil { _ = db.UpdateUserDefaultHold(h.DB, user.DID, holdDID) - // Refresh captain record for the selected hold so badge tiers are available immediately + // 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 refreshCaptainRecord(holdDID, h.DB) + go func() { + storage.EnsureCrewMembership( + context.Background(), client, h.Refresher, + holdDID, middleware.GetGlobalAuthorizer(), + ) + refreshCaptainRecord(holdDID, h.DB) + refreshCrewMembership(holdDID, user.DID, h.DB) + }() } } @@ -334,3 +357,48 @@ func refreshCaptainRecord(holdDID string, dbConn *sql.DB) { slog.Info("Refreshed captain record for hold", "hold_did", holdDID, "badge_tiers", captainRecord.SupporterBadgeTiers) } + +// 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) +} diff --git a/pkg/appview/handlers/user.go b/pkg/appview/handlers/user.go index 4330502..bc77675 100644 --- a/pkg/appview/handlers/user.go +++ b/pkg/appview/handlers/user.go @@ -64,8 +64,15 @@ func (h *UserPageHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { // Check for supporter badge on user's default hold var supporterBadge string - if hasProfile && h.ReadOnlyDB != nil && viewedUser.DefaultHoldDID != "" { - supporterBadge = db.GetSupporterBadge(h.ReadOnlyDB, viewedUser.DID, viewedUser.DefaultHoldDID) + if h.ReadOnlyDB != nil { + holdDID := viewedUser.DefaultHoldDID + if holdDID == "" { + // Fallback: check if user has any crew membership + holdDID = db.GetCrewHoldDID(h.ReadOnlyDB, viewedUser.DID) + } + if holdDID != "" { + supporterBadge = db.GetSupporterBadge(h.ReadOnlyDB, viewedUser.DID, holdDID) + } } // Build page meta diff --git a/pkg/appview/handlers/webhooks.go b/pkg/appview/handlers/webhooks.go index b271784..67a9201 100644 --- a/pkg/appview/handlers/webhooks.go +++ b/pkg/appview/handlers/webhooks.go @@ -402,4 +402,3 @@ func (h *BaseUIHandler) renderAlert(w http.ResponseWriter, alertType, message st "Message": message, }) } - diff --git a/pkg/appview/src/css/main.css b/pkg/appview/src/css/main.css index 35549f0..b05fd59 100644 --- a/pkg/appview/src/css/main.css +++ b/pkg/appview/src/css/main.css @@ -410,10 +410,10 @@ @apply inline-flex items-stretch text-xs font-semibold leading-none; } .vuln-strip > span { - @apply px-2 py-1 min-w-[1.75rem] text-center cursor-pointer; + @apply px-2 py-1 min-w-7 text-center cursor-pointer; } - .vuln-strip > span:first-child { @apply rounded-l; } - .vuln-strip > span:last-child { @apply rounded-r; } + .vuln-strip > span:first-child { @apply rounded-l-sm; } + .vuln-strip > span:last-child { @apply rounded-r-sm; } .vuln-box-critical { background-color: oklch(45% 0.16 20); color: oklch(97% 0.01 20); } .vuln-box-high { background-color: oklch(58% 0.18 35); color: oklch(97% 0.01 35); } .vuln-box-medium { background-color: oklch(72% 0.15 70); color: oklch(25% 0.05 70); } diff --git a/pkg/appview/templates/pages/settings.html b/pkg/appview/templates/pages/settings.html index 1da917a..af7e9b8 100644 --- a/pkg/appview/templates/pages/settings.html +++ b/pkg/appview/templates/pages/settings.html @@ -155,8 +155,8 @@ {{ if .OwnedHolds }} {{ range .OwnedHolds }} - {{ end }} @@ -165,8 +165,8 @@ {{ if .CrewHolds }} {{ range .CrewHolds }} - {{ end }} @@ -175,8 +175,8 @@ {{ if .EligibleHolds }} {{ range .EligibleHolds }} - {{ end }} @@ -199,6 +199,8 @@
Region:
+
Status:
+
Your Access:
@@ -408,6 +410,16 @@ document.getElementById('hold-did').textContent = hold.did; document.getElementById('hold-region').textContent = hold.region || 'Unknown'; + // Set status badge + const statusEl = document.getElementById('hold-status-badge'); + if (hold.status === 'offline') { + statusEl.innerHTML = 'Offline'; + } else if (hold.status === 'online') { + statusEl.innerHTML = 'Online'; + } else { + statusEl.innerHTML = 'Unknown'; + } + // Set access level with badge const accessEl = document.getElementById('hold-access'); const accessLabel = { diff --git a/pkg/appview/templates/partials/webhooks_list.html b/pkg/appview/templates/partials/webhooks_list.html index a65b752..53547b3 100644 --- a/pkg/appview/templates/partials/webhooks_list.html +++ b/pkg/appview/templates/partials/webhooks_list.html @@ -28,7 +28,7 @@ Trigger Events
{{ range .TriggerInfo }} -