345 lines
9.4 KiB
Go
345 lines
9.4 KiB
Go
// Package handlers — ext_api.go
|
|
//
|
|
// v0.29.1 CS1: Extension API routes. Starlark packages serve custom
|
|
// JSON endpoints via the existing Gin router. Mounted at
|
|
// /s/:slug/api/*path with JWT auth.
|
|
//
|
|
// Manifest format:
|
|
//
|
|
// {
|
|
// "api_routes": [
|
|
// {"method": "GET", "path": "/status"},
|
|
// {"method": "POST", "path": "/webhook"},
|
|
// {"method": "*", "path": "/proxy/*"}
|
|
// ]
|
|
// }
|
|
//
|
|
// Route matching:
|
|
// - method: exact match (case-insensitive), or "*" for any method
|
|
// - path: exact match, or trailing "*" for prefix match
|
|
// - If api_routes is boolean true, all routes are forwarded
|
|
//
|
|
// Starlark contract:
|
|
//
|
|
// def on_request(req):
|
|
// # req = {"method": "GET", "path": "/status", "headers": {...},
|
|
// # "query": {...}, "body": "", "user_id": "..."}
|
|
// return {"status": 200, "body": '{"ok": true}'}
|
|
//
|
|
// Response dict fields:
|
|
// - status: int (default 200)
|
|
// - headers: dict (optional, string→string)
|
|
// - body: string (default "")
|
|
package handlers
|
|
|
|
import (
|
|
"fmt"
|
|
"io"
|
|
"log"
|
|
"net/http"
|
|
"strings"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"go.starlark.net/starlark"
|
|
|
|
"chat-switchboard/models"
|
|
"chat-switchboard/sandbox"
|
|
"chat-switchboard/store"
|
|
)
|
|
|
|
// ExtAPIHandler serves extension API routes via Starlark.
|
|
type ExtAPIHandler struct {
|
|
stores store.Stores
|
|
runner *sandbox.Runner
|
|
}
|
|
|
|
// NewExtAPIHandler creates the extension API handler.
|
|
func NewExtAPIHandler(stores store.Stores, runner *sandbox.Runner) *ExtAPIHandler {
|
|
return &ExtAPIHandler{
|
|
stores: stores,
|
|
runner: runner,
|
|
}
|
|
}
|
|
|
|
// Handle processes an incoming request to /s/:slug/api/*path.
|
|
// It loads the target package, validates it, matches the route against
|
|
// the manifest's api_routes, and dispatches to the Starlark on_request
|
|
// entry point.
|
|
func (h *ExtAPIHandler) Handle(c *gin.Context) {
|
|
slug := c.Param("slug")
|
|
rawPath := c.Param("path") // includes leading "/"
|
|
|
|
if slug == "" {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "extension not found"})
|
|
return
|
|
}
|
|
|
|
// Gin *path captures include leading slash; normalize
|
|
if rawPath == "" {
|
|
rawPath = "/"
|
|
}
|
|
|
|
// ── 1. Load package ────────────────────────
|
|
pkg, err := h.stores.Packages.Get(c.Request.Context(), slug)
|
|
if err != nil || pkg == nil {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "extension not found"})
|
|
return
|
|
}
|
|
|
|
// ── 2. Validate package state ──────────────
|
|
if !pkg.Enabled {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "extension is disabled"})
|
|
return
|
|
}
|
|
if pkg.Status != models.PackageStatusActive {
|
|
c.JSON(http.StatusForbidden, gin.H{"error": "extension is " + pkg.Status})
|
|
return
|
|
}
|
|
if pkg.Tier != models.ExtTierStarlark {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "extension is not a starlark package"})
|
|
return
|
|
}
|
|
|
|
// ── 3. Check api.http permission ───────────
|
|
if h.stores.ExtPermissions == nil {
|
|
c.JSON(http.StatusForbidden, gin.H{"error": "permission system unavailable"})
|
|
return
|
|
}
|
|
granted, err := h.stores.ExtPermissions.GrantedForPackage(c.Request.Context(), pkg.ID)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "permission check failed"})
|
|
return
|
|
}
|
|
if !hasPermission(granted, models.ExtPermAPIHTTP) {
|
|
c.JSON(http.StatusForbidden, gin.H{"error": "extension does not have api.http permission"})
|
|
return
|
|
}
|
|
|
|
// ── 4. Match route against manifest ────────
|
|
if !matchAPIRoute(pkg.Manifest, c.Request.Method, rawPath) {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "route not declared in extension manifest"})
|
|
return
|
|
}
|
|
|
|
// ── 5. Build request dict ──────────────────
|
|
reqDict, err := buildRequestDict(c, rawPath)
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "failed to read request: " + err.Error()})
|
|
return
|
|
}
|
|
|
|
// ── 6. Call on_request entry point ─────────
|
|
rc := &sandbox.RunContext{UserID: getUserID(c)}
|
|
val, output, err := h.runner.CallEntryPoint(
|
|
c.Request.Context(), pkg, "on_request",
|
|
starlark.Tuple{reqDict}, nil, rc,
|
|
)
|
|
if err != nil {
|
|
log.Printf("⚠️ ext_api %s: on_request error: %v", pkg.ID, err)
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "extension error: " + err.Error()})
|
|
return
|
|
}
|
|
|
|
if output != "" {
|
|
log.Printf(" 🔧 ext_api %s print: %s", pkg.ID, output)
|
|
}
|
|
|
|
// ── 7. Parse and write response ────────────
|
|
writeStarlarkResponse(c, val)
|
|
}
|
|
|
|
// ─── Route Matching ─────────────────────────
|
|
|
|
// matchAPIRoute checks whether the incoming method+path is declared in
|
|
// the package manifest's api_routes field.
|
|
//
|
|
// Formats accepted:
|
|
// - boolean true: all routes match
|
|
// - []any of route dicts: {"method": "GET", "path": "/status"}
|
|
// method "*" matches any method; path trailing "*" matches prefix
|
|
func matchAPIRoute(manifest map[string]any, method, path string) bool {
|
|
raw, ok := manifest["api_routes"]
|
|
if !ok {
|
|
return false
|
|
}
|
|
|
|
// Boolean shorthand: true = all routes
|
|
if b, ok := raw.(bool); ok {
|
|
return b
|
|
}
|
|
|
|
// Array of route declarations
|
|
routes, ok := raw.([]any)
|
|
if !ok {
|
|
return false
|
|
}
|
|
|
|
method = strings.ToUpper(method)
|
|
|
|
for _, entry := range routes {
|
|
route, ok := entry.(map[string]any)
|
|
if !ok {
|
|
continue
|
|
}
|
|
|
|
// Match method
|
|
rm, _ := route["method"].(string)
|
|
rm = strings.ToUpper(rm)
|
|
if rm != "*" && rm != method {
|
|
continue
|
|
}
|
|
|
|
// Match path
|
|
rp, _ := route["path"].(string)
|
|
if rp == "" {
|
|
continue
|
|
}
|
|
|
|
if strings.HasSuffix(rp, "*") {
|
|
// Prefix match: "/proxy/*" matches "/proxy/anything"
|
|
prefix := strings.TrimSuffix(rp, "*")
|
|
if strings.HasPrefix(path, prefix) {
|
|
return true
|
|
}
|
|
} else {
|
|
// Exact match
|
|
if path == rp {
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
// ─── Request Building ───────────────────────
|
|
|
|
// buildRequestDict creates a Starlark dict from the Gin context:
|
|
//
|
|
// {
|
|
// "method": "GET",
|
|
// "path": "/status",
|
|
// "headers": {"content-type": "application/json", ...},
|
|
// "query": {"page": "1", ...},
|
|
// "body": "...",
|
|
// "user_id": "uuid",
|
|
// }
|
|
func buildRequestDict(c *gin.Context, path string) (*starlark.Dict, error) {
|
|
// Read body (capped at 1 MB for safety)
|
|
body, err := io.ReadAll(io.LimitReader(c.Request.Body, 1<<20))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("reading body: %w", err)
|
|
}
|
|
|
|
// Headers dict (lowercase keys, first value)
|
|
hdrs := starlark.NewDict(len(c.Request.Header))
|
|
for k, vals := range c.Request.Header {
|
|
if len(vals) > 0 {
|
|
_ = hdrs.SetKey(starlark.String(strings.ToLower(k)), starlark.String(vals[0]))
|
|
}
|
|
}
|
|
|
|
// Query dict (first value per key)
|
|
query := starlark.NewDict(len(c.Request.URL.Query()))
|
|
for k, vals := range c.Request.URL.Query() {
|
|
if len(vals) > 0 {
|
|
_ = query.SetKey(starlark.String(k), starlark.String(vals[0]))
|
|
}
|
|
}
|
|
|
|
d := starlark.NewDict(6)
|
|
_ = d.SetKey(starlark.String("method"), starlark.String(c.Request.Method))
|
|
_ = d.SetKey(starlark.String("path"), starlark.String(path))
|
|
_ = d.SetKey(starlark.String("headers"), hdrs)
|
|
_ = d.SetKey(starlark.String("query"), query)
|
|
_ = d.SetKey(starlark.String("body"), starlark.String(string(body)))
|
|
_ = d.SetKey(starlark.String("user_id"), starlark.String(getUserID(c)))
|
|
return d, nil
|
|
}
|
|
|
|
// ─── Response Writing ───────────────────────
|
|
|
|
// writeStarlarkResponse parses the Starlark return value and writes
|
|
// the HTTP response. Expected: dict with "status", "headers", "body".
|
|
// Missing fields use defaults (200, no extra headers, empty body).
|
|
func writeStarlarkResponse(c *gin.Context, val starlark.Value) {
|
|
if val == nil || val == starlark.None {
|
|
c.Status(http.StatusNoContent)
|
|
c.Writer.WriteHeaderNow()
|
|
return
|
|
}
|
|
|
|
dict, ok := val.(*starlark.Dict)
|
|
if !ok {
|
|
c.JSON(http.StatusInternalServerError, gin.H{
|
|
"error": fmt.Sprintf("on_request must return a dict, got %s", val.Type()),
|
|
})
|
|
return
|
|
}
|
|
|
|
// Status code
|
|
status := http.StatusOK
|
|
if sv, found, _ := dict.Get(starlark.String("status")); found {
|
|
if i, err := starlark.AsInt32(sv); err == nil {
|
|
status = i
|
|
}
|
|
}
|
|
|
|
// Response headers
|
|
if hv, found, _ := dict.Get(starlark.String("headers")); found {
|
|
if hd, ok := hv.(*starlark.Dict); ok {
|
|
for _, item := range hd.Items() {
|
|
k, ok1 := starlark.AsString(item[0])
|
|
v, ok2 := starlark.AsString(item[1])
|
|
if ok1 && ok2 {
|
|
c.Header(k, v)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Body
|
|
body := ""
|
|
if bv, found, _ := dict.Get(starlark.String("body")); found {
|
|
body, _ = starlark.AsString(bv)
|
|
}
|
|
|
|
// Auto-detect content type if not set explicitly
|
|
if c.Writer.Header().Get("Content-Type") == "" {
|
|
if len(body) > 0 && (body[0] == '{' || body[0] == '[') {
|
|
c.Header("Content-Type", "application/json")
|
|
} else {
|
|
c.Header("Content-Type", "text/plain; charset=utf-8")
|
|
}
|
|
}
|
|
|
|
c.String(status, body)
|
|
}
|
|
|
|
// ─── Helpers ────────────────────────────────
|
|
|
|
func hasPermission(granted []string, perm string) bool {
|
|
for _, p := range granted {
|
|
if p == perm {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// HasAPIRoutes checks whether a package manifest declares API routes.
|
|
// Used by startup discovery and admin UI to identify API-capable packages.
|
|
func HasAPIRoutes(manifest map[string]any) bool {
|
|
raw, ok := manifest["api_routes"]
|
|
if !ok {
|
|
return false
|
|
}
|
|
if b, ok := raw.(bool); ok {
|
|
return b
|
|
}
|
|
if routes, ok := raw.([]any); ok {
|
|
return len(routes) > 0
|
|
}
|
|
return false
|
|
}
|