Monorepo for Tangled tangled.org
5

Configure Feed

Select the types of activity you want to include in your feed.

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}