forked from tangled.org/core
Monorepo for Tangled — https://tangled.org
at master 11 kB view raw
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 res *idresolver.Resolver 36} 37 38func (h *InternalHandle) PushAllowed(w http.ResponseWriter, r *http.Request) { 39 user := r.URL.Query().Get("user") 40 repo := r.URL.Query().Get("repo") 41 42 if user == "" || repo == "" { 43 w.WriteHeader(http.StatusBadRequest) 44 return 45 } 46 47 ok, err := h.e.IsPushAllowed(user, rbac.ThisServer, repo) 48 if err != nil || !ok { 49 w.WriteHeader(http.StatusForbidden) 50 return 51 } 52 53 w.WriteHeader(http.StatusNoContent) 54} 55 56func (h *InternalHandle) InternalKeys(w http.ResponseWriter, r *http.Request) { 57 keys, err := h.db.GetAllPublicKeys() 58 if err != nil { 59 writeError(w, err.Error(), http.StatusInternalServerError) 60 return 61 } 62 63 data := make([]map[string]interface{}, 0) 64 for _, key := range keys { 65 j := key.JSON() 66 data = append(data, j) 67 } 68 writeJSON(w, data) 69} 70 71// response in text/plain format 72// the body will be qualified repository path on success/push-denied 73// or an error message when process failed 74func (h *InternalHandle) Guard(w http.ResponseWriter, r *http.Request) { 75 l := h.l.With("handler", "PostReceiveHook") 76 77 var ( 78 incomingUser = r.URL.Query().Get("user") 79 repo = r.URL.Query().Get("repo") 80 gitCommand = r.URL.Query().Get("gitCmd") 81 ) 82 83 if incomingUser == "" || repo == "" || gitCommand == "" { 84 w.WriteHeader(http.StatusBadRequest) 85 l.Error("invalid request", "incomingUser", incomingUser, "repo", repo, "gitCommand", gitCommand) 86 fmt.Fprintln(w, "invalid internal request") 87 return 88 } 89 90 // did:foo/repo-name or 91 // handle/repo-name or 92 // any of the above with a leading slash (/) 93 components := strings.Split(strings.TrimPrefix(strings.Trim(repo, "'"), "/"), "/") 94 l.Info("command components", "components", components) 95 96 if len(components) != 2 { 97 w.WriteHeader(http.StatusBadRequest) 98 l.Error("invalid repo format", "components", components) 99 fmt.Fprintln(w, "invalid repo format, needs <user>/<repo> or /<user>/<repo>") 100 return 101 } 102 repoOwner := components[0] 103 repoName := components[1] 104 105 resolver := idresolver.DefaultResolver(h.c.Server.PlcUrl) 106 107 repoOwnerIdent, err := resolver.ResolveIdent(r.Context(), repoOwner) 108 if err != nil || repoOwnerIdent.Handle.IsInvalidHandle() { 109 l.Error("Error resolving handle", "handle", repoOwner, "err", err) 110 w.WriteHeader(http.StatusInternalServerError) 111 fmt.Fprintf(w, "error resolving handle: invalid handle\n") 112 return 113 } 114 repoOwnerDid := repoOwnerIdent.DID.String() 115 116 qualifiedRepo, _ := securejoin.SecureJoin(repoOwnerDid, repoName) 117 118 if gitCommand == "git-receive-pack" { 119 ok, err := h.e.IsPushAllowed(incomingUser, rbac.ThisServer, qualifiedRepo) 120 if err != nil || !ok { 121 w.WriteHeader(http.StatusForbidden) 122 fmt.Fprint(w, repo) 123 return 124 } 125 } 126 127 w.WriteHeader(http.StatusOK) 128 fmt.Fprint(w, qualifiedRepo) 129} 130 131type PushOptions struct { 132 skipCi bool 133 verboseCi bool 134} 135 136func (h *InternalHandle) PostReceiveHook(w http.ResponseWriter, r *http.Request) { 137 l := h.l.With("handler", "PostReceiveHook") 138 139 gitAbsoluteDir := r.Header.Get("X-Git-Dir") 140 gitRelativeDir, err := filepath.Rel(h.c.Repo.ScanPath, gitAbsoluteDir) 141 if err != nil { 142 l.Error("failed to calculate relative git dir", "scanPath", h.c.Repo.ScanPath, "gitAbsoluteDir", gitAbsoluteDir) 143 return 144 } 145 146 parts := strings.SplitN(gitRelativeDir, "/", 2) 147 if len(parts) != 2 { 148 l.Error("invalid git dir", "gitRelativeDir", gitRelativeDir) 149 return 150 } 151 repoDid := parts[0] 152 repoName := parts[1] 153 154 gitUserDid := r.Header.Get("X-Git-User-Did") 155 156 lines, err := git.ParsePostReceive(r.Body) 157 if err != nil { 158 l.Error("failed to parse post-receive payload", "err", err) 159 // non-fatal 160 } 161 162 // extract any push options 163 pushOptionsRaw := r.Header.Values("X-Git-Push-Option") 164 pushOptions := PushOptions{} 165 for _, option := range pushOptionsRaw { 166 if option == "skip-ci" || option == "ci-skip" { 167 pushOptions.skipCi = true 168 } 169 if option == "verbose-ci" || option == "ci-verbose" { 170 pushOptions.verboseCi = true 171 } 172 } 173 174 resp := hook.HookResponse{ 175 Messages: make([]string, 0), 176 } 177 178 for _, line := range lines { 179 err := h.insertRefUpdate(line, gitUserDid, repoDid, repoName) 180 if err != nil { 181 l.Error("failed to insert op", "err", err, "line", line, "did", gitUserDid, "repo", gitRelativeDir) 182 // non-fatal 183 } 184 185 err = h.emitCompareLink(&resp.Messages, line, repoDid, repoName) 186 if err != nil { 187 l.Error("failed to reply with compare link", "err", err, "line", line, "did", gitUserDid, "repo", gitRelativeDir) 188 // non-fatal 189 } 190 191 err = h.triggerPipeline(&resp.Messages, line, gitUserDid, repoDid, repoName, pushOptions) 192 if err != nil { 193 l.Error("failed to trigger pipeline", "err", err, "line", line, "did", gitUserDid, "repo", gitRelativeDir) 194 // non-fatal 195 } 196 } 197 198 writeJSON(w, resp) 199} 200 201func (h *InternalHandle) insertRefUpdate(line git.PostReceiveLine, gitUserDid, repoDid, repoName string) error { 202 didSlashRepo, err := securejoin.SecureJoin(repoDid, repoName) 203 if err != nil { 204 return err 205 } 206 207 repoPath, err := securejoin.SecureJoin(h.c.Repo.ScanPath, didSlashRepo) 208 if err != nil { 209 return err 210 } 211 212 gr, err := git.Open(repoPath, line.Ref) 213 if err != nil { 214 return fmt.Errorf("failed to open git repo at ref %s: %w", line.Ref, err) 215 } 216 217 var errs error 218 meta, err := gr.RefUpdateMeta(line) 219 errors.Join(errs, err) 220 221 metaRecord := meta.AsRecord() 222 223 refUpdate := tangled.GitRefUpdate{ 224 OldSha: line.OldSha.String(), 225 NewSha: line.NewSha.String(), 226 Ref: line.Ref, 227 CommitterDid: gitUserDid, 228 RepoDid: repoDid, 229 RepoName: repoName, 230 Meta: &metaRecord, 231 } 232 eventJson, err := json.Marshal(refUpdate) 233 if err != nil { 234 return err 235 } 236 237 event := db.Event{ 238 Rkey: TID(), 239 Nsid: tangled.GitRefUpdateNSID, 240 EventJson: string(eventJson), 241 } 242 243 return errors.Join(errs, h.db.InsertEvent(event, h.n)) 244} 245 246func (h *InternalHandle) triggerPipeline( 247 clientMsgs *[]string, 248 line git.PostReceiveLine, 249 gitUserDid string, 250 repoDid string, 251 repoName string, 252 pushOptions PushOptions, 253) error { 254 if pushOptions.skipCi { 255 return nil 256 } 257 258 didSlashRepo, err := securejoin.SecureJoin(repoDid, repoName) 259 if err != nil { 260 return err 261 } 262 263 repoPath, err := securejoin.SecureJoin(h.c.Repo.ScanPath, didSlashRepo) 264 if err != nil { 265 return err 266 } 267 268 gr, err := git.Open(repoPath, line.Ref) 269 if err != nil { 270 return err 271 } 272 273 workflowDir, err := gr.FileTree(context.Background(), workflow.WorkflowDir) 274 if err != nil { 275 return err 276 } 277 278 var pipeline workflow.RawPipeline 279 for _, e := range workflowDir { 280 if !e.IsFile() { 281 continue 282 } 283 284 fpath := filepath.Join(workflow.WorkflowDir, e.Name) 285 contents, err := gr.RawContent(fpath) 286 if err != nil { 287 continue 288 } 289 290 pipeline = append(pipeline, workflow.RawWorkflow{ 291 Name: e.Name, 292 Contents: contents, 293 }) 294 } 295 296 trigger := tangled.Pipeline_PushTriggerData{ 297 Ref: line.Ref, 298 OldSha: line.OldSha.String(), 299 NewSha: line.NewSha.String(), 300 } 301 302 compiler := workflow.Compiler{ 303 Trigger: tangled.Pipeline_TriggerMetadata{ 304 Kind: string(workflow.TriggerKindPush), 305 Push: &trigger, 306 Repo: &tangled.Pipeline_TriggerRepo{ 307 Did: repoDid, 308 Knot: h.c.Server.Hostname, 309 Repo: repoName, 310 }, 311 }, 312 } 313 314 cp := compiler.Compile(compiler.Parse(pipeline)) 315 eventJson, err := json.Marshal(cp) 316 if err != nil { 317 return err 318 } 319 320 for _, e := range compiler.Diagnostics.Errors { 321 *clientMsgs = append(*clientMsgs, e.String()) 322 } 323 324 if pushOptions.verboseCi { 325 if compiler.Diagnostics.IsEmpty() { 326 *clientMsgs = append(*clientMsgs, "success: pipeline compiled with no diagnostics") 327 } 328 329 for _, w := range compiler.Diagnostics.Warnings { 330 *clientMsgs = append(*clientMsgs, w.String()) 331 } 332 } 333 334 // do not run empty pipelines 335 if cp.Workflows == nil { 336 return nil 337 } 338 339 event := db.Event{ 340 Rkey: TID(), 341 Nsid: tangled.PipelineNSID, 342 EventJson: string(eventJson), 343 } 344 345 return h.db.InsertEvent(event, h.n) 346} 347 348func (h *InternalHandle) emitCompareLink( 349 clientMsgs *[]string, 350 line git.PostReceiveLine, 351 repoDid string, 352 repoName string, 353) error { 354 // this is a second push to a branch, don't reply with the link again 355 if !line.OldSha.IsZero() { 356 return nil 357 } 358 359 // the ref was not updated to a new hash, don't reply with the link 360 // 361 // NOTE: do we need this? 362 if line.NewSha.String() == line.OldSha.String() { 363 return nil 364 } 365 366 pushedRef := plumbing.ReferenceName(line.Ref) 367 368 userIdent, err := h.res.ResolveIdent(context.Background(), repoDid) 369 user := repoDid 370 if err == nil { 371 user = userIdent.Handle.String() 372 } 373 374 didSlashRepo, err := securejoin.SecureJoin(repoDid, repoName) 375 if err != nil { 376 return err 377 } 378 379 repoPath, err := securejoin.SecureJoin(h.c.Repo.ScanPath, didSlashRepo) 380 if err != nil { 381 return err 382 } 383 384 gr, err := git.PlainOpen(repoPath) 385 if err != nil { 386 return err 387 } 388 389 defaultBranch, err := gr.FindMainBranch() 390 if err != nil { 391 return err 392 } 393 394 // pushing to default branch 395 if pushedRef == plumbing.NewBranchReferenceName(defaultBranch) { 396 return nil 397 } 398 399 // pushing a tag, don't prompt the user the open a PR 400 if pushedRef.IsTag() { 401 return nil 402 } 403 404 ZWS := "\u200B" 405 *clientMsgs = append(*clientMsgs, ZWS) 406 *clientMsgs = append(*clientMsgs, fmt.Sprintf("Create a PR pointing to %s", defaultBranch)) 407 *clientMsgs = append(*clientMsgs, fmt.Sprintf("\t%s/%s/%s/compare/%s...%s", h.c.AppViewEndpoint, user, repoName, defaultBranch, strings.TrimPrefix(line.Ref, "refs/heads/"))) 408 *clientMsgs = append(*clientMsgs, ZWS) 409 return nil 410} 411 412func Internal(ctx context.Context, c *config.Config, db *db.DB, e *rbac.Enforcer, n *notifier.Notifier) http.Handler { 413 r := chi.NewRouter() 414 l := log.FromContext(ctx) 415 l = log.SubLogger(l, "internal") 416 res := idresolver.DefaultResolver(c.Server.PlcUrl) 417 418 h := InternalHandle{ 419 db, 420 c, 421 e, 422 l, 423 n, 424 res, 425 } 426 427 r.Get("/push-allowed", h.PushAllowed) 428 r.Get("/keys", h.InternalKeys) 429 r.Get("/guard", h.Guard) 430 r.Post("/hooks/post-receive", h.PostReceiveHook) 431 r.Mount("/debug", middleware.Profiler()) 432 433 return r 434}