diff --git a/pkg/appview/handlers/legal.go b/pkg/appview/handlers/legal.go index c25478e..6243085 100644 --- a/pkg/appview/handlers/legal.go +++ b/pkg/appview/handlers/legal.go @@ -3,9 +3,15 @@ package handlers import ( "context" "net/http" + "sync" + "sync/atomic" "time" ) +// managedHoldResolveTimeout bounds the one-shot background resolution of the +// managed-hold display names. +const managedHoldResolveTimeout = 30 * time.Second + // LegalPageData contains data for legal pages (terms, privacy). type LegalPageData struct { PageData @@ -52,19 +58,54 @@ func formatLegalDate(raw string) string { // PrivacyPolicyHandler handles the /privacy page type PrivacyPolicyHandler struct { BaseUIHandler + + // holdNames caches the managed-hold display names; see managedHoldNames. + // The handler is constructed once at route registration and shared across + // requests, so this cache is process-wide. + holdNamesOnce sync.Once + holdNames atomic.Pointer[[]string] } -// resolveManagedHoldNames maps the configured managed-hold DIDs to friendly -// display names (handle, decoded did:web domain, or truncated did:plc) for -// listing on the privacy page. -func (h *PrivacyPolicyHandler) resolveManagedHoldNames(ctx context.Context) []string { - names := make([]string, 0, len(h.ManagedHolds)) - for _, did := range h.ManagedHolds { - if name := resolveHoldDisplayName(ctx, &h.BaseUIHandler, did); name != "" { - names = append(names, name) +// managedHoldNames returns display names for the configured managed holds, +// resolved once and cached. +// +// Resolution is deliberately off the render path. resolveHoldDisplayName makes +// up to two sequential network calls per DID (DID document, then handle +// verification), /privacy is public and unauthenticated, and the server sets no +// HTTP write timeout — so resolving inline let an unreachable hold or a +// plc.directory outage stall the page for tens of seconds for every visitor, +// recurring each time the identity directory's short negative-cache entry +// lapsed. The DIDs come from config and never change while the process runs, so +// one background pass is enough; until it lands the page renders the offline +// names, which are already correct for did:web holds. +func (h *PrivacyPolicyHandler) managedHoldNames() []string { + h.holdNamesOnce.Do(func() { + offline := make([]string, 0, len(h.ManagedHolds)) + for _, did := range h.ManagedHolds { + if name := holdDisplayNameOffline(did); name != "" { + offline = append(offline, name) + } } + h.holdNames.Store(&offline) + + go func() { + ctx, cancel := context.WithTimeout(context.Background(), managedHoldResolveTimeout) + defer cancel() + + names := make([]string, 0, len(h.ManagedHolds)) + for _, did := range h.ManagedHolds { + if name := resolveHoldDisplayName(ctx, &h.BaseUIHandler, did); name != "" { + names = append(names, name) + } + } + h.holdNames.Store(&names) + }() + }) + + if names := h.holdNames.Load(); names != nil { + return *names } - return names + return nil } func (h *PrivacyPolicyHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { @@ -81,7 +122,7 @@ func (h *PrivacyPolicyHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) CompanyName: company, Jurisdiction: jurisdiction, LastUpdated: formatLegalDate(privacyLastUpdated), - ManagedHolds: h.resolveManagedHoldNames(r.Context()), + ManagedHolds: h.managedHoldNames(), } if err := h.Templates.ExecuteTemplate(w, "privacy", data); err != nil { diff --git a/pkg/appview/handlers/settings.go b/pkg/appview/handlers/settings.go index c98db81..d53705a 100644 --- a/pkg/appview/handlers/settings.go +++ b/pkg/appview/handlers/settings.go @@ -347,8 +347,32 @@ func (h *SettingsHandler) buildSubscriptionDisplay(userDID string) SubscriptionD 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. Falls back to domain extraction (did:web) or truncation (did:plc). +// 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 "" @@ -365,20 +389,7 @@ func resolveHoldDisplayName(ctx context.Context, h *BaseUIHandler, did string) s } } - // Fallback: extract domain from did:web - if after, ok := strings.CutPrefix(did, "did:web:"); ok { - domain := after - 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 + return holdDisplayNameOffline(did) } // UpdateDefaultHoldHandler handles updating the default hold diff --git a/pkg/appview/templates/pages/privacy.html b/pkg/appview/templates/pages/privacy.html index 9ee835c..20c1653 100644 --- a/pkg/appview/templates/pages/privacy.html +++ b/pkg/appview/templates/pages/privacy.html @@ -67,7 +67,7 @@
  • Layer records in the hold's embedded PDS linking your DID to blob references
  • Crew membership records for access control
  • -

    Hold services on *.{{ .SiteURL }} domains are operated by us and covered by this policy.

    + {{ if .ManagedHolds }}

    The hold services listed above are the ones we operate, and they are the ones covered by this policy. Any other hold, including one you deploy yourself, is not.

    {{ else }}

    Hold services we operate are covered by this policy. Holds deployed by anyone else, including any you deploy yourself, are not.

    {{ end }}

    User-Deployed Hold Services (BYOS)

    You may use "Bring Your Own Storage" by deploying your own hold service. Data on user-deployed holds is governed by that operator's privacy policy, not ours. We can request deletion on your behalf but cannot guarantee it for services we do not control.