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/notifier" 24 "tangled.org/core/rbac" 25 "tangled.org/core/workflow" 26) 27 28type InternalHandle struct { 29 db *db.DB 30 c *config.Config 31 e *rbac.Enforcer 32 l *slog.Logger 33 n *notifier.Notifier 34} 35 36func (h *InternalHandle) PushAllowed(w http.ResponseWriter, r *http.Request) { 37 user := r.URL.Query().Get("user") 38 repo := r.URL.Query().Get("repo") 39 40 if user == "" || repo == "" { 41 w.WriteHeader(http.StatusBadRequest) 42 return 43 } 44 45 ok, err := h.e.IsPushAllowed(user, rbac.ThisServer, repo) 46 if err != nil || !ok { 47 w.WriteHeader(http.StatusForbidden) 48 return 49 } 50 51 w.WriteHeader(http.StatusNoContent) 52} 53 54func (h *InternalHandle) InternalKeys(w http.ResponseWriter, r *http.Request) { 55 keys, err := h.db.GetAllPublicKeys() 56 if err != nil { 57 writeError(w, err.Error(), http.StatusInternalServerError) 58 return 59 } 60 61 data := make([]map[string]interface{}, 0) 62 for _, key := range keys { 63 j := key.JSON() 64 data = append(data, j) 65 } 66 writeJSON(w, data) 67} 68 69type PushOptions struct { 70 skipCi bool 71 verboseCi bool 72} 73 74func (h *InternalHandle) PostReceiveHook(w http.ResponseWriter, r *http.Request) { 75 l := h.l.With("handler", "PostReceiveHook") 76 77 gitAbsoluteDir := r.Header.Get("X-Git-Dir") 78 gitRelativeDir, err := filepath.Rel(h.c.Repo.ScanPath, gitAbsoluteDir) 79 if err != nil { 80 l.Error("failed to calculate relative git dir", "scanPath", h.c.Repo.ScanPath, "gitAbsoluteDir", gitAbsoluteDir) 81 return 82 } 83 84 parts := strings.SplitN(gitRelativeDir, "/", 2) 85 if len(parts) != 2 { 86 l.Error("invalid git dir", "gitRelativeDir", gitRelativeDir) 87 return 88 } 89 repoDid := parts[0] 90 repoName := parts[1] 91 92 gitUserDid := r.Header.Get("X-Git-User-Did") 93 94 lines, err := git.ParsePostReceive(r.Body) 95 if err != nil { 96 l.Error("failed to parse post-receive payload", "err", err) 97 // non-fatal 98 } 99 100 // extract any push options 101 pushOptionsRaw := r.Header.Values("X-Git-Push-Option") 102 pushOptions := PushOptions{} 103 for _, option := range pushOptionsRaw { 104 if option == "skip-ci" || option == "ci-skip" { 105 pushOptions.skipCi = true 106 } 107 if option == "verbose-ci" || option == "ci-verbose" { 108 pushOptions.verboseCi = true 109 } 110 } 111 112 resp := hook.HookResponse{ 113 Messages: make([]string, 0), 114 } 115 116 for _, line := range lines { 117 err := h.insertRefUpdate(line, gitUserDid, repoDid, repoName) 118 if err != nil { 119 l.Error("failed to insert op", "err", err, "line", line, "did", gitUserDid, "repo", gitRelativeDir) 120 // non-fatal 121 } 122 123 if (line.NewSha.String() != line.OldSha.String()) && line.OldSha.IsZero() { 124 msg, err := h.replyCompare(line, gitUserDid, gitRelativeDir, repoName, r.Context()) 125 if err != nil { 126 l.Error("failed to reply with compare link", "err", err, "line", line, "did", gitUserDid, "repo", gitRelativeDir) 127 // non-fatal 128 } else { 129 for msgLine := range msg { 130 resp.Messages = append(resp.Messages, msg[msgLine]) 131 } 132 } 133 } 134 135 err = h.triggerPipeline(&resp.Messages, line, gitUserDid, repoDid, repoName, pushOptions) 136 if err != nil { 137 l.Error("failed to trigger pipeline", "err", err, "line", line, "did", gitUserDid, "repo", gitRelativeDir) 138 // non-fatal 139 } 140 } 141 142 writeJSON(w, resp) 143} 144 145func (h *InternalHandle) replyCompare(line git.PostReceiveLine, gitUserDid string, gitRelativeDir string, repoName string, ctx context.Context) ([]string, error) { 146 l := h.l.With("handler", "replyCompare") 147 userIdent, err := idresolver.DefaultResolver().ResolveIdent(ctx, gitUserDid) 148 user := gitUserDid 149 if err != nil { 150 l.Error("Failed to fetch user identity", "err", err) 151 // non-fatal 152 } else { 153 user = userIdent.Handle.String() 154 } 155 gr, err := git.PlainOpen(gitRelativeDir) 156 if err != nil { 157 l.Error("Failed to open git repository", "err", err) 158 return []string{}, err 159 } 160 defaultBranch, err := gr.FindMainBranch() 161 if err != nil { 162 l.Error("Failed to fetch default branch", "err", err) 163 return []string{}, err 164 } 165 if line.Ref == plumbing.NewBranchReferenceName(defaultBranch).String() { 166 return []string{}, nil 167 } 168 ZWS := "\u200B" 169 var msg []string 170 msg = append(msg, ZWS) 171 msg = append(msg, fmt.Sprintf("Create a PR pointing to %s", defaultBranch)) 172 msg = append(msg, fmt.Sprintf("\t%s/%s/%s/compare/%s...%s", h.c.AppViewEndpoint, user, repoName, defaultBranch, strings.TrimPrefix(line.Ref, "refs/heads/"))) 173 msg = append(msg, ZWS) 174 return msg, nil 175} 176 177func (h *InternalHandle) insertRefUpdate(line git.PostReceiveLine, gitUserDid, repoDid, repoName string) error { 178 didSlashRepo, err := securejoin.SecureJoin(repoDid, repoName) 179 if err != nil { 180 return err 181 } 182 183 repoPath, err := securejoin.SecureJoin(h.c.Repo.ScanPath, didSlashRepo) 184 if err != nil { 185 return err 186 } 187 188 gr, err := git.Open(repoPath, line.Ref) 189 if err != nil { 190 return fmt.Errorf("failed to open git repo at ref %s: %w", line.Ref, err) 191 } 192 193 var errs error 194 meta, err := gr.RefUpdateMeta(line) 195 errors.Join(errs, err) 196 197 metaRecord := meta.AsRecord() 198 199 refUpdate := tangled.GitRefUpdate{ 200 OldSha: line.OldSha.String(), 201 NewSha: line.NewSha.String(), 202 Ref: line.Ref, 203 CommitterDid: gitUserDid, 204 RepoDid: repoDid, 205 RepoName: repoName, 206 Meta: &metaRecord, 207 } 208 eventJson, err := json.Marshal(refUpdate) 209 if err != nil { 210 return err 211 } 212 213 event := db.Event{ 214 Rkey: TID(), 215 Nsid: tangled.GitRefUpdateNSID, 216 EventJson: string(eventJson), 217 } 218 219 return errors.Join(errs, h.db.InsertEvent(event, h.n)) 220} 221 222func (h *InternalHandle) triggerPipeline(clientMsgs *[]string, line git.PostReceiveLine, gitUserDid, repoDid, repoName string, pushOptions PushOptions) error { 223 if pushOptions.skipCi { 224 return nil 225 } 226 227 didSlashRepo, err := securejoin.SecureJoin(repoDid, repoName) 228 if err != nil { 229 return err 230 } 231 232 repoPath, err := securejoin.SecureJoin(h.c.Repo.ScanPath, didSlashRepo) 233 if err != nil { 234 return err 235 } 236 237 gr, err := git.Open(repoPath, line.Ref) 238 if err != nil { 239 return err 240 } 241 242 workflowDir, err := gr.FileTree(context.Background(), workflow.WorkflowDir) 243 if err != nil { 244 return err 245 } 246 247 var pipeline workflow.RawPipeline 248 for _, e := range workflowDir { 249 if !e.IsFile { 250 continue 251 } 252 253 fpath := filepath.Join(workflow.WorkflowDir, e.Name) 254 contents, err := gr.RawContent(fpath) 255 if err != nil { 256 continue 257 } 258 259 pipeline = append(pipeline, workflow.RawWorkflow{ 260 Name: e.Name, 261 Contents: contents, 262 }) 263 } 264 265 trigger := tangled.Pipeline_PushTriggerData{ 266 Ref: line.Ref, 267 OldSha: line.OldSha.String(), 268 NewSha: line.NewSha.String(), 269 } 270 271 compiler := workflow.Compiler{ 272 Trigger: tangled.Pipeline_TriggerMetadata{ 273 Kind: string(workflow.TriggerKindPush), 274 Push: &trigger, 275 Repo: &tangled.Pipeline_TriggerRepo{ 276 Did: repoDid, 277 Knot: h.c.Server.Hostname, 278 Repo: repoName, 279 }, 280 }, 281 } 282 283 cp := compiler.Compile(compiler.Parse(pipeline)) 284 eventJson, err := json.Marshal(cp) 285 if err != nil { 286 return err 287 } 288 289 for _, e := range compiler.Diagnostics.Errors { 290 *clientMsgs = append(*clientMsgs, e.String()) 291 } 292 293 if pushOptions.verboseCi { 294 if compiler.Diagnostics.IsEmpty() { 295 *clientMsgs = append(*clientMsgs, "success: pipeline compiled with no diagnostics") 296 } 297 298 for _, w := range compiler.Diagnostics.Warnings { 299 *clientMsgs = append(*clientMsgs, w.String()) 300 } 301 } 302 303 // do not run empty pipelines 304 if cp.Workflows == nil { 305 return nil 306 } 307 308 event := db.Event{ 309 Rkey: TID(), 310 Nsid: tangled.PipelineNSID, 311 EventJson: string(eventJson), 312 } 313 314 return h.db.InsertEvent(event, h.n) 315} 316 317func Internal(ctx context.Context, c *config.Config, db *db.DB, e *rbac.Enforcer, l *slog.Logger, n *notifier.Notifier) http.Handler { 318 r := chi.NewRouter() 319 320 h := InternalHandle{ 321 db, 322 c, 323 e, 324 l, 325 n, 326 } 327 328 r.Get("/push-allowed", h.PushAllowed) 329 r.Get("/keys", h.InternalKeys) 330 r.Post("/hooks/post-receive", h.PostReceiveHook) 331 r.Mount("/debug", middleware.Profiler()) 332 333 return r 334}