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 | "github.com/niklasfasching/go-org/org" | 21 | "github.com/niklasfasching/go-org/org" |
| 22 | "github.com/yuin/goldmark" | 22 | "github.com/yuin/goldmark" |
| 23 | 23 | ||
| 24 | "gitbay.org/gitbay/internal/autolink" | ||
| 24 | "gitbay.org/gitbay/internal/control" | 25 | "gitbay.org/gitbay/internal/control" |
| 25 | "gitbay.org/gitbay/internal/gitutil" | 26 | "gitbay.org/gitbay/internal/gitutil" |
| 26 | "gitbay.org/gitbay/internal/sig" | 27 | "gitbay.org/gitbay/internal/sig" |
| @@ -403,6 +404,65 @@ func mdHTML(raw string) template.HTML { | |||
| 403 | return template.HTML(buf.String()) | 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 | // renderedComment pairs a comment with its rendered body for templates. | 466 | // renderedComment pairs a comment with its rendered body for templates. |
| 407 | type renderedComment struct { | 467 | type renderedComment struct { |
| 408 | Author string | 468 | Author string |
| @@ -410,10 +470,10 @@ type renderedComment struct { | |||
| 410 | BodyHTML template.HTML | 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 | var out []renderedComment | 474 | var out []renderedComment |
| 415 | for _, c := range cs { | 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 | return out | 478 | return out |
| 419 | } | 479 | } |
| @@ -510,7 +570,7 @@ type diffThread struct { | |||
| 510 | // attachThreads injects review threads under their anchored diff lines; | 570 | // attachThreads injects review threads under their anchored diff lines; |
| 511 | // threads whose anchor no longer appears (stale after force-push, or on a | 571 | // threads whose anchor no longer appears (stale after force-push, or on a |
| 512 | // context line outside the current diff) are returned separately. | 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 | type anchor struct { | 574 | type anchor struct { |
| 515 | path string | 575 | path string |
| 516 | side string | 576 | side string |
| @@ -522,11 +582,11 @@ func attachThreads(lines []diffLine, comments []store.DiffComment, headSHA strin | |||
| 522 | for _, cm := range comments { | 582 | for _, cm := range comments { |
| 523 | if cm.ReplyTo == 0 { | 583 | if cm.ReplyTo == 0 { |
| 524 | threads[cm.ID] = &diffThread{ID: cm.ID, Resolved: cm.ResolvedBy, Stale: cm.HeadSHA != headSHA, | 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 | anchors[cm.ID] = anchor{cm.Path, cm.Side, cm.Line} | 586 | anchors[cm.ID] = anchor{cm.Path, cm.Side, cm.Line} |
| 527 | order = append(order, cm.ID) | 587 | order = append(order, cm.ID) |
| 528 | } else if th, ok := threads[cm.ReplyTo]; ok { | 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 | placed := map[int64]bool{} | 592 | placed := map[int64]bool{} |
| @@ -737,13 +797,14 @@ func (s *Server) issue(w http.ResponseWriter, r *http.Request) { | |||
| 737 | http.Error(w, "internal error", http.StatusInternalServerError) | 797 | http.Error(w, "internal error", http.StatusInternalServerError) |
| 738 | return | 798 | return |
| 739 | } | 799 | } |
| 800 | md := s.ugcFor(r, p.Repo) | ||
| 740 | s.render(w, "issue.html", struct { | 801 | s.render(w, "issue.html", struct { |
| 741 | repoPage | 802 | repoPage |
| 742 | Issue store.Issue | 803 | Issue store.Issue |
| 743 | BodyHTML template.HTML | 804 | BodyHTML template.HTML |
| 744 | Comments []renderedComment | 805 | Comments []renderedComment |
| 745 | LabelColors map[string]template.CSS | 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 | func (s *Server) mrs(w http.ResponseWriter, r *http.Request) { | 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 | lines = classifyDiff(patch) | 867 | lines = classifyDiff(patch) |
| 807 | } | 868 | } |
| 808 | } | 869 | } |
| 870 | md := s.ugcFor(r, p.Repo) | ||
| 809 | var detachedThreads []diffThread | 871 | var detachedThreads []diffThread |
| 810 | lines, detachedThreads = attachThreads(lines, diffComments, m.HeadSHA) | 872 | lines, detachedThreads = attachThreads(lines, diffComments, m.HeadSHA, md) |
| 811 | s.render(w, "mr.html", struct { | 873 | s.render(w, "mr.html", struct { |
| 812 | repoPage | 874 | repoPage |
| 813 | MR store.MR | 875 | MR store.MR |
| @@ -818,7 +880,7 @@ func (s *Server) mr(w http.ResponseWriter, r *http.Request) { | |||
| 818 | Reviews []store.MRReview | 880 | Reviews []store.MRReview |
| 819 | DiffLines []diffLine | 881 | DiffLines []diffLine |
| 820 | DetachedThreads []diffThread | 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 | func (s *Server) refs(w http.ResponseWriter, r *http.Request) { | 886 | func (s *Server) refs(w http.ResponseWriter, r *http.Request) { |