package controllers
import (
"context"
"encoding/json"
"fmt"
"net/http"
"os"
"os/exec"
"path/filepath"
"regexp"
"strings"
"time"
"theskyscape.com/repo/skycode/models"
"theskyscape.com/repo/skykit"
)
// Controller constants
const (
// Git command timeout
gitTimeout = 60 * time.Second
// Maximum request body size (10MB)
maxRequestBodySize = 10 * 1024 * 1024
// Search limits
maxSearchResults = 100
maxRegexLength = 200
searchContextWindow = 30
// Session cookie expiry (30 minutes)
sessionCookieMaxAge = 30 * 60
// Clone redirect cookie expiry (10 minutes)
cloneCookieMaxAge = 600
// Settings validation limits
minFontSize = 10
maxFontSize = 32
minTabSize = 1
maxTabSize = 8
// Shell metacharacters that could be used for command injection
shellMetaChars = ";&|`$(){}[]<>\\\"'"
shellMetaCharsWithWS = ";&|`$(){}[]<>\\\"' \t\n"
)
// isValidGitURL validates a git clone URL for protocol and shell safety
func isValidGitURL(url string) bool {
// Must use a valid protocol
if !strings.HasPrefix(url, "https://") && !strings.HasPrefix(url, "http://") && !strings.HasPrefix(url, "git://") {
return false
}
// Block shell metacharacters
if strings.ContainsAny(url, shellMetaChars) {
return false
}
return true
}
// isValidGitRef validates a git branch or remote name for shell safety
func isValidGitRef(name string) bool {
return !strings.ContainsAny(name, shellMetaCharsWithWS)
}
// gitCommand creates a git command with timeout context
func gitCommand(ctx context.Context, dir string, args ...string) *exec.Cmd {
cmd := exec.CommandContext(ctx, "git", args...)
cmd.Dir = dir
return cmd
}
// gitCommandWithCredentials creates a git command that uses provided credentials
// via GIT_ASKPASS for authentication (credentials are not saved)
func gitCommandWithCredentials(ctx context.Context, dir, username, password string, args ...string) *exec.Cmd {
cmd := exec.CommandContext(ctx, "git", args...)
cmd.Dir = dir
// Create environment with credential helper
// GIT_ASKPASS is called with "Username for ..." or "Password for ..." as argument
// We use a simple shell command that echoes the appropriate credential
askpassScript := fmt.Sprintf(`#!/bin/sh
case "$1" in
*Username*|*username*) echo '%s' ;;
*Password*|*password*|*token*|*Token*) echo '%s' ;;
esac`, username, password)
// Write temporary askpass script
tmpFile, err := os.CreateTemp("", "git-askpass-*.sh")
if err == nil {
tmpFile.WriteString(askpassScript)
tmpFile.Chmod(0700)
tmpFile.Close()
// Set environment to use the askpass script
cmd.Env = append(os.Environ(),
"GIT_ASKPASS="+tmpFile.Name(),
"GIT_TERMINAL_PROMPT=0",
)
// Clean up temp file after command completes (in a goroutine)
go func() {
// Wait a bit for the command to use the file
time.Sleep(5 * time.Second)
os.Remove(tmpFile.Name())
}()
}
return cmd
}
// isAuthError checks if git output indicates an authentication failure
func isAuthError(output string) bool {
authPatterns := []string{
"Authentication failed",
"could not read Username",
"could not read Password",
"Invalid username or password",
"fatal: Authentication failed",
"remote: Invalid username or password",
"Authorization failed",
"403",
"401",
"Permission denied",
"support for password authentication was removed",
}
outputLower := strings.ToLower(output)
for _, pattern := range authPatterns {
if strings.Contains(outputLower, strings.ToLower(pattern)) {
return true
}
}
return false
}
func Code() (string, skykit.Handler) {
return "code", &CodeController{}
}
type CodeController struct {
skykit.Controller
}
func (c *CodeController) Setup(app *skykit.Application) {
c.Controller.Setup(app)
// Public routes - home page checks for pending clone
http.Handle("GET /", c.Serve("home.html", c.checkPendingClone))
// Protected routes - require authentication
http.Handle("GET /editor", c.Serve("editor.html", c.requireAuth))
// API routes
http.HandleFunc("GET /api/files", c.Protect(c.listFiles, c.requireAuth))
http.HandleFunc("GET /api/files/open", c.Protect(c.openFile, c.requireAuth))
http.HandleFunc("POST /api/files/save", c.Protect(c.saveFile, c.requireAuth))
http.HandleFunc("POST /api/files/rename", c.Protect(c.renameFile, c.requireAuth))
http.HandleFunc("DELETE /api/files", c.Protect(c.deleteFile, c.requireAuth))
http.HandleFunc("GET /api/search", c.Protect(c.searchFiles, c.requireAuth))
// Git API routes
http.HandleFunc("GET /api/git/status", c.Protect(c.gitStatus, c.requireAuth))
http.HandleFunc("GET /api/git/diff", c.Protect(c.gitDiff, c.requireAuth))
http.HandleFunc("POST /api/git/stage", c.Protect(c.gitStage, c.requireAuth))
http.HandleFunc("POST /api/git/unstage", c.Protect(c.gitUnstage, c.requireAuth))
http.HandleFunc("POST /api/git/commit", c.Protect(c.gitCommit, c.requireAuth))
http.HandleFunc("POST /api/git/init", c.Protect(c.gitInit, c.requireAuth))
// Settings API routes
http.HandleFunc("GET /api/settings", c.Protect(c.getSettings, c.requireAuth))
http.HandleFunc("PUT /api/settings", c.Protect(c.saveSettings, c.requireAuth))
// Workspace sync and clone
http.HandleFunc("POST /api/workspace/sync", c.Protect(c.syncWorkspace, c.requireAuth))
http.HandleFunc("POST /api/workspace/clone", c.Protect(c.cloneRepo, c.requireAuth))
// Public clone endpoint - redirects to signin if needed, then clones repo
http.HandleFunc("GET /clone", c.handleClone)
// Project API routes
http.HandleFunc("GET /api/projects", c.Protect(c.listProjects, c.requireAuth))
http.HandleFunc("POST /api/projects", c.Protect(c.createProject, c.requireAuth))
http.HandleFunc("DELETE /api/projects/{id}", c.Protect(c.deleteProject, c.requireAuth))
http.HandleFunc("POST /api/projects/{id}/default", c.Protect(c.setDefaultProject, c.requireAuth))
// Git branch operations
http.HandleFunc("GET /api/git/branches", c.Protect(c.gitBranches, c.requireAuth))
http.HandleFunc("POST /api/git/checkout", c.Protect(c.gitCheckout, c.requireAuth))
http.HandleFunc("POST /api/git/push", c.Protect(c.gitPush, c.requireAuth))
http.HandleFunc("POST /api/git/pull", c.Protect(c.gitPull, c.requireAuth))
}
func (c CodeController) Handle(r *http.Request) skykit.Handler {
c.Request = r
return &c
}
// checkPendingClone redirects to /clone if there's a pending clone cookie
func (c *CodeController) checkPendingClone(app *skykit.Application, w http.ResponseWriter, r *http.Request) bool {
if cookie, err := r.Cookie("clone_repo"); err == nil && cookie.Value != "" {
http.Redirect(w, r, "/clone", http.StatusSeeOther)
return false
}
return true
}
// jsonError writes a JSON error response
func jsonError(w http.ResponseWriter, msg string, status int) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(map[string]string{"error": msg})
}
// requireAuth redirects unauthenticated users to sign in
func (c *CodeController) requireAuth(app *skykit.Application, w http.ResponseWriter, r *http.Request) bool {
user, err := app.Users.Authenticate(r)
if err != nil || user == nil {
// For API routes, return 401
if r.Header.Get("Accept") == "application/json" || strings.HasPrefix(r.URL.Path, "/api") {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusUnauthorized)
json.NewEncoder(w).Encode(map[string]string{"error": "unauthorized"})
return false
}
// For page routes, redirect to signin
http.Redirect(w, r, "/auth/signin", http.StatusSeeOther)
return false
}
return true
}
// Template method - user's files for sidebar
func (c *CodeController) UserFiles() []*models.File {
user, err := c.Users.Authenticate(c.Request)
if err != nil || user == nil {
return nil
}
return models.ListFiles(user.ID)
}
// GET /api/files - list all files for current user
func (c *CodeController) listFiles(w http.ResponseWriter, r *http.Request) {
user, _ := c.Users.Authenticate(r)
files := models.ListFiles(user.ID)
type fileInfo struct {
Path string `json:"path"`
Language string `json:"language"`
}
out := make([]fileInfo, 0, len(files))
for _, f := range files {
out = append(out, fileInfo{Path: f.Path, Language: f.Language})
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(out)
}
// GET /api/files/open?path=main.go - open a file
func (c *CodeController) openFile(w http.ResponseWriter, r *http.Request) {
user, _ := c.Users.Authenticate(r)
path := r.URL.Query().Get("path")
if path == "" {
jsonError(w, "path required", http.StatusBadRequest)
return
}
file, err := models.GetFile(user.ID, path)
if err != nil {
jsonError(w, "file not found", http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{
"path": file.Path,
"content": file.Content,
"language": file.Language,
})
}
// POST /api/files/save - save a file
func (c *CodeController) saveFile(w http.ResponseWriter, r *http.Request) {
user, _ := c.Users.Authenticate(r)
// Limit request body size
r.Body = http.MaxBytesReader(w, r.Body, maxRequestBodySize)
var body struct {
Path string `json:"path"`
Content string `json:"content"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
jsonError(w, "invalid JSON or file too large", http.StatusBadRequest)
return
}
if body.Path == "" {
jsonError(w, "path required", http.StatusBadRequest)
return
}
// Check if user wants to persist workspace
settings := models.GetSettings(user.ID)
var file *models.File
var err error
if settings.PersistWorkspace {
// Save to database
file, err = models.SaveFile(user.ID, body.Path, body.Content)
if err != nil {
jsonError(w, err.Error(), http.StatusInternalServerError)
return
}
} else {
// Just create a file object for workspace sync (don't persist to DB)
file = &models.File{
Path: body.Path,
Content: body.Content,
}
}
// Sync to workspace if session exists
sessionID := c.getSessionID(r)
sess, err := c.getWorkspaceSession(r)
if err != nil {
models.LogError("saveFile:session_error", "sessionID="+sessionID+" err="+err.Error())
} else if sess == nil {
models.LogError("saveFile:no_session", "sessionID="+sessionID+" (terminal not opened yet?)")
} else if !sess.IsInitialized() {
models.LogError("saveFile:not_initialized", "sessionID="+sessionID+" workDir="+sess.WorkDir)
} else {
if err := sess.SyncFileToWorkspace(file.Path, file.Content); err != nil {
models.LogError("saveFile:sync_failed", "path="+file.Path+" workDir="+sess.WorkDir+" err="+err.Error())
}
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{
"ok": true,
"path": file.Path,
"language": file.Language,
})
}
// POST /api/files/rename - rename a file
func (c *CodeController) renameFile(w http.ResponseWriter, r *http.Request) {
user, _ := c.Users.Authenticate(r)
var body struct {
OldPath string `json:"oldPath"`
NewPath string `json:"newPath"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
jsonError(w, "invalid JSON", http.StatusBadRequest)
return
}
if body.OldPath == "" || body.NewPath == "" {
jsonError(w, "oldPath and newPath required", http.StatusBadRequest)
return
}
if err := models.RenameFile(user.ID, body.OldPath, body.NewPath); err != nil {
jsonError(w, err.Error(), http.StatusBadRequest)
return
}
// Rename in workspace if session exists
if sess, _ := c.getWorkspaceSession(r); sess != nil {
_ = sess.RenameFileInWorkspace(body.OldPath, body.NewPath)
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{"ok": true})
}
// DELETE /api/files?path=main.go - delete a file
func (c *CodeController) deleteFile(w http.ResponseWriter, r *http.Request) {
user, _ := c.Users.Authenticate(r)
path := r.URL.Query().Get("path")
if path == "" {
jsonError(w, "path required", http.StatusBadRequest)
return
}
if err := models.DeleteFile(user.ID, path); err != nil {
jsonError(w, err.Error(), http.StatusNotFound)
return
}
// Delete from workspace if session exists
if sess, _ := c.getWorkspaceSession(r); sess != nil {
_ = sess.DeleteFileFromWorkspace(path) // Ignore error - DB is source of truth
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{"ok": true})
}
func (c *CodeController) getSessionID(r *http.Request) string {
cookie, err := r.Cookie("skycode_session")
if err != nil {
return ""
}
return cookie.Value
}
// SearchResult represents a single search match
type SearchResult struct {
Path string `json:"path"`
Line int `json:"line"`
Text string `json:"text"`
Column int `json:"column"`
Context string `json:"context"` // Line with match highlighted
}
// GET /api/search?q=pattern®ex=true - search across all files
func (c *CodeController) searchFiles(w http.ResponseWriter, r *http.Request) {
user, _ := c.Users.Authenticate(r)
query := r.URL.Query().Get("q")
isRegex := r.URL.Query().Get("regex") == "true"
caseSensitive := r.URL.Query().Get("case") == "true"
if query == "" {
jsonError(w, "query required", http.StatusBadRequest)
return
}
files := models.ListFiles(user.ID)
results := []SearchResult{}
var re *regexp.Regexp
var err error
if isRegex {
// Limit regex length to prevent ReDoS
if len(query) > maxRegexLength {
jsonError(w, "regex pattern too long", http.StatusBadRequest)
return
}
if caseSensitive {
re, err = regexp.Compile(query)
} else {
re, err = regexp.Compile("(?i)" + query)
}
if err != nil {
jsonError(w, "invalid regex: "+err.Error(), http.StatusBadRequest)
return
}
} else if !caseSensitive {
query = strings.ToLower(query)
}
for _, f := range files {
if len(results) >= maxSearchResults {
break
}
content := f.Content
lines := strings.Split(content, "\n")
for lineNum, line := range lines {
if len(results) >= maxSearchResults {
break
}
var matches [][]int
if isRegex {
matches = re.FindAllStringIndex(line, -1)
} else {
searchLine := line
if !caseSensitive {
searchLine = strings.ToLower(line)
}
idx := 0
for {
pos := strings.Index(searchLine[idx:], query)
if pos == -1 {
break
}
matches = append(matches, []int{idx + pos, idx + pos + len(query)})
idx = idx + pos + 1
}
}
for _, match := range matches {
if len(results) >= maxSearchResults {
break
}
// Get a context window around the match
contextStart := max(0, match[0]-searchContextWindow)
contextEnd := min(len(line), match[1]+searchContextWindow)
context := ""
if contextStart > 0 {
context = "..."
}
context += line[contextStart:contextEnd]
if contextEnd < len(line) {
context += "..."
}
results = append(results, SearchResult{
Path: f.Path,
Line: lineNum + 1, // 1-indexed
Column: match[0] + 1,
Text: line[match[0]:match[1]],
Context: context,
})
}
}
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(results)
}
// GitFileStatus represents the status of a single file
type GitFileStatus struct {
Path string `json:"path"`
Status string `json:"status"` // M, A, D, ??, etc.
Staged bool `json:"staged"`
}
// getWorkspaceSession returns the workspace session for the current user (does not create)
func (c *CodeController) getWorkspaceSession(r *http.Request) (*models.Session, error) {
user, _ := c.Users.Authenticate(r)
if user == nil {
return nil, nil
}
sessionID := c.getSessionID(r)
if sessionID == "" {
return nil, nil
}
return models.GetOrCreateSession(sessionID, user.ID)
}
// getOrCreateWorkspaceSession returns or creates a workspace session, setting cookie if needed
func (c *CodeController) getOrCreateWorkspaceSession(w http.ResponseWriter, r *http.Request) (*models.Session, error) {
user, _ := c.Users.Authenticate(r)
if user == nil {
return nil, nil
}
sessionID := c.getSessionID(r)
if sessionID == "" {
// Create new session ID and set cookie
sessionID = models.GenerateSessionID()
http.SetCookie(w, &http.Cookie{
Name: "skycode_session",
Value: sessionID,
Path: "/",
HttpOnly: true,
Secure: true,
SameSite: http.SameSiteLaxMode,
MaxAge: sessionCookieMaxAge,
})
}
return models.GetOrCreateSession(sessionID, user.ID)
}
// getProjectDir returns the working directory for git operations (workspace or project subfolder)
func (c *CodeController) getProjectDir(sess *models.Session, r *http.Request) string {
project := r.URL.Query().Get("project")
if project == "" {
return sess.WorkDir
}
// Clean the project path to prevent traversal
project = models.NormalizePath(project)
if project == "" {
return sess.WorkDir
}
// Use filepath.Join to safely construct path and validate it stays within workspace
projectDir := filepath.Join(sess.WorkDir, project)
// Ensure the path doesn't escape the workspace directory
if !strings.HasPrefix(projectDir, sess.WorkDir+string(filepath.Separator)) && projectDir != sess.WorkDir {
return sess.WorkDir
}
return projectDir
}
// GET /api/git/status - get git status
func (c *CodeController) gitStatus(w http.ResponseWriter, r *http.Request) {
sess, err := c.getOrCreateWorkspaceSession(w, r)
if err != nil || sess == nil {
jsonError(w, "failed to create workspace session", http.StatusInternalServerError)
return
}
// Initialize workspace if needed (thread-safe, runs only once)
if err := sess.InitializeOnce(); err != nil {
jsonError(w, "failed to initialize workspace: "+err.Error(), http.StatusInternalServerError)
return
}
workDir := c.getProjectDir(sess, r)
// Check if git is initialized
cmd := exec.Command("git", "rev-parse", "--git-dir")
cmd.Dir = workDir
if err := cmd.Run(); err != nil {
// Not a git repo
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{
"initialized": false,
"files": []GitFileStatus{},
})
return
}
// Get status
cmd = exec.Command("git", "status", "--porcelain")
cmd.Dir = workDir
output, err := cmd.Output()
if err != nil {
jsonError(w, "git status failed", http.StatusInternalServerError)
return
}
files := []GitFileStatus{}
lines := strings.Split(string(output), "\n")
for _, line := range lines {
if len(line) < 4 {
continue
}
staged := line[0]
unstaged := line[1]
path := strings.TrimSpace(line[3:])
// Staged changes
if staged != ' ' && staged != '?' {
files = append(files, GitFileStatus{
Path: path,
Status: string(staged),
Staged: true,
})
}
// Unstaged changes
if unstaged != ' ' || staged == '?' {
status := string(unstaged)
if staged == '?' {
status = "?"
}
files = append(files, GitFileStatus{
Path: path,
Status: status,
Staged: false,
})
}
}
// Get current branch
cmd = exec.Command("git", "branch", "--show-current")
cmd.Dir = workDir
branchOutput, _ := cmd.Output()
branch := strings.TrimSpace(string(branchOutput))
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{
"initialized": true,
"branch": branch,
"files": files,
})
}
// GET /api/git/diff?path=file.go - get diff for a file
func (c *CodeController) gitDiff(w http.ResponseWriter, r *http.Request) {
sess, err := c.getOrCreateWorkspaceSession(w, r)
if err != nil || sess == nil {
jsonError(w, "failed to create workspace session", http.StatusInternalServerError)
return
}
workDir := c.getProjectDir(sess, r)
path := r.URL.Query().Get("path")
staged := r.URL.Query().Get("staged") == "true"
args := []string{"diff"}
if staged {
args = append(args, "--cached")
}
if path != "" {
args = append(args, "--", path)
}
cmd := exec.Command("git", args...)
cmd.Dir = workDir
output, err := cmd.Output()
if err != nil {
// Empty diff or error
output = []byte{}
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{
"diff": string(output),
"path": path,
})
}
// POST /api/git/stage - stage files
func (c *CodeController) gitStage(w http.ResponseWriter, r *http.Request) {
sess, err := c.getOrCreateWorkspaceSession(w, r)
if err != nil || sess == nil {
jsonError(w, "failed to create workspace session", http.StatusInternalServerError)
return
}
// Ensure workspace is initialized (thread-safe, runs only once)
if err := sess.InitializeOnce(); err != nil {
jsonError(w, "failed to initialize workspace: "+err.Error(), http.StatusInternalServerError)
return
}
workDir := c.getProjectDir(sess, r)
var body struct {
Paths []string `json:"paths"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
jsonError(w, "invalid JSON", http.StatusBadRequest)
return
}
if len(body.Paths) == 0 {
// Stage all
cmd := exec.Command("git", "add", "-A")
cmd.Dir = workDir
if err := cmd.Run(); err != nil {
jsonError(w, "git add failed", http.StatusInternalServerError)
return
}
} else {
args := append([]string{"add", "--"}, body.Paths...)
cmd := exec.Command("git", args...)
cmd.Dir = workDir
if err := cmd.Run(); err != nil {
jsonError(w, "git add failed", http.StatusInternalServerError)
return
}
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{"ok": true})
}
// POST /api/git/unstage - unstage files
func (c *CodeController) gitUnstage(w http.ResponseWriter, r *http.Request) {
sess, err := c.getOrCreateWorkspaceSession(w, r)
if err != nil || sess == nil {
jsonError(w, "failed to create workspace session", http.StatusInternalServerError)
return
}
workDir := c.getProjectDir(sess, r)
var body struct {
Paths []string `json:"paths"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
jsonError(w, "invalid JSON", http.StatusBadRequest)
return
}
if len(body.Paths) == 0 {
// Unstage all
cmd := exec.Command("git", "reset", "HEAD")
cmd.Dir = workDir
cmd.Run() // Ignore error (fails if nothing staged)
} else {
args := append([]string{"reset", "HEAD", "--"}, body.Paths...)
cmd := exec.Command("git", args...)
cmd.Dir = workDir
cmd.Run()
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{"ok": true})
}
// POST /api/git/commit - create a commit
func (c *CodeController) gitCommit(w http.ResponseWriter, r *http.Request) {
sess, err := c.getOrCreateWorkspaceSession(w, r)
if err != nil || sess == nil {
jsonError(w, "failed to create workspace session", http.StatusInternalServerError)
return
}
// Ensure workspace is initialized (thread-safe, runs only once)
if err := sess.InitializeOnce(); err != nil {
jsonError(w, "failed to initialize workspace: "+err.Error(), http.StatusInternalServerError)
return
}
workDir := c.getProjectDir(sess, r)
var body struct {
Message string `json:"message"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
jsonError(w, "invalid JSON", http.StatusBadRequest)
return
}
if body.Message == "" {
jsonError(w, "commit message required", http.StatusBadRequest)
return
}
// Configure git user in this repo
user, _ := c.Users.Authenticate(r)
gitCommand(context.Background(), workDir, "config", "user.email", user.Email).Run()
gitCommand(context.Background(), workDir, "config", "user.name", user.Name).Run()
ctx, cancel := context.WithTimeout(context.Background(), gitTimeout)
defer cancel()
cmd := gitCommand(ctx, workDir, "commit", "-m", body.Message)
output, err := cmd.CombinedOutput()
if err != nil {
if ctx.Err() == context.DeadlineExceeded {
jsonError(w, "commit timed out", http.StatusGatewayTimeout)
return
}
jsonError(w, "commit failed: "+string(output), http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{
"ok": true,
"message": string(output),
})
}
// POST /api/git/init - initialize git repo
func (c *CodeController) gitInit(w http.ResponseWriter, r *http.Request) {
sess, err := c.getOrCreateWorkspaceSession(w, r)
if err != nil || sess == nil {
jsonError(w, "failed to create workspace session", http.StatusInternalServerError)
return
}
// Materialize files from DB to workspace first (thread-safe, runs only once)
if err := sess.InitializeOnce(); err != nil {
jsonError(w, "failed to initialize workspace: "+err.Error(), http.StatusInternalServerError)
return
}
workDir := c.getProjectDir(sess, r)
// Ensure project directory exists
if err := os.MkdirAll(workDir, 0755); err != nil {
jsonError(w, "failed to create project directory", http.StatusInternalServerError)
return
}
cmd := exec.Command("git", "init", "-b", "main")
cmd.Dir = workDir
if err := cmd.Run(); err != nil {
jsonError(w, "git init failed", http.StatusInternalServerError)
return
}
// Configure git user in this repo
user, _ := c.Users.Authenticate(r)
emailCmd := exec.Command("git", "config", "user.email", user.Email)
emailCmd.Dir = workDir
emailCmd.Run()
nameCmd := exec.Command("git", "config", "user.name", user.Name)
nameCmd.Dir = workDir
nameCmd.Run()
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{"ok": true})
}
// GET /api/settings - get user settings
func (c *CodeController) getSettings(w http.ResponseWriter, r *http.Request) {
user, _ := c.Users.Authenticate(r)
settings := models.GetSettings(user.ID)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(settings)
}
// PUT /api/settings - save user settings
func (c *CodeController) saveSettings(w http.ResponseWriter, r *http.Request) {
user, _ := c.Users.Authenticate(r)
var settings models.UserSettings
if err := json.NewDecoder(r.Body).Decode(&settings); err != nil {
jsonError(w, "invalid JSON", http.StatusBadRequest)
return
}
// Validate settings
if settings.FontSize < minFontSize || settings.FontSize > maxFontSize {
settings.FontSize = 14
}
if settings.TabSize < minTabSize || settings.TabSize > maxTabSize {
settings.TabSize = 4
}
// DaisyUI themes
validThemes := map[string]bool{"dark": true, "light": true, "dracula": true, "nord": true, "sunset": true}
if !validThemes[settings.Theme] {
settings.Theme = "dark"
}
if err := models.SaveSettings(user.ID, &settings); err != nil {
jsonError(w, "failed to save settings", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{"ok": true})
}
// POST /api/workspace/sync - bidirectional sync between filesystem and database
func (c *CodeController) syncWorkspace(w http.ResponseWriter, r *http.Request) {
sess, err := c.getOrCreateWorkspaceSession(w, r)
if err != nil || sess == nil {
jsonError(w, "failed to get workspace session", http.StatusInternalServerError)
return
}
// Mark as initialized (user is explicitly syncing)
sess.SetInitialized()
// 1. Sync filesystem → database (picks up terminal changes)
if err := sess.SyncWorkspaceToDB(); err != nil {
jsonError(w, "sync failed: "+err.Error(), http.StatusInternalServerError)
return
}
// 2. Sync database → filesystem (picks up Monaco editor changes not yet in workspace)
if err := sess.SyncDBToWorkspace(); err != nil {
jsonError(w, "sync failed: "+err.Error(), http.StatusInternalServerError)
return
}
// 3. Ensure home project exists for orphan files
user, _ := c.Users.Authenticate(r)
models.GetOrCreateHomeProject(user.ID)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{"ok": true})
}
// POST /api/workspace/clone - clone a git repository
func (c *CodeController) cloneRepo(w http.ResponseWriter, r *http.Request) {
sess, err := c.getOrCreateWorkspaceSession(w, r)
if err != nil || sess == nil {
jsonError(w, "failed to get workspace session", http.StatusInternalServerError)
return
}
// Materialize existing files first (thread-safe, runs only once)
if err := sess.InitializeOnce(); err != nil {
jsonError(w, "failed to initialize workspace: "+err.Error(), http.StatusInternalServerError)
return
}
var body struct {
URL string `json:"url"`
Folder string `json:"folder"` // Optional: target folder name
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
jsonError(w, "invalid JSON", http.StatusBadRequest)
return
}
if body.URL == "" {
jsonError(w, "repository URL required", http.StatusBadRequest)
return
}
// Validate URL for protocol and shell safety
if !isValidGitURL(body.URL) {
jsonError(w, "invalid repository URL", http.StatusBadRequest)
return
}
// Determine target folder - extract repo name from URL if not specified
folder := body.Folder
if folder == "" {
// Extract repo name from URL (e.g., https://github.com/user/repo.git -> repo)
url := strings.TrimSuffix(body.URL, ".git")
parts := strings.Split(url, "/")
if len(parts) > 0 {
folder = parts[len(parts)-1]
}
}
folder = models.NormalizePath(folder)
if folder == "" {
folder = "cloned-repo"
}
targetDir := filepath.Join(sess.WorkDir, folder)
// Run git clone with timeout (shallow clone, explicitly clone main branch)
ctx, cancel := context.WithTimeout(context.Background(), gitTimeout)
defer cancel()
cmd := gitCommand(ctx, sess.WorkDir, "clone", "--depth", "1", "--branch", "main", "--single-branch", body.URL, targetDir)
output, err := cmd.CombinedOutput()
if err != nil {
// If main branch doesn't exist, try default branch
ctx2, cancel2 := context.WithTimeout(context.Background(), gitTimeout)
defer cancel2()
cmd = gitCommand(ctx2, sess.WorkDir, "clone", "--depth", "1", body.URL, targetDir)
output, err = cmd.CombinedOutput()
if err != nil {
if ctx2.Err() == context.DeadlineExceeded {
jsonError(w, "clone timed out", http.StatusGatewayTimeout)
return
}
jsonError(w, "clone failed: "+string(output), http.StatusBadRequest)
return
}
}
// Configure git user in cloned repo (user already validated at start of handler)
user, _ := c.Users.Authenticate(r)
gitCommand(context.Background(), targetDir, "config", "user.email", user.Email).Run()
gitCommand(context.Background(), targetDir, "config", "user.name", user.Name).Run()
// Create or update project record with remote URL (needed for re-cloning on session restore)
models.GetOrCreateProject(user.ID, folder, folder, body.URL)
// Sync cloned files to database
sess.SyncWorkspaceToDB() // Ignore error - files are cloned
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{
"ok": true,
"path": folder,
"message": string(output),
})
}
// GET /api/projects - list all projects for current user
func (c *CodeController) listProjects(w http.ResponseWriter, r *http.Request) {
user, _ := c.Users.Authenticate(r)
projects := models.ListProjects(user.ID)
// Ensure at least default project exists
if len(projects) == 0 {
defaultProject, err := models.GetDefaultProject(user.ID)
if err == nil && defaultProject != nil {
projects = []*models.Project{defaultProject}
}
}
type projectInfo struct {
ID string `json:"id"`
Name string `json:"name"`
Path string `json:"path"`
RemoteURL string `json:"remoteUrl"`
IsDefault bool `json:"isDefault"`
}
out := make([]projectInfo, 0, len(projects))
for _, p := range projects {
out = append(out, projectInfo{
ID: p.ID,
Name: p.Name,
Path: p.Path,
RemoteURL: p.RemoteURL,
IsDefault: p.IsDefault,
})
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(out)
}
// POST /api/projects - create a new project
func (c *CodeController) createProject(w http.ResponseWriter, r *http.Request) {
user, _ := c.Users.Authenticate(r)
var body struct {
Name string `json:"name"`
RemoteURL string `json:"remoteUrl"` // Optional: clone from URL
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
jsonError(w, "invalid JSON", http.StatusBadRequest)
return
}
if body.Name == "" {
jsonError(w, "project name required", http.StatusBadRequest)
return
}
// Create the project
project, err := models.CreateProject(user.ID, body.Name, body.RemoteURL, false)
if err != nil {
jsonError(w, err.Error(), http.StatusBadRequest)
return
}
// If remote URL provided, clone it
if body.RemoteURL != "" {
// Validate URL for protocol and shell safety
if !isValidGitURL(body.RemoteURL) {
jsonError(w, "invalid repository URL", http.StatusBadRequest)
return
}
sess, err := c.getOrCreateWorkspaceSession(w, r)
if err != nil || sess == nil {
jsonError(w, "failed to create workspace session", http.StatusInternalServerError)
return
}
// Ensure workspace is initialized (thread-safe, runs only once)
if err := sess.InitializeOnce(); err != nil {
jsonError(w, "failed to initialize workspace: "+err.Error(), http.StatusInternalServerError)
return
}
targetDir := filepath.Join(sess.WorkDir, project.Path)
// Clone the repository (shallow clone, explicitly clone main branch)
ctx, cancel := context.WithTimeout(context.Background(), gitTimeout)
defer cancel()
cmd := gitCommand(ctx, sess.WorkDir, "clone", "--depth", "1", "--branch", "main", "--single-branch", body.RemoteURL, targetDir)
output, err := cmd.CombinedOutput()
if err != nil {
// If main branch doesn't exist, try default branch
ctx2, cancel2 := context.WithTimeout(context.Background(), gitTimeout)
defer cancel2()
cmd = gitCommand(ctx2, sess.WorkDir, "clone", "--depth", "1", body.RemoteURL, targetDir)
output, err = cmd.CombinedOutput()
if err != nil {
// Delete the project if clone failed
models.DeleteProject(project)
if ctx2.Err() == context.DeadlineExceeded {
jsonError(w, "clone timed out", http.StatusGatewayTimeout)
return
}
jsonError(w, "clone failed: "+string(output), http.StatusBadRequest)
return
}
}
// Configure git user
gitCommand(context.Background(), targetDir, "config", "user.email", user.Email).Run()
gitCommand(context.Background(), targetDir, "config", "user.name", user.Name).Run()
// Sync cloned files to database
sess.SyncWorkspaceToDB()
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{
"ok": true,
"project": map[string]any{
"id": project.ID,
"name": project.Name,
"path": project.Path,
"remoteUrl": project.RemoteURL,
"isDefault": project.IsDefault,
},
})
}
// DELETE /api/projects/{id} - delete a project
func (c *CodeController) deleteProject(w http.ResponseWriter, r *http.Request) {
user, _ := c.Users.Authenticate(r)
projectID := r.PathValue("id")
if projectID == "" {
jsonError(w, "project ID required", http.StatusBadRequest)
return
}
project, err := models.GetProject(projectID)
if err != nil || project == nil {
jsonError(w, "project not found", http.StatusNotFound)
return
}
if project.OwnerID != user.ID {
jsonError(w, "project not found", http.StatusNotFound)
return
}
// Delete from workspace if session exists
if sess, _ := c.getWorkspaceSession(r); sess != nil && sess.IsInitialized() {
projectDir := filepath.Join(sess.WorkDir, project.Path)
os.RemoveAll(projectDir)
}
if err := models.DeleteProject(project); err != nil {
jsonError(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{"ok": true})
}
// POST /api/projects/{id}/default - set project as default
func (c *CodeController) setDefaultProject(w http.ResponseWriter, r *http.Request) {
user, _ := c.Users.Authenticate(r)
projectID := r.PathValue("id")
if projectID == "" {
jsonError(w, "project ID required", http.StatusBadRequest)
return
}
if err := models.SetDefaultProject(user.ID, projectID); err != nil {
jsonError(w, err.Error(), http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{"ok": true})
}
// GET /api/git/branches - list branches
func (c *CodeController) gitBranches(w http.ResponseWriter, r *http.Request) {
sess, err := c.getOrCreateWorkspaceSession(w, r)
if err != nil || sess == nil {
jsonError(w, "failed to create workspace session", http.StatusInternalServerError)
return
}
if err := sess.InitializeOnce(); err != nil {
jsonError(w, "failed to initialize workspace: "+err.Error(), http.StatusInternalServerError)
return
}
workDir := c.getProjectDir(sess, r)
// Check if git is initialized
cmd := exec.Command("git", "rev-parse", "--git-dir")
cmd.Dir = workDir
if err := cmd.Run(); err != nil {
jsonError(w, "not a git repository", http.StatusBadRequest)
return
}
// Get all branches
cmd = exec.Command("git", "branch", "-a", "--format=%(refname:short)")
cmd.Dir = workDir
output, err := cmd.Output()
if err != nil {
jsonError(w, "failed to list branches", http.StatusInternalServerError)
return
}
// Get current branch
cmd = exec.Command("git", "branch", "--show-current")
cmd.Dir = workDir
currentOutput, _ := cmd.Output()
current := strings.TrimSpace(string(currentOutput))
branches := []map[string]any{}
for _, line := range strings.Split(string(output), "\n") {
branch := strings.TrimSpace(line)
if branch == "" {
continue
}
// Skip HEAD pointers
if strings.Contains(branch, "HEAD") {
continue
}
isRemote := strings.HasPrefix(branch, "origin/")
branches = append(branches, map[string]any{
"name": branch,
"current": branch == current,
"isRemote": isRemote,
})
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{
"current": current,
"branches": branches,
})
}
// POST /api/git/checkout - switch branch
func (c *CodeController) gitCheckout(w http.ResponseWriter, r *http.Request) {
sess, err := c.getOrCreateWorkspaceSession(w, r)
if err != nil || sess == nil {
jsonError(w, "failed to create workspace session", http.StatusInternalServerError)
return
}
if err := sess.InitializeOnce(); err != nil {
jsonError(w, "failed to initialize workspace: "+err.Error(), http.StatusInternalServerError)
return
}
workDir := c.getProjectDir(sess, r)
var body struct {
Branch string `json:"branch"`
Create bool `json:"create"` // If true, create new branch
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
jsonError(w, "invalid JSON", http.StatusBadRequest)
return
}
if body.Branch == "" {
jsonError(w, "branch name required", http.StatusBadRequest)
return
}
// Validate branch name for shell safety
if !isValidGitRef(body.Branch) {
jsonError(w, "invalid branch name", http.StatusBadRequest)
return
}
args := []string{"checkout"}
if body.Create {
args = append(args, "-b")
}
args = append(args, body.Branch)
ctx, cancel := context.WithTimeout(context.Background(), gitTimeout)
defer cancel()
cmd := gitCommand(ctx, workDir, args...)
output, err := cmd.CombinedOutput()
if err != nil {
if ctx.Err() == context.DeadlineExceeded {
jsonError(w, "checkout timed out", http.StatusGatewayTimeout)
return
}
jsonError(w, "checkout failed: "+string(output), http.StatusBadRequest)
return
}
// Sync workspace to DB after checkout
sess.SyncWorkspaceToDB()
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{
"ok": true,
"branch": body.Branch,
"message": string(output),
})
}
// POST /api/git/push - push to remote
func (c *CodeController) gitPush(w http.ResponseWriter, r *http.Request) {
sess, err := c.getOrCreateWorkspaceSession(w, r)
if err != nil || sess == nil {
jsonError(w, "failed to create workspace session", http.StatusInternalServerError)
return
}
if err := sess.InitializeOnce(); err != nil {
jsonError(w, "failed to initialize workspace: "+err.Error(), http.StatusInternalServerError)
return
}
workDir := c.getProjectDir(sess, r)
var body struct {
Remote string `json:"remote"` // Default: origin
Branch string `json:"branch"` // Optional: specific branch
SetUpstream bool `json:"setUpstream"` // If true, set upstream
Username string `json:"username"` // Optional: for auth retry
Password string `json:"password"` // Optional: for auth retry (token)
}
json.NewDecoder(r.Body).Decode(&body)
remote := body.Remote
if remote == "" {
remote = "origin"
}
// Validate remote name for shell safety
if !isValidGitRef(remote) {
jsonError(w, "invalid remote name", http.StatusBadRequest)
return
}
args := []string{"push"}
if body.SetUpstream {
args = append(args, "-u")
}
args = append(args, remote)
if body.Branch != "" {
// Validate branch name for shell safety
if !isValidGitRef(body.Branch) {
jsonError(w, "invalid branch name", http.StatusBadRequest)
return
}
args = append(args, body.Branch)
}
ctx, cancel := context.WithTimeout(context.Background(), 2*gitTimeout) // Push may take longer
defer cancel()
cmd := gitCommand(ctx, workDir, args...)
// If credentials provided, set up GIT_ASKPASS
if body.Username != "" && body.Password != "" {
cmd = gitCommandWithCredentials(ctx, workDir, body.Username, body.Password, args...)
}
output, err := cmd.CombinedOutput()
if err != nil {
if ctx.Err() == context.DeadlineExceeded {
jsonError(w, "push timed out", http.StatusGatewayTimeout)
return
}
// Check if this is an authentication failure
outputStr := string(output)
if isAuthError(outputStr) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusUnauthorized)
json.NewEncoder(w).Encode(map[string]any{
"error": "authentication required",
"needsAuth": true,
})
return
}
jsonError(w, "push failed: "+outputStr, http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{
"ok": true,
"message": string(output),
})
}
// GET /clone?repo=URL - public endpoint to clone a repo (redirects to signin if needed)
func (c *CodeController) handleClone(w http.ResponseWriter, r *http.Request) {
repoURL := r.URL.Query().Get("repo")
// If no repo param, check if we have one stored in cookie (returning from signin)
if repoURL == "" {
if cookie, err := r.Cookie("clone_repo"); err == nil && cookie.Value != "" {
repoURL = cookie.Value
// Clear the cookie
http.SetCookie(w, &http.Cookie{
Name: "clone_repo",
Value: "",
Path: "/",
HttpOnly: true,
MaxAge: -1,
})
}
}
if repoURL == "" {
http.Redirect(w, r, "/", http.StatusSeeOther)
return
}
// Validate URL for protocol and shell safety
if !isValidGitURL(repoURL) {
http.Error(w, "Invalid repository URL", http.StatusBadRequest)
return
}
// Check authentication
user, err := c.Users.Authenticate(r)
if err != nil || user == nil {
// Not authenticated - store repo URL in cookie and set auth_redirect to come back here
http.SetCookie(w, &http.Cookie{
Name: "clone_repo",
Value: repoURL,
Path: "/",
HttpOnly: true,
Secure: true,
SameSite: http.SameSiteLaxMode,
MaxAge: cloneCookieMaxAge,
})
// Set auth_redirect so skykit brings us back to /clone after signin
http.SetCookie(w, &http.Cookie{
Name: "auth_redirect",
Value: "/clone",
Path: "/",
HttpOnly: true,
Secure: true,
SameSite: http.SameSiteLaxMode,
MaxAge: cloneCookieMaxAge,
})
http.Redirect(w, r, "/auth/signin", http.StatusSeeOther)
return
}
// User is authenticated - clone the repo
sess, err := c.getOrCreateWorkspaceSession(w, r)
if err != nil || sess == nil {
http.Error(w, "Failed to create workspace session", http.StatusInternalServerError)
return
}
// Materialize existing files first (thread-safe, runs only once)
if err := sess.InitializeOnce(); err != nil {
http.Error(w, "Failed to initialize workspace: "+err.Error(), http.StatusInternalServerError)
return
}
// Extract folder name from URL
url := strings.TrimSuffix(repoURL, ".git")
parts := strings.Split(url, "/")
folder := "cloned-repo"
if len(parts) > 0 {
folder = parts[len(parts)-1]
}
folder = models.NormalizePath(folder)
if folder == "" {
folder = "cloned-repo"
}
targetDir := filepath.Join(sess.WorkDir, folder)
// Check if folder already exists - if so, just open the editor
if _, err := os.Stat(targetDir); err == nil {
// Already cloned, redirect to editor
http.Redirect(w, r, "/editor", http.StatusSeeOther)
return
}
// Run git clone with timeout (shallow clone, explicitly clone main branch)
ctx, cancel := context.WithTimeout(context.Background(), gitTimeout)
defer cancel()
cmd := gitCommand(ctx, sess.WorkDir, "clone", "--depth", "1", "--branch", "main", "--single-branch", repoURL, targetDir)
output, err := cmd.CombinedOutput()
if err != nil {
// If main branch doesn't exist, try default branch
ctx2, cancel2 := context.WithTimeout(context.Background(), gitTimeout)
defer cancel2()
cmd = gitCommand(ctx2, sess.WorkDir, "clone", "--depth", "1", repoURL, targetDir)
output, err = cmd.CombinedOutput()
if err != nil {
if ctx2.Err() == context.DeadlineExceeded {
http.Error(w, "Clone timed out", http.StatusGatewayTimeout)
return
}
http.Error(w, "Clone failed: "+string(output), http.StatusBadRequest)
return
}
}
// Configure git user in cloned repo
gitCommand(context.Background(), targetDir, "config", "user.email", user.Email).Run()
gitCommand(context.Background(), targetDir, "config", "user.name", user.Name).Run()
// Create or get project for the cloned repo
models.GetOrCreateProject(user.ID, folder, folder, repoURL)
// Sync cloned files to database
sess.SyncWorkspaceToDB()
// Redirect to editor
http.Redirect(w, r, "/editor", http.StatusSeeOther)
}
// POST /api/git/pull - pull from remote
func (c *CodeController) gitPull(w http.ResponseWriter, r *http.Request) {
sess, err := c.getOrCreateWorkspaceSession(w, r)
if err != nil || sess == nil {
jsonError(w, "failed to create workspace session", http.StatusInternalServerError)
return
}
if err := sess.InitializeOnce(); err != nil {
jsonError(w, "failed to initialize workspace: "+err.Error(), http.StatusInternalServerError)
return
}
workDir := c.getProjectDir(sess, r)
var body struct {
Remote string `json:"remote"` // Default: origin
Branch string `json:"branch"` // Optional: specific branch
}
json.NewDecoder(r.Body).Decode(&body)
remote := body.Remote
if remote == "" {
remote = "origin"
}
// Validate remote name for shell safety
if !isValidGitRef(remote) {
jsonError(w, "invalid remote name", http.StatusBadRequest)
return
}
args := []string{"pull", remote}
if body.Branch != "" {
// Validate branch name for shell safety
if !isValidGitRef(body.Branch) {
jsonError(w, "invalid branch name", http.StatusBadRequest)
return
}
args = append(args, body.Branch)
}
ctx, cancel := context.WithTimeout(context.Background(), 2*gitTimeout) // Pull may take longer
defer cancel()
cmd := gitCommand(ctx, workDir, args...)
output, err := cmd.CombinedOutput()
if err != nil {
if ctx.Err() == context.DeadlineExceeded {
jsonError(w, "pull timed out", http.StatusGatewayTimeout)
return
}
jsonError(w, "pull failed: "+string(output), http.StatusBadRequest)
return
}
// Sync workspace to DB after pull
sess.SyncWorkspaceToDB()
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{
"ok": true,
"message": string(output),
})
}