package app import ( "context" "encoding/json" "errors" "fmt" "io" "net/http" "net/url" "path/filepath" "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 } projectDir, err := a.projectDirFor(p) if err != nil { return err } socketPath := filepath.Join(projectDir, "app.sock") route := map[string]any{ "@id": "project-route-" + p.UserID + "-" + p.Slug, "match": []any{map[string]any{"host": []string{p.RouteHost}}}, "handle": []any{map[string]any{"handler": "reverse_proxy", "upstreams": []any{map[string]any{"dial": "unix/" + socketPath}}}}, } cfg, err := a.fetchCaddyConfig(ctx) if err != nil { return err } indexes := findCaddyRouteIndexesByID(cfg, a.cfg.CaddyServerID, route["@id"].(string)) if len(indexes) > 0 { // Update first matching route in place. if err := a.putCaddyRouteAtIndex(ctx, indexes[0], route); err != nil { return err } // Remove stale duplicates from highest index to lowest. for i := len(indexes) - 1; i >= 1; i-- { if err := a.deleteCaddyRouteAtIndex(ctx, indexes[i]); err != nil { return err } } return nil } if err := a.appendCaddyRoute(ctx, route); err != nil { // If duplicate race happened, retry once through dedupe/update path. if strings.Contains(err.Error(), "duplicate ID") { cfg2, ferr := a.fetchCaddyConfig(ctx) if ferr != nil { return err } indexes2 := findCaddyRouteIndexesByID(cfg2, a.cfg.CaddyServerID, route["@id"].(string)) if len(indexes2) == 0 { return err } if uerr := a.putCaddyRouteAtIndex(ctx, indexes2[0], route); uerr != nil { return uerr } for i := len(indexes2) - 1; i >= 1; i-- { if derr := a.deleteCaddyRouteAtIndex(ctx, indexes2[i]); derr != nil { return derr } } return nil } return err } return nil } func (a *App) appendCaddyRoute(ctx context.Context, route map[string]any) error { body, _ := json.Marshal(route) url := fmt.Sprintf("%s/config/apps/http/servers/%s/routes", a.cfg.CaddyAdminURL, a.cfg.CaddyServerID) req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, strings.NewReader(string(body))) if err != nil { return err } req.Header.Set("Content-Type", "application/json") resp, err := http.DefaultClient.Do(req) if err != nil { return fmt.Errorf("caddy request failed: %w", err) } defer resp.Body.Close() if resp.StatusCode >= 300 { respBody, _ := io.ReadAll(resp.Body) return fmt.Errorf("caddy error: status=%d body=%s", resp.StatusCode, string(respBody)) } return nil } func (a *App) putCaddyRouteAtIndex(ctx context.Context, idx int, route map[string]any) error { body, _ := json.Marshal(route) url := fmt.Sprintf("%s/config/apps/http/servers/%s/routes/%d", a.cfg.CaddyAdminURL, a.cfg.CaddyServerID, idx) req, err := http.NewRequestWithContext(ctx, http.MethodPut, url, strings.NewReader(string(body))) if err != nil { return err } req.Header.Set("Content-Type", "application/json") resp, err := http.DefaultClient.Do(req) if err != nil { return fmt.Errorf("caddy update request failed: %w", err) } defer resp.Body.Close() if resp.StatusCode >= 300 { respBody, _ := io.ReadAll(resp.Body) return fmt.Errorf("caddy update error: status=%d body=%s", resp.StatusCode, string(respBody)) } return nil } func (a *App) deleteCaddyRouteAtIndex(ctx context.Context, idx int) error { url := fmt.Sprintf("%s/config/apps/http/servers/%s/routes/%d", a.cfg.CaddyAdminURL, a.cfg.CaddyServerID, idx) req, err := http.NewRequestWithContext(ctx, http.MethodDelete, url, nil) if err != nil { return err } resp, err := http.DefaultClient.Do(req) if err != nil { return fmt.Errorf("caddy delete request failed: %w", err) } defer resp.Body.Close() if resp.StatusCode >= 300 { respBody, _ := io.ReadAll(resp.Body) return fmt.Errorf("caddy delete error: status=%d body=%s", resp.StatusCode, string(respBody)) } return nil } func findCaddyRouteIndexesByID(cfg map[string]any, serverID, routeID string) []int { var out []int apps, ok := cfg["apps"].(map[string]any) if !ok { return out } httpApp, ok := apps["http"].(map[string]any) if !ok { return out } servers, ok := httpApp["servers"].(map[string]any) if !ok { return out } server, ok := servers[serverID].(map[string]any) if !ok { return out } routes, ok := server["routes"].([]any) if !ok { return out } for i, r := range routes { rm, ok := r.(map[string]any) if !ok { continue } if rid, ok := rm["@id"].(string); ok && rid == routeID { out = append(out, i) } } return out } 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) ensureCaddyTLSSubject(ctx context.Context, host string) error { host = strings.TrimSpace(host) if host == "" { return nil } cfg, err := a.fetchCaddyConfig(ctx) if err != nil { return err } idx, subjects, found, err := findTLSPolicyForHost(cfg, host) if err != nil { return err } if found { return nil } if idx < 0 { return errors.New("no suitable Caddy TLS automation policy found") } // Preferred: append one subject. appendURL := fmt.Sprintf("%s/config/apps/tls/automation/policies/%d/subjects", a.cfg.CaddyAdminURL, idx) body, _ := json.Marshal(host) req, err := http.NewRequestWithContext(ctx, http.MethodPost, appendURL, strings.NewReader(string(body))) if err != nil { return err } req.Header.Set("Content-Type", "application/json") resp, err := http.DefaultClient.Do(req) if err == nil { defer resp.Body.Close() if resp.StatusCode < 300 { return nil } } // Fallback: replace the subjects array with host appended. subjects = append(subjects, host) replaceBody, _ := json.Marshal(subjects) putReq, err := http.NewRequestWithContext(ctx, http.MethodPut, appendURL, strings.NewReader(string(replaceBody))) if err != nil { return err } putReq.Header.Set("Content-Type", "application/json") putResp, err := http.DefaultClient.Do(putReq) if err != nil { return fmt.Errorf("caddy tls subjects update failed: %w", err) } defer putResp.Body.Close() if putResp.StatusCode >= 300 { b, _ := io.ReadAll(putResp.Body) return fmt.Errorf("caddy tls subjects update error: status=%d body=%s", putResp.StatusCode, string(b)) } return nil } func findTLSPolicyForHost(cfg map[string]any, host string) (policyIdx int, subjects []string, alreadyPresent bool, err error) { policyIdx = -1 apps, ok := cfg["apps"].(map[string]any) if !ok { return -1, nil, false, errors.New("caddy config missing apps") } tlsObj, ok := apps["tls"].(map[string]any) if !ok { return -1, nil, false, errors.New("caddy config missing apps.tls") } automation, ok := tlsObj["automation"].(map[string]any) if !ok { return -1, nil, false, errors.New("caddy config missing apps.tls.automation") } policiesAny, ok := automation["policies"].([]any) if !ok || len(policiesAny) == 0 { return -1, nil, false, errors.New("caddy config missing tls automation policies") } // Prefer a non-internal policy with subjects list. for i, pAny := range policiesAny { pol, ok := pAny.(map[string]any) if !ok { continue } subjectsRaw, ok := pol["subjects"].([]any) if !ok { continue } cur := make([]string, 0, len(subjectsRaw)) for _, s := range subjectsRaw { if sv, ok := s.(string); ok { cur = append(cur, sv) if strings.EqualFold(strings.TrimSpace(sv), host) { return i, cur, true, nil } } } if !policyHasInternalIssuer(pol) && policyIdx < 0 { policyIdx = i subjects = cur } } // Fallback to first policy that has subjects. if policyIdx < 0 { for i, pAny := range policiesAny { pol, ok := pAny.(map[string]any) if !ok { continue } subjectsRaw, ok := pol["subjects"].([]any) if !ok { continue } cur := make([]string, 0, len(subjectsRaw)) for _, s := range subjectsRaw { if sv, ok := s.(string); ok { cur = append(cur, sv) if strings.EqualFold(strings.TrimSpace(sv), host) { return i, cur, true, nil } } } policyIdx = i subjects = cur break } } return policyIdx, subjects, false, nil } func policyHasInternalIssuer(pol map[string]any) bool { issuersAny, ok := pol["issuers"].([]any) if !ok { return false } for _, iAny := range issuersAny { iss, ok := iAny.(map[string]any) if !ok { continue } if mod, ok := iss["module"].(string); ok && strings.EqualFold(strings.TrimSpace(mod), "internal") { return true } } return false } 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 }