Monorepo for Tangled tangled.org
6

Configure Feed

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

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