All checks were successful
Co-authored-by: Jeffrey Smith <jasafpro@gmail.com> Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
311 lines
9.1 KiB
Go
311 lines
9.1 KiB
Go
package handlers
|
|
|
|
// Pattern follows apiconfigs.go (ProviderConfigHandler).
|
|
|
|
import (
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"net/http"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
|
|
"armature/crypto"
|
|
"armature/models"
|
|
"armature/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)
|
|
// so users can see which connections are available to them.
|
|
conns, err := h.stores.Connections.ListAccessible(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 — base64 encoded by json.Marshal for []byte).
|
|
func toBytes(v interface{}) []byte {
|
|
switch t := v.(type) {
|
|
case []byte:
|
|
return t
|
|
case string:
|
|
if t == "" {
|
|
return nil
|
|
}
|
|
// JSON round-trip: json.Marshal encodes []byte as base64 strings.
|
|
// Decode back to the original bytes.
|
|
decoded, err := base64.StdEncoding.DecodeString(t)
|
|
if err != nil {
|
|
// Fallback: might be raw string (shouldn't happen, but be safe)
|
|
return []byte(t)
|
|
}
|
|
return decoded
|
|
}
|
|
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"),
|
|
}
|
|
}
|