Monorepo for Tangled tangled.org
10

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