Co-authored-by: gobha <jasafpro@gmail.com> Co-committed-by: gobha <jasafpro@gmail.com>
619 lines
18 KiB
Go
619 lines
18 KiB
Go
package handlers
|
|
|
|
import (
|
|
"archive/zip"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"log"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"strings"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
|
|
"chat-switchboard/database"
|
|
"chat-switchboard/sandbox"
|
|
"chat-switchboard/store"
|
|
)
|
|
|
|
// validPackageID matches lowercase alphanumeric slugs with optional hyphens.
|
|
// No leading/trailing hyphens, 2-64 chars.
|
|
var validPackageID = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,62}[a-z0-9]$`)
|
|
|
|
// PackageHandler manages unified package lifecycle (admin-only).
|
|
// Replaces SurfaceHandler (v0.28.7).
|
|
type PackageHandler struct {
|
|
stores store.Stores
|
|
packagesDir string // e.g. /data/packages — where archives are extracted
|
|
sandbox *sandbox.Sandbox // v0.30.0: for schema migration scripts
|
|
}
|
|
|
|
func NewPackageHandler(s store.Stores, packagesDir ...string) *PackageHandler {
|
|
dir := ""
|
|
if len(packagesDir) > 0 {
|
|
dir = packagesDir[0]
|
|
}
|
|
return &PackageHandler{stores: s, packagesDir: dir}
|
|
}
|
|
|
|
// SetSandbox attaches a Starlark sandbox for schema migrations (v0.30.0).
|
|
func (h *PackageHandler) SetSandbox(sb *sandbox.Sandbox) {
|
|
h.sandbox = sb
|
|
}
|
|
|
|
// ListPackages returns all registered packages.
|
|
// GET /api/v1/admin/packages
|
|
// GET /api/v1/admin/surfaces (alias)
|
|
func (h *PackageHandler) ListPackages(c *gin.Context) {
|
|
typeFilter := c.Query("type")
|
|
|
|
var pkgs []store.PackageRegistration
|
|
var err error
|
|
if typeFilter != "" {
|
|
pkgs, err = h.stores.Packages.ListByType(c.Request.Context(), typeFilter)
|
|
} else {
|
|
pkgs, err = h.stores.Packages.List(c.Request.Context())
|
|
}
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list packages"})
|
|
return
|
|
}
|
|
if pkgs == nil {
|
|
pkgs = []store.PackageRegistration{}
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{"data": pkgs})
|
|
}
|
|
|
|
// GetPackage returns a single package by ID.
|
|
// GET /api/v1/admin/packages/:id
|
|
// GET /api/v1/admin/surfaces/:id (alias)
|
|
func (h *PackageHandler) GetPackage(c *gin.Context) {
|
|
id := c.Param("id")
|
|
pkg, err := h.stores.Packages.Get(c.Request.Context(), id)
|
|
if err != nil || pkg == nil {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "package not found"})
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, pkg)
|
|
}
|
|
|
|
// EnablePackage enables a package.
|
|
// PUT /api/v1/admin/packages/:id/enable
|
|
// PUT /api/v1/admin/surfaces/:id/enable (alias)
|
|
func (h *PackageHandler) EnablePackage(c *gin.Context) {
|
|
id := c.Param("id")
|
|
if err := h.stores.Packages.SetEnabled(c.Request.Context(), id, true); err != nil {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "package not found"})
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{"id": id, "enabled": true})
|
|
}
|
|
|
|
// DisablePackage disables a package. Chat and Admin cannot be disabled.
|
|
// PUT /api/v1/admin/packages/:id/disable
|
|
// PUT /api/v1/admin/surfaces/:id/disable (alias)
|
|
func (h *PackageHandler) DisablePackage(c *gin.Context) {
|
|
id := c.Param("id")
|
|
|
|
if id == "chat" || id == "admin" {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": id + " cannot be disabled"})
|
|
return
|
|
}
|
|
|
|
if err := h.stores.Packages.SetEnabled(c.Request.Context(), id, false); err != nil {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "package not found"})
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{"id": id, "enabled": false})
|
|
}
|
|
|
|
// DeletePackage uninstalls a package. Core packages cannot be deleted.
|
|
// DELETE /api/v1/admin/packages/:id
|
|
// DELETE /api/v1/admin/surfaces/:id (alias)
|
|
func (h *PackageHandler) DeletePackage(c *gin.Context) {
|
|
id := c.Param("id")
|
|
|
|
pkg, err := h.stores.Packages.Get(c.Request.Context(), id)
|
|
if err != nil || pkg == nil {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "package not found"})
|
|
return
|
|
}
|
|
if pkg.Source == "core" {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "core packages cannot be uninstalled"})
|
|
return
|
|
}
|
|
|
|
// v0.29.2: Drop namespaced DB tables before removing the package record.
|
|
if err := DropExtTables(c.Request.Context(), database.DB, h.stores, id); err != nil {
|
|
log.Printf("[ext-db] schema drop failed for %s: %v", id, err)
|
|
}
|
|
|
|
if err := h.stores.Packages.Delete(c.Request.Context(), id); err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete package"})
|
|
return
|
|
}
|
|
|
|
// Clean up extracted static assets
|
|
if h.packagesDir != "" {
|
|
assetDir := filepath.Join(h.packagesDir, id)
|
|
if err := os.RemoveAll(assetDir); err != nil {
|
|
log.Printf("⚠️ Failed to clean up package assets at %s: %v", assetDir, err)
|
|
}
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{"id": id, "deleted": true})
|
|
}
|
|
|
|
// InstallPackage uploads and installs a .pkg/.surface archive.
|
|
// POST /api/v1/admin/packages/install
|
|
// POST /api/v1/admin/surfaces/install (alias)
|
|
//
|
|
// Also supports pre-downloaded files via gin context:
|
|
// c.Set("_registry_file", "/path/to/file.pkg") — skip form upload
|
|
// c.Set("_registry_source", "registry") — override source
|
|
func (h *PackageHandler) InstallPackage(c *gin.Context) {
|
|
var tmpPath string
|
|
var cleanupTmp bool
|
|
|
|
// v0.30.0: Check for pre-downloaded file from registry install
|
|
if regFile, ok := c.Get("_registry_file"); ok {
|
|
tmpPath = regFile.(string)
|
|
// Don't remove — caller manages lifecycle
|
|
} else {
|
|
file, header, err := c.Request.FormFile("file")
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "no file uploaded"})
|
|
return
|
|
}
|
|
defer file.Close()
|
|
|
|
// Validate extension
|
|
validExt := strings.HasSuffix(header.Filename, ".pkg") ||
|
|
strings.HasSuffix(header.Filename, ".surface") ||
|
|
strings.HasSuffix(header.Filename, ".zip")
|
|
if !validExt {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "file must be a .pkg, .surface, or .zip archive"})
|
|
return
|
|
}
|
|
|
|
// Size limit: 50MB
|
|
if header.Size > 50*1024*1024 {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "archive too large (max 50MB)"})
|
|
return
|
|
}
|
|
|
|
// Read into temp file for zip processing
|
|
tmpFile, err := os.CreateTemp("", "package-*.zip")
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create temp file"})
|
|
return
|
|
}
|
|
tmpPath = tmpFile.Name()
|
|
cleanupTmp = true
|
|
|
|
if _, err := io.Copy(tmpFile, file); err != nil {
|
|
tmpFile.Close()
|
|
os.Remove(tmpPath)
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to read upload"})
|
|
return
|
|
}
|
|
tmpFile.Close()
|
|
}
|
|
if cleanupTmp {
|
|
defer os.Remove(tmpPath)
|
|
}
|
|
|
|
// Open as zip
|
|
zr, err := zip.OpenReader(tmpPath)
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid zip archive"})
|
|
return
|
|
}
|
|
defer zr.Close()
|
|
|
|
// Find and parse manifest.json
|
|
var manifest map[string]any
|
|
for _, f := range zr.File {
|
|
name := filepath.Base(f.Name)
|
|
if name == "manifest.json" && !f.FileInfo().IsDir() {
|
|
rc, err := f.Open()
|
|
if err != nil {
|
|
continue
|
|
}
|
|
data, err := io.ReadAll(rc)
|
|
rc.Close()
|
|
if err != nil {
|
|
continue
|
|
}
|
|
if err := json.Unmarshal(data, &manifest); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid manifest.json: " + err.Error()})
|
|
return
|
|
}
|
|
break
|
|
}
|
|
}
|
|
|
|
if manifest == nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "archive missing manifest.json"})
|
|
return
|
|
}
|
|
|
|
// Validate required fields
|
|
pkgID, _ := manifest["id"].(string)
|
|
title, _ := manifest["title"].(string)
|
|
if pkgID == "" || title == "" {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "manifest must have 'id' and 'title'"})
|
|
return
|
|
}
|
|
|
|
// Validate package ID is a safe slug
|
|
if !validPackageID.MatchString(pkgID) {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "package id must be a lowercase slug (a-z, 0-9, hyphens, 2-64 chars)"})
|
|
return
|
|
}
|
|
|
|
// Determine type (default: surface for backward compat)
|
|
pkgType, _ := manifest["type"].(string)
|
|
if pkgType == "" {
|
|
pkgType = "surface"
|
|
}
|
|
if pkgType != "surface" && pkgType != "extension" && pkgType != "full" && pkgType != "workflow" {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "manifest type must be 'surface', 'extension', 'full', or 'workflow'"})
|
|
return
|
|
}
|
|
|
|
// Type-specific validation
|
|
hasRoute := manifest["route"] != nil || manifest["routes"] != nil
|
|
hasTools := manifest["tools"] != nil
|
|
hasPipes := manifest["pipes"] != nil
|
|
hasHooks := manifest["hooks"] != nil
|
|
hasSettings := manifest["settings"] != nil
|
|
hasExtBehavior := hasTools || hasPipes || hasHooks
|
|
|
|
switch pkgType {
|
|
case "surface":
|
|
// route is optional for surfaces (core surfaces don't always have it in manifest)
|
|
case "extension":
|
|
if !hasExtBehavior {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "extension packages require at least one of: tools, pipes, hooks"})
|
|
return
|
|
}
|
|
if hasRoute {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "extension packages cannot have a route — use type 'full' for both"})
|
|
return
|
|
}
|
|
case "full":
|
|
if !hasRoute {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "full packages require a route"})
|
|
return
|
|
}
|
|
// v0.31.0: full packages need either server-side behavior (tools/pipes/hooks)
|
|
// or a settings schema. A surface-with-settings is a valid "full" package
|
|
// (e.g. editor: browser-tier surface + admin-configurable settings).
|
|
if !hasExtBehavior && !hasSettings {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "full packages require at least one of: tools, pipes, hooks, settings"})
|
|
return
|
|
}
|
|
case "workflow":
|
|
if manifest["workflow_definition"] == nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "workflow packages require a 'workflow_definition' in the manifest"})
|
|
return
|
|
}
|
|
}
|
|
|
|
// Check for conflicts with core packages
|
|
existing, _ := h.stores.Packages.Get(c.Request.Context(), pkgID)
|
|
if existing != nil && existing.Source == "core" {
|
|
c.JSON(http.StatusConflict, gin.H{"error": "cannot overwrite core package: " + pkgID})
|
|
return
|
|
}
|
|
|
|
// Extract static assets (js/, css/, assets/) to packagesDir/{id}/
|
|
if h.packagesDir != "" {
|
|
destDir := filepath.Join(h.packagesDir, pkgID)
|
|
os.MkdirAll(destDir, 0755)
|
|
|
|
for _, f := range zr.File {
|
|
if f.FileInfo().IsDir() {
|
|
continue
|
|
}
|
|
|
|
relPath := extractableRelPath(f.Name)
|
|
if relPath == "" {
|
|
continue
|
|
}
|
|
|
|
destPath := filepath.Join(destDir, relPath)
|
|
|
|
// Security: prevent path traversal
|
|
if !strings.HasPrefix(filepath.Clean(destPath), filepath.Clean(destDir)) {
|
|
continue
|
|
}
|
|
|
|
os.MkdirAll(filepath.Dir(destPath), 0755)
|
|
|
|
rc, err := f.Open()
|
|
if err != nil {
|
|
continue
|
|
}
|
|
out, err := os.Create(destPath)
|
|
if err != nil {
|
|
rc.Close()
|
|
continue
|
|
}
|
|
io.Copy(out, rc)
|
|
out.Close()
|
|
rc.Close()
|
|
}
|
|
|
|
log.Printf("[packages] Extracted %s to %s", pkgID, destDir)
|
|
}
|
|
|
|
// Extract manifest fields for DB columns
|
|
version, _ := manifest["version"].(string)
|
|
if version == "" {
|
|
version = "0.0.0"
|
|
}
|
|
description, _ := manifest["description"].(string)
|
|
author, _ := manifest["author"].(string)
|
|
tier, _ := manifest["tier"].(string)
|
|
if tier == "" {
|
|
tier = "browser"
|
|
}
|
|
|
|
userID := c.GetString("user_id")
|
|
|
|
// v0.30.0: use registry source if set by registry install handler
|
|
pkgSource := "extension"
|
|
if src, ok := c.Get("_registry_source"); ok {
|
|
pkgSource = src.(string)
|
|
}
|
|
|
|
// Register in database via Seed (upsert — handles re-install)
|
|
if err := h.stores.Packages.Seed(c.Request.Context(), pkgID, title, pkgSource, manifest); err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to register package"})
|
|
return
|
|
}
|
|
|
|
// Read back to get the preserved enabled state (Seed doesn't override it)
|
|
existing, _ = h.stores.Packages.Get(c.Request.Context(), pkgID)
|
|
preservedEnabled := true
|
|
if existing != nil {
|
|
preservedEnabled = existing.Enabled
|
|
}
|
|
|
|
// Update fields that Seed doesn't set (type, version, author, etc.)
|
|
pkg := &store.PackageRegistration{
|
|
Title: title,
|
|
Type: pkgType,
|
|
Version: version,
|
|
Description: description,
|
|
Author: author,
|
|
Tier: tier,
|
|
IsSystem: false,
|
|
Enabled: preservedEnabled,
|
|
Manifest: manifest,
|
|
}
|
|
if userID != "" {
|
|
pkg.InstalledBy = &userID
|
|
}
|
|
if err := h.stores.Packages.Update(c.Request.Context(), pkgID, pkg); err != nil {
|
|
log.Printf("[packages] Failed to update package metadata for %s: %v", pkgID, err)
|
|
}
|
|
|
|
// v0.29.2: Create namespaced DB tables declared in the manifest.
|
|
if tables, ok := ParseDBTables(manifest); ok {
|
|
if err := CreateExtTables(c.Request.Context(), database.DB, database.IsPostgres(), h.stores, pkgID, tables); err != nil {
|
|
log.Printf("[ext-db] schema create failed for %s: %v", pkgID, err)
|
|
}
|
|
}
|
|
|
|
// v0.30.0: Run schema migrations if declared.
|
|
newSchemaVersion := ParseSchemaVersion(manifest)
|
|
if newSchemaVersion > 0 && h.sandbox != nil {
|
|
oldSchemaVersion := 0
|
|
if existing != nil {
|
|
oldSchemaVersion = existing.SchemaVersion
|
|
}
|
|
if newSchemaVersion < oldSchemaVersion {
|
|
c.JSON(http.StatusConflict, gin.H{
|
|
"error": fmt.Sprintf("downgrade not supported (current: %d, new: %d); uninstall first",
|
|
oldSchemaVersion, newSchemaVersion),
|
|
})
|
|
return
|
|
}
|
|
if newSchemaVersion > oldSchemaVersion {
|
|
if err := RunSchemaMigrations(
|
|
c.Request.Context(), h.sandbox, h.stores,
|
|
database.DB, database.IsPostgres(),
|
|
pkgID, manifest, oldSchemaVersion, newSchemaVersion,
|
|
); err != nil {
|
|
log.Printf("[pkg-migrate] migration failed for %s: %v", pkgID, err)
|
|
c.JSON(http.StatusInternalServerError, gin.H{
|
|
"error": "schema migration failed: " + err.Error(),
|
|
})
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
// v0.30.2: Install workflow definition from package manifest.
|
|
if pkgType == "workflow" {
|
|
if err := InstallWorkflowFromManifest(c, h.stores, pkgID, manifest); err != nil {
|
|
log.Printf("[packages] workflow install failed for %s: %v", pkgID, err)
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "workflow install failed: " + err.Error()})
|
|
return
|
|
}
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"id": pkgID,
|
|
"title": title,
|
|
"type": pkgType,
|
|
"version": version,
|
|
"source": pkgSource,
|
|
"enabled": preservedEnabled,
|
|
})
|
|
}
|
|
|
|
// extractableRelPath returns the relative path for a zip entry if it
|
|
// belongs to an extractable static directory (js/, css/, assets/).
|
|
// Returns "" if the file should be skipped.
|
|
func extractableRelPath(name string) string {
|
|
staticPrefixes := []string{"js/", "css/", "assets/"}
|
|
|
|
for _, p := range staticPrefixes {
|
|
if strings.HasPrefix(name, p) {
|
|
return name
|
|
}
|
|
}
|
|
|
|
idx := strings.Index(name, "/")
|
|
if idx <= 0 {
|
|
return ""
|
|
}
|
|
|
|
rest := name[idx+1:]
|
|
for _, p := range staticPrefixes {
|
|
if strings.HasPrefix(rest, p) {
|
|
return rest
|
|
}
|
|
}
|
|
|
|
return ""
|
|
}
|
|
|
|
// ── Package settings (v0.30.0 CS2) ──────────────
|
|
|
|
// GetPackageSettings returns the settings schema from the manifest merged
|
|
// with current admin-configured values.
|
|
// GET /api/v1/admin/packages/:id/settings
|
|
func (h *PackageHandler) GetPackageSettings(c *gin.Context) {
|
|
id := c.Param("id")
|
|
pkg, err := h.stores.Packages.Get(c.Request.Context(), id)
|
|
if err != nil || pkg == nil {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "package not found"})
|
|
return
|
|
}
|
|
|
|
// Extract settings schema from manifest
|
|
schema, _ := pkg.Manifest["settings"].([]any)
|
|
|
|
// Get current values
|
|
current, err := h.stores.Packages.GetPackageSettings(c.Request.Context(), id)
|
|
if err != nil {
|
|
current = json.RawMessage("{}")
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"schema": schema,
|
|
"values": json.RawMessage(current),
|
|
})
|
|
}
|
|
|
|
// UpdatePackageSettings validates and stores admin-configured package settings.
|
|
// PUT /api/v1/admin/packages/:id/settings
|
|
func (h *PackageHandler) UpdatePackageSettings(c *gin.Context) {
|
|
id := c.Param("id")
|
|
pkg, err := h.stores.Packages.Get(c.Request.Context(), id)
|
|
if err != nil || pkg == nil {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "package not found"})
|
|
return
|
|
}
|
|
|
|
var body map[string]any
|
|
if err := c.ShouldBindJSON(&body); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid JSON body"})
|
|
return
|
|
}
|
|
|
|
// Validate keys against declared schema
|
|
schemaArr, _ := pkg.Manifest["settings"].([]any)
|
|
validKeys := make(map[string]map[string]any, len(schemaArr))
|
|
for _, s := range schemaArr {
|
|
if entry, ok := s.(map[string]any); ok {
|
|
if key, ok := entry["key"].(string); ok {
|
|
validKeys[key] = entry
|
|
}
|
|
}
|
|
}
|
|
|
|
// Filter to only declared keys and type-check
|
|
filtered := make(map[string]any, len(body))
|
|
for k, v := range body {
|
|
schemaDef, ok := validKeys[k]
|
|
if !ok {
|
|
continue // skip undeclared keys
|
|
}
|
|
settingType, _ := schemaDef["type"].(string)
|
|
if validateSettingType(v, settingType) {
|
|
filtered[k] = v
|
|
}
|
|
}
|
|
|
|
settingsJSON, _ := json.Marshal(filtered)
|
|
if err := h.stores.Packages.SetPackageSettings(c.Request.Context(), id, json.RawMessage(settingsJSON)); err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save settings"})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{"data": json.RawMessage(settingsJSON)})
|
|
}
|
|
|
|
// validateSettingType checks if a value matches the expected setting type.
|
|
func validateSettingType(v any, settingType string) bool {
|
|
switch settingType {
|
|
case "string", "select":
|
|
_, ok := v.(string)
|
|
return ok
|
|
case "number":
|
|
_, ok := v.(float64)
|
|
return ok
|
|
case "boolean":
|
|
_, ok := v.(bool)
|
|
return ok
|
|
default:
|
|
return true // unknown types pass through
|
|
}
|
|
}
|
|
|
|
// ListEnabledSurfaces returns enabled surface/full packages for nav rendering.
|
|
// GET /api/v1/surfaces
|
|
func (h *PackageHandler) ListEnabledSurfaces(c *gin.Context) {
|
|
pkgs, err := h.stores.Packages.List(c.Request.Context())
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list surfaces"})
|
|
return
|
|
}
|
|
|
|
type navSurface struct {
|
|
ID string `json:"id"`
|
|
Title string `json:"title"`
|
|
Route string `json:"route"`
|
|
}
|
|
|
|
var enabled []navSurface
|
|
for _, p := range pkgs {
|
|
if !p.Enabled {
|
|
continue
|
|
}
|
|
// Only surface and full types have routes
|
|
if p.Type != "surface" && p.Type != "full" {
|
|
continue
|
|
}
|
|
route, _ := p.Manifest["route"].(string)
|
|
enabled = append(enabled, navSurface{
|
|
ID: p.ID,
|
|
Title: p.Title,
|
|
Route: route,
|
|
})
|
|
}
|
|
if enabled == nil {
|
|
enabled = []navSurface{}
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{"data": enabled})
|
|
}
|