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