Monorepo for Tangled
tangled.org
1package knotserver
2
3import (
4 "context"
5 "encoding/json"
6 "fmt"
7 "log/slog"
8 "net"
9 "net/http"
10 "net/url"
11 "os"
12 "path"
13 "path/filepath"
14 "strings"
15
16 securejoin "github.com/cyphar/filepath-securejoin"
17 "github.com/go-chi/chi/v5"
18 "github.com/go-chi/chi/v5/middleware"
19 "github.com/go-git/go-git/v5/plumbing"
20 "tangled.org/core/api/tangled"
21 "tangled.org/core/eventstream"
22 "tangled.org/core/hook"
23 "tangled.org/core/idresolver"
24 "tangled.org/core/knotserver/config"
25 "tangled.org/core/knotserver/db"
26 "tangled.org/core/knotserver/git"
27 "tangled.org/core/log"
28 "tangled.org/core/notifier"
29 "tangled.org/core/rbac"
30 "tangled.org/core/tid"
31)
32
33type InternalHandle struct {
34 db *db.DB
35 c *config.Config
36 e *rbac.Enforcer
37 l *slog.Logger
38 n *notifier.Notifier
39 res *idresolver.Resolver
40}
41
42func (h *InternalHandle) PushAllowed(w http.ResponseWriter, r *http.Request) {
43 user := r.URL.Query().Get("user")
44 repo := r.URL.Query().Get("repo")
45
46 if user == "" || repo == "" {
47 w.WriteHeader(http.StatusBadRequest)
48 return
49 }
50
51 ok, err := h.e.IsPushAllowed(user, rbac.ThisServer, repo)
52 if err != nil || !ok {
53 w.WriteHeader(http.StatusForbidden)
54 return
55 }
56
57 w.WriteHeader(http.StatusNoContent)
58}
59
60func (h *InternalHandle) InternalKeys(w http.ResponseWriter, r *http.Request) {
61 keys, err := h.db.GetAllPublicKeys()
62 if err != nil {
63 writeError(w, err.Error(), http.StatusInternalServerError)
64 return
65 }
66
67 data := make([]map[string]interface{}, 0)
68 for _, key := range keys {
69 j := key.JSON()
70 data = append(data, j)
71 }
72 writeJSON(w, data)
73}
74
75// response in text/plain format
76// the body will be qualified repository path on success/push-denied
77// or an error message when process failed
78func (h *InternalHandle) Guard(w http.ResponseWriter, r *http.Request) {
79 l := h.l.With("handler", "Guard")
80
81 var (
82 incomingUser = r.URL.Query().Get("user")
83 repo = r.URL.Query().Get("repo")
84 gitCommand = r.URL.Query().Get("gitCmd")
85 )
86
87 if incomingUser == "" || repo == "" || gitCommand == "" {
88 w.WriteHeader(http.StatusBadRequest)
89 l.Error("invalid request", "incomingUser", incomingUser, "repo", repo, "gitCommand", gitCommand)
90 fmt.Fprintln(w, "invalid internal request")
91 return
92 }
93
94 components := strings.Split(strings.TrimPrefix(strings.Trim(repo, "'"), "/"), "/")
95 l.Info("command components", "components", components)
96
97 var rbacResource string
98 var diskRelative string
99
100 switch {
101 case len(components) == 1 && strings.HasPrefix(components[0], "did:"):
102 repoDid := components[0]
103 repoPath, _, _, lookupErr := h.db.ResolveRepoDIDOnDisk(h.c.Repo.ScanPath, repoDid)
104 if lookupErr != nil {
105 w.WriteHeader(http.StatusNotFound)
106 l.Error("repo DID not found", "repoDid", repoDid, "err", lookupErr)
107 fmt.Fprintln(w, "repo not found")
108 return
109 }
110 rbacResource = repoDid
111 rel, relErr := filepath.Rel(h.c.Repo.ScanPath, repoPath)
112 if relErr != nil {
113 w.WriteHeader(http.StatusInternalServerError)
114 l.Error("failed to compute relative path", "repoPath", repoPath, "err", relErr)
115 fmt.Fprintln(w, "internal error")
116 return
117 }
118 diskRelative = rel
119
120 case len(components) == 2:
121 repoOwner := components[0]
122 ownerIdent, resolveErr := h.res.ResolveAtIdentifier(r.Context(), repoOwner)
123 if resolveErr != nil {
124 l.Error("error resolving owner", "owner", repoOwner, "err", resolveErr)
125 w.WriteHeader(http.StatusInternalServerError)
126 fmt.Fprintf(w, "error resolving owner: invalid did or handle\n")
127 return
128 }
129 ownerDid := ownerIdent.DID
130 repoName := components[1]
131 repoDid, didErr := h.db.GetRepoDid(ownerDid.String(), repoName)
132 var repoPath string
133 if didErr == nil {
134 var lookupErr error
135 repoPath, _, _, lookupErr = h.db.ResolveRepoDIDOnDisk(h.c.Repo.ScanPath, repoDid)
136 if lookupErr != nil {
137 w.WriteHeader(http.StatusNotFound)
138 l.Error("repo not found on disk", "repoDid", repoDid, "err", lookupErr)
139 fmt.Fprintln(w, "repo not found")
140 return
141 }
142 rbacResource = repoDid
143 } else {
144 legacyPath, joinErr := securejoin.SecureJoin(h.c.Repo.ScanPath, filepath.Join(ownerDid.String(), repoName))
145 if joinErr != nil {
146 w.Header().Set("Content-Type", "text/plain; charset=UTF-8")
147 w.WriteHeader(http.StatusNotFound)
148 fmt.Fprint(w, "repo not found\n")
149 return
150 }
151 if _, statErr := os.Stat(legacyPath); statErr != nil {
152 l.Info("legacy repo path missing, checking rename history", "owner", ownerDid, "name", repoName)
153 w.Header().Set("Content-Type", "text/plain; charset=UTF-8")
154 w.WriteHeader(http.StatusNotFound)
155 fmt.Fprint(w, "repo not found\n")
156 return
157 }
158 repoPath = legacyPath
159 rbacResource = ownerDid.String() + "/" + repoName
160 }
161 rel, relErr := filepath.Rel(h.c.Repo.ScanPath, repoPath)
162 if relErr != nil {
163 w.WriteHeader(http.StatusInternalServerError)
164 l.Error("failed to compute relative path", "repoPath", repoPath, "err", relErr)
165 fmt.Fprintln(w, "internal error")
166 return
167 }
168 diskRelative = rel
169
170 default:
171 w.WriteHeader(http.StatusBadRequest)
172 l.Error("invalid repo format", "components", components)
173 fmt.Fprintln(w, "invalid repo format, needs <user>/<repo>, /<user>/<repo>, or <repo-did>")
174 return
175 }
176
177 if gitCommand == "git-receive-pack" {
178 ok, err := h.e.IsPushAllowed(incomingUser, rbac.ThisServer, rbacResource)
179 if err != nil || !ok {
180 w.WriteHeader(http.StatusForbidden)
181 fmt.Fprint(w, repo)
182 return
183 }
184 }
185
186 w.WriteHeader(http.StatusOK)
187 fmt.Fprint(w, diskRelative)
188}
189
190func (h *InternalHandle) PostReceiveHook(w http.ResponseWriter, r *http.Request) {
191 l := h.l.With("handler", "PostReceiveHook")
192
193 gitAbsoluteDir := r.Header.Get("X-Git-Dir")
194 gitRelativeDir, err := filepath.Rel(h.c.Repo.ScanPath, gitAbsoluteDir)
195 if err != nil {
196 l.Error("failed to calculate relative git dir", "scanPath", h.c.Repo.ScanPath, "gitAbsoluteDir", gitAbsoluteDir)
197 w.WriteHeader(http.StatusInternalServerError)
198 return
199 }
200
201 var repoDid string
202 var ownerDid, repoName string
203
204 if strings.HasPrefix(gitRelativeDir, "did:") {
205 repoDid = gitRelativeDir
206 var err error
207 ownerDid, repoName, err = h.db.GetRepoKeyOwner(repoDid)
208 if err != nil {
209 l.Error("failed to resolve repo DID from git dir", "repoDid", repoDid, "err", err)
210 w.WriteHeader(http.StatusBadRequest)
211 return
212 }
213 } else {
214 components := strings.SplitN(gitRelativeDir, "/", 2)
215 if len(components) != 2 {
216 l.Error("invalid git dir, expected repo DID or owner/repo", "gitRelativeDir", gitRelativeDir)
217 w.WriteHeader(http.StatusBadRequest)
218 return
219 }
220 ownerDid = components[0]
221 repoName = components[1]
222 var didErr error
223 repoDid, didErr = h.db.GetRepoDid(ownerDid, repoName)
224 if didErr != nil {
225 l.Error("failed to resolve repo DID from legacy path", "gitRelativeDir", gitRelativeDir, "err", didErr)
226 w.WriteHeader(http.StatusBadRequest)
227 return
228 }
229 }
230
231 gitUserDid := r.Header.Get("X-Git-User-Did")
232
233 lines, err := git.ParsePostReceive(r.Body)
234 if err != nil {
235 l.Error("failed to parse post-receive payload", "err", err)
236 // non-fatal
237 }
238
239 // extract max 50 push options
240 pushOptions := r.Header.Values("X-Git-Push-Option")
241 if len(pushOptions) > 50 {
242 pushOptions = pushOptions[:50]
243 }
244
245 resp := hook.HookResponse{
246 Messages: make([]string, 0),
247 }
248
249 for _, line := range lines {
250 err := h.insertRefUpdate(line, gitUserDid, ownerDid, repoDid, pushOptions)
251 if err != nil {
252 l.Error("failed to insert op", "err", err, "line", line, "did", gitUserDid, "repo", gitRelativeDir)
253 }
254
255 err = h.emitPullRequestLink(&resp.Messages, line, ownerDid, repoName, repoDid)
256 if err != nil {
257 l.Error("failed to reply with pull request link", "err", err, "line", line, "did", gitUserDid, "repo", gitRelativeDir)
258 }
259
260 // emit pipeline logs link
261 if h.c.LogsAddr != "" {
262 host, port, err := net.SplitHostPort(h.c.LogsAddr)
263 if err == nil {
264 resp.Messages = append(resp.Messages, "→ Browse CI logs in your terminal:")
265 resp.Messages = append(resp.Messages, fmt.Sprintf(" ssh -t -p %s %s %s %s", port, host, repoDid, line.NewSha))
266 }
267 }
268 }
269
270 writeJSON(w, resp)
271}
272
273func (h *InternalHandle) insertRefUpdate(line git.PostReceiveLine, gitUserDid, ownerDid, repoDid string, pushOptions []string) error {
274 refUpdate := tangled.GitRefUpdate{
275 OldSha: line.OldSha.String(),
276 NewSha: line.NewSha.String(),
277 Ref: line.Ref,
278 CommitterDid: gitUserDid,
279 OwnerDid: &ownerDid,
280 Repo: repoDid,
281 Meta: nil,
282 PushOptions: pushOptions,
283 }
284
285 if !line.NewSha.IsZero() {
286 repoPath, _, _, resolveErr := h.db.ResolveRepoDIDOnDisk(h.c.Repo.ScanPath, repoDid)
287 if resolveErr != nil {
288 return fmt.Errorf("failed to resolve repo on disk: %w", resolveErr)
289 }
290
291 gr, err := git.Open(repoPath, line.Ref)
292 if err != nil {
293 return fmt.Errorf("failed to open git repo at ref %s: %w", line.Ref, err)
294 }
295
296 changedFiles, err := gr.ChangedFilesBetween(line.OldSha.String(), line.NewSha.String())
297 if err != nil {
298 return fmt.Errorf("failed to get ref update changed files: %w", err)
299 }
300 refUpdate.ChangedFiles = changedFiles
301
302 meta, err := gr.RefUpdateMeta(line)
303 if err != nil {
304 return fmt.Errorf("failed to get ref update metadata: %w", err)
305 }
306
307 refUpdate.Meta = new(tangled.GitRefUpdate_Meta)
308 *refUpdate.Meta = meta.AsRecord()
309 }
310
311 eventJson, err := json.Marshal(refUpdate)
312 if err != nil {
313 return err
314 }
315
316 event := eventstream.Event{
317 Rkey: tid.TID(),
318 Nsid: tangled.GitRefUpdateNSID,
319 EventJson: eventJson,
320 }
321
322 return h.db.InsertEvent(event, h.n)
323}
324
325func (h *InternalHandle) emitPullRequestLink(
326 clientMsgs *[]string,
327 line git.PostReceiveLine,
328 ownerDid string,
329 repoName string,
330 repoDid string,
331) error {
332 if line.NewSha.IsZero() {
333 return nil
334 }
335
336 // the ref was not updated to a new hash, don't reply with the link
337 //
338 // NOTE: do we need this?
339 if line.NewSha == line.OldSha {
340 return nil
341 }
342
343 pushedRef := plumbing.ReferenceName(line.Ref)
344 if !pushedRef.IsBranch() {
345 return nil
346 }
347
348 if !line.OldSha.IsZero() {
349 return nil
350 }
351
352 repoPath, _, _, resolveErr := h.db.ResolveRepoDIDOnDisk(h.c.Repo.ScanPath, repoDid)
353 if resolveErr != nil {
354 return fmt.Errorf("failed to resolve repo on disk: %w", resolveErr)
355 }
356
357 gr, err := git.PlainOpen(repoPath)
358 if err != nil {
359 return err
360 }
361
362 remote, err := gr.Remote()
363 if err != nil {
364 return fmt.Errorf("checking for upstream remote: %w", err)
365 }
366
367 defaultBranch, err := gr.FindMainBranch()
368 if err != nil {
369 return err
370 }
371
372 pushedBranch := pushedRef.Short()
373
374 // pushing to default branch
375 if pushedBranch == defaultBranch {
376 return nil
377 }
378
379 userIdent, err := h.res.ResolveIdent(context.Background(), ownerDid)
380 user := ownerDid
381 if err == nil {
382 user = userIdent.Handle.String()
383 }
384
385 pullURL, err := h.createPullURL(h.c.AppViewEndpoint, remote, user, ownerDid, repoName, pushedBranch, defaultBranch)
386 if err != nil {
387 return err
388 }
389
390 ZWS := "\u200B"
391 *clientMsgs = append(*clientMsgs, ZWS)
392 *clientMsgs = append(*clientMsgs, "→ Open pull request:")
393 *clientMsgs = append(*clientMsgs, " "+pullURL)
394 *clientMsgs = append(*clientMsgs, ZWS)
395 return nil
396}
397
398func (h *InternalHandle) createPullURL(appviewURL, remote, user, ownerDID, repoName, pushedBranch, defaultBranch string) (string, error) {
399 if remote != "" {
400 return h.createForkPullURL(appviewURL, remote, ownerDID, repoName, pushedBranch, defaultBranch)
401 }
402
403 query := url.Values{}
404
405 query.Set("source", "branch")
406 query.Set("sourceBranch", pushedBranch)
407 query.Set("targetBranch", defaultBranch)
408
409 basePath, err := url.JoinPath(appviewURL, user, repoName, "pulls", "new")
410 if err != nil {
411 return "", err
412 }
413 pullURL := basePath + "?" + query.Encode()
414 return pullURL, nil
415}
416
417func (h *InternalHandle) createForkPullURL(appviewURL, remote, ownerDID, repoName, pushedBranch, defaultBranch string) (string, error) {
418 query := url.Values{}
419
420 query.Set("fork", fmt.Sprintf("%s/%s", ownerDID, repoName))
421 query.Set("source", "fork")
422 query.Set("sourceBranch", pushedBranch)
423 query.Set("targetBranch", defaultBranch)
424
425 repoPath, err := h.getRemoteOwnerRepoNamePath(remote)
426 if err != nil {
427 return "", err
428 }
429
430 basePath, err := url.JoinPath(appviewURL, repoPath, "pulls", "new")
431 if err != nil {
432 return "", err
433 }
434 pullURL := basePath + "?" + query.Encode()
435 return pullURL, nil
436}
437
438func (h *InternalHandle) getRemoteOwnerRepoNamePath(remote string) (string, error) {
439 u, err := url.Parse(remote)
440 if err != nil {
441 return "", fmt.Errorf("invalid remote: %w", err)
442 }
443
444 if u.Scheme != "file" {
445 return u.Path, nil
446 }
447
448 repoDid := path.Base(u.String())
449
450 owner, name, err := h.db.GetRepoKeyOwner(repoDid)
451 if err != nil {
452 return "", err
453 }
454
455 return fmt.Sprintf("%s/%s", owner, name), nil
456}
457
458func Internal(ctx context.Context, c *config.Config, db *db.DB, e *rbac.Enforcer, n *notifier.Notifier, res *idresolver.Resolver) http.Handler {
459 r := chi.NewRouter()
460 l := log.FromContext(ctx)
461 l = log.SubLogger(l, "internal")
462
463 h := InternalHandle{
464 db: db,
465 c: c,
466 e: e,
467 l: l,
468 n: n,
469 res: res,
470 }
471
472 r.Get("/push-allowed", h.PushAllowed)
473 r.Get("/keys", h.InternalKeys)
474 r.Get("/guard", h.Guard)
475 r.Post("/hooks/post-receive", h.PostReceiveHook)
476 r.Mount("/debug", middleware.Profiler())
477
478 return r
479}