Monorepo for Tangled
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

at master 499 lines 13 kB view raw
1package knotserver 2 3import ( 4 "context" 5 "encoding/json" 6 "fmt" 7 "log/slog" 8 "net/http" 9 "os" 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", "Guard") 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 components := strings.Split(strings.TrimPrefix(strings.Trim(repo, "'"), "/"), "/") 91 l.Info("command components", "components", components) 92 93 var rbacResource string 94 var diskRelative string 95 96 switch { 97 case len(components) == 1 && strings.HasPrefix(components[0], "did:"): 98 repoDid := components[0] 99 repoPath, _, _, lookupErr := h.db.ResolveRepoDIDOnDisk(h.c.Repo.ScanPath, repoDid) 100 if lookupErr != nil { 101 w.WriteHeader(http.StatusNotFound) 102 l.Error("repo DID not found", "repoDid", repoDid, "err", lookupErr) 103 fmt.Fprintln(w, "repo not found") 104 return 105 } 106 rbacResource = repoDid 107 rel, relErr := filepath.Rel(h.c.Repo.ScanPath, repoPath) 108 if relErr != nil { 109 w.WriteHeader(http.StatusInternalServerError) 110 l.Error("failed to compute relative path", "repoPath", repoPath, "err", relErr) 111 fmt.Fprintln(w, "internal error") 112 return 113 } 114 diskRelative = rel 115 116 case len(components) == 2: 117 repoOwner := components[0] 118 ownerIdent, resolveErr := h.res.ResolveAtIdentifier(r.Context(), repoOwner) 119 if resolveErr != nil { 120 l.Error("error resolving owner", "owner", repoOwner, "err", resolveErr) 121 w.WriteHeader(http.StatusInternalServerError) 122 fmt.Fprintf(w, "error resolving owner: invalid did or handle\n") 123 return 124 } 125 ownerDid := ownerIdent.DID 126 repoName := components[1] 127 repoDid, didErr := h.db.GetRepoDid(ownerDid.String(), repoName) 128 var repoPath string 129 if didErr == nil { 130 var lookupErr error 131 repoPath, _, _, lookupErr = h.db.ResolveRepoDIDOnDisk(h.c.Repo.ScanPath, repoDid) 132 if lookupErr != nil { 133 w.WriteHeader(http.StatusNotFound) 134 l.Error("repo not found on disk", "repoDid", repoDid, "err", lookupErr) 135 fmt.Fprintln(w, "repo not found") 136 return 137 } 138 rbacResource = repoDid 139 } else { 140 legacyPath, joinErr := securejoin.SecureJoin(h.c.Repo.ScanPath, filepath.Join(ownerDid.String(), repoName)) 141 if joinErr != nil { 142 w.WriteHeader(http.StatusNotFound) 143 fmt.Fprintln(w, "repo not found") 144 return 145 } 146 if _, statErr := os.Stat(legacyPath); statErr != nil { 147 w.WriteHeader(http.StatusNotFound) 148 l.Error("repo not found on disk (legacy)", "owner", ownerDid, "name", repoName) 149 fmt.Fprintln(w, "repo not found") 150 return 151 } 152 repoPath = legacyPath 153 rbacResource = ownerDid.String() + "/" + repoName 154 } 155 rel, relErr := filepath.Rel(h.c.Repo.ScanPath, repoPath) 156 if relErr != nil { 157 w.WriteHeader(http.StatusInternalServerError) 158 l.Error("failed to compute relative path", "repoPath", repoPath, "err", relErr) 159 fmt.Fprintln(w, "internal error") 160 return 161 } 162 diskRelative = rel 163 164 default: 165 w.WriteHeader(http.StatusBadRequest) 166 l.Error("invalid repo format", "components", components) 167 fmt.Fprintln(w, "invalid repo format, needs <user>/<repo>, /<user>/<repo>, or <repo-did>") 168 return 169 } 170 171 if gitCommand == "git-receive-pack" { 172 ok, err := h.e.IsPushAllowed(incomingUser, rbac.ThisServer, rbacResource) 173 if err != nil || !ok { 174 w.WriteHeader(http.StatusForbidden) 175 fmt.Fprint(w, repo) 176 return 177 } 178 } 179 180 w.WriteHeader(http.StatusOK) 181 fmt.Fprint(w, diskRelative) 182} 183 184type PushOptions struct { 185 skipCi bool 186 verboseCi bool 187} 188 189func (h *InternalHandle) PostReceiveHook(w http.ResponseWriter, r *http.Request) { 190 l := h.l.With("handler", "PostReceiveHook") 191 192 gitAbsoluteDir := r.Header.Get("X-Git-Dir") 193 gitRelativeDir, err := filepath.Rel(h.c.Repo.ScanPath, gitAbsoluteDir) 194 if err != nil { 195 l.Error("failed to calculate relative git dir", "scanPath", h.c.Repo.ScanPath, "gitAbsoluteDir", gitAbsoluteDir) 196 w.WriteHeader(http.StatusInternalServerError) 197 return 198 } 199 200 var repoDid string 201 var ownerDid, repoName string 202 203 if strings.HasPrefix(gitRelativeDir, "did:") { 204 repoDid = gitRelativeDir 205 var err error 206 ownerDid, repoName, err = h.db.GetRepoKeyOwner(repoDid) 207 if err != nil { 208 l.Error("failed to resolve repo DID from git dir", "repoDid", repoDid, "err", err) 209 w.WriteHeader(http.StatusBadRequest) 210 return 211 } 212 } else { 213 components := strings.SplitN(gitRelativeDir, "/", 2) 214 if len(components) != 2 { 215 l.Error("invalid git dir, expected repo DID or owner/repo", "gitRelativeDir", gitRelativeDir) 216 w.WriteHeader(http.StatusBadRequest) 217 return 218 } 219 ownerDid = components[0] 220 repoName = components[1] 221 var didErr error 222 repoDid, didErr = h.db.GetRepoDid(ownerDid, repoName) 223 if didErr != nil { 224 l.Error("failed to resolve repo DID from legacy path", "gitRelativeDir", gitRelativeDir, "err", didErr) 225 w.WriteHeader(http.StatusBadRequest) 226 return 227 } 228 } 229 230 gitUserDid := r.Header.Get("X-Git-User-Did") 231 232 lines, err := git.ParsePostReceive(r.Body) 233 if err != nil { 234 l.Error("failed to parse post-receive payload", "err", err) 235 // non-fatal 236 } 237 238 // extract any push options 239 pushOptionsRaw := r.Header.Values("X-Git-Push-Option") 240 pushOptions := PushOptions{} 241 for _, option := range pushOptionsRaw { 242 if option == "skip-ci" || option == "ci-skip" { 243 pushOptions.skipCi = true 244 } 245 if option == "verbose-ci" || option == "ci-verbose" { 246 pushOptions.verboseCi = true 247 } 248 } 249 250 resp := hook.HookResponse{ 251 Messages: make([]string, 0), 252 } 253 254 for _, line := range lines { 255 err := h.insertRefUpdate(line, gitUserDid, ownerDid, repoName, repoDid) 256 if err != nil { 257 l.Error("failed to insert op", "err", err, "line", line, "did", gitUserDid, "repo", gitRelativeDir) 258 } 259 260 err = h.emitCompareLink(&resp.Messages, line, ownerDid, repoName, repoDid) 261 if err != nil { 262 l.Error("failed to reply with compare link", "err", err, "line", line, "did", gitUserDid, "repo", gitRelativeDir) 263 } 264 265 err = h.triggerPipeline(&resp.Messages, line, gitUserDid, ownerDid, repoName, repoDid, pushOptions) 266 if err != nil { 267 l.Error("failed to trigger pipeline", "err", err, "line", line, "did", gitUserDid, "repo", gitRelativeDir) 268 } 269 } 270 271 writeJSON(w, resp) 272} 273 274func (h *InternalHandle) insertRefUpdate(line git.PostReceiveLine, gitUserDid, ownerDid, repoName, repoDid string) error { 275 repoPath, _, _, resolveErr := h.db.ResolveRepoDIDOnDisk(h.c.Repo.ScanPath, repoDid) 276 if resolveErr != nil { 277 return fmt.Errorf("failed to resolve repo on disk: %w", resolveErr) 278 } 279 280 gr, err := git.Open(repoPath, line.Ref) 281 if err != nil { 282 return fmt.Errorf("failed to open git repo at ref %s: %w", line.Ref, err) 283 } 284 285 meta, err := gr.RefUpdateMeta(line) 286 if err != nil { 287 return fmt.Errorf("failed to get ref update metadata: %w", err) 288 } 289 290 metaRecord := meta.AsRecord() 291 292 refUpdate := tangled.GitRefUpdate{ 293 OldSha: line.OldSha.String(), 294 NewSha: line.NewSha.String(), 295 Ref: line.Ref, 296 CommitterDid: gitUserDid, 297 OwnerDid: &ownerDid, 298 RepoName: repoName, 299 RepoDid: &repoDid, 300 Meta: &metaRecord, 301 } 302 303 eventJson, err := json.Marshal(refUpdate) 304 if err != nil { 305 return err 306 } 307 308 event := db.Event{ 309 Rkey: TID(), 310 Nsid: tangled.GitRefUpdateNSID, 311 EventJson: string(eventJson), 312 } 313 314 return h.db.InsertEvent(event, h.n) 315} 316 317func (h *InternalHandle) triggerPipeline( 318 clientMsgs *[]string, 319 line git.PostReceiveLine, 320 gitUserDid string, 321 ownerDid string, 322 repoName string, 323 repoDid string, 324 pushOptions PushOptions, 325) error { 326 if pushOptions.skipCi { 327 return nil 328 } 329 330 repoPath, _, _, resolveErr := h.db.ResolveRepoDIDOnDisk(h.c.Repo.ScanPath, repoDid) 331 if resolveErr != nil { 332 return fmt.Errorf("failed to resolve repo on disk: %w", resolveErr) 333 } 334 335 gr, err := git.Open(repoPath, line.Ref) 336 if err != nil { 337 return err 338 } 339 340 workflowDir, err := gr.FileTree(context.Background(), workflow.WorkflowDir) 341 if err != nil { 342 return err 343 } 344 345 var pipeline workflow.RawPipeline 346 for _, e := range workflowDir { 347 if !e.IsFile() { 348 continue 349 } 350 351 fpath := filepath.Join(workflow.WorkflowDir, e.Name) 352 contents, err := gr.RawContent(fpath) 353 if err != nil { 354 continue 355 } 356 357 pipeline = append(pipeline, workflow.RawWorkflow{ 358 Name: e.Name, 359 Contents: contents, 360 }) 361 } 362 363 trigger := tangled.Pipeline_PushTriggerData{ 364 Ref: line.Ref, 365 OldSha: line.OldSha.String(), 366 NewSha: line.NewSha.String(), 367 } 368 369 triggerRepo := &tangled.Pipeline_TriggerRepo{ 370 Did: ownerDid, 371 Knot: h.c.Server.Hostname, 372 Repo: &repoName, 373 RepoDid: &repoDid, 374 } 375 376 compiler := workflow.Compiler{ 377 Trigger: tangled.Pipeline_TriggerMetadata{ 378 Kind: string(workflow.TriggerKindPush), 379 Push: &trigger, 380 Repo: triggerRepo, 381 }, 382 } 383 384 cp := compiler.Compile(compiler.Parse(pipeline)) 385 eventJson, err := json.Marshal(cp) 386 if err != nil { 387 return err 388 } 389 390 for _, e := range compiler.Diagnostics.Errors { 391 *clientMsgs = append(*clientMsgs, e.String()) 392 } 393 394 if pushOptions.verboseCi { 395 if compiler.Diagnostics.IsEmpty() { 396 *clientMsgs = append(*clientMsgs, "success: pipeline compiled with no diagnostics") 397 } 398 399 for _, w := range compiler.Diagnostics.Warnings { 400 *clientMsgs = append(*clientMsgs, w.String()) 401 } 402 } 403 404 // do not run empty pipelines 405 if cp.Workflows == nil { 406 return nil 407 } 408 409 event := db.Event{ 410 Rkey: TID(), 411 Nsid: tangled.PipelineNSID, 412 EventJson: string(eventJson), 413 } 414 415 return h.db.InsertEvent(event, h.n) 416} 417 418func (h *InternalHandle) emitCompareLink( 419 clientMsgs *[]string, 420 line git.PostReceiveLine, 421 ownerDid string, 422 repoName string, 423 repoDid string, 424) error { 425 // this is a second push to a branch, don't reply with the link again 426 if !line.OldSha.IsZero() { 427 return nil 428 } 429 430 // the ref was not updated to a new hash, don't reply with the link 431 // 432 // NOTE: do we need this? 433 if line.NewSha.String() == line.OldSha.String() { 434 return nil 435 } 436 437 pushedRef := plumbing.ReferenceName(line.Ref) 438 439 userIdent, err := h.res.ResolveIdent(context.Background(), ownerDid) 440 user := ownerDid 441 if err == nil { 442 user = userIdent.Handle.String() 443 } 444 445 repoPath, _, _, resolveErr := h.db.ResolveRepoDIDOnDisk(h.c.Repo.ScanPath, repoDid) 446 if resolveErr != nil { 447 return fmt.Errorf("failed to resolve repo on disk: %w", resolveErr) 448 } 449 450 gr, err := git.PlainOpen(repoPath) 451 if err != nil { 452 return err 453 } 454 455 defaultBranch, err := gr.FindMainBranch() 456 if err != nil { 457 return err 458 } 459 460 // pushing to default branch 461 if pushedRef == plumbing.NewBranchReferenceName(defaultBranch) { 462 return nil 463 } 464 465 // pushing a tag, don't prompt the user the open a PR 466 if pushedRef.IsTag() { 467 return nil 468 } 469 470 ZWS := "\u200B" 471 *clientMsgs = append(*clientMsgs, ZWS) 472 *clientMsgs = append(*clientMsgs, fmt.Sprintf("Create a PR pointing to %s", defaultBranch)) 473 *clientMsgs = append(*clientMsgs, fmt.Sprintf("\t%s/%s/%s/compare/%s...%s", h.c.AppViewEndpoint, user, repoName, defaultBranch, strings.TrimPrefix(line.Ref, "refs/heads/"))) 474 *clientMsgs = append(*clientMsgs, ZWS) 475 return nil 476} 477 478func Internal(ctx context.Context, c *config.Config, db *db.DB, e *rbac.Enforcer, n *notifier.Notifier, res *idresolver.Resolver) http.Handler { 479 r := chi.NewRouter() 480 l := log.FromContext(ctx) 481 l = log.SubLogger(l, "internal") 482 483 h := InternalHandle{ 484 db: db, 485 c: c, 486 e: e, 487 l: l, 488 n: n, 489 res: res, 490 } 491 492 r.Get("/push-allowed", h.PushAllowed) 493 r.Get("/keys", h.InternalKeys) 494 r.Get("/guard", h.Guard) 495 r.Post("/hooks/post-receive", h.PostReceiveHook) 496 r.Mount("/debug", middleware.Profiler()) 497 498 return r 499}