347 lines
9.3 KiB
Go
347 lines
9.3 KiB
Go
package handlers
|
|
|
|
import (
|
|
"database/sql"
|
|
"encoding/json"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
|
|
"git.gobha.me/xcaliber/chat-switchboard/models"
|
|
"git.gobha.me/xcaliber/chat-switchboard/store"
|
|
)
|
|
|
|
// ExtensionHandler serves extension management endpoints.
|
|
type ExtensionHandler struct {
|
|
stores store.Stores
|
|
}
|
|
|
|
func NewExtensionHandler(stores store.Stores) *ExtensionHandler {
|
|
return &ExtensionHandler{stores: stores}
|
|
}
|
|
|
|
// ── User endpoints ──────────────────────────────
|
|
|
|
// ListUserExtensions returns all enabled extensions for the current user,
|
|
// with user-specific settings and enabled state merged in.
|
|
// GET /api/v1/extensions
|
|
func (h *ExtensionHandler) ListUserExtensions(c *gin.Context) {
|
|
userID := c.GetString("user_id")
|
|
tier := c.Query("tier") // optional filter
|
|
|
|
exts, err := h.stores.Extensions.ListForUser(c.Request.Context(), userID)
|
|
if err != nil {
|
|
c.JSON(500, gin.H{"error": "failed to list extensions"})
|
|
return
|
|
}
|
|
|
|
if tier != "" {
|
|
filtered := make([]models.UserExtension, 0)
|
|
for _, e := range exts {
|
|
if e.Tier == tier {
|
|
filtered = append(filtered, e)
|
|
}
|
|
}
|
|
exts = filtered
|
|
}
|
|
|
|
c.JSON(200, gin.H{"data": exts})
|
|
}
|
|
|
|
// UpdateUserExtensionSettings saves per-user settings for an extension.
|
|
// POST /api/v1/extensions/:id/settings
|
|
func (h *ExtensionHandler) UpdateUserExtensionSettings(c *gin.Context) {
|
|
userID := c.GetString("user_id")
|
|
extID := c.Param("id")
|
|
|
|
// Verify extension exists
|
|
ext, err := h.stores.Extensions.GetByID(c.Request.Context(), extID)
|
|
if err == sql.ErrNoRows {
|
|
c.JSON(404, gin.H{"error": "extension not found"})
|
|
return
|
|
}
|
|
if err != nil {
|
|
c.JSON(500, gin.H{"error": "failed to fetch extension"})
|
|
return
|
|
}
|
|
|
|
// System extensions can't be disabled by users
|
|
var body struct {
|
|
Settings json.RawMessage `json:"settings"`
|
|
IsEnabled *bool `json:"is_enabled"`
|
|
}
|
|
if err := c.ShouldBindJSON(&body); err != nil {
|
|
c.JSON(400, gin.H{"error": "invalid request body"})
|
|
return
|
|
}
|
|
|
|
enabled := true
|
|
if body.IsEnabled != nil {
|
|
if ext.IsSystem && !*body.IsEnabled {
|
|
c.JSON(403, gin.H{"error": "system extensions cannot be disabled"})
|
|
return
|
|
}
|
|
enabled = *body.IsEnabled
|
|
}
|
|
|
|
settings := json.RawMessage("{}")
|
|
if body.Settings != nil {
|
|
settings = body.Settings
|
|
}
|
|
|
|
eus := &models.ExtensionUserSettings{
|
|
ExtensionID: extID,
|
|
UserID: userID,
|
|
Settings: settings,
|
|
IsEnabled: enabled,
|
|
}
|
|
if err := h.stores.Extensions.SetUserSettings(c.Request.Context(), eus); err != nil {
|
|
c.JSON(500, gin.H{"error": "failed to save settings"})
|
|
return
|
|
}
|
|
|
|
c.JSON(200, gin.H{"ok": true})
|
|
}
|
|
|
|
// ── Admin endpoints ─────────────────────────────
|
|
|
|
// AdminListExtensions returns all extensions (enabled and disabled).
|
|
// GET /api/v1/admin/extensions
|
|
func (h *ExtensionHandler) AdminListExtensions(c *gin.Context) {
|
|
exts, err := h.stores.Extensions.ListAll(c.Request.Context())
|
|
if err != nil {
|
|
c.JSON(500, gin.H{"error": "failed to list extensions"})
|
|
return
|
|
}
|
|
c.JSON(200, gin.H{"data": exts})
|
|
}
|
|
|
|
// AdminInstallExtension installs an extension from a manifest.
|
|
// POST /api/v1/admin/extensions
|
|
func (h *ExtensionHandler) AdminInstallExtension(c *gin.Context) {
|
|
userID := c.GetString("user_id")
|
|
|
|
var body struct {
|
|
ExtID string `json:"ext_id" binding:"required"`
|
|
Name string `json:"name" binding:"required"`
|
|
Version string `json:"version"`
|
|
Tier string `json:"tier"`
|
|
Description string `json:"description"`
|
|
Author string `json:"author"`
|
|
Manifest json.RawMessage `json:"manifest"`
|
|
IsSystem bool `json:"is_system"`
|
|
IsEnabled bool `json:"is_enabled"`
|
|
}
|
|
if err := c.ShouldBindJSON(&body); err != nil {
|
|
c.JSON(400, gin.H{"error": "invalid request: " + err.Error()})
|
|
return
|
|
}
|
|
|
|
// Defaults
|
|
if body.Version == "" {
|
|
body.Version = "0.0.0"
|
|
}
|
|
if body.Tier == "" {
|
|
body.Tier = models.ExtTierBrowser
|
|
}
|
|
if body.Manifest == nil {
|
|
body.Manifest = json.RawMessage("{}")
|
|
}
|
|
|
|
// Check for duplicate ext_id
|
|
existing, err := h.stores.Extensions.GetByExtID(c.Request.Context(), body.ExtID)
|
|
if err != nil && err != sql.ErrNoRows {
|
|
c.JSON(500, gin.H{"error": "failed to check existing extension"})
|
|
return
|
|
}
|
|
if existing != nil {
|
|
c.JSON(409, gin.H{"error": "extension with ext_id '" + body.ExtID + "' already installed"})
|
|
return
|
|
}
|
|
|
|
ext := &models.Extension{
|
|
ExtID: body.ExtID,
|
|
Name: body.Name,
|
|
Version: body.Version,
|
|
Tier: body.Tier,
|
|
Description: body.Description,
|
|
Author: body.Author,
|
|
Manifest: body.Manifest,
|
|
IsSystem: body.IsSystem,
|
|
IsEnabled: body.IsEnabled,
|
|
Scope: models.ScopeGlobal,
|
|
InstalledBy: &userID,
|
|
}
|
|
|
|
if err := h.stores.Extensions.Create(c.Request.Context(), ext); err != nil {
|
|
c.JSON(500, gin.H{"error": "failed to install extension"})
|
|
return
|
|
}
|
|
|
|
c.JSON(201, gin.H{"data": ext})
|
|
}
|
|
|
|
// AdminUpdateExtension updates an extension's config.
|
|
// PUT /api/v1/admin/extensions/:id
|
|
func (h *ExtensionHandler) AdminUpdateExtension(c *gin.Context) {
|
|
id := c.Param("id")
|
|
|
|
ext, err := h.stores.Extensions.GetByID(c.Request.Context(), id)
|
|
if err == sql.ErrNoRows {
|
|
c.JSON(404, gin.H{"error": "extension not found"})
|
|
return
|
|
}
|
|
if err != nil {
|
|
c.JSON(500, gin.H{"error": "failed to fetch extension"})
|
|
return
|
|
}
|
|
|
|
var body struct {
|
|
Name *string `json:"name"`
|
|
Description *string `json:"description"`
|
|
IsSystem *bool `json:"is_system"`
|
|
IsEnabled *bool `json:"is_enabled"`
|
|
Manifest *json.RawMessage `json:"manifest"`
|
|
}
|
|
if err := c.ShouldBindJSON(&body); err != nil {
|
|
c.JSON(400, gin.H{"error": "invalid request body"})
|
|
return
|
|
}
|
|
|
|
if body.Name != nil {
|
|
ext.Name = *body.Name
|
|
}
|
|
if body.Description != nil {
|
|
ext.Description = *body.Description
|
|
}
|
|
if body.IsSystem != nil {
|
|
ext.IsSystem = *body.IsSystem
|
|
}
|
|
if body.IsEnabled != nil {
|
|
ext.IsEnabled = *body.IsEnabled
|
|
}
|
|
if body.Manifest != nil {
|
|
ext.Manifest = *body.Manifest
|
|
}
|
|
|
|
if err := h.stores.Extensions.Update(c.Request.Context(), id, ext); err != nil {
|
|
c.JSON(500, gin.H{"error": "failed to update extension"})
|
|
return
|
|
}
|
|
|
|
c.JSON(200, gin.H{"data": ext})
|
|
}
|
|
|
|
// ServeExtensionAsset serves browser extension JS files.
|
|
// GET /api/v1/extensions/:id/assets/*path
|
|
//
|
|
// For the MVP, scripts are stored inline in manifest._script.
|
|
// The :id param is the ext_id (e.g. "mermaid-renderer"), not the UUID.
|
|
func (h *ExtensionHandler) ServeExtensionAsset(c *gin.Context) {
|
|
extID := c.Param("id")
|
|
// path := c.Param("path") // reserved for future multi-file support
|
|
|
|
ext, err := h.stores.Extensions.GetByExtID(c.Request.Context(), extID)
|
|
if err != nil {
|
|
c.JSON(404, gin.H{"error": "extension not found"})
|
|
return
|
|
}
|
|
|
|
if !ext.IsEnabled {
|
|
c.JSON(404, gin.H{"error": "extension not enabled"})
|
|
return
|
|
}
|
|
|
|
// Extract inline script from manifest
|
|
var manifest map[string]json.RawMessage
|
|
if err := json.Unmarshal(ext.Manifest, &manifest); err != nil {
|
|
c.JSON(500, gin.H{"error": "invalid manifest"})
|
|
return
|
|
}
|
|
|
|
scriptRaw, ok := manifest["_script"]
|
|
if !ok {
|
|
c.JSON(404, gin.H{"error": "no script in manifest"})
|
|
return
|
|
}
|
|
|
|
// _script is a JSON string — unquote it
|
|
var script string
|
|
if err := json.Unmarshal(scriptRaw, &script); err != nil {
|
|
c.JSON(500, gin.H{"error": "invalid script in manifest"})
|
|
return
|
|
}
|
|
|
|
c.Header("Content-Type", "application/javascript; charset=utf-8")
|
|
c.Header("Cache-Control", "public, max-age=3600")
|
|
c.String(200, script)
|
|
}
|
|
|
|
// GetExtensionManifest returns the manifest for a specific extension.
|
|
// GET /api/v1/extensions/:id/manifest
|
|
func (h *ExtensionHandler) GetExtensionManifest(c *gin.Context) {
|
|
extID := c.Param("id")
|
|
|
|
ext, err := h.stores.Extensions.GetByExtID(c.Request.Context(), extID)
|
|
if err != nil {
|
|
c.JSON(404, gin.H{"error": "extension not found"})
|
|
return
|
|
}
|
|
|
|
c.JSON(200, gin.H{"data": ext.Manifest})
|
|
}
|
|
|
|
// ListBrowserToolSchemas returns tool schemas from all enabled browser extensions.
|
|
// Used by the completion handler to include browser tools in LLM requests.
|
|
// GET /api/v1/extensions/tools
|
|
func (h *ExtensionHandler) ListBrowserToolSchemas(c *gin.Context) {
|
|
userID := c.GetString("user_id")
|
|
|
|
exts, err := h.stores.Extensions.ListForUser(c.Request.Context(), userID)
|
|
if err != nil {
|
|
c.JSON(500, gin.H{"error": "failed to list extensions"})
|
|
return
|
|
}
|
|
|
|
var toolSchemas []json.RawMessage
|
|
for _, ext := range exts {
|
|
if ext.Tier != "browser" {
|
|
continue
|
|
}
|
|
var manifest struct {
|
|
Tools []json.RawMessage `json:"tools"`
|
|
}
|
|
if err := json.Unmarshal(ext.Manifest, &manifest); err != nil {
|
|
continue
|
|
}
|
|
toolSchemas = append(toolSchemas, manifest.Tools...)
|
|
}
|
|
|
|
if toolSchemas == nil {
|
|
toolSchemas = make([]json.RawMessage, 0)
|
|
}
|
|
c.JSON(200, gin.H{"data": toolSchemas})
|
|
}
|
|
|
|
// AdminUninstallExtension removes an extension.
|
|
// DELETE /api/v1/admin/extensions/:id
|
|
func (h *ExtensionHandler) AdminUninstallExtension(c *gin.Context) {
|
|
id := c.Param("id")
|
|
|
|
_, err := h.stores.Extensions.GetByID(c.Request.Context(), id)
|
|
if err == sql.ErrNoRows {
|
|
c.JSON(404, gin.H{"error": "extension not found"})
|
|
return
|
|
}
|
|
if err != nil {
|
|
c.JSON(500, gin.H{"error": "failed to fetch extension"})
|
|
return
|
|
}
|
|
|
|
if err := h.stores.Extensions.Delete(c.Request.Context(), id); err != nil {
|
|
c.JSON(500, gin.H{"error": "failed to uninstall extension"})
|
|
return
|
|
}
|
|
|
|
c.JSON(200, gin.H{"ok": true})
|
|
}
|