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 }