- go.mod module name
- All 714 import references across 289 Go files
- VERSION: 0.1.0
- CI DB names: switchboard_core_{ci,dev,test}
- Docker image: gobha/switchboard-core
- Test fixtures: JWT issuer, repo names
- .env.example, docker-compose container name
- Compiles clean (go build exit 0)
437 lines
13 KiB
Go
437 lines
13 KiB
Go
package handlers
|
|
|
|
import (
|
|
"crypto/ed25519"
|
|
"crypto/rand"
|
|
"encoding/json"
|
|
"encoding/pem"
|
|
"fmt"
|
|
"net/http"
|
|
"strconv"
|
|
|
|
"golang.org/x/crypto/ssh"
|
|
|
|
"switchboard-core/crypto"
|
|
"switchboard-core/models"
|
|
"switchboard-core/store"
|
|
"switchboard-core/workspace"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
)
|
|
|
|
// =========================================
|
|
// GIT HANDLER — Git operations on workspaces
|
|
// =========================================
|
|
|
|
type GitHandler struct {
|
|
stores store.Stores
|
|
gitOps *workspace.GitOps
|
|
}
|
|
|
|
func NewGitHandler(s store.Stores, gitOps *workspace.GitOps) *GitHandler {
|
|
return &GitHandler{stores: s, gitOps: gitOps}
|
|
}
|
|
|
|
// ── Clone ────────────────────────────────────
|
|
|
|
func (h *GitHandler) Clone(c *gin.Context) {
|
|
wsID := c.Param("id")
|
|
var req struct {
|
|
URL string `json:"url" binding:"required"`
|
|
Branch string `json:"branch"`
|
|
CredentialID string `json:"credential_id"`
|
|
}
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
w, err := h.stores.Workspaces.GetByID(c.Request.Context(), wsID)
|
|
if err != nil {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "workspace not found"})
|
|
return
|
|
}
|
|
|
|
if err := h.gitOps.Clone(c.Request.Context(), w, req.URL, req.Branch, req.CredentialID); err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{"status": "cloned"})
|
|
}
|
|
|
|
// ── Pull ─────────────────────────────────────
|
|
|
|
func (h *GitHandler) Pull(c *gin.Context) {
|
|
w, err := h.loadWorkspace(c)
|
|
if err != nil {
|
|
return
|
|
}
|
|
if err := h.gitOps.Pull(c.Request.Context(), w); err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{"status": "pulled"})
|
|
}
|
|
|
|
// ── Push ─────────────────────────────────────
|
|
|
|
func (h *GitHandler) Push(c *gin.Context) {
|
|
w, err := h.loadWorkspace(c)
|
|
if err != nil {
|
|
return
|
|
}
|
|
if err := h.gitOps.Push(c.Request.Context(), w); err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{"status": "pushed"})
|
|
}
|
|
|
|
// ── Status ───────────────────────────────────
|
|
|
|
func (h *GitHandler) Status(c *gin.Context) {
|
|
w, err := h.loadWorkspace(c)
|
|
if err != nil {
|
|
return
|
|
}
|
|
status, err := h.gitOps.Status(c.Request.Context(), w)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, status)
|
|
}
|
|
|
|
// ── Diff ─────────────────────────────────────
|
|
|
|
func (h *GitHandler) Diff(c *gin.Context) {
|
|
w, err := h.loadWorkspace(c)
|
|
if err != nil {
|
|
return
|
|
}
|
|
path := c.Query("path")
|
|
diff, err := h.gitOps.Diff(c.Request.Context(), w, path)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{"diff": diff})
|
|
}
|
|
|
|
// ── Commit ───────────────────────────────────
|
|
|
|
func (h *GitHandler) Commit(c *gin.Context) {
|
|
w, err := h.loadWorkspace(c)
|
|
if err != nil {
|
|
return
|
|
}
|
|
var req struct {
|
|
Message string `json:"message" binding:"required"`
|
|
Paths []string `json:"paths"`
|
|
}
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
if err := h.gitOps.Commit(c.Request.Context(), w, req.Message, req.Paths); err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{"status": "committed"})
|
|
}
|
|
|
|
// ── Log ──────────────────────────────────────
|
|
|
|
func (h *GitHandler) Log(c *gin.Context) {
|
|
w, err := h.loadWorkspace(c)
|
|
if err != nil {
|
|
return
|
|
}
|
|
n := 20
|
|
if v := c.Query("n"); v != "" {
|
|
if parsed, err := strconv.Atoi(v); err == nil && parsed > 0 {
|
|
n = parsed
|
|
if n > 100 {
|
|
n = 100
|
|
}
|
|
}
|
|
}
|
|
entries, err := h.gitOps.Log(c.Request.Context(), w, n)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
if entries == nil {
|
|
entries = []models.GitLogEntry{}
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{"data": entries})
|
|
}
|
|
|
|
// ── Branches ─────────────────────────────────
|
|
|
|
func (h *GitHandler) Branches(c *gin.Context) {
|
|
w, err := h.loadWorkspace(c)
|
|
if err != nil {
|
|
return
|
|
}
|
|
branches, current, err := h.gitOps.BranchList(c.Request.Context(), w)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"current": current,
|
|
"branches": branches,
|
|
})
|
|
}
|
|
|
|
// ── Checkout ─────────────────────────────────
|
|
|
|
func (h *GitHandler) Checkout(c *gin.Context) {
|
|
w, err := h.loadWorkspace(c)
|
|
if err != nil {
|
|
return
|
|
}
|
|
var req struct {
|
|
Branch string `json:"branch" binding:"required"`
|
|
}
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
if err := h.gitOps.Checkout(c.Request.Context(), w, req.Branch); err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{"status": "checked out", "branch": req.Branch})
|
|
}
|
|
|
|
// loadWorkspace loads the workspace and validates git is configured.
|
|
func (h *GitHandler) loadWorkspace(c *gin.Context) (*models.Workspace, error) {
|
|
wsID := c.Param("id")
|
|
w, err := h.stores.Workspaces.GetByID(c.Request.Context(), wsID)
|
|
if err != nil {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "workspace not found"})
|
|
return nil, err
|
|
}
|
|
if w.GitRemoteURL == nil || *w.GitRemoteURL == "" {
|
|
err := fmt.Errorf("workspace has no git remote configured")
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return nil, err
|
|
}
|
|
return w, nil
|
|
}
|
|
|
|
// =========================================
|
|
// GIT CREDENTIAL HANDLER — CRUD for encrypted git credentials
|
|
// =========================================
|
|
|
|
type GitCredentialHandler struct {
|
|
stores store.Stores
|
|
vault *crypto.KeyResolver
|
|
}
|
|
|
|
func NewGitCredentialHandler(s store.Stores, vault *crypto.KeyResolver) *GitCredentialHandler {
|
|
return &GitCredentialHandler{stores: s, vault: vault}
|
|
}
|
|
|
|
// Create stores a new encrypted git credential.
|
|
func (h *GitCredentialHandler) Create(c *gin.Context) {
|
|
userID := getUserID(c)
|
|
var req struct {
|
|
Name string `json:"name" binding:"required"`
|
|
AuthType string `json:"auth_type" binding:"required"`
|
|
// Raw credential data — encrypted before storage
|
|
Token string `json:"token,omitempty"` // for https_pat
|
|
Username string `json:"username,omitempty"` // for https_basic
|
|
Password string `json:"password,omitempty"` // for https_basic
|
|
PrivateKey string `json:"private_key,omitempty"` // for ssh_key
|
|
Passphrase string `json:"passphrase,omitempty"` // for ssh_key
|
|
}
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
// Validate auth type
|
|
switch req.AuthType {
|
|
case "https_pat":
|
|
if req.Token == "" {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "token is required for https_pat"})
|
|
return
|
|
}
|
|
case "https_basic":
|
|
if req.Username == "" || req.Password == "" {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "username and password required for https_basic"})
|
|
return
|
|
}
|
|
case "ssh_key":
|
|
if req.PrivateKey == "" {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "private_key is required for ssh_key"})
|
|
return
|
|
}
|
|
default:
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "auth_type must be https_pat, https_basic, or ssh_key"})
|
|
return
|
|
}
|
|
|
|
// Build plaintext JSON
|
|
var credData map[string]string
|
|
switch req.AuthType {
|
|
case "https_pat":
|
|
credData = map[string]string{"token": req.Token}
|
|
case "https_basic":
|
|
credData = map[string]string{"username": req.Username, "password": req.Password}
|
|
case "ssh_key":
|
|
credData = map[string]string{"private_key": req.PrivateKey, "passphrase": req.Passphrase}
|
|
}
|
|
plaintext, _ := json.Marshal(credData)
|
|
|
|
// Encrypt using global scope (same vault pattern as BYOK)
|
|
ciphertext, nonce, err := h.vault.EncryptForScope(string(plaintext), "global", userID)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "encryption failed"})
|
|
return
|
|
}
|
|
|
|
cred := &models.GitCredential{
|
|
UserID: userID,
|
|
Name: req.Name,
|
|
AuthType: req.AuthType,
|
|
EncryptedData: ciphertext,
|
|
Nonce: nonce,
|
|
}
|
|
|
|
if err := h.stores.GitCredentials.Create(c.Request.Context(), cred); err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusCreated, cred.Summary())
|
|
}
|
|
|
|
// List returns all git credentials for the current user (summaries only).
|
|
func (h *GitCredentialHandler) List(c *gin.Context) {
|
|
userID := getUserID(c)
|
|
creds, err := h.stores.GitCredentials.ListByUser(c.Request.Context(), userID)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
summaries := make([]models.GitCredentialSummary, 0, len(creds))
|
|
for _, cred := range creds {
|
|
summaries = append(summaries, cred.Summary())
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{"data": summaries})
|
|
}
|
|
|
|
// Delete removes a git credential.
|
|
func (h *GitCredentialHandler) Delete(c *gin.Context) {
|
|
userID := getUserID(c)
|
|
credID := c.Param("id")
|
|
|
|
if err := h.stores.GitCredentials.Delete(c.Request.Context(), credID, userID); err != nil {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "credential not found"})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{"deleted": true})
|
|
}
|
|
|
|
// Generate creates a new ED25519 SSH keypair server-side.
|
|
// The private key is vault-encrypted before storage. Only the public key
|
|
// and fingerprint are returned — the private key never leaves the server.
|
|
//
|
|
// POST /git-credentials/generate
|
|
func (h *GitCredentialHandler) Generate(c *gin.Context) {
|
|
userID := getUserID(c)
|
|
var req struct {
|
|
Name string `json:"name" binding:"required"`
|
|
PersonaID *string `json:"persona_id,omitempty"`
|
|
}
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
// Generate ED25519 keypair
|
|
pubKey, privKey, err := ed25519.GenerateKey(rand.Reader)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "key generation failed"})
|
|
return
|
|
}
|
|
|
|
// Marshal public key to authorized_keys format
|
|
sshPub, err := ssh.NewPublicKey(pubKey)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "public key encoding failed"})
|
|
return
|
|
}
|
|
authorizedKey := string(ssh.MarshalAuthorizedKey(sshPub))
|
|
|
|
// Fingerprint (SHA256)
|
|
fingerprint := ssh.FingerprintSHA256(sshPub)
|
|
|
|
// Marshal private key to OpenSSH PEM format
|
|
privPEM, err := ssh.MarshalPrivateKey(privKey, "")
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "private key encoding failed"})
|
|
return
|
|
}
|
|
privPEMBytes := pem.EncodeToMemory(privPEM)
|
|
|
|
// Encrypt private key via vault
|
|
credData := map[string]string{"private_key": string(privPEMBytes)}
|
|
plaintext, _ := json.Marshal(credData)
|
|
|
|
ciphertext, nonce, err := h.vault.EncryptForScope(string(plaintext), "global", userID)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "encryption failed"})
|
|
return
|
|
}
|
|
|
|
cred := &models.GitCredential{
|
|
UserID: userID,
|
|
Name: req.Name,
|
|
AuthType: "ssh_key",
|
|
EncryptedData: ciphertext,
|
|
Nonce: nonce,
|
|
PublicKey: authorizedKey,
|
|
Fingerprint: fingerprint,
|
|
PersonaID: req.PersonaID,
|
|
}
|
|
|
|
if err := h.stores.GitCredentials.Create(c.Request.Context(), cred); err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusCreated, cred.Summary())
|
|
}
|
|
|
|
// GetPublicKey returns the public key for a credential (for copy-to-clipboard).
|
|
//
|
|
// GET /git-credentials/:id/public-key
|
|
func (h *GitCredentialHandler) GetPublicKey(c *gin.Context) {
|
|
userID := getUserID(c)
|
|
credID := c.Param("id")
|
|
|
|
cred, err := h.stores.GitCredentials.GetByID(c.Request.Context(), credID)
|
|
if err != nil || cred.UserID != userID {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "credential not found"})
|
|
return
|
|
}
|
|
|
|
if cred.PublicKey == "" {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "no public key for this credential type"})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"public_key": cred.PublicKey,
|
|
"fingerprint": cred.Fingerprint,
|
|
})
|
|
}
|