This repository has been archived on 2026-04-03. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
core/server/handlers/git.go
2026-03-15 01:33:38 +00:00

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"
"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,
})
}