Commit c90a10435b
Verified · cmc
Layout: unified · split
e2e/autolink_test.go added +92
| @@ -0,0 +1,92 @@ | ||
| 1 | package e2e | |
| 2 | ||
| 3 | import ( | |
| 4 | "os" | |
| 5 | "path/filepath" | |
| 6 | "strings" | |
| 7 | "testing" | |
| 8 | ) | |
| 9 | ||
| 10 | func TestWebAutolinks(t *testing.T) { | |
| 11 | inst := startInstance(t) | |
| 12 | aliceKey := inst.newKey(t, "alice") | |
| 13 | bobKey := inst.newKey(t, "bob") | |
| 14 | inst.admin(t, "admin", "user", "create", "alice", | |
| 15 | "--key", aliceKey+".pub", "--email", "alice@example.test", "--verified") | |
| 16 | inst.admin(t, "admin", "user", "create", "bob", "--key", bobKey+".pub") | |
| 17 | ||
| 18 | // Public repo with an issue, an MR, and a private repo with an issue. | |
| 19 | if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/app"); code != 0 { | |
| 20 | t.Fatalf("repo create: %s", errOut) | |
| 21 | } | |
| 22 | if _, _, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/secret", "--private"); code != 0 { | |
| 23 | t.Fatal("private repo create failed") | |
| 24 | } | |
| 25 | if _, _, code := inst.ssh(t, aliceKey, "", "issue", "create", "alice/secret", "--title", "'hidden'"); code != 0 { | |
| 26 | t.Fatal("private issue create failed") | |
| 27 | } | |
| 28 | work := t.TempDir() | |
| 29 | env := inst.gitEnv(aliceKey) | |
| 30 | mustGit(t, work, env, "clone", inst.sshURL("alice/app"), "w") | |
| 31 | dir := filepath.Join(work, "w") | |
| 32 | os.WriteFile(filepath.Join(dir, "a.txt"), []byte("a\n"), 0o644) | |
| 33 | mustGit(t, dir, env, "checkout", "-q", "-b", "main") | |
| 34 | mustGit(t, dir, env, "add", ".") | |
| 35 | mustGit(t, dir, env, "commit", "-q", "-m", "base") | |
| 36 | mustGit(t, dir, env, "push", "-q", "origin", "main") | |
| 37 | mustGit(t, dir, env, "checkout", "-q", "-b", "feat") | |
| 38 | os.WriteFile(filepath.Join(dir, "b.txt"), []byte("b\n"), 0o644) | |
| 39 | mustGit(t, dir, env, "add", ".") | |
| 40 | mustGit(t, dir, env, "commit", "-q", "-m", "feat") | |
| 41 | mustGit(t, dir, env, "push", "-q", "origin", "feat") | |
| 42 | if _, errOut, code := inst.ssh(t, aliceKey, "", "mr", "create", "alice/app", | |
| 43 | "--source", "feat", "--target", "main", "--title", "'feat'"); code != 0 { | |
| 44 | t.Fatalf("mr create: %s", errOut) | |
| 45 | } | |
| 46 | if _, _, code := inst.ssh(t, aliceKey, "", "issue", "create", "alice/app", "--title", "'first'"); code != 0 { | |
| 47 | t.Fatal("issue 1 create failed") | |
| 48 | } | |
| 49 | if _, _, code := inst.ssh(t, aliceKey, "", "issue", "create", "alice/app", | |
| 50 | "--title", "'refs'", "--body", | |
| 51 | "'see #1 and !1 and alice/app#1 and @bob, not #99, not alice/secret#1, not `#1` in code'"); code != 0 { | |
| 52 | t.Fatal("issue 2 create failed") | |
| 53 | } | |
| 54 | ||
| 55 | status, body := inst.get(t, "/alice/app/issues/2") | |
| 56 | if status != 200 { | |
| 57 | t.Fatalf("issue page: %d", status) | |
| 58 | } | |
| 59 | for _, want := range []string{ | |
| 60 | `<a href="/alice/app/issues/1" class="xref">#1</a>`, | |
| 61 | `<a href="/alice/app/mrs/1" class="xref">!1</a>`, | |
| 62 | `<a href="/alice/app/issues/1" class="xref">alice/app#1</a>`, | |
| 63 | `<a href="/bob" class="xref">@bob</a>`, | |
| 64 | } { | |
| 65 | if !strings.Contains(body, want) { | |
| 66 | t.Errorf("missing %q on issue page", want) | |
| 67 | } | |
| 68 | } | |
| 69 | if strings.Contains(body, "issues/99") { | |
| 70 | t.Error("nonexistent issue got linked") | |
| 71 | } | |
| 72 | // The private repo must stay plain text for anonymous viewers: a link | |
| 73 | // would confirm it exists. | |
| 74 | if strings.Contains(body, `href="/alice/secret`) { | |
| 75 | t.Error("private repo reference leaked as a link") | |
| 76 | } | |
| 77 | if !strings.Contains(body, "alice/secret#1") { | |
| 78 | t.Error("private repo reference text missing entirely") | |
| 79 | } | |
| 80 | if !strings.Contains(body, "<code>#1</code>") { | |
| 81 | t.Error("code-span reference was rewritten") | |
| 82 | } | |
| 83 | ||
| 84 | // Comments on MR pages get the same treatment. | |
| 85 | if _, _, code := inst.ssh(t, bobKey, "", "mr", "comment", "alice/app", "1", "--message", "'closes #2'"); code != 0 { | |
| 86 | t.Fatal("mr comment failed") | |
| 87 | } | |
| 88 | _, body = inst.get(t, "/alice/app/mrs/1") | |
| 89 | if !strings.Contains(body, `<a href="/alice/app/issues/2" class="xref">#2</a>`) { | |
| 90 | t.Error("MR comment reference not linked") | |
| 91 | } | |
| 92 | } | |
internal/httpd/web.go +70 −8
| @@ -21,6 +21,7 @@ import ( | ||
| 21 | 21 | "github.com/niklasfasching/go-org/org" |
| 22 | 22 | "github.com/yuin/goldmark" |
| 23 | 23 | |
| 24 | "gitbay.org/gitbay/internal/autolink" | |
| 24 | 25 | "gitbay.org/gitbay/internal/control" |
| 25 | 26 | "gitbay.org/gitbay/internal/gitutil" |
| 26 | 27 | "gitbay.org/gitbay/internal/sig" |
| @@ -403,6 +404,65 @@ func mdHTML(raw string) template.HTML { | ||
| 403 | 404 | return template.HTML(buf.String()) |
| 404 | 405 | } |
| 405 | 406 | |
| 407 | // webResolver answers autolink lookups for one viewer. Cross-repo | |
| 408 | // references to repositories the viewer cannot read stay plain text, per | |
| 409 | // the enumeration rule: a link would confirm the repo exists. | |
| 410 | type webResolver struct { | |
| 411 | s *Server | |
| 412 | viewer store.User | |
| 413 | } | |
| 414 | ||
| 415 | func (r webResolver) RefURL(owner, name string, kind byte, n int64) string { | |
| 416 | repo, err := r.s.st.RepoByPath(owner + "/" + name) | |
| 417 | if err != nil { | |
| 418 | return "" | |
| 419 | } | |
| 420 | grant := "" | |
| 421 | if r.viewer.ID != 0 { | |
| 422 | grant, _ = r.s.st.AccessRole(repo.ID, r.viewer.ID) | |
| 423 | } | |
| 424 | if !policy.CanRead(r.viewer, repo, grant) { | |
| 425 | return "" | |
| 426 | } | |
| 427 | if kind == '#' { | |
| 428 | if _, err := r.s.st.IssueByNumber(repo.ID, n); err != nil { | |
| 429 | return "" | |
| 430 | } | |
| 431 | return autolink.IssueURL(repo.OwnerName, repo.Name, n) | |
| 432 | } | |
| 433 | if _, err := r.s.st.MRByNumber(repo.ID, n); err != nil { | |
| 434 | return "" | |
| 435 | } | |
| 436 | return autolink.MRURL(repo.OwnerName, repo.Name, n) | |
| 437 | } | |
| 438 | ||
| 439 | func (r webResolver) UserURL(name string) string { | |
| 440 | if _, err := r.s.st.UserByUsername(name); err == nil { | |
| 441 | return "/" + name | |
| 442 | } | |
| 443 | if _, err := r.s.st.OrgByName(name); err == nil { | |
| 444 | return "/" + name | |
| 445 | } | |
| 446 | return "" | |
| 447 | } | |
| 448 | ||
| 449 | // ugcFor returns a renderer for user-authored markdown on one repo's pages: | |
| 450 | // mdHTML plus cross-reference and mention autolinking for this viewer. | |
| 451 | func (s *Server) ugcFor(r *http.Request, repo store.Repo) func(string) template.HTML { | |
| 452 | viewer := store.User{} | |
| 453 | if s.cfg.Web.Mode == "accounts" { | |
| 454 | viewer = s.viewer(r) | |
| 455 | } | |
| 456 | res := webResolver{s, viewer} | |
| 457 | return func(raw string) template.HTML { | |
| 458 | h := mdHTML(raw) | |
| 459 | if h == "" { | |
| 460 | return h | |
| 461 | } | |
| 462 | return template.HTML(autolink.Rewrite(string(h), repo.OwnerName, repo.Name, res)) | |
| 463 | } | |
| 464 | } | |
| 465 | ||
| 406 | 466 | // renderedComment pairs a comment with its rendered body for templates. |
| 407 | 467 | type renderedComment struct { |
| 408 | 468 | Author string |
| @@ -410,10 +470,10 @@ type renderedComment struct { | ||
| 410 | 470 | BodyHTML template.HTML |
| 411 | 471 | } |
| 412 | 472 | |
| 413 | func renderComments(cs []store.IssueComment) []renderedComment { | |
| 473 | func renderComments(cs []store.IssueComment, md func(string) template.HTML) []renderedComment { | |
| 414 | 474 | var out []renderedComment |
| 415 | 475 | for _, c := range cs { |
| 416 | out = append(out, renderedComment{c.Author, c.CreatedAt, mdHTML(c.Body)}) | |
| 476 | out = append(out, renderedComment{c.Author, c.CreatedAt, md(c.Body)}) | |
| 417 | 477 | } |
| 418 | 478 | return out |
| 419 | 479 | } |
| @@ -510,7 +570,7 @@ type diffThread struct { | ||
| 510 | 570 | // attachThreads injects review threads under their anchored diff lines; |
| 511 | 571 | // threads whose anchor no longer appears (stale after force-push, or on a |
| 512 | 572 | // context line outside the current diff) are returned separately. |
| 513 | func attachThreads(lines []diffLine, comments []store.DiffComment, headSHA string) ([]diffLine, []diffThread) { | |
| 573 | func attachThreads(lines []diffLine, comments []store.DiffComment, headSHA string, md func(string) template.HTML) ([]diffLine, []diffThread) { | |
| 514 | 574 | type anchor struct { |
| 515 | 575 | path string |
| 516 | 576 | side string |
| @@ -522,11 +582,11 @@ func attachThreads(lines []diffLine, comments []store.DiffComment, headSHA strin | ||
| 522 | 582 | for _, cm := range comments { |
| 523 | 583 | if cm.ReplyTo == 0 { |
| 524 | 584 | threads[cm.ID] = &diffThread{ID: cm.ID, Resolved: cm.ResolvedBy, Stale: cm.HeadSHA != headSHA, |
| 525 | Comments: []renderedComment{{cm.Author, cm.CreatedAt, mdHTML(cm.Body)}}} | |
| 585 | Comments: []renderedComment{{cm.Author, cm.CreatedAt, md(cm.Body)}}} | |
| 526 | 586 | anchors[cm.ID] = anchor{cm.Path, cm.Side, cm.Line} |
| 527 | 587 | order = append(order, cm.ID) |
| 528 | 588 | } else if th, ok := threads[cm.ReplyTo]; ok { |
| 529 | th.Comments = append(th.Comments, renderedComment{cm.Author, cm.CreatedAt, mdHTML(cm.Body)}) | |
| 589 | th.Comments = append(th.Comments, renderedComment{cm.Author, cm.CreatedAt, md(cm.Body)}) | |
| 530 | 590 | } |
| 531 | 591 | } |
| 532 | 592 | placed := map[int64]bool{} |
| @@ -737,13 +797,14 @@ func (s *Server) issue(w http.ResponseWriter, r *http.Request) { | ||
| 737 | 797 | http.Error(w, "internal error", http.StatusInternalServerError) |
| 738 | 798 | return |
| 739 | 799 | } |
| 800 | md := s.ugcFor(r, p.Repo) | |
| 740 | 801 | s.render(w, "issue.html", struct { |
| 741 | 802 | repoPage |
| 742 | 803 | Issue store.Issue |
| 743 | 804 | BodyHTML template.HTML |
| 744 | 805 | Comments []renderedComment |
| 745 | 806 | LabelColors map[string]template.CSS |
| 746 | }{p, iss, mdHTML(iss.Body), renderComments(comments), s.labelColors(p.Repo.ID)}) | |
| 807 | }{p, iss, md(iss.Body), renderComments(comments, md), s.labelColors(p.Repo.ID)}) | |
| 747 | 808 | } |
| 748 | 809 | |
| 749 | 810 | func (s *Server) mrs(w http.ResponseWriter, r *http.Request) { |
| @@ -806,8 +867,9 @@ func (s *Server) mr(w http.ResponseWriter, r *http.Request) { | ||
| 806 | 867 | lines = classifyDiff(patch) |
| 807 | 868 | } |
| 808 | 869 | } |
| 870 | md := s.ugcFor(r, p.Repo) | |
| 809 | 871 | var detachedThreads []diffThread |
| 810 | lines, detachedThreads = attachThreads(lines, diffComments, m.HeadSHA) | |
| 872 | lines, detachedThreads = attachThreads(lines, diffComments, m.HeadSHA, md) | |
| 811 | 873 | s.render(w, "mr.html", struct { |
| 812 | 874 | repoPage |
| 813 | 875 | MR store.MR |
| @@ -818,7 +880,7 @@ func (s *Server) mr(w http.ResponseWriter, r *http.Request) { | ||
| 818 | 880 | Reviews []store.MRReview |
| 819 | 881 | DiffLines []diffLine |
| 820 | 882 | DetachedThreads []diffThread |
| 821 | }{p, m, mdHTML(m.Body), checks, store.CombinedStatus(checks), renderComments(comments), reviews, lines, detachedThreads}) | |
| 883 | }{p, m, md(m.Body), checks, store.CombinedStatus(checks), renderComments(comments, md), reviews, lines, detachedThreads}) | |
| 822 | 884 | } |
| 823 | 885 | |
| 824 | 886 | func (s *Server) refs(w http.ResponseWriter, r *http.Request) { |