forked from tangled.org/core
Monorepo for Tangled — https://tangled.org
1package knotserver 2 3import ( 4 "context" 5 "encoding/json" 6 "errors" 7 "fmt" 8 "log/slog" 9 "net/http" 10 "path/filepath" 11 "strings" 12 13 securejoin "github.com/cyphar/filepath-securejoin" 14 "github.com/go-chi/chi/v5" 15 "github.com/go-chi/chi/v5/middleware" 16 "github.com/go-git/go-git/v5/plumbing" 17 "tangled.org/core/api/tangled" 18 "tangled.org/core/hook" 19 "tangled.org/core/idresolver" 20 "tangled.org/core/knotserver/config" 21 "tangled.org/core/knotserver/db" 22 "tangled.org/core/knotserver/git" 23 "tangled.org/core/log" 24 "tangled.org/core/notifier" 25 "tangled.org/core/rbac" 26 "tangled.org/core/workflow" 27) 28 29type InternalHandle struct { 30 db *db.DB 31 c *config.Config 32 e *rbac.Enforcer 33 l *slog.Logger 34 n *notifier.Notifier 35} 36 37func (h *InternalHandle) PushAllowed(w http.ResponseWriter, r *http.Request) { 38 user := r.URL.Query().Get("user") 39 repo := r.URL.Query().Get("repo") 40 41 if user == "" || repo == "" { 42 w.WriteHeader(http.StatusBadRequest) 43 return 44 } 45 46 ok, err := h.e.IsPushAllowed(user, rbac.ThisServer, repo) 47 if err != nil || !ok { 48 w.WriteHeader(http.StatusForbidden) 49 return 50 } 51 52 w.WriteHeader(http.StatusNoContent) 53} 54 55func (h *InternalHandle) InternalKeys(w http.ResponseWriter, r *http.Request) { 56 keys, err := h.db.GetAllPublicKeys() 57 if err != nil { 58 writeError(w, err.Error(), http.StatusInternalServerError) 59 return 60 } 61 62 data := make([]map[string]interface{}, 0) 63 for _, key := range keys { 64 j := key.JSON() 65 data = append(data, j) 66 } 67 writeJSON(w, data) 68} 69 70type PushOptions struct { 71 skipCi bool 72 verboseCi bool 73} 74 75func (h *InternalHandle) PostReceiveHook(w http.ResponseWriter, r *http.Request) { 76 l := h.l.With("handler", "PostReceiveHook") 77 78 gitAbsoluteDir := r.Header.Get("X-Git-Dir") 79 gitRelativeDir, err := filepath.Rel(h.c.Repo.ScanPath, gitAbsoluteDir) 80 if err != nil { 81 l.Error("failed to calculate relative git dir", "scanPath", h.c.Repo.ScanPath, "gitAbsoluteDir", gitAbsoluteDir) 82 return 83 } 84 85 parts := strings.SplitN(gitRelativeDir, "/", 2) 86 if len(parts) != 2 { 87 l.Error("invalid git dir", "gitRelativeDir", gitRelativeDir) 88 return 89 } 90 repoDid := parts[0] 91 repoName := parts[1] 92 93 gitUserDid := r.Header.Get("X-Git-User-Did") 94 95 lines, err := git.ParsePostReceive(r.Body) 96 if err != nil { 97 l.Error("failed to parse post-receive payload", "err", err) 98 // non-fatal 99 } 100 101 // extract any push options 102 pushOptionsRaw := r.Header.Values("X-Git-Push-Option") 103 pushOptions := PushOptions{} 104 for _, option := range pushOptionsRaw { 105 if option == "skip-ci" || option == "ci-skip" { 106 pushOptions.skipCi = true 107 } 108 if option == "verbose-ci" || option == "ci-verbose" { 109 pushOptions.verboseCi = true 110 } 111 } 112 113 resp := hook.HookResponse{ 114 Messages: make([]string, 0), 115 } 116 117 for _, line := range lines { 118 err := h.insertRefUpdate(line, gitUserDid, repoDid, repoName) 119 if err != nil { 120 l.Error("failed to insert op", "err", err, "line", line, "did", gitUserDid, "repo", gitRelativeDir) 121 // non-fatal 122 } 123 124 if (line.NewSha.String() != line.OldSha.String()) && line.OldSha.IsZero() { 125 msg, err := h.replyCompare(line, repoDid, gitRelativeDir, repoName, r.Context()) 126 if err != nil { 127 l.Error("failed to reply with compare link", "err", err, "line", line, "did", gitUserDid, "repo", gitRelativeDir) 128 // non-fatal 129 } else { 130 for msgLine := range msg { 131 resp.Messages = append(resp.Messages, msg[msgLine]) 132 } 133 } 134 } 135 136 err = h.triggerPipeline(&resp.Messages, line, gitUserDid, repoDid, repoName, pushOptions) 137 if err != nil { 138 l.Error("failed to trigger pipeline", "err", err, "line", line, "did", gitUserDid, "repo", gitRelativeDir) 139 // non-fatal 140 } 141 } 142 143 writeJSON(w, resp) 144} 145 146func (h *InternalHandle) replyCompare(line git.PostReceiveLine, repoOwner string, gitRelativeDir string, repoName string, ctx context.Context) ([]string, error) { 147 l := h.l.With("handler", "replyCompare") 148 userIdent, err := idresolver.DefaultResolver().ResolveIdent(ctx, repoOwner) 149 user := repoOwner 150 if err != nil { 151 l.Error("Failed to fetch user identity", "err", err) 152 // non-fatal 153 } else { 154 user = userIdent.Handle.String() 155 } 156 gr, err := git.PlainOpen(gitRelativeDir) 157 if err != nil { 158 l.Error("Failed to open git repository", "err", err) 159 return []string{}, err 160 } 161 defaultBranch, err := gr.FindMainBranch() 162 if err != nil { 163 l.Error("Failed to fetch default branch", "err", err) 164 return []string{}, err 165 } 166 if line.Ref == plumbing.NewBranchReferenceName(defaultBranch).String() { 167 return []string{}, nil 168 } 169 ZWS := "\u200B" 170 var msg []string 171 msg = append(msg, ZWS) 172 msg = append(msg, fmt.Sprintf("Create a PR pointing to %s", defaultBranch)) 173 msg = append(msg, fmt.Sprintf("\t%s/%s/%s/compare/%s...%s", h.c.AppViewEndpoint, user, repoName, defaultBranch, strings.TrimPrefix(line.Ref, "refs/heads/"))) 174 msg = append(msg, ZWS) 175 return msg, nil 176} 177 178func (h *InternalHandle) insertRefUpdate(line git.PostReceiveLine, gitUserDid, repoDid, repoName string) error { 179 didSlashRepo, err := securejoin.SecureJoin(repoDid, repoName) 180 if err != nil { 181 return err 182 } 183 184 repoPath, err := securejoin.SecureJoin(h.c.Repo.ScanPath, didSlashRepo) 185 if err != nil { 186 return err 187 } 188 189 gr, err := git.Open(repoPath, line.Ref) 190 if err != nil { 191 return fmt.Errorf("failed to open git repo at ref %s: %w", line.Ref, err) 192 } 193 194 var errs error 195 meta, err := gr.RefUpdateMeta(line) 196 errors.Join(errs, err) 197 198 metaRecord := meta.AsRecord() 199 200 refUpdate := tangled.GitRefUpdate{ 201 OldSha: line.OldSha.String(), 202 NewSha: line.NewSha.String(), 203 Ref: line.Ref, 204 CommitterDid: gitUserDid, 205 RepoDid: repoDid, 206 RepoName: repoName, 207 Meta: &metaRecord, 208 } 209 eventJson, err := json.Marshal(refUpdate) 210 if err != nil { 211 return err 212 } 213 214 event := db.Event{ 215 Rkey: TID(), 216 Nsid: tangled.GitRefUpdateNSID, 217 EventJson: string(eventJson), 218 } 219 220 return errors.Join(errs, h.db.InsertEvent(event, h.n)) 221} 222 223func (h *InternalHandle) triggerPipeline(clientMsgs *[]string, line git.PostReceiveLine, gitUserDid, repoDid, repoName string, pushOptions PushOptions) error { 224 if pushOptions.skipCi { 225 return nil 226 } 227 228 didSlashRepo, err := securejoin.SecureJoin(repoDid, repoName) 229 if err != nil { 230 return err 231 } 232 233 repoPath, err := securejoin.SecureJoin(h.c.Repo.ScanPath, didSlashRepo) 234 if err != nil { 235 return err 236 } 237 238 gr, err := git.Open(repoPath, line.Ref) 239 if err != nil { 240 return err 241 } 242 243 workflowDir, err := gr.FileTree(context.Background(), workflow.WorkflowDir) 244 if err != nil { 245 return err 246 } 247 248 var pipeline workflow.RawPipeline 249 for _, e := range workflowDir { 250 if !e.IsFile { 251 continue 252 } 253 254 fpath := filepath.Join(workflow.WorkflowDir, e.Name) 255 contents, err := gr.RawContent(fpath) 256 if err != nil { 257 continue 258 } 259 260 pipeline = append(pipeline, workflow.RawWorkflow{ 261 Name: e.Name, 262 Contents: contents, 263 }) 264 } 265 266 trigger := tangled.Pipeline_PushTriggerData{ 267 Ref: line.Ref, 268 OldSha: line.OldSha.String(), 269 NewSha: line.NewSha.String(), 270 } 271 272 compiler := workflow.Compiler{ 273 Trigger: tangled.Pipeline_TriggerMetadata{ 274 Kind: string(workflow.TriggerKindPush), 275 Push: &trigger, 276 Repo: &tangled.Pipeline_TriggerRepo{ 277 Did: repoDid, 278 Knot: h.c.Server.Hostname, 279 Repo: repoName, 280 }, 281 }, 282 } 283 284 cp := compiler.Compile(compiler.Parse(pipeline)) 285 eventJson, err := json.Marshal(cp) 286 if err != nil { 287 return err 288 } 289 290 for _, e := range compiler.Diagnostics.Errors { 291 *clientMsgs = append(*clientMsgs, e.String()) 292 } 293 294 if pushOptions.verboseCi { 295 if compiler.Diagnostics.IsEmpty() { 296 *clientMsgs = append(*clientMsgs, "success: pipeline compiled with no diagnostics") 297 } 298 299 for _, w := range compiler.Diagnostics.Warnings { 300 *clientMsgs = append(*clientMsgs, w.String()) 301 } 302 } 303 304 // do not run empty pipelines 305 if cp.Workflows == nil { 306 return nil 307 } 308 309 event := db.Event{ 310 Rkey: TID(), 311 Nsid: tangled.PipelineNSID, 312 EventJson: string(eventJson), 313 } 314 315 return h.db.InsertEvent(event, h.n) 316} 317 318func Internal(ctx context.Context, c *config.Config, db *db.DB, e *rbac.Enforcer, n *notifier.Notifier) http.Handler { 319 r := chi.NewRouter() 320 l := log.FromContext(ctx) 321 l = log.SubLogger(l, "internal") 322 323 h := InternalHandle{ 324 db, 325 c, 326 e, 327 l, 328 n, 329 } 330 331 r.Get("/push-allowed", h.PushAllowed) 332 r.Get("/keys", h.InternalKeys) 333 r.Post("/hooks/post-receive", h.PostReceiveHook) 334 r.Mount("/debug", middleware.Profiler()) 335 336 return r 337}