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