forked from tangled.org/core
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 "tangled.sh/tangled.sh/core/api/tangled" 17 "tangled.sh/tangled.sh/core/hook" 18 "tangled.sh/tangled.sh/core/knotserver/config" 19 "tangled.sh/tangled.sh/core/knotserver/db" 20 "tangled.sh/tangled.sh/core/knotserver/git" 21 "tangled.sh/tangled.sh/core/notifier" 22 "tangled.sh/tangled.sh/core/rbac" 23 "tangled.sh/tangled.sh/core/workflow" 24) 25 26type InternalHandle struct { 27 db *db.DB 28 c *config.Config 29 e *rbac.Enforcer 30 l *slog.Logger 31 n *notifier.Notifier 32} 33 34func (h *InternalHandle) PushAllowed(w http.ResponseWriter, r *http.Request) { 35 user := r.URL.Query().Get("user") 36 repo := r.URL.Query().Get("repo") 37 38 if user == "" || repo == "" { 39 w.WriteHeader(http.StatusBadRequest) 40 return 41 } 42 43 ok, err := h.e.IsPushAllowed(user, rbac.ThisServer, repo) 44 if err != nil || !ok { 45 w.WriteHeader(http.StatusForbidden) 46 return 47 } 48 49 w.WriteHeader(http.StatusNoContent) 50 return 51} 52 53func (h *InternalHandle) InternalKeys(w http.ResponseWriter, r *http.Request) { 54 keys, err := h.db.GetAllPublicKeys() 55 if err != nil { 56 writeError(w, err.Error(), http.StatusInternalServerError) 57 return 58 } 59 60 data := make([]map[string]interface{}, 0) 61 for _, key := range keys { 62 j := key.JSON() 63 data = append(data, j) 64 } 65 writeJSON(w, data) 66 return 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 err = h.triggerPipeline(&resp.Messages, line, gitUserDid, repoDid, repoName, pushOptions) 124 if err != nil { 125 l.Error("failed to trigger pipeline", "err", err, "line", line, "did", gitUserDid, "repo", gitRelativeDir) 126 // non-fatal 127 } 128 } 129 130 writeJSON(w, resp) 131} 132 133func (h *InternalHandle) insertRefUpdate(line git.PostReceiveLine, gitUserDid, repoDid, repoName string) error { 134 didSlashRepo, err := securejoin.SecureJoin(repoDid, repoName) 135 if err != nil { 136 return err 137 } 138 139 repoPath, err := securejoin.SecureJoin(h.c.Repo.ScanPath, didSlashRepo) 140 if err != nil { 141 return err 142 } 143 144 gr, err := git.Open(repoPath, line.Ref) 145 if err != nil { 146 return fmt.Errorf("failed to open git repo at ref %s: %w", line.Ref, err) 147 } 148 149 var errs error 150 meta, err := gr.RefUpdateMeta(line) 151 errors.Join(errs, err) 152 153 metaRecord := meta.AsRecord() 154 155 refUpdate := tangled.GitRefUpdate{ 156 OldSha: line.OldSha.String(), 157 NewSha: line.NewSha.String(), 158 Ref: line.Ref, 159 CommitterDid: gitUserDid, 160 RepoDid: repoDid, 161 RepoName: repoName, 162 Meta: &metaRecord, 163 } 164 eventJson, err := json.Marshal(refUpdate) 165 if err != nil { 166 return err 167 } 168 169 event := db.Event{ 170 Rkey: TID(), 171 Nsid: tangled.GitRefUpdateNSID, 172 EventJson: string(eventJson), 173 } 174 175 return errors.Join(errs, h.db.InsertEvent(event, h.n)) 176} 177 178func (h *InternalHandle) triggerPipeline(clientMsgs *[]string, line git.PostReceiveLine, gitUserDid, repoDid, repoName string, pushOptions PushOptions) error { 179 if pushOptions.skipCi { 180 return nil 181 } 182 183 didSlashRepo, err := securejoin.SecureJoin(repoDid, repoName) 184 if err != nil { 185 return err 186 } 187 188 repoPath, err := securejoin.SecureJoin(h.c.Repo.ScanPath, didSlashRepo) 189 if err != nil { 190 return err 191 } 192 193 gr, err := git.Open(repoPath, line.Ref) 194 if err != nil { 195 return err 196 } 197 198 workflowDir, err := gr.FileTree(context.Background(), workflow.WorkflowDir) 199 if err != nil { 200 return err 201 } 202 203 var pipeline workflow.RawPipeline 204 for _, e := range workflowDir { 205 if !e.IsFile { 206 continue 207 } 208 209 fpath := filepath.Join(workflow.WorkflowDir, e.Name) 210 contents, err := gr.RawContent(fpath) 211 if err != nil { 212 continue 213 } 214 215 pipeline = append(pipeline, workflow.RawWorkflow{ 216 Name: e.Name, 217 Contents: contents, 218 }) 219 } 220 221 trigger := tangled.Pipeline_PushTriggerData{ 222 Ref: line.Ref, 223 OldSha: line.OldSha.String(), 224 NewSha: line.NewSha.String(), 225 } 226 227 compiler := workflow.Compiler{ 228 Trigger: tangled.Pipeline_TriggerMetadata{ 229 Kind: string(workflow.TriggerKindPush), 230 Push: &trigger, 231 Repo: &tangled.Pipeline_TriggerRepo{ 232 Did: repoDid, 233 Knot: h.c.Server.Hostname, 234 Repo: repoName, 235 }, 236 }, 237 } 238 239 cp := compiler.Compile(compiler.Parse(pipeline)) 240 eventJson, err := json.Marshal(cp) 241 if err != nil { 242 return err 243 } 244 245 if pushOptions.verboseCi { 246 if compiler.Diagnostics.IsEmpty() { 247 *clientMsgs = append(*clientMsgs, "success: pipeline compiled with no diagnostics") 248 } 249 250 for _, e := range compiler.Diagnostics.Errors { 251 *clientMsgs = append(*clientMsgs, e.String()) 252 } 253 254 for _, w := range compiler.Diagnostics.Warnings { 255 *clientMsgs = append(*clientMsgs, w.String()) 256 } 257 } 258 259 // do not run empty pipelines 260 if cp.Workflows == nil { 261 return nil 262 } 263 264 event := db.Event{ 265 Rkey: TID(), 266 Nsid: tangled.PipelineNSID, 267 EventJson: string(eventJson), 268 } 269 270 return h.db.InsertEvent(event, h.n) 271} 272 273func Internal(ctx context.Context, c *config.Config, db *db.DB, e *rbac.Enforcer, l *slog.Logger, n *notifier.Notifier) http.Handler { 274 r := chi.NewRouter() 275 276 h := InternalHandle{ 277 db, 278 c, 279 e, 280 l, 281 n, 282 } 283 284 r.Get("/push-allowed", h.PushAllowed) 285 r.Get("/keys", h.InternalKeys) 286 r.Post("/hooks/post-receive", h.PostReceiveHook) 287 r.Mount("/debug", middleware.Profiler()) 288 289 return r 290}