mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-08-30 20:57:01 +00:00
Entitlements were keyed on the Stripe subscription alone, so a subscriber
who switched to a self-hosted hold kept paying for features the appview
cannot deliver, and could still reach checkout.
- billing.ActiveHoldChecker and Manager.onManagedHold gate every
entitlement. An empty default hold counts as managed: the user has no
explicit preference and falls back to the operator's primary managed
hold.
- The checker reads the primary DB, not the read replica. A hold switch
writes default_hold_did to the primary, and replica lag would keep
paid features alive after a switch away.
- db.GetUserDefaultHoldDID is the clean default-hold signal, unlike
GetUserHoldDID which falls back to a manifest hold_endpoint (a URL,
not a DID).
- Jetstream fails closed: an unresolvable hold reference is cached raw
rather than left empty, since an empty value reads as managed.
- UI: the billing tab is hidden on self-hosted, a cancel/manage banner
appears when a self-hosted user still has an active plan, the image
advisor returns managed_hold_required instead of upgrade_required,
and the checkout route returns 403. The portal stays open so existing
subscribers can still cancel.
Two consistency fixes fall out of wiring this up:
The settings UI reads the resolved default_hold_did rather than the raw
profile.DefaultHold. The profile field is the record value as written and
may be a URL-form reference; jetstream resolves it to a DID on the way
into the DB, and the server-side gate reads that resolved value. Comparing
the raw form against managed DIDs would show the "you are self-hosted"
banner and hide billing from a user whose entitlements say otherwise.
HasAIAdvisor falls back to the free tier's AIAdvisor setting when
off-managed instead of a hard false, matching GetWebhookLimits. Losing a
managed hold should drop a user to free-tier entitlements, not below them.
BEHAVIOR CHANGE for existing paying users on self-hosted holds: they lose
the AI advisor, supporter badge and paid webhook limits as soon as this
deploys, while Stripe keeps charging them. The only notice is the banner
on /settings/storage, which they have to visit to see. Decide on a
migration (notification, or a one-time reconciliation over active
subscriptions) before shipping this.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
789 lines
25 KiB
Go
789 lines
25 KiB
Go
package handlers
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"fmt"
|
|
"log/slog"
|
|
"net/http"
|
|
"net/url"
|
|
"slices"
|
|
"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"
|
|
"atcr.io/pkg/auth"
|
|
|
|
"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"`
|
|
ReadOnly bool `json:"readOnly"` // crew member without blob:write — pushes would be rejected
|
|
Status string `json:"status"` // "" = unknown, "online", "offline"
|
|
IsActive bool `json:"isActive"`
|
|
}
|
|
|
|
// SettingsHandler handles the settings page — dispatches per-tab.
|
|
type SettingsHandler struct {
|
|
BaseUIHandler
|
|
}
|
|
|
|
// settingsTab describes a tab entry rendered in the tablist.
|
|
// Icons live in the template (settings.html) so the icon-sprite generator
|
|
// — which only scans templates and JS — can discover them.
|
|
type settingsTab struct {
|
|
Slug string
|
|
Label string
|
|
}
|
|
|
|
func settingsTabs(billingEnabled bool) []settingsTab {
|
|
tabs := []settingsTab{{Slug: "user", Label: "User"}}
|
|
if billingEnabled {
|
|
tabs = append(tabs, settingsTab{Slug: "billing", Label: "Billing"})
|
|
}
|
|
tabs = append(tabs,
|
|
settingsTab{Slug: "storage", Label: "Storage"},
|
|
settingsTab{Slug: "devices", Label: "Devices"},
|
|
settingsTab{Slug: "webhooks", Label: "Webhooks"},
|
|
settingsTab{Slug: "advanced", Label: "Advanced"},
|
|
)
|
|
return tabs
|
|
}
|
|
|
|
func isValidSettingsTab(tab string, billingEnabled bool) bool {
|
|
switch tab {
|
|
case "user", "storage", "devices", "webhooks", "advanced":
|
|
return true
|
|
case "billing":
|
|
return billingEnabled
|
|
}
|
|
return false
|
|
}
|
|
|
|
// settingsProfile is the sidebar identity info shared across all tabs.
|
|
type settingsProfile struct {
|
|
Handle string
|
|
DID string
|
|
PDSEndpoint string
|
|
DefaultHold string
|
|
AutoRemoveUntagged bool
|
|
OciClient string
|
|
RegistryDomain string
|
|
AIAdvisorEnabled bool
|
|
HasAIAdvisorAccess bool
|
|
}
|
|
|
|
// settingsPageData is the struct passed to the settings shell + panel templates.
|
|
// MemberHolds are holds where the user is already owner/crew; EligibleHolds
|
|
// are ones they can opt-in to join. Splitting them upstream keeps the
|
|
// hold_selector template from doing filter-the-same-list-twice gymnastics.
|
|
type settingsPageData struct {
|
|
PageData
|
|
Meta *PageMeta
|
|
ActiveTab string
|
|
Tabs []settingsTab
|
|
Profile settingsProfile
|
|
RegistryDomains []string
|
|
ActiveHold *HoldDisplay
|
|
OtherHolds []HoldDisplay
|
|
MemberHolds []HoldDisplay
|
|
EligibleHolds []HoldDisplay
|
|
WebhooksData webhooksTemplateData
|
|
Subscription SubscriptionDisplay
|
|
// SelfHostedHold is true when the user's active hold is self-hosted while the
|
|
// deployment has billing enabled — paid features (and billing) don't apply.
|
|
SelfHostedHold bool
|
|
// SelfHostedActivePlan is true when SelfHostedHold AND the user still has an
|
|
// active Stripe subscription, so they're being charged without paid features.
|
|
SelfHostedActivePlan bool
|
|
}
|
|
|
|
// ServeHTTP redirects /settings to /settings/user.
|
|
func (h *SettingsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|
http.Redirect(w, r, "/settings/user", http.StatusFound)
|
|
}
|
|
|
|
// ServeTab returns an http.Handler for a specific settings tab.
|
|
// If HX-Request is set, only the panel fragment is rendered.
|
|
func (h *SettingsHandler) ServeTab(tab string) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
if !isValidSettingsTab(tab, h.BillingEnabled) {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
|
|
user := middleware.GetUser(r)
|
|
if user == nil {
|
|
http.Redirect(w, r, "/auth/oauth/login?return_to=/settings/"+tab, http.StatusFound)
|
|
return
|
|
}
|
|
|
|
client := atproto.NewClientWithSessionProvider(user.PDSEndpoint, user.DID, h.Refresher)
|
|
|
|
profile, err := storage.GetProfile(r.Context(), client)
|
|
if err != nil {
|
|
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 {
|
|
slog.Warn("Profile doesn't exist, logging out", "component", "settings", "did", user.DID)
|
|
http.Redirect(w, r, "/auth/logout", http.StatusFound)
|
|
return
|
|
}
|
|
|
|
meta := NewPageMeta(
|
|
"Settings - "+h.ClientShortName,
|
|
"Manage your "+h.ClientShortName+" account settings, authorized devices, and storage preferences",
|
|
).WithRobots("noindex").
|
|
WithSiteName(h.ClientShortName)
|
|
|
|
// Billing is a managed-hold concept: hide it for users whose active hold
|
|
// is self-hosted (paid features require a managed hold). An unset default
|
|
// hold means the operator's primary managed hold, so it counts as managed.
|
|
//
|
|
// Read the resolved default_hold_did rather than profile.DefaultHold. The
|
|
// profile field is the raw record value and may be a URL-form reference;
|
|
// jetstream resolves it to a DID on the way into the DB, and the
|
|
// server-side entitlement gate reads that resolved value. Comparing the
|
|
// raw form here against managed DIDs would tell a managed user they are
|
|
// self-hosted while their entitlements say otherwise.
|
|
activeHoldDID := db.GetUserDefaultHoldDID(h.DB, user.DID)
|
|
showBilling := h.BillingEnabled && h.IsManagedHold(activeHoldDID)
|
|
|
|
data := settingsPageData{
|
|
PageData: NewPageData(r, &h.BaseUIHandler),
|
|
Meta: meta,
|
|
ActiveTab: tab,
|
|
Tabs: settingsTabs(showBilling),
|
|
Profile: settingsProfile{
|
|
Handle: user.Handle,
|
|
DID: user.DID,
|
|
PDSEndpoint: user.PDSEndpoint,
|
|
DefaultHold: profile.DefaultHold,
|
|
AutoRemoveUntagged: profile.AutoRemoveUntagged,
|
|
OciClient: profile.OciClient,
|
|
RegistryDomain: profile.RegistryDomain,
|
|
AIAdvisorEnabled: profile.AIAdvisorEnabled == nil || *profile.AIAdvisorEnabled,
|
|
},
|
|
RegistryDomains: h.RegistryDomains,
|
|
}
|
|
if h.BillingManager != nil {
|
|
data.Profile.HasAIAdvisorAccess = h.BillingManager.HasAIAdvisor(user.DID)
|
|
}
|
|
|
|
// Per-tab data fetch.
|
|
switch tab {
|
|
case "storage":
|
|
data.ActiveHold, data.OtherHolds, data.MemberHolds, data.EligibleHolds = h.buildHoldsData(r.Context(), user.DID, profile.DefaultHold)
|
|
// On a self-hosted hold, billing/paid features don't apply. If the
|
|
// user still has an active plan, surface a cancel/manage banner.
|
|
if h.BillingEnabled && !h.IsManagedHold(activeHoldDID) {
|
|
data.SelfHostedHold = true
|
|
if h.BillingManager != nil {
|
|
data.SelfHostedActivePlan = h.BillingManager.HasActiveSubscription(user.DID)
|
|
}
|
|
}
|
|
case "billing":
|
|
data.Subscription = h.buildSubscriptionDisplay(user.DID, activeHoldDID)
|
|
case "webhooks":
|
|
data.WebhooksData = h.buildWebhooksData(user.DID)
|
|
}
|
|
|
|
// htmx partial: render just the panel.
|
|
tmplName := "settings"
|
|
if r.Header.Get("HX-Request") == "true" {
|
|
tmplName = "settings-panel"
|
|
}
|
|
if err := h.Templates.ExecuteTemplate(w, tmplName, data); err != nil {
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
// buildHoldsData resolves the current user's holds for the storage tab.
|
|
// Returns: the currently-active hold (if any), non-active member holds, the
|
|
// full member-hold list (including active, for selector rendering), and
|
|
// eligible holds (the user could join but isn't yet a member of).
|
|
func (h *SettingsHandler) buildHoldsData(ctx context.Context, userDID, defaultHold string) (*HoldDisplay, []HoldDisplay, []HoldDisplay, []HoldDisplay) {
|
|
if h.DB == nil {
|
|
return nil, nil, nil, nil
|
|
}
|
|
|
|
availableHolds, err := db.GetAvailableHolds(h.DB, userDID)
|
|
if err != nil {
|
|
slog.Warn("Failed to get available holds", "component", "settings", "did", userDID, "error", err)
|
|
return nil, nil, nil, nil
|
|
}
|
|
|
|
var activeHold *HoldDisplay
|
|
var otherHolds, memberHolds, eligibleHolds []HoldDisplay
|
|
|
|
for _, hold := range availableHolds {
|
|
display := HoldDisplay{
|
|
DID: hold.HoldDID,
|
|
DisplayName: resolveHoldDisplayName(ctx, &h.BaseUIHandler, hold.HoldDID),
|
|
Region: hold.Region,
|
|
Membership: hold.Membership,
|
|
IsActive: hold.HoldDID == defaultHold,
|
|
}
|
|
|
|
if hold.Permissions != "" {
|
|
if err := json.Unmarshal([]byte(hold.Permissions), &display.Permissions); err != nil {
|
|
slog.Warn("Failed to parse permissions JSON", "component", "settings", "did", userDID, "hold_did", hold.HoldDID, "error", err)
|
|
}
|
|
}
|
|
|
|
// Owners hold all permissions implicitly; crew need blob:write to push.
|
|
display.ReadOnly = hold.Membership == "crew" && !slices.Contains(display.Permissions, "blob:write")
|
|
|
|
if h.HealthChecker != nil {
|
|
if status := h.HealthChecker.GetStatus(ctx, hold.HoldDID); status != nil {
|
|
if status.Reachable {
|
|
display.Status = "online"
|
|
} else {
|
|
display.Status = "offline"
|
|
}
|
|
}
|
|
}
|
|
|
|
if hold.Membership == "eligible" {
|
|
eligibleHolds = append(eligibleHolds, display)
|
|
continue
|
|
}
|
|
|
|
memberHolds = append(memberHolds, display)
|
|
if display.IsActive {
|
|
holdCopy := display
|
|
activeHold = &holdCopy
|
|
} else {
|
|
otherHolds = append(otherHolds, display)
|
|
}
|
|
}
|
|
|
|
return activeHold, otherHolds, memberHolds, eligibleHolds
|
|
}
|
|
|
|
// 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 {
|
|
flags := webhooks.TriggerFlags(wh.Triggers)
|
|
data.Webhooks[i] = webhookEntry{
|
|
ID: wh.ID,
|
|
Triggers: wh.Triggers,
|
|
URL: wh.URL,
|
|
HasSecret: wh.HasSecret,
|
|
CreatedAt: wh.CreatedAt.Format(time.RFC3339),
|
|
HasPush: flags&webhooks.TriggerPush != 0,
|
|
HasFirst: flags&webhooks.TriggerFirst != 0,
|
|
HasAll: flags&webhooks.TriggerAll != 0,
|
|
HasChanged: flags&webhooks.TriggerChanged != 0,
|
|
HasQuota: flags&webhooks.TriggerQuota != 0,
|
|
Threshold: webhooks.ThresholdPct(wh.Triggers),
|
|
}
|
|
}
|
|
|
|
return data
|
|
}
|
|
|
|
// buildSubscriptionDisplay fetches subscription info for SSR in the settings page.
|
|
// Billing is hidden for users whose active (default) hold is self-hosted: paid
|
|
// features require a managed hold.
|
|
func (h *SettingsHandler) buildSubscriptionDisplay(userDID, defaultHold string) SubscriptionDisplay {
|
|
if h.BillingManager == nil || !h.BillingManager.Enabled() {
|
|
return SubscriptionDisplay{HideBilling: true}
|
|
}
|
|
if !h.IsManagedHold(defaultHold) {
|
|
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 {
|
|
if tier.PriceCentsMonthly%100 == 0 {
|
|
td.PriceMonthly = fmt.Sprintf("$%d/mo", tier.PriceCentsMonthly/100)
|
|
} else {
|
|
td.PriceMonthly = fmt.Sprintf("$%.2f/mo", float64(tier.PriceCentsMonthly)/100.0)
|
|
}
|
|
}
|
|
if tier.PriceCentsYearly > 0 {
|
|
if tier.PriceCentsYearly%100 == 0 {
|
|
td.PriceYearly = fmt.Sprintf("$%d/yr", tier.PriceCentsYearly/100)
|
|
} else {
|
|
td.PriceYearly = fmt.Sprintf("$%.2f/yr", float64(tier.PriceCentsYearly)/100.0)
|
|
}
|
|
}
|
|
display.Tiers = append(display.Tiers, td)
|
|
}
|
|
|
|
return display
|
|
}
|
|
|
|
// holdDisplayNameOffline derives a display name for a hold DID without making
|
|
// any network call: the decoded domain for did:web, the DID itself otherwise.
|
|
// Returns "" for an empty DID.
|
|
//
|
|
// did:plc values are returned whole. This used to truncate them to 24 chars plus
|
|
// an ellipsis, which is shorter than a did:plc and therefore yields a string
|
|
// that cannot be resolved back to a hold — actively misleading anywhere the name
|
|
// stands in for the identity, such as the privacy page's list of operated
|
|
// services. Shortening for display is the template's job.
|
|
func holdDisplayNameOffline(did string) string {
|
|
if did == "" {
|
|
return ""
|
|
}
|
|
if after, ok := strings.CutPrefix(did, "did:web:"); ok {
|
|
if decoded, err := url.QueryUnescape(after); err == nil {
|
|
return decoded
|
|
}
|
|
return after
|
|
}
|
|
return did
|
|
}
|
|
|
|
// resolveHoldDisplayName resolves a hold DID to a human-readable handle via the
|
|
// identity directory, falling back to [holdDisplayNameOffline]. This makes up to
|
|
// two sequential network calls, so callers on a request path should cache the
|
|
// result rather than resolving per render.
|
|
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()
|
|
}
|
|
}
|
|
}
|
|
|
|
return holdDisplayNameOffline(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 {
|
|
// hx-swap="none" on the selector form means an inline alert
|
|
// would be discarded — route through RenderHTMXError so
|
|
// the client-side toast handler fires instead.
|
|
RenderHTMXError(w, r, http.StatusForbidden, "You don't have access to this hold", nil)
|
|
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 {
|
|
RenderHTMXError(w, r, http.StatusInternalServerError, "Couldn't update your default hold", err)
|
|
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(), user.DID, holdDID, middleware.GetGlobalAuthorizer(),
|
|
func(ctx context.Context, holdDID string) (string, error) {
|
|
return auth.GetOrFetchServiceToken(ctx, h.Refresher, user.DID, holdDID, user.PDSEndpoint)
|
|
},
|
|
)
|
|
refreshCaptainRecord(holdDID, h.DB)
|
|
refreshCrewMembership(holdDID, user.DID, h.DB)
|
|
}()
|
|
}
|
|
}
|
|
|
|
// Fire a success toast via HX-Trigger in addition to the HX-Refresh — the
|
|
// page reloads so the user sees the new hold applied, and the toast
|
|
// confirms the action took effect.
|
|
trigger, _ := json.Marshal(map[string]map[string]string{
|
|
"toast": {"message": "Default hold updated", "type": "success"},
|
|
})
|
|
w.Header().Set("HX-Trigger", string(trigger))
|
|
w.Header().Set("HX-Refresh", "true")
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
|
|
// validOciClients is the set of allowed OCI client values.
|
|
// "none" means "image reference only" — no `<client> pull ` prefix.
|
|
var validOciClients = map[string]bool{
|
|
"docker": true,
|
|
"podman": true,
|
|
"buildah": true,
|
|
"nerdctl": true,
|
|
"crane": true,
|
|
"none": true,
|
|
}
|
|
|
|
// UpdateOciClientHandler handles updating the preferred OCI client
|
|
type UpdateOciClientHandler struct {
|
|
BaseUIHandler
|
|
}
|
|
|
|
func (h *UpdateOciClientHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|
user := middleware.GetUser(r)
|
|
if user == nil {
|
|
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
|
|
ociClient := r.FormValue("oci_client")
|
|
if !validOciClients[ociClient] {
|
|
http.Error(w, "Invalid OCI client", http.StatusBadRequest)
|
|
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
|
|
}
|
|
|
|
// Update OCI client preference (store empty string for "docker" as it's the default)
|
|
if ociClient == "docker" {
|
|
profile.OciClient = ""
|
|
} else {
|
|
profile.OciClient = ociClient
|
|
}
|
|
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
|
|
}
|
|
|
|
// Cache locally
|
|
if h.DB != nil {
|
|
_ = db.UpdateUserOciClient(h.DB, user.DID, ociClient)
|
|
}
|
|
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
// UpdateRegistryDomainHandler handles updating the preferred registry domain
|
|
type UpdateRegistryDomainHandler struct {
|
|
BaseUIHandler
|
|
}
|
|
|
|
func (h *UpdateRegistryDomainHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|
user := middleware.GetUser(r)
|
|
if user == nil {
|
|
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
|
|
registryDomain := r.FormValue("registry_domain")
|
|
// Empty means "use the primary domain". Any non-empty value must be one of
|
|
// the configured registry domains.
|
|
if registryDomain != "" && !slices.Contains(h.RegistryDomains, registryDomain) {
|
|
http.Error(w, "Invalid registry domain", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// Store empty string when the primary (first configured) domain is selected,
|
|
// so the preference tracks the primary even if the admin reorders domains.
|
|
if len(h.RegistryDomains) > 0 && registryDomain == h.RegistryDomains[0] {
|
|
registryDomain = ""
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
profile.RegistryDomain = registryDomain
|
|
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
|
|
}
|
|
|
|
// Cache locally
|
|
if h.DB != nil {
|
|
_ = db.UpdateUserRegistryDomain(h.DB, user.DID, registryDomain)
|
|
}
|
|
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
// UpdateAIAdvisorHandler handles toggling the AI Image Advisor setting
|
|
type UpdateAIAdvisorHandler struct {
|
|
BaseUIHandler
|
|
}
|
|
|
|
func (h *UpdateAIAdvisorHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|
user := middleware.GetUser(r)
|
|
if user == nil {
|
|
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
|
|
client := atproto.NewClientWithSessionProvider(user.PDSEndpoint, user.DID, h.Refresher)
|
|
|
|
profile, err := storage.GetProfile(r.Context(), client)
|
|
if err != nil || profile == nil {
|
|
http.Error(w, "Failed to fetch profile", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// Toggle: nil/true → false, false → nil (default enabled)
|
|
if profile.AIAdvisorEnabled == nil || *profile.AIAdvisorEnabled {
|
|
f := false
|
|
profile.AIAdvisorEnabled = &f
|
|
} else {
|
|
profile.AIAdvisorEnabled = nil
|
|
}
|
|
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)
|
|
}
|