package app import ( "context" "encoding/json" "errors" "fmt" "io" "net/http" "net/url" "os" "os/exec" "path/filepath" "sort" "strings" ) func (a *App) createForgejoRepo(ctx context.Context, name, description string, private bool) (string, error) { if a.cfg.ForgejoBaseURL == "" || a.cfg.ForgejoToken == "" { return "", nil } payload := map[string]any{"name": name, "description": description, "private": private, "auto_init": true} body, _ := json.Marshal(payload) endpoint := a.cfg.ForgejoBaseURL + "/api/v1/user/repos" if a.cfg.ForgejoOrg != "" { endpoint = fmt.Sprintf("%s/api/v1/orgs/%s/repos", a.cfg.ForgejoBaseURL, a.cfg.ForgejoOrg) } req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, strings.NewReader(string(body))) if err != nil { return "", err } req.Header.Set("Authorization", "token "+a.cfg.ForgejoToken) req.Header.Set("Content-Type", "application/json") resp, err := http.DefaultClient.Do(req) if err != nil { return "", fmt.Errorf("forgejo request failed: %w", err) } defer resp.Body.Close() respBody, _ := io.ReadAll(resp.Body) if resp.StatusCode >= 300 { return "", fmt.Errorf("forgejo error: status=%d body=%s", resp.StatusCode, string(respBody)) } var parsed struct { HTMLURL string `json:"html_url"` } if err := json.Unmarshal(respBody, &parsed); err != nil { return "", nil } return parsed.HTMLURL, nil } func (a *App) buildRouteHost(slug, username string) string { if a.cfg.CaddyDomainSuffix == "" { return fmt.Sprintf("%s-%s.local", slugify(username), slug) } return fmt.Sprintf("%s.%s", slug, strings.TrimPrefix(a.cfg.CaddyDomainSuffix, ".")) } func (a *App) ensureCaddyRoute(ctx context.Context, p Project) error { if !a.cfg.CaddyRouteEnabled { return nil } return a.syncCaddyConfigFragment(ctx) } func (a *App) syncCaddyConfigFragment(ctx context.Context) error { path := strings.TrimSpace(a.cfg.CaddyFragmentPath) if path == "" { return errors.New("CADDY_FRAGMENT_PATH is required when CADDY_ROUTE_ENABLED=true") } routes, err := a.listCaddyProjectRoutes(ctx) if err != nil { return err } content, err := a.renderCaddyRoutesFragment(routes) if err != nil { return err } if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { return err } if err := os.Chmod(filepath.Dir(path), 0o755); err != nil { return err } tmp := path + ".tmp" if err := os.WriteFile(tmp, []byte(content), 0o644); err != nil { return err } if err := os.Rename(tmp, path); err != nil { return err } if err := os.Chmod(path, 0o644); err != nil { return err } return a.reloadCaddy() } func (a *App) reloadCaddy() error { reloadCmd := strings.TrimSpace(a.cfg.CaddyReloadCommand) if reloadCmd == "" { return nil } cmd := exec.Command("bash", "-lc", reloadCmd) out, err := cmd.CombinedOutput() if err != nil { return fmt.Errorf("caddy reload failed: %w output=%s", err, strings.TrimSpace(string(out))) } return nil } func (a *App) listCaddyProjectRoutes(ctx context.Context) ([]Project, error) { rows, err := a.db.QueryContext(ctx, ` SELECT p.id, p.user_id, p.name, p.slug, p.route_host, p.workspace_id, w.name, w.unix_user FROM projects p JOIN workspaces w ON w.id = p.workspace_id WHERE p.route_host <> '' `) if err != nil { return nil, err } defer rows.Close() var out []Project for rows.Next() { var p Project if err := rows.Scan(&p.ID, &p.UserID, &p.Name, &p.Slug, &p.RouteHost, &p.WorkspaceID, &p.Workspace, &p.UnixUser); err != nil { return nil, err } out = append(out, p) } if err := rows.Err(); err != nil { return nil, err } sort.Slice(out, func(i, j int) bool { if out[i].RouteHost == out[j].RouteHost { return out[i].ID < out[j].ID } return out[i].RouteHost < out[j].RouteHost }) return out, nil } func (a *App) renderCaddyRoutesFragment(projects []Project) (string, error) { var b strings.Builder b.WriteString("# Managed by project-manager. Do not edit manually.\n") for _, p := range projects { projectDir, err := a.projectDirFor(p) if err != nil { return "", err } socketPath := filepath.Join(projectDir, "app.sock") b.WriteString("\n") b.WriteString(p.RouteHost) b.WriteString(" {\n") if a.cfg.CaddyAuthEnabled { importName := strings.TrimSpace(a.cfg.CaddyAuthImportName) if importName == "" { importName = "auth" } b.WriteString(" import ") b.WriteString(importName) b.WriteString("\n") } b.WriteString(" log\n") b.WriteString(" reverse_proxy unix//") b.WriteString(socketPath) b.WriteString("\n") b.WriteString("}\n") } return b.String(), nil } func (a *App) fetchCaddyConfig(ctx context.Context) (map[string]any, error) { req, err := http.NewRequestWithContext(ctx, http.MethodGet, a.cfg.CaddyAdminURL+"/config/", nil) if err != nil { return nil, err } resp, err := http.DefaultClient.Do(req) if err != nil { return nil, fmt.Errorf("caddy config request failed: %w", err) } defer resp.Body.Close() body, _ := io.ReadAll(resp.Body) if resp.StatusCode >= 300 { return nil, fmt.Errorf("caddy config error: status=%d body=%s", resp.StatusCode, string(body)) } var out map[string]any if err := json.Unmarshal(body, &out); err != nil { return nil, fmt.Errorf("caddy config parse failed: %w", err) } return out, nil } func (a *App) persistCaddyConfigToDisk(ctx context.Context) error { path := strings.TrimSpace(a.cfg.CaddyPersistConfigPath) if path == "" { return nil } req, err := http.NewRequestWithContext(ctx, http.MethodGet, a.cfg.CaddyAdminURL+"/config/", nil) if err != nil { return err } resp, err := http.DefaultClient.Do(req) if err != nil { return fmt.Errorf("caddy config fetch for persist failed: %w", err) } defer resp.Body.Close() body, _ := io.ReadAll(resp.Body) if resp.StatusCode >= 300 { return fmt.Errorf("caddy config fetch for persist error: status=%d body=%s", resp.StatusCode, string(body)) } tmp := path + ".tmp" if err := os.WriteFile(tmp, body, 0o640); err != nil { return err } return os.Rename(tmp, path) } func (a *App) ensureCaddyTLSSubject(ctx context.Context, host string) error { return nil } func (a *App) ensureForgejoDeployWebhook(ctx context.Context, p Project) error { if a.cfg.ForgejoBaseURL == "" || a.cfg.ForgejoToken == "" || p.RepoURL == "" { return nil } if a.cfg.WebhookBaseURL == "" { return errors.New("WEBHOOK_BASE_URL is required for automatic Forgejo webhook provisioning") } owner, repo, err := parseForgejoRepoOwnerRepo(a.cfg.ForgejoBaseURL, p.RepoURL) if err != nil { // Existing repo is not on configured Forgejo host; skip auto-provision. return nil } hooksURL := fmt.Sprintf("%s/api/v1/repos/%s/%s/hooks", a.cfg.ForgejoBaseURL, owner, repo) req, err := http.NewRequestWithContext(ctx, http.MethodGet, hooksURL, nil) if err != nil { return err } req.Header.Set("Authorization", "token "+a.cfg.ForgejoToken) resp, err := http.DefaultClient.Do(req) if err != nil { return fmt.Errorf("forgejo hooks list failed: %w", err) } defer resp.Body.Close() body, _ := io.ReadAll(resp.Body) if resp.StatusCode >= 300 { return fmt.Errorf("forgejo hooks list error: status=%d body=%s", resp.StatusCode, string(body)) } var hooks []struct { Config struct { URL string `json:"url"` } `json:"config"` } _ = json.Unmarshal(body, &hooks) for _, h := range hooks { if strings.TrimSpace(h.Config.URL) == strings.TrimSpace(p.WebhookURL) { return nil } } payload := map[string]any{ "type": "gitea", "active": true, "events": []string{"push"}, "config": map[string]any{ "url": p.WebhookURL, "content_type": "json", }, } bodyBytes, _ := json.Marshal(payload) createReq, err := http.NewRequestWithContext(ctx, http.MethodPost, hooksURL, strings.NewReader(string(bodyBytes))) if err != nil { return err } createReq.Header.Set("Authorization", "token "+a.cfg.ForgejoToken) createReq.Header.Set("Content-Type", "application/json") createResp, err := http.DefaultClient.Do(createReq) if err != nil { return fmt.Errorf("forgejo hook create failed: %w", err) } defer createResp.Body.Close() createBody, _ := io.ReadAll(createResp.Body) if createResp.StatusCode >= 300 { return fmt.Errorf("forgejo hook create error: status=%d body=%s", createResp.StatusCode, string(createBody)) } return nil } func parseForgejoRepoOwnerRepo(forgejoBaseURL, repoURL string) (string, string, error) { fBase, err := url.Parse(forgejoBaseURL) if err != nil { return "", "", err } normalized := strings.TrimSpace(repoURL) if strings.HasPrefix(normalized, "git@") { // git@host:owner/repo(.git) at := strings.Index(normalized, "@") colon := strings.Index(normalized, ":") if at < 0 || colon < 0 || colon <= at+1 { return "", "", errors.New("invalid ssh repo url") } host := normalized[at+1 : colon] if !strings.EqualFold(host, fBase.Hostname()) { return "", "", errors.New("repo host does not match configured forgejo host") } path := strings.TrimPrefix(normalized[colon+1:], "/") path = strings.TrimSuffix(path, ".git") parts := strings.Split(path, "/") if len(parts) < 2 { return "", "", errors.New("repo path does not include owner/repo") } return parts[0], parts[1], nil } u, err := url.Parse(normalized) if err != nil { return "", "", err } if !strings.EqualFold(u.Hostname(), fBase.Hostname()) { return "", "", errors.New("repo host does not match configured forgejo host") } path := strings.TrimPrefix(u.Path, "/") path = strings.TrimSuffix(path, ".git") parts := strings.Split(path, "/") if len(parts) < 2 { return "", "", errors.New("repo path does not include owner/repo") } return parts[0], parts[1], nil }