feat: gsc-shell-api v0.1 — central chrome data API
Tiny Go service that returns ShellConfig JSON for any registered app.
Backs the runtime-loaded <AppShell> component being added to
@limitless/ui (next).
Endpoints:
GET /api/v1/shell/{appKey} → app + branding + user + menus, ETag-cached
GET /api/v1/apps → registered app inventory
GET /healthz, /readyz → ops probes
Auth:
Keycloak Bearer JWT validated against the gosecCloud realm.
Discovery URL is overridable so pods can hit Keycloak via the
in-cluster service (https://keycloak.keycloak.svc.cluster.local:8443)
while still validating the canonical issuer (auth.gosec.cloud).
Lazy JWKS init — pod stays up if Keycloak is briefly unreachable.
Data model (gsc_core.shell):
apps · menu_items (zone enum: topbar/sidebar/footer/user-menu) ·
menu_role_grants (Keycloak realm roles, OR semantics, empty=all) ·
branding
Seed includes the 8 gsc-crm sidebar items + topbar search +
user-menu (settings/support/logout) + footer (docs).
K8s:
Namespace gsc-shell (ambient mesh).
Deployment 2 replicas, internal-only ingress shell-api.gosec.internal,
EJBCA SERVER cert.
ServiceEntry for auth.gosec.cloud (vestigial — discovery now uses
in-cluster path; keeping for ad-hoc curl from inside pods).
Added to keycloak/allow-keycloak-clients AuthorizationPolicy out of band.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
187
internal/auth/keycloak.go
Normal file
187
internal/auth/keycloak.go
Normal file
@@ -0,0 +1,187 @@
|
||||
// Package auth verifies Keycloak-issued JWTs.
|
||||
//
|
||||
// JWKS discovery is lazy: we don't hit `.well-known/openid-configuration`
|
||||
// at startup because pod-to-public-internet TLS may be slow during pod
|
||||
// boot and we don't want a healthy DB-only deployment to crash because
|
||||
// auth.gosec.cloud hiccupped. Discovery happens on first token validation
|
||||
// and the result is cached. We retry on every fresh request until it
|
||||
// succeeds.
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/coreos/go-oidc/v3/oidc"
|
||||
"github.com/gofiber/fiber/v2"
|
||||
)
|
||||
|
||||
type UserClaims struct {
|
||||
Subject string `json:"sub"`
|
||||
Email string `json:"email"`
|
||||
DisplayName string `json:"name"`
|
||||
GivenName string `json:"given_name"`
|
||||
FamilyName string `json:"family_name"`
|
||||
TenantID string `json:"tenant_id"`
|
||||
Roles []string // realm roles + flattened client roles
|
||||
}
|
||||
|
||||
type Verifier struct {
|
||||
issuer string // the canonical issuer claim Keycloak emits
|
||||
discoveryURL string // optional: point discovery at an internal URL
|
||||
allowedAudCSV string
|
||||
|
||||
mu sync.Mutex
|
||||
v *oidc.IDTokenVerifier // nil until first successful discovery
|
||||
}
|
||||
|
||||
// NewVerifier returns a Verifier that validates tokens whose `iss` matches
|
||||
// `issuer`. If `discoveryURL` is non-empty, OIDC discovery is performed
|
||||
// against that URL instead — useful when the canonical issuer is a public
|
||||
// hostname (auth.gosec.cloud) but the pod can only reach Keycloak through
|
||||
// an in-cluster service.
|
||||
func NewVerifier(_ context.Context, issuer, discoveryURL, allowedAudiencesCSV string) (*Verifier, error) {
|
||||
if issuer == "" {
|
||||
return nil, errors.New("issuer is required")
|
||||
}
|
||||
return &Verifier{
|
||||
issuer: issuer,
|
||||
discoveryURL: discoveryURL,
|
||||
allowedAudCSV: allowedAudiencesCSV,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ensure resolves the underlying OIDC verifier on demand. Cached after first success.
|
||||
func (vr *Verifier) ensure(ctx context.Context) (*oidc.IDTokenVerifier, error) {
|
||||
vr.mu.Lock()
|
||||
defer vr.mu.Unlock()
|
||||
if vr.v != nil {
|
||||
return vr.v, nil
|
||||
}
|
||||
disc, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
target := vr.issuer
|
||||
if vr.discoveryURL != "" {
|
||||
// Tell go-oidc that "the issuer claim Keycloak puts in tokens" and
|
||||
// "the URL we hit for discovery" can differ — otherwise it errors
|
||||
// because the discovery doc's issuer field won't match what we
|
||||
// passed to NewProvider.
|
||||
disc = oidc.InsecureIssuerURLContext(disc, vr.issuer)
|
||||
target = vr.discoveryURL
|
||||
|
||||
// In-cluster Keycloak presents a cert for `auth.gosec.cloud`, not
|
||||
// for `keycloak.keycloak.svc.cluster.local`. Ambient mesh policy
|
||||
// gates who can reach Keycloak; we don't need cert hostname
|
||||
// verification on top of that for the internal discovery hop.
|
||||
client := &http.Client{
|
||||
Transport: &http.Transport{
|
||||
TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, //nolint:gosec
|
||||
},
|
||||
Timeout: 10 * time.Second,
|
||||
}
|
||||
disc = oidc.ClientContext(disc, client)
|
||||
}
|
||||
|
||||
provider, err := oidc.NewProvider(disc, target)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("oidc discovery: %w", err)
|
||||
}
|
||||
vr.v = provider.Verifier(&oidc.Config{
|
||||
// Custom audience check below; many Keycloak access tokens have
|
||||
// aud="account" which would fail strict OIDC checks.
|
||||
SkipClientIDCheck: true,
|
||||
})
|
||||
return vr.v, nil
|
||||
}
|
||||
|
||||
func (vr *Verifier) Verify(ctx context.Context, raw string) (*UserClaims, error) {
|
||||
v, err := vr.ensure(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tok, err := v.Verify(ctx, raw)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var raw1 struct {
|
||||
Subject string `json:"sub"`
|
||||
Email string `json:"email"`
|
||||
Name string `json:"name"`
|
||||
GivenName string `json:"given_name"`
|
||||
FamilyName string `json:"family_name"`
|
||||
TenantID string `json:"tenant_id"`
|
||||
AzpAudience string `json:"azp"`
|
||||
RealmAccess struct {
|
||||
Roles []string `json:"roles"`
|
||||
} `json:"realm_access"`
|
||||
ResourceAccess map[string]struct {
|
||||
Roles []string `json:"roles"`
|
||||
} `json:"resource_access"`
|
||||
}
|
||||
if err := tok.Claims(&raw1); err != nil {
|
||||
return nil, fmt.Errorf("decode claims: %w", err)
|
||||
}
|
||||
|
||||
if vr.allowedAudCSV != "" {
|
||||
if !contains(strings.Split(vr.allowedAudCSV, ","), raw1.AzpAudience) {
|
||||
return nil, errors.New("token azp not in allowed audiences")
|
||||
}
|
||||
}
|
||||
|
||||
roles := append([]string{}, raw1.RealmAccess.Roles...)
|
||||
for _, ra := range raw1.ResourceAccess {
|
||||
roles = append(roles, ra.Roles...)
|
||||
}
|
||||
|
||||
return &UserClaims{
|
||||
Subject: raw1.Subject,
|
||||
Email: raw1.Email,
|
||||
DisplayName: raw1.Name,
|
||||
GivenName: raw1.GivenName,
|
||||
FamilyName: raw1.FamilyName,
|
||||
TenantID: raw1.TenantID,
|
||||
Roles: roles,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (vr *Verifier) Middleware() fiber.Handler {
|
||||
return func(c *fiber.Ctx) error {
|
||||
header := c.Get("Authorization")
|
||||
if !strings.HasPrefix(header, "Bearer ") {
|
||||
return c.Status(fiber.StatusUnauthorized).
|
||||
JSON(fiber.Map{"error": "missing bearer token"})
|
||||
}
|
||||
raw := strings.TrimPrefix(header, "Bearer ")
|
||||
claims, err := vr.Verify(c.UserContext(), raw)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusUnauthorized).
|
||||
JSON(fiber.Map{"error": fmt.Sprintf("invalid token: %v", err)})
|
||||
}
|
||||
c.Locals("user", claims)
|
||||
return c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
func User(c *fiber.Ctx) *UserClaims {
|
||||
if u, ok := c.Locals("user").(*UserClaims); ok {
|
||||
return u
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func contains(haystack []string, needle string) bool {
|
||||
for _, h := range haystack {
|
||||
if strings.TrimSpace(h) == needle {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
49
internal/config/config.go
Normal file
49
internal/config/config.go
Normal file
@@ -0,0 +1,49 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
// Config holds runtime configuration. All values come from env.
|
||||
type Config struct {
|
||||
Port int
|
||||
DatabaseURL string
|
||||
KeycloakIssuer string // e.g. https://auth.gosec.cloud/realms/gosecCloud
|
||||
KeycloakDiscovery string // optional in-cluster discovery URL override
|
||||
KeycloakAudCSV string // comma-separated allowed audiences (client_id values)
|
||||
CacheTTLSeconds int // for client-side Cache-Control hint
|
||||
}
|
||||
|
||||
func Load() (*Config, error) {
|
||||
c := &Config{
|
||||
Port: 8080,
|
||||
KeycloakIssuer: os.Getenv("KEYCLOAK_ISSUER"),
|
||||
KeycloakDiscovery: os.Getenv("KEYCLOAK_DISCOVERY_URL"),
|
||||
KeycloakAudCSV: os.Getenv("KEYCLOAK_AUDIENCES"),
|
||||
DatabaseURL: os.Getenv("DATABASE_URL"),
|
||||
CacheTTLSeconds: 60,
|
||||
}
|
||||
if p := os.Getenv("PORT"); p != "" {
|
||||
v, err := strconv.Atoi(p)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("PORT: %w", err)
|
||||
}
|
||||
c.Port = v
|
||||
}
|
||||
if t := os.Getenv("CACHE_TTL_SECONDS"); t != "" {
|
||||
v, err := strconv.Atoi(t)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("CACHE_TTL_SECONDS: %w", err)
|
||||
}
|
||||
c.CacheTTLSeconds = v
|
||||
}
|
||||
if c.DatabaseURL == "" {
|
||||
return nil, fmt.Errorf("DATABASE_URL is required")
|
||||
}
|
||||
if c.KeycloakIssuer == "" {
|
||||
return nil, fmt.Errorf("KEYCLOAK_ISSUER is required")
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
149
internal/db/db.go
Normal file
149
internal/db/db.go
Normal file
@@ -0,0 +1,149 @@
|
||||
// Package db contains the queries the API serves.
|
||||
//
|
||||
// All reads are filtered against a per-app key. Role filtering happens
|
||||
// in-memory after the menu_items + role_grants are loaded — the joins
|
||||
// are small enough (typically <100 rows per app) that hand-filtering in
|
||||
// Go is simpler than a SQL CTE.
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
type DB struct {
|
||||
pool *pgxpool.Pool
|
||||
}
|
||||
|
||||
func New(ctx context.Context, url string) (*DB, error) {
|
||||
pool, err := pgxpool.New(ctx, url)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("pgxpool.New: %w", err)
|
||||
}
|
||||
if err := pool.Ping(ctx); err != nil {
|
||||
return nil, fmt.Errorf("ping: %w", err)
|
||||
}
|
||||
return &DB{pool: pool}, nil
|
||||
}
|
||||
|
||||
func (d *DB) Close() { d.pool.Close() }
|
||||
|
||||
type App struct {
|
||||
Key string
|
||||
DisplayName string
|
||||
BaseURL string
|
||||
}
|
||||
|
||||
type Branding struct {
|
||||
LogoURL string
|
||||
ProductName string
|
||||
FooterHTML *string
|
||||
BrandColor *string
|
||||
}
|
||||
|
||||
type MenuItem struct {
|
||||
ID string
|
||||
ParentID *string
|
||||
Zone string
|
||||
Key string
|
||||
TranslationKey string
|
||||
Href string
|
||||
Icon *string
|
||||
SortOrder int
|
||||
IsExternal bool
|
||||
Roles []string // empty = visible to anyone authenticated
|
||||
}
|
||||
|
||||
func (d *DB) GetApp(ctx context.Context, appKey string) (*App, error) {
|
||||
row := d.pool.QueryRow(ctx, `
|
||||
SELECT app_key, display_name, base_url
|
||||
FROM shell.apps
|
||||
WHERE app_key = $1 AND is_active = true
|
||||
`, appKey)
|
||||
var a App
|
||||
if err := row.Scan(&a.Key, &a.DisplayName, &a.BaseURL); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &a, nil
|
||||
}
|
||||
|
||||
func (d *DB) GetBranding(ctx context.Context, appKey string) (*Branding, error) {
|
||||
row := d.pool.QueryRow(ctx, `
|
||||
SELECT logo_url, product_name, footer_html, brand_color
|
||||
FROM shell.branding
|
||||
WHERE app_key = $1
|
||||
`, appKey)
|
||||
var b Branding
|
||||
if err := row.Scan(&b.LogoURL, &b.ProductName, &b.FooterHTML, &b.BrandColor); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &b, nil
|
||||
}
|
||||
|
||||
func (d *DB) ListMenuItems(ctx context.Context, appKey string) ([]MenuItem, error) {
|
||||
rows, err := d.pool.Query(ctx, `
|
||||
SELECT
|
||||
mi.id::text,
|
||||
mi.parent_id::text,
|
||||
mi.zone::text,
|
||||
mi.key,
|
||||
mi.translation_key,
|
||||
mi.href,
|
||||
mi.icon,
|
||||
mi.sort_order,
|
||||
mi.is_external,
|
||||
COALESCE(
|
||||
(SELECT array_agg(role ORDER BY role) FROM shell.menu_role_grants WHERE menu_item_id = mi.id),
|
||||
ARRAY[]::text[]
|
||||
) AS roles
|
||||
FROM shell.menu_items mi
|
||||
WHERE mi.app_key = $1 AND mi.is_active = true
|
||||
ORDER BY mi.zone, mi.sort_order, mi.key
|
||||
`, appKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []MenuItem
|
||||
for rows.Next() {
|
||||
var m MenuItem
|
||||
var parent *string
|
||||
if err := rows.Scan(
|
||||
&m.ID, &parent, &m.Zone, &m.Key, &m.TranslationKey, &m.Href,
|
||||
&m.Icon, &m.SortOrder, &m.IsExternal, &m.Roles,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if parent != nil && *parent != "" {
|
||||
m.ParentID = parent
|
||||
}
|
||||
out = append(out, m)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// ListApps returns every active app — used by GET /api/v1/apps.
|
||||
func (d *DB) ListApps(ctx context.Context) ([]App, error) {
|
||||
rows, err := d.pool.Query(ctx, `
|
||||
SELECT app_key, display_name, base_url
|
||||
FROM shell.apps
|
||||
WHERE is_active = true
|
||||
ORDER BY app_key
|
||||
`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []App
|
||||
for rows.Next() {
|
||||
var a App
|
||||
if err := rows.Scan(&a.Key, &a.DisplayName, &a.BaseURL); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, a)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
28
internal/handlers/health.go
Normal file
28
internal/handlers/health.go
Normal file
@@ -0,0 +1,28 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
|
||||
"github.com/gosec/gsc-shell-api/internal/db"
|
||||
)
|
||||
|
||||
type HealthHandlers struct {
|
||||
DB *db.DB
|
||||
}
|
||||
|
||||
func (h *HealthHandlers) Live(c *fiber.Ctx) error {
|
||||
return c.JSON(fiber.Map{"status": "live"})
|
||||
}
|
||||
|
||||
func (h *HealthHandlers) Ready(c *fiber.Ctx) error {
|
||||
ctx, cancel := context.WithTimeout(c.UserContext(), 2*time.Second)
|
||||
defer cancel()
|
||||
if _, err := h.DB.ListApps(ctx); err != nil {
|
||||
return c.Status(fiber.StatusServiceUnavailable).
|
||||
JSON(fiber.Map{"status": "not-ready", "error": err.Error()})
|
||||
}
|
||||
return c.JSON(fiber.Map{"status": "ready"})
|
||||
}
|
||||
234
internal/handlers/shell.go
Normal file
234
internal/handlers/shell.go
Normal file
@@ -0,0 +1,234 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/jackc/pgx/v5"
|
||||
|
||||
"github.com/gosec/gsc-shell-api/internal/auth"
|
||||
"github.com/gosec/gsc-shell-api/internal/db"
|
||||
)
|
||||
|
||||
type ShellHandlers struct {
|
||||
DB *db.DB
|
||||
CacheTTLSeconds int
|
||||
}
|
||||
|
||||
// MenuItemDTO is the JSON shape sent to clients. Children flattened from
|
||||
// menu_items via parent_id.
|
||||
type MenuItemDTO struct {
|
||||
ID string `json:"id"`
|
||||
Key string `json:"key"`
|
||||
TranslationKey string `json:"translationKey"`
|
||||
Href string `json:"href"`
|
||||
Icon *string `json:"icon,omitempty"`
|
||||
IsExternal bool `json:"isExternal,omitempty"`
|
||||
Children []*MenuItemDTO `json:"children,omitempty"`
|
||||
}
|
||||
|
||||
type AppDTO struct {
|
||||
Key string `json:"key"`
|
||||
DisplayName string `json:"displayName"`
|
||||
BaseURL string `json:"baseUrl"`
|
||||
}
|
||||
|
||||
type BrandingDTO struct {
|
||||
LogoURL string `json:"logoUrl"`
|
||||
ProductName string `json:"productName"`
|
||||
FooterHTML *string `json:"footerHtml,omitempty"`
|
||||
BrandColor *string `json:"brandColor,omitempty"`
|
||||
}
|
||||
|
||||
type UserDTO struct {
|
||||
ID string `json:"id"`
|
||||
Email string `json:"email,omitempty"`
|
||||
DisplayName string `json:"displayName"`
|
||||
GivenName string `json:"givenName,omitempty"`
|
||||
FamilyName string `json:"familyName,omitempty"`
|
||||
TenantID string `json:"tenantId,omitempty"`
|
||||
Roles []string `json:"roles"`
|
||||
}
|
||||
|
||||
type ShellConfigDTO struct {
|
||||
Version int `json:"version"`
|
||||
App AppDTO `json:"app"`
|
||||
Branding BrandingDTO `json:"branding"`
|
||||
User UserDTO `json:"user"`
|
||||
Menus map[string][]*MenuItemDTO `json:"menus"`
|
||||
}
|
||||
|
||||
// GetShell returns the full chrome configuration for an app, filtered by the
|
||||
// caller's roles. Honors If-None-Match for ETag-based caching.
|
||||
func (h *ShellHandlers) GetShell(c *fiber.Ctx) error {
|
||||
appKey := c.Params("appKey")
|
||||
user := auth.User(c)
|
||||
if user == nil {
|
||||
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "unauthenticated"})
|
||||
}
|
||||
|
||||
ctx := c.UserContext()
|
||||
|
||||
app, err := h.DB.GetApp(ctx, appKey)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return c.Status(fiber.StatusNotFound).
|
||||
JSON(fiber.Map{"error": fmt.Sprintf("unknown app: %s", appKey)})
|
||||
}
|
||||
return c.Status(fiber.StatusInternalServerError).
|
||||
JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
|
||||
branding, err := h.DB.GetBranding(ctx, appKey)
|
||||
if err != nil {
|
||||
if !errors.Is(err, pgx.ErrNoRows) {
|
||||
return c.Status(fiber.StatusInternalServerError).
|
||||
JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
// Branding is optional — fall back to app display name.
|
||||
branding = &db.Branding{ProductName: app.DisplayName}
|
||||
}
|
||||
|
||||
items, err := h.DB.ListMenuItems(ctx, appKey)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).
|
||||
JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
|
||||
// Filter by role grants (OR semantics; empty grants = visible to everyone authenticated).
|
||||
visible := filterByRoles(items, user.Roles)
|
||||
|
||||
// Group by zone, build trees on parent_id.
|
||||
zones := buildZones(visible)
|
||||
|
||||
// Sort roles for stable output (and stable ETag).
|
||||
sortedRoles := append([]string{}, user.Roles...)
|
||||
sort.Strings(sortedRoles)
|
||||
|
||||
cfg := ShellConfigDTO{
|
||||
Version: 1,
|
||||
App: AppDTO{
|
||||
Key: app.Key,
|
||||
DisplayName: app.DisplayName,
|
||||
BaseURL: app.BaseURL,
|
||||
},
|
||||
Branding: BrandingDTO{
|
||||
LogoURL: branding.LogoURL,
|
||||
ProductName: branding.ProductName,
|
||||
FooterHTML: branding.FooterHTML,
|
||||
BrandColor: branding.BrandColor,
|
||||
},
|
||||
User: UserDTO{
|
||||
ID: user.Subject,
|
||||
Email: user.Email,
|
||||
DisplayName: user.DisplayName,
|
||||
GivenName: user.GivenName,
|
||||
FamilyName: user.FamilyName,
|
||||
TenantID: user.TenantID,
|
||||
Roles: sortedRoles,
|
||||
},
|
||||
Menus: zones,
|
||||
}
|
||||
|
||||
body, err := json.Marshal(cfg)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).
|
||||
JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
|
||||
etag := fmt.Sprintf(`"%s"`, hashShort(body))
|
||||
if match := c.Get("If-None-Match"); match == etag {
|
||||
return c.Status(fiber.StatusNotModified).Send(nil)
|
||||
}
|
||||
|
||||
c.Set("ETag", etag)
|
||||
c.Set("Cache-Control",
|
||||
fmt.Sprintf("private, max-age=%d", h.CacheTTLSeconds))
|
||||
c.Set("Content-Type", "application/json")
|
||||
return c.Status(fiber.StatusOK).Send(body)
|
||||
}
|
||||
|
||||
// ListApps is unauthenticated-friendly: just an inventory of registered apps.
|
||||
// Useful for service-discovery-style callers.
|
||||
func (h *ShellHandlers) ListApps(c *fiber.Ctx) error {
|
||||
apps, err := h.DB.ListApps(c.UserContext())
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).
|
||||
JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
out := make([]AppDTO, 0, len(apps))
|
||||
for _, a := range apps {
|
||||
out = append(out, AppDTO{Key: a.Key, DisplayName: a.DisplayName, BaseURL: a.BaseURL})
|
||||
}
|
||||
return c.JSON(fiber.Map{"apps": out})
|
||||
}
|
||||
|
||||
func filterByRoles(items []db.MenuItem, userRoles []string) []db.MenuItem {
|
||||
if len(items) == 0 {
|
||||
return items
|
||||
}
|
||||
roleSet := map[string]struct{}{}
|
||||
for _, r := range userRoles {
|
||||
roleSet[r] = struct{}{}
|
||||
}
|
||||
out := make([]db.MenuItem, 0, len(items))
|
||||
for _, it := range items {
|
||||
if len(it.Roles) == 0 {
|
||||
out = append(out, it) // visible to all authenticated users
|
||||
continue
|
||||
}
|
||||
for _, r := range it.Roles {
|
||||
if _, ok := roleSet[r]; ok {
|
||||
out = append(out, it)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func buildZones(items []db.MenuItem) map[string][]*MenuItemDTO {
|
||||
// First, materialize every item as a DTO so we can wire children.
|
||||
dto := make(map[string]*MenuItemDTO, len(items))
|
||||
zoneOf := make(map[string]string, len(items))
|
||||
for _, it := range items {
|
||||
d := &MenuItemDTO{
|
||||
ID: it.ID,
|
||||
Key: it.Key,
|
||||
TranslationKey: it.TranslationKey,
|
||||
Href: it.Href,
|
||||
Icon: it.Icon,
|
||||
IsExternal: it.IsExternal,
|
||||
}
|
||||
dto[it.ID] = d
|
||||
zoneOf[it.ID] = it.Zone
|
||||
}
|
||||
|
||||
// Roots per zone, plus children attached via parent_id.
|
||||
zones := map[string][]*MenuItemDTO{}
|
||||
for _, it := range items {
|
||||
d := dto[it.ID]
|
||||
if it.ParentID == nil {
|
||||
zones[it.Zone] = append(zones[it.Zone], d)
|
||||
continue
|
||||
}
|
||||
parent, ok := dto[*it.ParentID]
|
||||
if !ok {
|
||||
// Parent was filtered out by role check; treat orphan as root.
|
||||
zones[it.Zone] = append(zones[it.Zone], d)
|
||||
continue
|
||||
}
|
||||
parent.Children = append(parent.Children, d)
|
||||
}
|
||||
return zones
|
||||
}
|
||||
|
||||
func hashShort(body []byte) string {
|
||||
sum := sha256.Sum256(body)
|
||||
return hex.EncodeToString(sum[:8]) // 16 hex chars
|
||||
}
|
||||
Reference in New Issue
Block a user