128 lines
4.8 KiB
Go
128 lines
4.8 KiB
Go
package httpapi
|
|
|
|
import (
|
|
"net/http"
|
|
"sort"
|
|
"strings"
|
|
|
|
"github.com/descrybe/descrybe-v2/apps/api/internal/billing"
|
|
)
|
|
|
|
// apiFeatureRoutes maps dashboard API path prefixes to the feature key that governs
|
|
// them, mirroring NAV_FEATURE_BY_HREF / GATED_ROUTES in
|
|
// apps/web/src/lib/plan-capabilities.ts. Longest prefix wins.
|
|
//
|
|
// Only surfaces an owner can actually restrict appear here: hiding a nav entry in the
|
|
// browser is cosmetic, so every hidden surface needs the matching API closed too.
|
|
var apiFeatureRoutes = map[string]string{
|
|
"/api/products": "catalog.products",
|
|
"/api/categories": "catalog.categories",
|
|
"/api/variables": "catalog.categories",
|
|
"/api/attributes": "catalog.attributes",
|
|
"/api/standard-fields": "catalog.standard_fields",
|
|
"/api/field-groups": "catalog.standard_fields",
|
|
"/api/structured-descriptions": "catalog.structured_descriptions",
|
|
"/api/vector-categories": "catalog.vector_categories",
|
|
"/api/feeds": "feeds.list",
|
|
"/api/export-feeds": "feeds.export_feeds",
|
|
"/api/files": "feeds.uploads",
|
|
"/api/processing": "processing.monitor",
|
|
"/api/woocommerce": "stores.woocommerce",
|
|
"/api/shopify": "stores.shopify",
|
|
"/api/campaigns": "marketing.campaigns",
|
|
"/api/marketing/calendar": "marketing.content_calendar",
|
|
"/api/seo": "marketing.seo",
|
|
"/api/brand": "marketing.brand_kit",
|
|
"/api/integrations/ai": "integrations.ai",
|
|
"/api/integrations/email": "integrations.email",
|
|
"/api/email/send": "integrations.email",
|
|
"/api/api-keys": "settings.api_keys",
|
|
"/api/team": "settings.team",
|
|
"/api/company": "settings.company",
|
|
"/api/support": "support.center",
|
|
"/api/billing/checkout": "billing.checkout",
|
|
"/api/billing/portal": "billing.customer_portal",
|
|
"/api/billing/usage": "billing.overview",
|
|
"/api/billing/plans": "billing.plans_compare",
|
|
"/api/billing/credit-packs": "billing.overview",
|
|
"/api/billing/stripe": "billing.overview",
|
|
}
|
|
|
|
// apiFeatureRoutePrefixes is apiFeatureRoutes' keys sorted longest-first so
|
|
// "/api/marketing/calendar" wins over a shorter overlapping prefix.
|
|
var apiFeatureRoutePrefixes = func() []string {
|
|
out := make([]string, 0, len(apiFeatureRoutes))
|
|
for prefix := range apiFeatureRoutes {
|
|
out = append(out, prefix)
|
|
}
|
|
sort.Slice(out, func(i, j int) bool { return len(out[i]) > len(out[j]) })
|
|
return out
|
|
}()
|
|
|
|
// featureKeyForAPIPath returns the governing feature key for a request path, or "".
|
|
func featureKeyForAPIPath(path string) string {
|
|
path = strings.TrimSuffix(path, "/")
|
|
for _, prefix := range apiFeatureRoutePrefixes {
|
|
if path == prefix || strings.HasPrefix(path, prefix+"/") || strings.HasPrefix(path, prefix+"?") {
|
|
return apiFeatureRoutes[prefix]
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// alwaysReachableAPIPaths stay open regardless of the overlay: the shell needs them to
|
|
// render at all, and a restricted member must still be able to read their own session,
|
|
// wallet and capability matrix (that matrix is what tells the UI what to hide).
|
|
var alwaysReachableAPIPaths = []string{
|
|
"/api/billing/credits",
|
|
"/api/billing/capabilities",
|
|
}
|
|
|
|
// RequireMemberFeature enforces the company owner's per-member access overlay on the
|
|
// dashboard API. Feature gating by plan stays where it is (billing.AssertFeature at the
|
|
// handler level); this middleware adds the per-member layer so a hidden nav entry is
|
|
// genuinely unreachable rather than just invisible.
|
|
//
|
|
// Denials return 403 with code "member_feature_denied" so the client can distinguish
|
|
// "ask your administrator" from a plan upgrade prompt.
|
|
func (s *Server) RequireMemberFeature(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if s.Billing == nil {
|
|
next.ServeHTTP(w, r)
|
|
return
|
|
}
|
|
path := r.URL.Path
|
|
for _, open := range alwaysReachableAPIPaths {
|
|
if path == open {
|
|
next.ServeHTTP(w, r)
|
|
return
|
|
}
|
|
}
|
|
key := featureKeyForAPIPath(path)
|
|
if key == "" {
|
|
next.ServeHTTP(w, r)
|
|
return
|
|
}
|
|
cid, okCompany := CompanyIDFromContext(r.Context())
|
|
uid, okUser := UserIDFromContext(r.Context())
|
|
if !okCompany || !okUser {
|
|
next.ServeHTTP(w, r)
|
|
return
|
|
}
|
|
perms, err := s.Billing.MemberPermissions(r.Context(), cid, uid)
|
|
if err != nil {
|
|
// Fail open on a lookup error: a transient DB blip must not lock the whole
|
|
// tenant out of their dashboard. The UI-level gate still applies.
|
|
next.ServeHTTP(w, r)
|
|
return
|
|
}
|
|
if billing.MemberDeniesFeature(perms, key) {
|
|
FieldError(w, http.StatusForbidden,
|
|
"your administrator has turned off access to this area",
|
|
"member_feature_denied", nil)
|
|
return
|
|
}
|
|
next.ServeHTTP(w, r)
|
|
})
|
|
}
|