Monorepo for Tangled
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)
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 res *idresolver.Resolver
35}
36
37func (h *InternalHandle) PushAllowed(w http.ResponseWriter, r *http.Request) {
38 user := r.URL.Query().Get("user")
39 repo := r.URL.Query().Get("repo")
40
41 if user == "" || repo == "" {
42 w.WriteHeader(http.StatusBadRequest)
43 return
44 }
45
46 ok, err := h.e.IsPushAllowed(user, rbac.ThisServer, repo)
47 if err != nil || !ok {
48 w.WriteHeader(http.StatusForbidden)
49 return
50 }
51
52 w.WriteHeader(http.StatusNoContent)
53}
54
55func (h *InternalHandle) InternalKeys(w http.ResponseWriter, r *http.Request) {
56 keys, err := h.db.GetAllPublicKeys()
57 if err != nil {
58 writeError(w, err.Error(), http.StatusInternalServerError)
59 return
60 }
61
62 data := make([]map[string]interface{}, 0)
63 for _, key := range keys {
64 j := key.JSON()
65 data = append(data, j)
66 }
67 writeJSON(w, data)
68}
69
70// response in text/plain format
71// the body will be qualified repository path on success/push-denied
72// or an error message when process failed
73func (h *InternalHandle) Guard(w http.ResponseWriter, r *http.Request) {
74 l := h.l.With("handler", "PostReceiveHook")
75
76 var (
77 incomingUser = r.URL.Query().Get("user")
78 repo = r.URL.Query().Get("repo")
79 gitCommand = r.URL.Query().Get("gitCmd")
80 )
81
82 if incomingUser == "" || repo == "" || gitCommand == "" {
83 w.WriteHeader(http.StatusBadRequest)
84 l.Error("invalid request", "incomingUser", incomingUser, "repo", repo, "gitCommand", gitCommand)
85 fmt.Fprintln(w, "invalid internal request")
86 return
87 }
88
89 // did:foo/repo-name or
90 // handle/repo-name or
91 // any of the above with a leading slash (/)
92 components := strings.Split(strings.TrimPrefix(strings.Trim(repo, "'"), "/"), "/")
93 l.Info("command components", "components", components)
94
95 if len(components) != 2 {
96 w.WriteHeader(http.StatusBadRequest)
97 l.Error("invalid repo format", "components", components)
98 fmt.Fprintln(w, "invalid repo format, needs <user>/<repo> or /<user>/<repo>")
99 return
100 }
101 repoOwner := components[0]
102 repoName := components[1]
103
104 resolver := idresolver.DefaultResolver(h.c.Server.PlcUrl)
105
106 repoOwnerIdent, err := resolver.ResolveIdent(r.Context(), repoOwner)
107 if err != nil || repoOwnerIdent.Handle.IsInvalidHandle() {
108 l.Error("Error resolving handle", "handle", repoOwner, "err", err)
109 w.WriteHeader(http.StatusInternalServerError)
110 fmt.Fprintf(w, "error resolving handle: invalid handle\n")
111 return
112 }
113 repoOwnerDid := repoOwnerIdent.DID.String()
114
115 qualifiedRepo, _ := securejoin.SecureJoin(repoOwnerDid, repoName)
116
117 if gitCommand == "git-receive-pack" {
118 ok, err := h.e.IsPushAllowed(incomingUser, rbac.ThisServer, qualifiedRepo)
119 if err != nil || !ok {
120 w.WriteHeader(http.StatusForbidden)
121 fmt.Fprint(w, repo)
122 return
123 }
124 }
125
126 w.WriteHeader(http.StatusOK)
127 fmt.Fprint(w, qualifiedRepo)
128}
129
130type PushOptions struct {
131 skipCi bool
132 verboseCi bool
133}
134
135func (h *InternalHandle) PostReceiveHook(w http.ResponseWriter, r *http.Request) {
136 l := h.l.With("handler", "PostReceiveHook")
137
138 gitAbsoluteDir := r.Header.Get("X-Git-Dir")
139 gitRelativeDir, err := filepath.Rel(h.c.Repo.ScanPath, gitAbsoluteDir)
140 if err != nil {
141 l.Error("failed to calculate relative git dir", "scanPath", h.c.Repo.ScanPath, "gitAbsoluteDir", gitAbsoluteDir)
142 return
143 }
144
145 parts := strings.SplitN(gitRelativeDir, "/", 2)
146 if len(parts) != 2 {
147 l.Error("invalid git dir", "gitRelativeDir", gitRelativeDir)
148 return
149 }
150 repoDid := parts[0]
151 repoName := parts[1]
152
153 gitUserDid := r.Header.Get("X-Git-User-Did")
154
155 lines, err := git.ParsePostReceive(r.Body)
156 if err != nil {
157 l.Error("failed to parse post-receive payload", "err", err)
158 // non-fatal
159 }
160
161 // extract any push options
162 pushOptionsRaw := r.Header.Values("X-Git-Push-Option")
163 pushOptions := PushOptions{}
164 for _, option := range pushOptionsRaw {
165 if option == "skip-ci" || option == "ci-skip" {
166 pushOptions.skipCi = true
167 }
168 if option == "verbose-ci" || option == "ci-verbose" {
169 pushOptions.verboseCi = true
170 }
171 }
172
173 resp := hook.HookResponse{
174 Messages: make([]string, 0),
175 }
176
177 for _, line := range lines {
178 // TODO: pass pushOptions to refUpdate
179 err := h.insertRefUpdate(line, gitUserDid, repoDid, repoName)
180 if err != nil {
181 l.Error("failed to insert op", "err", err, "line", line, "did", gitUserDid, "repo", gitRelativeDir)
182 // non-fatal
183 }
184
185 err = h.emitCompareLink(&resp.Messages, line, repoDid, repoName)
186 if err != nil {
187 l.Error("failed to reply with compare link", "err", err, "line", line, "did", gitUserDid, "repo", gitRelativeDir)
188 // non-fatal
189 }
190 }
191
192 writeJSON(w, resp)
193}
194
195func (h *InternalHandle) insertRefUpdate(line git.PostReceiveLine, gitUserDid, repoDid, repoName string) error {
196 didSlashRepo, err := securejoin.SecureJoin(repoDid, repoName)
197 if err != nil {
198 return err
199 }
200
201 repoPath, err := securejoin.SecureJoin(h.c.Repo.ScanPath, didSlashRepo)
202 if err != nil {
203 return err
204 }
205
206 gr, err := git.Open(repoPath, line.Ref)
207 if err != nil {
208 return fmt.Errorf("failed to open git repo at ref %s: %w", line.Ref, err)
209 }
210
211 var errs error
212 meta, err := gr.RefUpdateMeta(line)
213 errors.Join(errs, err)
214
215 metaRecord := meta.AsRecord()
216
217 refUpdate := tangled.GitRefUpdate{
218 OldSha: line.OldSha.String(),
219 NewSha: line.NewSha.String(),
220 Ref: line.Ref,
221 CommitterDid: gitUserDid,
222 RepoDid: repoDid,
223 RepoName: repoName,
224 Meta: &metaRecord,
225 }
226 eventJson, err := json.Marshal(refUpdate)
227 if err != nil {
228 return err
229 }
230
231 event := db.Event{
232 Rkey: TID(),
233 Nsid: tangled.GitRefUpdateNSID,
234 EventJson: string(eventJson),
235 }
236
237 return errors.Join(errs, h.db.InsertEvent(event, h.n))
238}
239
240func (h *InternalHandle) emitCompareLink(
241 clientMsgs *[]string,
242 line git.PostReceiveLine,
243 repoDid string,
244 repoName string,
245) error {
246 // this is a second push to a branch, don't reply with the link again
247 if !line.OldSha.IsZero() {
248 return nil
249 }
250
251 // the ref was not updated to a new hash, don't reply with the link
252 //
253 // NOTE: do we need this?
254 if line.NewSha.String() == line.OldSha.String() {
255 return nil
256 }
257
258 pushedRef := plumbing.ReferenceName(line.Ref)
259
260 userIdent, err := h.res.ResolveIdent(context.Background(), repoDid)
261 user := repoDid
262 if err == nil {
263 user = userIdent.Handle.String()
264 }
265
266 didSlashRepo, err := securejoin.SecureJoin(repoDid, repoName)
267 if err != nil {
268 return err
269 }
270
271 repoPath, err := securejoin.SecureJoin(h.c.Repo.ScanPath, didSlashRepo)
272 if err != nil {
273 return err
274 }
275
276 gr, err := git.PlainOpen(repoPath)
277 if err != nil {
278 return err
279 }
280
281 defaultBranch, err := gr.FindMainBranch()
282 if err != nil {
283 return err
284 }
285
286 // pushing to default branch
287 if pushedRef == plumbing.NewBranchReferenceName(defaultBranch) {
288 return nil
289 }
290
291 // pushing a tag, don't prompt the user the open a PR
292 if pushedRef.IsTag() {
293 return nil
294 }
295
296 ZWS := "\u200B"
297 *clientMsgs = append(*clientMsgs, ZWS)
298 *clientMsgs = append(*clientMsgs, fmt.Sprintf("Create a PR pointing to %s", defaultBranch))
299 *clientMsgs = append(*clientMsgs, fmt.Sprintf("\t%s/%s/%s/compare/%s...%s", h.c.AppViewEndpoint, user, repoName, defaultBranch, strings.TrimPrefix(line.Ref, "refs/heads/")))
300 *clientMsgs = append(*clientMsgs, ZWS)
301 return nil
302}
303
304func Internal(ctx context.Context, c *config.Config, db *db.DB, e *rbac.Enforcer, n *notifier.Notifier) http.Handler {
305 r := chi.NewRouter()
306 l := log.FromContext(ctx)
307 l = log.SubLogger(l, "internal")
308 res := idresolver.DefaultResolver(c.Server.PlcUrl)
309
310 h := InternalHandle{
311 db,
312 c,
313 e,
314 l,
315 n,
316 res,
317 }
318
319 r.Get("/push-allowed", h.PushAllowed)
320 r.Get("/keys", h.InternalKeys)
321 r.Get("/guard", h.Guard)
322 r.Post("/hooks/post-receive", h.PostReceiveHook)
323 r.Mount("/debug", middleware.Profiler())
324
325 return r
326}