package handlers import ( "crypto/ed25519" "crypto/rand" "encoding/json" "encoding/pem" "fmt" "net/http" "strconv" "golang.org/x/crypto/ssh" "git.gobha.me/xcaliber/chat-switchboard/crypto" "git.gobha.me/xcaliber/chat-switchboard/models" "git.gobha.me/xcaliber/chat-switchboard/store" "git.gobha.me/xcaliber/chat-switchboard/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, }) }