this repo has no description
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 // TODO: pass pushOptions to refUpdate 180 err := h.insertRefUpdate(line, gitUserDid, repoDid, repoName) 181 if err != nil { 182 l.Error("failed to insert op", "err", err, "line", line, "did", gitUserDid, "repo", gitRelativeDir) 183 // non-fatal 184 } 185 186 err = h.emitCompareLink(&resp.Messages, line, repoDid, repoName) 187 if err != nil { 188 l.Error("failed to reply with compare link", "err", err, "line", line, "did", gitUserDid, "repo", gitRelativeDir) 189 // non-fatal 190 } 191 192 err = h.triggerPipeline(&resp.Messages, line, gitUserDid, repoDid, repoName, pushOptions) 193 if err != nil { 194 l.Error("failed to trigger pipeline", "err", err, "line", line, "did", gitUserDid, "repo", gitRelativeDir) 195 // non-fatal 196 } 197 } 198 199 writeJSON(w, resp) 200} 201 202func (h *InternalHandle) insertRefUpdate(line git.PostReceiveLine, gitUserDid, repoDid, repoName string) error { 203 didSlashRepo, err := securejoin.SecureJoin(repoDid, repoName) 204 if err != nil { 205 return err 206 } 207 208 repoPath, err := securejoin.SecureJoin(h.c.Repo.ScanPath, didSlashRepo) 209 if err != nil { 210 return err 211 } 212 213 gr, err := git.Open(repoPath, line.Ref) 214 if err != nil { 215 return fmt.Errorf("failed to open git repo at ref %s: %w", line.Ref, err) 216 } 217 218 var errs error 219 meta, err := gr.RefUpdateMeta(line) 220 errors.Join(errs, err) 221 222 metaRecord := meta.AsRecord() 223 224 refUpdate := tangled.GitRefUpdate{ 225 OldSha: line.OldSha.String(), 226 NewSha: line.NewSha.String(), 227 Ref: line.Ref, 228 CommitterDid: gitUserDid, 229 RepoDid: repoDid, 230 RepoName: repoName, 231 Meta: &metaRecord, 232 } 233 eventJson, err := json.Marshal(refUpdate) 234 if err != nil { 235 return err 236 } 237 238 event := db.Event{ 239 Rkey: TID(), 240 Nsid: tangled.GitRefUpdateNSID, 241 EventJson: string(eventJson), 242 } 243 244 return errors.Join(errs, h.db.InsertEvent(event, h.n)) 245} 246 247func (h *InternalHandle) triggerPipeline( 248 clientMsgs *[]string, 249 line git.PostReceiveLine, 250 gitUserDid string, 251 repoDid string, 252 repoName string, 253 pushOptions PushOptions, 254) error { 255 if pushOptions.skipCi { 256 return nil 257 } 258 259 didSlashRepo, err := securejoin.SecureJoin(repoDid, repoName) 260 if err != nil { 261 return err 262 } 263 264 repoPath, err := securejoin.SecureJoin(h.c.Repo.ScanPath, didSlashRepo) 265 if err != nil { 266 return err 267 } 268 269 gr, err := git.Open(repoPath, line.Ref) 270 if err != nil { 271 return err 272 } 273 274 workflowDir, err := gr.FileTree(context.Background(), workflow.WorkflowDir) 275 if err != nil { 276 return err 277 } 278 279 var pipeline workflow.RawPipeline 280 for _, e := range workflowDir { 281 if !e.IsFile() { 282 continue 283 } 284 285 fpath := filepath.Join(workflow.WorkflowDir, e.Name) 286 contents, err := gr.RawContent(fpath) 287 if err != nil { 288 continue 289 } 290 291 pipeline = append(pipeline, workflow.RawWorkflow{ 292 Name: e.Name, 293 Contents: contents, 294 }) 295 } 296 297 trigger := tangled.Pipeline_PushTriggerData{ 298 Ref: line.Ref, 299 OldSha: line.OldSha.String(), 300 NewSha: line.NewSha.String(), 301 } 302 303 compiler := workflow.Compiler{ 304 Trigger: tangled.Pipeline_TriggerMetadata{ 305 Kind: string(workflow.TriggerKindPush), 306 Push: &trigger, 307 Repo: &tangled.Pipeline_TriggerRepo{ 308 Did: repoDid, 309 Knot: h.c.Server.Hostname, 310 Repo: repoName, 311 }, 312 }, 313 } 314 315 cp := compiler.Compile(compiler.Parse(pipeline)) 316 eventJson, err := json.Marshal(cp) 317 if err != nil { 318 return err 319 } 320 321 for _, e := range compiler.Diagnostics.Errors { 322 *clientMsgs = append(*clientMsgs, e.String()) 323 } 324 325 if pushOptions.verboseCi { 326 if compiler.Diagnostics.IsEmpty() { 327 *clientMsgs = append(*clientMsgs, "success: pipeline compiled with no diagnostics") 328 } 329 330 for _, w := range compiler.Diagnostics.Warnings { 331 *clientMsgs = append(*clientMsgs, w.String()) 332 } 333 } 334 335 // do not run empty pipelines 336 if cp.Workflows == nil { 337 return nil 338 } 339 340 event := db.Event{ 341 Rkey: TID(), 342 Nsid: tangled.PipelineNSID, 343 EventJson: string(eventJson), 344 } 345 346 return h.db.InsertEvent(event, h.n) 347} 348 349func (h *InternalHandle) emitCompareLink( 350 clientMsgs *[]string, 351 line git.PostReceiveLine, 352 repoDid string, 353 repoName string, 354) error { 355 // this is a second push to a branch, don't reply with the link again 356 if !line.OldSha.IsZero() { 357 return nil 358 } 359 360 // the ref was not updated to a new hash, don't reply with the link 361 // 362 // NOTE: do we need this? 363 if line.NewSha.String() == line.OldSha.String() { 364 return nil 365 } 366 367 pushedRef := plumbing.ReferenceName(line.Ref) 368 369 userIdent, err := h.res.ResolveIdent(context.Background(), repoDid) 370 user := repoDid 371 if err == nil { 372 user = userIdent.Handle.String() 373 } 374 375 didSlashRepo, err := securejoin.SecureJoin(repoDid, repoName) 376 if err != nil { 377 return err 378 } 379 380 repoPath, err := securejoin.SecureJoin(h.c.Repo.ScanPath, didSlashRepo) 381 if err != nil { 382 return err 383 } 384 385 gr, err := git.PlainOpen(repoPath) 386 if err != nil { 387 return err 388 } 389 390 defaultBranch, err := gr.FindMainBranch() 391 if err != nil { 392 return err 393 } 394 395 // pushing to default branch 396 if pushedRef == plumbing.NewBranchReferenceName(defaultBranch) { 397 return nil 398 } 399 400 // pushing a tag, don't prompt the user the open a PR 401 if pushedRef.IsTag() { 402 return nil 403 } 404 405 ZWS := "\u200B" 406 *clientMsgs = append(*clientMsgs, ZWS) 407 *clientMsgs = append(*clientMsgs, fmt.Sprintf("Create a PR pointing to %s", defaultBranch)) 408 *clientMsgs = append(*clientMsgs, fmt.Sprintf("\t%s/%s/%s/compare/%s...%s", h.c.AppViewEndpoint, user, repoName, defaultBranch, strings.TrimPrefix(line.Ref, "refs/heads/"))) 409 *clientMsgs = append(*clientMsgs, ZWS) 410 return nil 411} 412 413func Internal(ctx context.Context, c *config.Config, db *db.DB, e *rbac.Enforcer, n *notifier.Notifier) http.Handler { 414 r := chi.NewRouter() 415 l := log.FromContext(ctx) 416 l = log.SubLogger(l, "internal") 417 res := idresolver.DefaultResolver(c.Server.PlcUrl) 418 419 h := InternalHandle{ 420 db, 421 c, 422 e, 423 l, 424 n, 425 res, 426 } 427 428 r.Get("/push-allowed", h.PushAllowed) 429 r.Get("/keys", h.InternalKeys) 430 r.Get("/guard", h.Guard) 431 r.Post("/hooks/post-receive", h.PostReceiveHook) 432 r.Mount("/debug", middleware.Profiler()) 433 434 return r 435}