Changeset 0.38.1 (#234)
Co-authored-by: gobha <jasafpro@gmail.com> Co-committed-by: gobha <jasafpro@gmail.com>
This commit is contained in:
299
server/handlers/connections.go
Normal file
299
server/handlers/connections.go
Normal file
@@ -0,0 +1,299 @@
|
||||
package handlers
|
||||
|
||||
// v0.38.1: Extension connection handlers — personal scope + resolution.
|
||||
// Pattern follows apiconfigs.go (ProviderConfigHandler).
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"chat-switchboard/crypto"
|
||||
"chat-switchboard/models"
|
||||
"chat-switchboard/store"
|
||||
)
|
||||
|
||||
// ConnectionHandler handles user-facing extension connection endpoints.
|
||||
type ConnectionHandler struct {
|
||||
stores store.Stores
|
||||
vault *crypto.KeyResolver
|
||||
}
|
||||
|
||||
func NewConnectionHandler(s store.Stores, vault *crypto.KeyResolver) *ConnectionHandler {
|
||||
return &ConnectionHandler{stores: s, vault: vault}
|
||||
}
|
||||
|
||||
// ListConnections returns the user's personal connections.
|
||||
func (h *ConnectionHandler) ListConnections(c *gin.Context) {
|
||||
userID := getUserID(c)
|
||||
conns, err := h.stores.Connections.ListForUser(c.Request.Context(), userID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list connections"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"data": maskConnectionSecrets(conns)})
|
||||
}
|
||||
|
||||
// CreateConnection creates a personal-scope connection.
|
||||
func (h *ConnectionHandler) CreateConnection(c *gin.Context) {
|
||||
userID := getUserID(c)
|
||||
|
||||
var req struct {
|
||||
Type string `json:"type" binding:"required"`
|
||||
PackageID string `json:"package_id" binding:"required"`
|
||||
Name string `json:"name" binding:"required"`
|
||||
Config models.JSONMap `json:"config"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
secretFields := h.lookupSecretFields(c, req.PackageID, req.Type)
|
||||
encConfig := h.encryptSecrets(req.Config, secretFields, models.ScopePersonal, userID)
|
||||
|
||||
conn := &models.ExtConnection{
|
||||
Type: req.Type,
|
||||
PackageID: req.PackageID,
|
||||
Scope: models.ScopePersonal,
|
||||
OwnerID: userID,
|
||||
Name: req.Name,
|
||||
Config: encConfig,
|
||||
IsActive: true,
|
||||
}
|
||||
|
||||
if err := h.stores.Connections.Create(c.Request.Context(), conn); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create connection"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, gin.H{
|
||||
"id": conn.ID,
|
||||
"type": conn.Type,
|
||||
"name": conn.Name,
|
||||
})
|
||||
}
|
||||
|
||||
// GetConnection returns a single connection by ID.
|
||||
func (h *ConnectionHandler) GetConnection(c *gin.Context) {
|
||||
userID := getUserID(c)
|
||||
id := c.Param("id")
|
||||
|
||||
conn, err := h.stores.Connections.GetByID(c.Request.Context(), id)
|
||||
if err != nil || conn.Scope != models.ScopePersonal || conn.OwnerID != userID {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "connection not found"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, maskSingleConnection(conn))
|
||||
}
|
||||
|
||||
// UpdateConnection updates a personal connection.
|
||||
func (h *ConnectionHandler) UpdateConnection(c *gin.Context) {
|
||||
userID := getUserID(c)
|
||||
id := c.Param("id")
|
||||
|
||||
existing, err := h.stores.Connections.GetByID(c.Request.Context(), id)
|
||||
if err != nil || existing.Scope != models.ScopePersonal || existing.OwnerID != userID {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "can only update your own connections"})
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Name *string `json:"name,omitempty"`
|
||||
Config models.JSONMap `json:"config,omitempty"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
patch := models.ExtConnectionPatch{Name: req.Name}
|
||||
if req.Config != nil {
|
||||
secretFields := h.lookupSecretFields(c, existing.PackageID, existing.Type)
|
||||
patch.Config = h.encryptSecrets(req.Config, secretFields, models.ScopePersonal, userID)
|
||||
}
|
||||
|
||||
if err := h.stores.Connections.Update(c.Request.Context(), id, patch); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update connection"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "connection updated"})
|
||||
}
|
||||
|
||||
// DeleteConnection deletes a personal connection.
|
||||
func (h *ConnectionHandler) DeleteConnection(c *gin.Context) {
|
||||
userID := getUserID(c)
|
||||
id := c.Param("id")
|
||||
|
||||
n, err := h.stores.Connections.DeleteByIDAndScope(c.Request.Context(), id, models.ScopePersonal, userID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete connection"})
|
||||
return
|
||||
}
|
||||
if n == 0 {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "connection not found"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "connection deleted"})
|
||||
}
|
||||
|
||||
// ResolveConnection resolves a connection via the scope chain.
|
||||
// GET /connections/resolve?type=gitea&name=Work+Gitea
|
||||
func (h *ConnectionHandler) ResolveConnection(c *gin.Context) {
|
||||
userID := getUserID(c)
|
||||
connType := c.Query("type")
|
||||
if connType == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "type query parameter required"})
|
||||
return
|
||||
}
|
||||
name := c.Query("name")
|
||||
|
||||
conn, err := h.stores.Connections.Resolve(c.Request.Context(), userID, connType, name)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "no connection found"})
|
||||
return
|
||||
}
|
||||
|
||||
// Decrypt secrets for the resolver response (used by frontend to test connections).
|
||||
secretFields := h.lookupSecretFields(c, conn.PackageID, conn.Type)
|
||||
conn.Config = h.decryptSecrets(conn.Config, secretFields, conn.Scope, conn.OwnerID)
|
||||
|
||||
c.JSON(http.StatusOK, conn)
|
||||
}
|
||||
|
||||
// ── Encryption helpers ─────────────────────────────
|
||||
|
||||
// lookupSecretFields reads the package manifest to identify which config
|
||||
// fields are of type "secret" for a given connection type.
|
||||
func (h *ConnectionHandler) lookupSecretFields(c *gin.Context, packageID, connType string) map[string]bool {
|
||||
result := map[string]bool{}
|
||||
pkg, err := h.stores.Packages.Get(c.Request.Context(), packageID)
|
||||
if err != nil || pkg == nil {
|
||||
return result
|
||||
}
|
||||
connsRaw, ok := pkg.Manifest["connections"]
|
||||
if !ok {
|
||||
return result
|
||||
}
|
||||
connsJSON, _ := json.Marshal(connsRaw)
|
||||
var conns []struct {
|
||||
Type string `json:"type"`
|
||||
Fields map[string]map[string]string `json:"fields"`
|
||||
}
|
||||
json.Unmarshal(connsJSON, &conns)
|
||||
for _, cd := range conns {
|
||||
if cd.Type == connType {
|
||||
for fieldName, fieldDef := range cd.Fields {
|
||||
if fieldDef["type"] == "secret" {
|
||||
result[fieldName] = true
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// encryptSecrets encrypts secret-type fields within a config map.
|
||||
// Non-secret fields pass through unchanged.
|
||||
func (h *ConnectionHandler) encryptSecrets(config models.JSONMap, secretFields map[string]bool, scope, ownerID string) models.JSONMap {
|
||||
if config == nil || h.vault == nil {
|
||||
return config
|
||||
}
|
||||
out := make(models.JSONMap, len(config))
|
||||
for k, v := range config {
|
||||
if secretFields[k] {
|
||||
if s, ok := v.(string); ok && s != "" {
|
||||
enc, nonce, err := h.vault.EncryptForScope(s, scope, ownerID)
|
||||
if err == nil {
|
||||
out[k] = map[string]interface{}{
|
||||
"enc": enc,
|
||||
"nonce": nonce,
|
||||
}
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
out[k] = v
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// decryptSecrets decrypts secret-type fields within a config map.
|
||||
func (h *ConnectionHandler) decryptSecrets(config models.JSONMap, secretFields map[string]bool, scope, ownerID string) models.JSONMap {
|
||||
if config == nil || h.vault == nil {
|
||||
return config
|
||||
}
|
||||
out := make(models.JSONMap, len(config))
|
||||
for k, v := range config {
|
||||
if secretFields[k] {
|
||||
if m, ok := v.(map[string]interface{}); ok {
|
||||
encRaw, _ := m["enc"]
|
||||
nonceRaw, _ := m["nonce"]
|
||||
// Type-assert to []byte or base64-decode from string
|
||||
enc := toBytes(encRaw)
|
||||
nonce := toBytes(nonceRaw)
|
||||
if len(enc) > 0 {
|
||||
plaintext, err := h.vault.Decrypt(enc, nonce, scope, ownerID)
|
||||
if err == nil {
|
||||
out[k] = plaintext
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out[k] = v
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// toBytes coerces an interface{} to []byte. Handles both []byte (from Go)
|
||||
// and string (from JSON unmarshal of base64).
|
||||
func toBytes(v interface{}) []byte {
|
||||
switch t := v.(type) {
|
||||
case []byte:
|
||||
return t
|
||||
case string:
|
||||
return []byte(t)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ── Response masking ───────────────────────────────
|
||||
|
||||
// maskConnectionSecrets returns safe connection summaries (no secret values).
|
||||
func maskConnectionSecrets(conns []models.ExtConnection) []map[string]interface{} {
|
||||
out := make([]map[string]interface{}, len(conns))
|
||||
for i, c := range conns {
|
||||
out[i] = map[string]interface{}{
|
||||
"id": c.ID,
|
||||
"type": c.Type,
|
||||
"package_id": c.PackageID,
|
||||
"scope": c.Scope,
|
||||
"name": c.Name,
|
||||
"is_active": c.IsActive,
|
||||
"has_config": len(c.Config) > 0,
|
||||
"created_at": c.CreatedAt.Format("2006-01-02T15:04:05Z"),
|
||||
"updated_at": c.UpdatedAt.Format("2006-01-02T15:04:05Z"),
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func maskSingleConnection(c *models.ExtConnection) map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"id": c.ID,
|
||||
"type": c.Type,
|
||||
"package_id": c.PackageID,
|
||||
"scope": c.Scope,
|
||||
"name": c.Name,
|
||||
"is_active": c.IsActive,
|
||||
"has_config": len(c.Config) > 0,
|
||||
"created_at": c.CreatedAt.Format("2006-01-02T15:04:05Z"),
|
||||
"updated_at": c.UpdatedAt.Format("2006-01-02T15:04:05Z"),
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user