mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-08-31 13:17:09 +00:00
338 lines
10 KiB
Go
338 lines
10 KiB
Go
package handlers
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"fmt"
|
|
"log/slog"
|
|
"net/http"
|
|
|
|
"atcr.io/pkg/appview/middleware"
|
|
"atcr.io/pkg/appview/storage"
|
|
"atcr.io/pkg/atproto"
|
|
"atcr.io/pkg/auth"
|
|
)
|
|
|
|
// SubscriptionInfo mirrors the hold's billing.SubscriptionInfo for JSON decoding.
|
|
type SubscriptionInfo struct {
|
|
UserDID string `json:"userDid"`
|
|
CurrentTier string `json:"currentTier"`
|
|
CrewTier string `json:"crewTier,omitempty"`
|
|
CurrentUsage int64 `json:"currentUsage"`
|
|
CurrentLimit *int64 `json:"currentLimit,omitempty"`
|
|
PaymentsEnabled bool `json:"paymentsEnabled"`
|
|
Tiers []TierInfo `json:"tiers"`
|
|
SubscriptionID string `json:"subscriptionId,omitempty"`
|
|
BillingInterval string `json:"billingInterval,omitempty"`
|
|
Error string `json:"error,omitempty"`
|
|
HideBilling bool `json:"-"` // hide entire section (no billing support)
|
|
HoldDisplayName string `json:"-"` // human-readable hold name for display
|
|
}
|
|
|
|
// TierInfo mirrors the hold's billing.TierInfo.
|
|
type TierInfo struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
Description string `json:"description,omitempty"`
|
|
QuotaBytes int64 `json:"quotaBytes"`
|
|
QuotaFormatted string `json:"quotaFormatted"`
|
|
PriceCentsMonthly int `json:"priceCentsMonthly,omitempty"`
|
|
PriceCentsYearly int `json:"priceCentsYearly,omitempty"`
|
|
PriceFormatted string `json:"-"` // computed in handler, e.g., "$5/month"
|
|
IsCurrent bool `json:"isCurrent,omitempty"`
|
|
}
|
|
|
|
// SubscriptionHandler returns subscription info as HTML for HTMX.
|
|
type SubscriptionHandler struct {
|
|
BaseUIHandler
|
|
}
|
|
|
|
func (h *SubscriptionHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|
user := middleware.GetUser(r)
|
|
if user == nil {
|
|
h.renderHidden(w)
|
|
return
|
|
}
|
|
|
|
// Use hold_did query param if provided (for previewing other holds),
|
|
// otherwise fall back to the user's saved default hold from their profile.
|
|
holdDID := r.URL.Query().Get("hold_did")
|
|
if holdDID == "" {
|
|
client := atproto.NewClientWithSessionProvider(user.PDSEndpoint, user.DID, h.Refresher)
|
|
profile, err := storage.GetProfile(r.Context(), client)
|
|
if err != nil {
|
|
slog.Warn("Failed to get profile for subscription", "did", user.DID, "error", err)
|
|
h.renderHidden(w)
|
|
return
|
|
}
|
|
holdDID = h.DefaultHoldDID
|
|
if profile != nil && profile.DefaultHold != "" {
|
|
holdDID = profile.DefaultHold
|
|
}
|
|
}
|
|
|
|
if holdDID == "" {
|
|
h.renderHidden(w)
|
|
return
|
|
}
|
|
|
|
// Resolve hold DID to endpoint
|
|
holdEndpoint := atproto.ResolveHoldURL(holdDID)
|
|
if holdEndpoint == "" {
|
|
slog.Warn("Failed to resolve hold endpoint", "holdDid", holdDID)
|
|
h.renderHidden(w)
|
|
return
|
|
}
|
|
|
|
// Fetch subscription info from hold (public endpoint, no auth needed)
|
|
subURL := fmt.Sprintf("%s/xrpc/io.atcr.hold.getSubscriptionInfo?userDid=%s", holdEndpoint, user.DID)
|
|
resp, err := http.Get(subURL)
|
|
if err != nil {
|
|
slog.Warn("Failed to fetch subscription info", "url", subURL, "error", err)
|
|
h.renderHidden(w)
|
|
return
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
slog.Warn("Hold returned error for subscription", "status", resp.StatusCode)
|
|
h.renderHidden(w)
|
|
return
|
|
}
|
|
|
|
var info SubscriptionInfo
|
|
if err := json.NewDecoder(resp.Body).Decode(&info); err != nil {
|
|
slog.Warn("Failed to decode subscription info", "error", err)
|
|
h.renderHidden(w)
|
|
return
|
|
}
|
|
|
|
if !info.PaymentsEnabled {
|
|
h.renderHidden(w)
|
|
return
|
|
}
|
|
|
|
// Set hold display name so users know which hold the subscription applies to
|
|
info.HoldDisplayName = deriveDisplayName(holdDID)
|
|
|
|
// Format prices for display
|
|
// Note: -1 means "has price, fetch from Stripe" (placeholder from hold)
|
|
for i := range info.Tiers {
|
|
tier := &info.Tiers[i]
|
|
hasMonthly := tier.PriceCentsMonthly != 0
|
|
hasYearly := tier.PriceCentsYearly != 0
|
|
|
|
switch {
|
|
case hasMonthly && tier.PriceCentsMonthly > 0:
|
|
tier.PriceFormatted = fmt.Sprintf("$%d/month", tier.PriceCentsMonthly/100)
|
|
case hasYearly && tier.PriceCentsYearly > 0:
|
|
tier.PriceFormatted = fmt.Sprintf("$%d/year", tier.PriceCentsYearly/100)
|
|
case hasMonthly || hasYearly:
|
|
// Has price but we don't know the amount (-1 sentinel)
|
|
tier.PriceFormatted = "Paid"
|
|
default:
|
|
tier.PriceFormatted = "Free"
|
|
}
|
|
}
|
|
|
|
// Render the subscription info
|
|
h.renderInfo(w, info)
|
|
}
|
|
|
|
func (h *SubscriptionHandler) renderInfo(w http.ResponseWriter, info SubscriptionInfo) {
|
|
w.Header().Set("Content-Type", "text/html")
|
|
if err := h.Templates.ExecuteTemplate(w, "subscription_info", info); err != nil {
|
|
slog.Error("Failed to render subscription template", "error", err)
|
|
h.renderError(w, "Failed to render template")
|
|
}
|
|
}
|
|
|
|
func (h *SubscriptionHandler) renderHidden(w http.ResponseWriter) {
|
|
w.Header().Set("Content-Type", "text/html")
|
|
info := SubscriptionInfo{HideBilling: true}
|
|
if err := h.Templates.ExecuteTemplate(w, "subscription_info", info); err != nil {
|
|
slog.Error("Failed to render hidden subscription template", "error", err)
|
|
}
|
|
}
|
|
|
|
func (h *SubscriptionHandler) renderError(w http.ResponseWriter, message string) {
|
|
w.Header().Set("Content-Type", "text/html")
|
|
fmt.Fprintf(w, `<div class="alert alert-error"><svg class="icon size-5" aria-hidden="true"><use href="/icons.svg#alert-circle"></use></svg> %s</div>`, message)
|
|
}
|
|
|
|
// SubscriptionCheckoutHandler redirects to hold's Stripe checkout.
|
|
type SubscriptionCheckoutHandler struct {
|
|
BaseUIHandler
|
|
}
|
|
|
|
func (h *SubscriptionCheckoutHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|
user := middleware.GetUser(r)
|
|
if user == nil {
|
|
http.Redirect(w, r, "/auth/oauth/login?return_to=/settings%23storage", http.StatusFound)
|
|
return
|
|
}
|
|
|
|
tier := r.URL.Query().Get("tier")
|
|
if tier == "" {
|
|
http.Error(w, "tier parameter required", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// Get user's default hold
|
|
client := atproto.NewClientWithSessionProvider(user.PDSEndpoint, user.DID, h.Refresher)
|
|
profile, err := storage.GetProfile(r.Context(), client)
|
|
if err != nil {
|
|
http.Error(w, "Failed to load profile", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
holdDID := h.DefaultHoldDID
|
|
if profile != nil && profile.DefaultHold != "" {
|
|
holdDID = profile.DefaultHold
|
|
}
|
|
|
|
if holdDID == "" {
|
|
http.Error(w, "No default hold configured", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// Resolve hold endpoint
|
|
holdEndpoint := atproto.ResolveHoldURL(holdDID)
|
|
if holdEndpoint == "" {
|
|
http.Error(w, "Failed to resolve hold", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// Get service token for the hold
|
|
serviceToken, err := auth.GetOrFetchServiceToken(r.Context(), h.Refresher, user.DID, holdDID, user.PDSEndpoint)
|
|
if err != nil {
|
|
slog.Warn("Failed to get service token for checkout", "did", user.DID, "holdDid", holdDID, "error", err)
|
|
http.Error(w, "Failed to authenticate with hold", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// Call hold's checkout endpoint
|
|
checkoutURL := fmt.Sprintf("%s/xrpc/io.atcr.hold.createCheckoutSession", holdEndpoint)
|
|
reqBody := map[string]string{
|
|
"tier": tier,
|
|
"returnUrl": h.SiteURL + "/settings#storage",
|
|
}
|
|
bodyBytes, _ := json.Marshal(reqBody)
|
|
|
|
req, err := http.NewRequestWithContext(r.Context(), "POST", checkoutURL, bytes.NewReader(bodyBytes))
|
|
if err != nil {
|
|
http.Error(w, "Failed to create request", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
req.Header.Set("Authorization", "Bearer "+serviceToken)
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req.Header.Set("X-User-DID", user.DID)
|
|
|
|
httpClient := &http.Client{}
|
|
resp, err := httpClient.Do(req)
|
|
if err != nil {
|
|
slog.Warn("Failed to call checkout endpoint", "error", err)
|
|
http.Error(w, "Failed to create checkout session", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
http.Error(w, "Hold returned error", resp.StatusCode)
|
|
return
|
|
}
|
|
|
|
var checkoutResp struct {
|
|
CheckoutURL string `json:"checkoutUrl"`
|
|
}
|
|
if err := json.NewDecoder(resp.Body).Decode(&checkoutResp); err != nil {
|
|
http.Error(w, "Invalid response from hold", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// Redirect to Stripe checkout
|
|
http.Redirect(w, r, checkoutResp.CheckoutURL, http.StatusFound)
|
|
}
|
|
|
|
// SubscriptionPortalHandler redirects to hold's Stripe billing portal.
|
|
type SubscriptionPortalHandler struct {
|
|
BaseUIHandler
|
|
}
|
|
|
|
func (h *SubscriptionPortalHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|
user := middleware.GetUser(r)
|
|
if user == nil {
|
|
http.Redirect(w, r, "/auth/oauth/login?return_to=/settings%23storage", http.StatusFound)
|
|
return
|
|
}
|
|
|
|
// Get user's default hold
|
|
client := atproto.NewClientWithSessionProvider(user.PDSEndpoint, user.DID, h.Refresher)
|
|
profile, err := storage.GetProfile(r.Context(), client)
|
|
if err != nil {
|
|
http.Error(w, "Failed to load profile", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
holdDID := h.DefaultHoldDID
|
|
if profile != nil && profile.DefaultHold != "" {
|
|
holdDID = profile.DefaultHold
|
|
}
|
|
|
|
if holdDID == "" {
|
|
http.Error(w, "No default hold configured", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
// Resolve hold endpoint
|
|
holdEndpoint := atproto.ResolveHoldURL(holdDID)
|
|
if holdEndpoint == "" {
|
|
http.Error(w, "Failed to resolve hold", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// Get service token
|
|
serviceToken, err := auth.GetOrFetchServiceToken(r.Context(), h.Refresher, user.DID, holdDID, user.PDSEndpoint)
|
|
if err != nil {
|
|
slog.Warn("Failed to get service token for portal", "did", user.DID, "holdDid", holdDID, "error", err)
|
|
http.Error(w, "Failed to authenticate with hold", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// Call hold's portal endpoint
|
|
portalURL := fmt.Sprintf("%s/xrpc/io.atcr.hold.getBillingPortalUrl?returnUrl=%s/settings%%23storage", holdEndpoint, h.SiteURL)
|
|
|
|
req, err := http.NewRequestWithContext(r.Context(), "GET", portalURL, nil)
|
|
if err != nil {
|
|
http.Error(w, "Failed to create request", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
req.Header.Set("Authorization", "Bearer "+serviceToken)
|
|
req.Header.Set("X-User-DID", user.DID)
|
|
|
|
httpClient := &http.Client{}
|
|
resp, err := httpClient.Do(req)
|
|
if err != nil {
|
|
slog.Warn("Failed to call portal endpoint", "error", err)
|
|
http.Error(w, "Failed to get billing portal", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
http.Error(w, "Hold returned error", resp.StatusCode)
|
|
return
|
|
}
|
|
|
|
var portalResp struct {
|
|
PortalURL string `json:"portalUrl"`
|
|
}
|
|
if err := json.NewDecoder(resp.Body).Decode(&portalResp); err != nil {
|
|
http.Error(w, "Invalid response from hold", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// Redirect to Stripe portal
|
|
http.Redirect(w, r, portalResp.PortalURL, http.StatusFound)
|
|
}
|