1package knotserver
2
3import (
4 "context"
5 "encoding/json"
6 "log/slog"
7 "net/http"
8 "path/filepath"
9 "strings"
10
11 securejoin "github.com/cyphar/filepath-securejoin"
12 "github.com/go-chi/chi/v5"
13 "github.com/go-chi/chi/v5/middleware"
14 "tangled.sh/tangled.sh/core/api/tangled"
15 "tangled.sh/tangled.sh/core/knotserver/config"
16 "tangled.sh/tangled.sh/core/knotserver/db"
17 "tangled.sh/tangled.sh/core/knotserver/git"
18 "tangled.sh/tangled.sh/core/knotserver/notifier"
19 "tangled.sh/tangled.sh/core/rbac"
20 "tangled.sh/tangled.sh/core/workflow"
21)
22
23type InternalHandle struct {
24 db *db.DB
25 c *config.Config
26 e *rbac.Enforcer
27 l *slog.Logger
28 n *notifier.Notifier
29}
30
31func (h *InternalHandle) PushAllowed(w http.ResponseWriter, r *http.Request) {
32 user := r.URL.Query().Get("user")
33 repo := r.URL.Query().Get("repo")
34
35 if user == "" || repo == "" {
36 w.WriteHeader(http.StatusBadRequest)
37 return
38 }
39
40 ok, err := h.e.IsPushAllowed(user, ThisServer, repo)
41 if err != nil || !ok {
42 w.WriteHeader(http.StatusForbidden)
43 return
44 }
45
46 w.WriteHeader(http.StatusNoContent)
47 return
48}
49
50func (h *InternalHandle) InternalKeys(w http.ResponseWriter, r *http.Request) {
51 keys, err := h.db.GetAllPublicKeys()
52 if err != nil {
53 writeError(w, err.Error(), http.StatusInternalServerError)
54 return
55 }
56
57 data := make([]map[string]interface{}, 0)
58 for _, key := range keys {
59 j := key.JSON()
60 data = append(data, j)
61 }
62 writeJSON(w, data)
63 return
64}
65
66func (h *InternalHandle) PostReceiveHook(w http.ResponseWriter, r *http.Request) {
67 l := h.l.With("handler", "PostReceiveHook")
68
69 gitAbsoluteDir := r.Header.Get("X-Git-Dir")
70 gitRelativeDir, err := filepath.Rel(h.c.Repo.ScanPath, gitAbsoluteDir)
71 if err != nil {
72 l.Error("failed to calculate relative git dir", "scanPath", h.c.Repo.ScanPath, "gitAbsoluteDir", gitAbsoluteDir)
73 return
74 }
75
76 parts := strings.SplitN(gitRelativeDir, "/", 2)
77 if len(parts) != 2 {
78 l.Error("invalid git dir", "gitRelativeDir", gitRelativeDir)
79 return
80 }
81 repoDid := parts[0]
82 repoName := parts[1]
83
84 gitUserDid := r.Header.Get("X-Git-User-Did")
85
86 lines, err := git.ParsePostReceive(r.Body)
87 if err != nil {
88 l.Error("failed to parse post-receive payload", "err", err)
89 // non-fatal
90 }
91
92 for _, line := range lines {
93 err := h.insertRefUpdate(line, gitUserDid, repoDid, repoName)
94 if err != nil {
95 l.Error("failed to insert op", "err", err, "line", line, "did", gitUserDid, "repo", gitRelativeDir)
96 // non-fatal
97 }
98
99 err = h.triggerPipeline(line, gitUserDid, repoDid, repoName)
100 if err != nil {
101 l.Error("failed to trigger pipeline", "err", err, "line", line, "did", gitUserDid, "repo", gitRelativeDir)
102 // non-fatal
103 }
104 }
105}
106
107func (h *InternalHandle) insertRefUpdate(line git.PostReceiveLine, gitUserDid, repoDid, repoName string) error {
108 refUpdate := tangled.GitRefUpdate{
109 OldSha: line.OldSha,
110 NewSha: line.NewSha,
111 Ref: line.Ref,
112 CommitterDid: gitUserDid,
113 RepoDid: repoDid,
114 RepoName: repoName,
115 }
116 eventJson, err := json.Marshal(refUpdate)
117 if err != nil {
118 return err
119 }
120
121 event := db.Event{
122 Rkey: TID(),
123 Nsid: tangled.GitRefUpdateNSID,
124 EventJson: string(eventJson),
125 }
126
127 return h.db.InsertEvent(event, h.n)
128}
129
130func (h *InternalHandle) triggerPipeline(line git.PostReceiveLine, gitUserDid, repoDid, repoName string) error {
131 const (
132 WorkflowDir = ".tangled/workflows"
133 )
134
135 didSlashRepo, err := securejoin.SecureJoin(repoDid, repoName)
136 if err != nil {
137 return err
138 }
139
140 repoPath, err := securejoin.SecureJoin(h.c.Repo.ScanPath, didSlashRepo)
141 if err != nil {
142 return err
143 }
144
145 gr, err := git.Open(repoPath, line.Ref)
146 if err != nil {
147 return err
148 }
149
150 workflowDir, err := gr.FileTree(context.Background(), WorkflowDir)
151 if err != nil {
152 return err
153 }
154
155 var pipeline workflow.Pipeline
156 for _, e := range workflowDir {
157 if !e.IsFile {
158 continue
159 }
160
161 fpath := filepath.Join(WorkflowDir, e.Name)
162 contents, err := gr.RawContent(fpath)
163 if err != nil {
164 continue
165 }
166
167 wf, err := workflow.FromFile(e.Name, contents)
168 if err != nil {
169 // TODO: log here, respond to client that is pushing
170 continue
171 }
172
173 pipeline = append(pipeline, wf)
174 }
175
176 trigger := tangled.Pipeline_PushTriggerData{
177 Ref: line.Ref,
178 OldSha: line.OldSha,
179 NewSha: line.NewSha,
180 }
181
182 compiler := workflow.Compiler{
183 Trigger: tangled.Pipeline_TriggerMetadata{
184 Kind: string(workflow.TriggerKindPush),
185 Push: &trigger,
186 Repo: &tangled.Pipeline_TriggerRepo{
187 Did: repoDid,
188 Knot: h.c.Server.Hostname,
189 Repo: repoName,
190 },
191 },
192 }
193
194 // TODO: send the diagnostics back to the user here via stderr
195 cp := compiler.Compile(pipeline)
196 eventJson, err := json.Marshal(cp)
197 if err != nil {
198 return err
199 }
200
201 event := db.Event{
202 Rkey: TID(),
203 Nsid: tangled.PipelineNSID,
204 EventJson: string(eventJson),
205 }
206
207 return h.db.InsertEvent(event, h.n)
208}
209
210func Internal(ctx context.Context, c *config.Config, db *db.DB, e *rbac.Enforcer, l *slog.Logger, n *notifier.Notifier) http.Handler {
211 r := chi.NewRouter()
212
213 h := InternalHandle{
214 db,
215 c,
216 e,
217 l,
218 n,
219 }
220
221 r.Get("/push-allowed", h.PushAllowed)
222 r.Get("/keys", h.InternalKeys)
223 r.Post("/hooks/post-receive", h.PostReceiveHook)
224 r.Mount("/debug", middleware.Profiler())
225
226 return r
227}