package app import ( "context" "database/sql" "encoding/json" "errors" "fmt" "io" "log" "net/http" "net/url" "os" "path/filepath" "strconv" "strings" "time" ) func (a *App) handleDeployWebhook(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } path := strings.Trim(strings.TrimPrefix(r.URL.Path, "/api/webhooks/deploy/"), "/") parts := strings.Split(path, "/") if len(parts) != 2 || parts[0] == "" || parts[1] == "" { http.Error(w, "bad path", http.StatusBadRequest) return } projectID, err := strconv.ParseInt(parts[0], 10, 64) if err != nil { http.Error(w, "invalid project id", http.StatusBadRequest) return } p, err := a.getProjectForWebhook(r.Context(), projectID, parts[1]) if err != nil { http.Error(w, "unauthorized", http.StatusUnauthorized) return } ref, err := parseWebhookRef(r) if err != nil { http.Error(w, "invalid webhook payload", http.StatusBadRequest) return } ref = strings.TrimSpace(ref) if ref == "" { writeJSON(w, http.StatusAccepted, map[string]string{"status": "ignored", "reason": "no ref in payload"}) return } if ref != "refs/heads/main" && ref != "main" { log.Printf("webhook ignored project_id=%d ref=%q", p.ID, ref) writeJSON(w, http.StatusAccepted, map[string]string{"status": "ignored", "reason": "not main branch"}) return } log.Printf("webhook deploy accepted project_id=%d ref=%q", p.ID, ref) if err := a.deployProjectFromMain(r.Context(), p); err != nil { log.Printf("deploy failed project_id=%d err=%v", p.ID, err) http.Error(w, err.Error(), http.StatusInternalServerError) return } writeJSON(w, http.StatusOK, map[string]string{"status": "deployed"}) } func parseWebhookRef(r *http.Request) (string, error) { rawBody, err := io.ReadAll(r.Body) if err != nil { return "", err } var payload struct { Ref string `json:"ref"` } if err := json.Unmarshal(rawBody, &payload); err == nil { return payload.Ref, nil } formVals, err := url.ParseQuery(string(rawBody)) if err != nil { return "", err } raw := strings.TrimSpace(formVals.Get("payload")) if raw == "" { return "", nil } if err := json.Unmarshal([]byte(raw), &payload); err != nil { return "", err } return payload.Ref, nil } func (a *App) getProjectForWebhook(ctx context.Context, projectID int64, token string) (Project, error) { var p Project err := a.db.QueryRowContext(ctx, `SELECT p.id, p.user_id, p.name, p.slug, p.description, p.repo_url, p.service_name, p.route_host, p.workspace_id, w.name, w.unix_user, p.deploy_token, p.created_at FROM projects p JOIN workspaces w ON w.id=p.workspace_id WHERE p.id=$1 AND p.deploy_token=$2`, projectID, token). Scan(&p.ID, &p.UserID, &p.Name, &p.Slug, &p.Description, &p.RepoURL, &p.ServiceName, &p.RouteHost, &p.WorkspaceID, &p.Workspace, &p.UnixUser, &p.DeployToken, &p.CreatedAt) if err != nil { if errors.Is(err, sql.ErrNoRows) { return Project{}, errors.New("project not found") } return Project{}, err } p.WebhookURL = a.deployWebhookURL(p.ID, p.DeployToken) return p, nil } func (a *App) deployProjectFromMain(_ context.Context, p Project) error { if p.RepoURL == "" { return errors.New("project has no repo_url to deploy from") } projectDir, err := a.projectDirFor(p) if err != nil { return err } if err := a.syncProjectRepoFromMain(p, projectDir); err != nil { return err } repoDir := filepath.Join(projectDir, "repo") if err := a.buildProjectBinaryFromRepoDir(p, repoDir); err != nil { return err } return runSystemctlUser(p.UnixUser, "restart", p.ServiceName) } func (a *App) buildProjectBinary(p Project) error { projectDir, err := a.projectDirFor(p) if err != nil { return err } if p.RepoURL == "" { return errors.New("project has no repo_url to build from") } if err := a.syncProjectRepoFromMain(p, projectDir); err != nil { return err } repoDir := filepath.Join(projectDir, "repo") return a.buildProjectBinaryFromRepoDir(p, repoDir) } func (a *App) buildProjectBinaryFromRepoDir(p Project, repoDir string) error { projectDir, err := a.projectDirFor(p) if err != nil { return err } binDir := filepath.Join(projectDir, "bin") if err := os.MkdirAll(binDir, 0o755); err != nil { return err } if err := ensureOwnedByUnixUser(binDir, p.UnixUser, 0o755); err != nil { return err } binaryPath := filepath.Join(binDir, "app") if err := runGoBuildForUser(p.UnixUser, repoDir, binaryPath); err != nil { return fmt.Errorf("build failed: %w", err) } if err := ensureOwnedByUnixUser(binaryPath, p.UnixUser, 0o755); err != nil { return err } if err := a.syncStaticAssetsForProject(p); err != nil { return err } commit, err := runCmdOut("git", "-C", repoDir, "rev-parse", "HEAD") if err != nil { return fmt.Errorf("resolve latest commit failed: %w", err) } if err := a.updateLastDeployment(p.ID, strings.TrimSpace(commit), time.Now().UTC()); err != nil { return err } return nil } func (a *App) updateLastDeployment(projectID int64, commit string, deployedAt time.Time) error { _, err := a.db.Exec(`UPDATE projects SET last_deployed_at=$1, last_deployed_commit=$2 WHERE id=$3`, deployedAt, strings.TrimSpace(commit), projectID) return err } func (a *App) syncProjectRepoFromMain(p Project, projectDir string) error { repoDir := filepath.Join(projectDir, "repo") plainRepoURL := strings.TrimSpace(p.RepoURL) repoCloneURL := a.repoURLForGitAuth(p.RepoURL) if _, err := os.Stat(repoDir); os.IsNotExist(err) { if err := os.MkdirAll(repoDir, 0o755); err != nil { return err } if err := runCmd("git", "-C", repoDir, "init"); err != nil { return err } if err := runCmd("git", "-C", repoDir, "remote", "add", "origin", plainRepoURL); err != nil { return err } if err := runCmd("git", "-C", repoDir, "fetch", repoCloneURL, "main", "--prune"); err != nil { return err } if err := runCmd("git", "-C", repoDir, "reset", "--hard", "FETCH_HEAD"); err != nil { return err } return nil } if err := runCmd("git", "-C", repoDir, "fetch", repoCloneURL, "main", "--prune"); err != nil { return err } if err := runCmd("git", "-C", repoDir, "reset", "--hard", "FETCH_HEAD"); err != nil { return err } return nil } func (a *App) repoURLForGitAuth(repoURL string) string { if repoURL == "" || a.cfg.ForgejoBaseURL == "" || a.cfg.ForgejoToken == "" { return repoURL } ru, err := url.Parse(repoURL) if err != nil || ru.Scheme != "https" { return repoURL } fu, err := url.Parse(a.cfg.ForgejoBaseURL) if err != nil { return repoURL } if !strings.EqualFold(ru.Hostname(), fu.Hostname()) { return repoURL } u := *ru u.User = url.UserPassword(a.cfg.ForgejoGitUsername, a.cfg.ForgejoToken) return u.String() } func runGoBuildForUser(unixUser, repoDir, binaryPath string) error { homeDir, err := homeForUnixUser(unixUser) if err != nil { return err } goPath := filepath.Join(homeDir, "go") goModCache := filepath.Join(goPath, "pkg", "mod") goCache := filepath.Join(homeDir, ".cache", "go-build") for _, dir := range []string{goPath, goModCache, goCache} { if err := os.MkdirAll(dir, 0o755); err != nil { return err } if err := ensureOwnedByUnixUser(dir, unixUser, 0o755); err != nil { return err } } return runCmd( "sudo", "-n", "-u", unixUser, "env", "HOME="+homeDir, "GOPATH="+goPath, "GOMODCACHE="+goModCache, "GOCACHE="+goCache, "GOTOOLCHAIN=auto", "go", "-C", repoDir, "build", "-buildvcs=false", "-o", binaryPath, ".", ) } func (a *App) syncStaticAssetsForProject(p Project) error { projectDir, err := a.projectDirFor(p) if err != nil { return err } repoStatic := filepath.Join(projectDir, "repo", "static") dataStatic := filepath.Join(projectDir, "data", "static") if _, err := os.Stat(repoStatic); err != nil { if os.IsNotExist(err) { return nil } return err } if err := os.MkdirAll(dataStatic, 0o755); err != nil { return err } if err := ensureOwnedByUnixUser(dataStatic, p.UnixUser, 0o755); err != nil { return err } // Preferred path: fast deterministic sync with deletes. if err := runCmd("sudo", "-n", "-u", p.UnixUser, "rsync", "-a", "--delete", repoStatic+"/", dataStatic+"/"); err == nil { return nil } // Fallback if rsync is unavailable: recreate target and copy. if err := runCmd("sudo", "-n", "-u", p.UnixUser, "rm", "-rf", dataStatic); err != nil { return err } if err := runCmd("sudo", "-n", "-u", p.UnixUser, "mkdir", "-p", dataStatic); err != nil { return err } return runCmd("sudo", "-n", "-u", p.UnixUser, "cp", "-a", repoStatic+"/.", dataStatic+"/") }