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}