vole
Clone
Read only, no credentials:
https://rodent.software/vole.git
Write over SSH with a registered key:
git clone ssh://git@rodent.software:2222/vole.git
A free and open-source forge that lives in your Git repo.
Publish a robots.txt and sitemap for public pages
80300457bddaf6b90f472a336893ed5dee3c0a48 · Keenan Nemetz · 1d ago · parent c980fe53
Publish a robots.txt and sitemap for public pages A crawler needs to know which paths are worth fetching and where the public pages are. robots.txt allows the public surface, closes the account, administration, write, fragment and Git paths, and points at a sitemap index. The index splits the instance pages from each public repository; a repository sitemap lists its overview, activity, code browser, issues, changes, releases and default-branch commits, with lastmod on detail pages. Visibility is the anonymous read rule, not the viewer's session: a private repository is absent from the index and its sitemap is a 404 even for an admin. Account profiles stay out of the sitemap and out of robots.txt so their existing noindex request remains visible to crawlers.
server/seo_test.go +170
| @@ -0,0 +1,170 @@ | |||
| 1 | package server_test | ||
| 2 | |||
| 3 | // robots.txt and the sitemap are the crawl surface: the paths worth fetching, | ||
| 4 | // and the public repositories and pages behind them. These tests pin the two | ||
| 5 | // rules that matter: a private repository never appears in either, and a | ||
| 6 | // repository's own sitemap is the same document for a stranger and for an admin. | ||
| 7 | |||
| 8 | import ( | ||
| 9 | "encoding/xml" | ||
| 10 | "net/http" | ||
| 11 | "strings" | ||
| 12 | "testing" | ||
| 13 | ) | ||
| 14 | |||
| 15 | func TestRobotsTxtDescribesThePublicSurface(t *testing.T) { | ||
| 16 | t.Parallel() | ||
| 17 | f := newFixture(t) | ||
| 18 | |||
| 19 | resp, body := f.browserGet(t, "/robots.txt", "") | ||
| 20 | if resp.StatusCode != http.StatusOK { | ||
| 21 | t.Fatalf("robots.txt = %d\n%s", resp.StatusCode, body) | ||
| 22 | } | ||
| 23 | if ct := resp.Header.Get("Content-Type"); !strings.HasPrefix(ct, "text/plain") { | ||
| 24 | t.Fatalf("robots.txt content type = %q", ct) | ||
| 25 | } | ||
| 26 | for _, want := range []string{ | ||
| 27 | "User-agent: *", | ||
| 28 | "Allow: /", | ||
| 29 | "Disallow: /admin", | ||
| 30 | "Disallow: /account", | ||
| 31 | "Disallow: /r/*/settings", | ||
| 32 | "Disallow: /r/*/issues/new", | ||
| 33 | "Disallow: /git/", | ||
| 34 | "Sitemap: " + f.http.URL + "/sitemap.xml", | ||
| 35 | } { | ||
| 36 | if !strings.Contains(body, want) { | ||
| 37 | t.Fatalf("robots.txt is missing %q:\n%s", want, body) | ||
| 38 | } | ||
| 39 | } | ||
| 40 | // Profiles are noindex pages, not disallowed ones: a crawler has to be able | ||
| 41 | // to fetch them to see the request. | ||
| 42 | for _, unwanted := range []string{"Disallow: /u\n", "Disallow: /u/"} { | ||
| 43 | if strings.Contains(body, unwanted) { | ||
| 44 | t.Fatalf("robots.txt hides a noindex page with %q:\n%s", unwanted, body) | ||
| 45 | } | ||
| 46 | } | ||
| 47 | } | ||
| 48 | |||
| 49 | func TestSitemapListsPublicRepositoriesAndPages(t *testing.T) { | ||
| 50 | t.Parallel() | ||
| 51 | f := newFixture(t) | ||
| 52 | f.publish(f.repo) | ||
| 53 | |||
| 54 | private, err := f.srv.Repos.Create("secret", f.ownerID, "private repo") | ||
| 55 | if err != nil { | ||
| 56 | t.Fatal(err) | ||
| 57 | } | ||
| 58 | if err := f.srv.Index.Upsert(private); err != nil { | ||
| 59 | t.Fatal(err) | ||
| 60 | } | ||
| 61 | |||
| 62 | resp, body := f.browserGet(t, "/sitemap.xml", "") | ||
| 63 | if resp.StatusCode != http.StatusOK { | ||
| 64 | t.Fatalf("sitemap.xml = %d\n%s", resp.StatusCode, body) | ||
| 65 | } | ||
| 66 | if ct := resp.Header.Get("Content-Type"); !strings.HasPrefix(ct, "application/xml") { | ||
| 67 | t.Fatalf("sitemap.xml content type = %q", ct) | ||
| 68 | } | ||
| 69 | for _, want := range []string{ | ||
| 70 | `<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">`, | ||
| 71 | "<loc>" + f.http.URL + "/sitemap-pages.xml</loc>", | ||
| 72 | "<loc>" + f.http.URL + "/r/demo/sitemap.xml</loc>", | ||
| 73 | } { | ||
| 74 | if !strings.Contains(body, want) { | ||
| 75 | t.Fatalf("sitemap index is missing %q:\n%s", want, body) | ||
| 76 | } | ||
| 77 | } | ||
| 78 | if strings.Contains(body, "secret") { | ||
| 79 | t.Fatalf("sitemap index names a private repository:\n%s", body) | ||
| 80 | } | ||
| 81 | |||
| 82 | // The answer belongs to the instance, not the session: an admin asking for | ||
| 83 | // it must not add a private repository to a cacheable document. | ||
| 84 | _, cookie := f.browserSession(t, f.ownerID) | ||
| 85 | if _, signedIn := f.browserGet(t, "/sitemap.xml", cookie); strings.Contains(signedIn, "secret") { | ||
| 86 | t.Fatalf("a signed-in sitemap names a private repository:\n%s", signedIn) | ||
| 87 | } | ||
| 88 | if code, _ := f.get("/r/secret/sitemap.xml"); code != http.StatusNotFound { | ||
| 89 | t.Fatalf("private repository sitemap = %d, want 404", code) | ||
| 90 | } | ||
| 91 | resp, _ = f.browserGet(t, "/r/secret/sitemap.xml", cookie) | ||
| 92 | if resp.StatusCode != http.StatusNotFound { | ||
| 93 | t.Fatalf("signed-in private repository sitemap = %d, want 404", resp.StatusCode) | ||
| 94 | } | ||
| 95 | |||
| 96 | _, body = f.browserGet(t, "/sitemap-pages.xml", "") | ||
| 97 | for _, want := range []string{ | ||
| 98 | "<loc>" + f.http.URL + "/</loc>", | ||
| 99 | "<loc>" + f.http.URL + "/activity</loc>", | ||
| 100 | } { | ||
| 101 | if !strings.Contains(body, want) { | ||
| 102 | t.Fatalf("page sitemap is missing %q:\n%s", want, body) | ||
| 103 | } | ||
| 104 | } | ||
| 105 | } | ||
| 106 | |||
| 107 | func TestRepoSitemapListsIssuesChangesAndReleases(t *testing.T) { | ||
| 108 | t.Parallel() | ||
| 109 | f := newFixture(t) | ||
| 110 | f.publish(f.repo) | ||
| 111 | |||
| 112 | work := workRepo(t, map[string]string{"readme.md": "sitemap\n"}) | ||
| 113 | if _, errs, err := f.git(work, "push", f.sshURL(f.repo), "main:refs/heads/main"); err != nil { | ||
| 114 | t.Fatalf("main push: %v\n%s", err, errs) | ||
| 115 | } | ||
| 116 | out, _, _ := runGit(t, work, "rev-parse", "HEAD") | ||
| 117 | head := strings.TrimSpace(out) | ||
| 118 | if _, errs, err := runGit(t, work, "tag", "-a", "v1.0.0", "-m", "first release\n"); err != nil { | ||
| 119 | t.Fatalf("tag: %v\n%s", err, errs) | ||
| 120 | } | ||
| 121 | if _, errs, err := f.git(work, "push", f.sshURL(f.repo), "refs/tags/v1.0.0:refs/tags/v1.0.0"); err != nil { | ||
| 122 | t.Fatalf("tag push: %v\n%s", err, errs) | ||
| 123 | } | ||
| 124 | |||
| 125 | const issueID = "01M5JR2JK29KKMAA1T9MDNTS01" | ||
| 126 | const changeID = "01M5JR2JK29KKMAA1T9MDNTS02" | ||
| 127 | meta := f.newMetaRepo(t) | ||
| 128 | if err := meta.apply(map[string]string{ | ||
| 129 | "issues/" + issueID + "/issue.md": issueFile("Sitemap issue", "keenan", "open", "2026-10-22T21:00:00Z", []string{"web"}, "body\n"), | ||
| 130 | "changes/" + changeID + "/change.md": requestFileTitled("Sitemap change", "main", head, "keenan", "body\n"), | ||
| 131 | }, "sitemap fixtures"); err != nil { | ||
| 132 | t.Fatalf("metadata push: %v", err) | ||
| 133 | } | ||
| 134 | |||
| 135 | resp, body := f.browserGet(t, "/r/"+f.repo+"/sitemap.xml", "") | ||
| 136 | if resp.StatusCode != http.StatusOK { | ||
| 137 | t.Fatalf("repository sitemap = %d\n%s", resp.StatusCode, body) | ||
| 138 | } | ||
| 139 | base := f.http.URL | ||
| 140 | for _, want := range []string{ | ||
| 141 | `<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">`, | ||
| 142 | "<loc>" + base + "/r/demo</loc>", | ||
| 143 | "<loc>" + base + "/r/demo/activity</loc>", | ||
| 144 | "<loc>" + base + "/r/demo/code</loc>", | ||
| 145 | "<loc>" + base + "/r/demo/issues</loc>", | ||
| 146 | "<loc>" + base + "/r/demo/issues/" + issueID + "</loc>", | ||
| 147 | "<loc>" + base + "/r/demo/changes</loc>", | ||
| 148 | "<loc>" + base + "/r/demo/changes/" + changeID + "</loc>", | ||
| 149 | "<loc>" + base + "/r/demo/releases</loc>", | ||
| 150 | "<loc>" + base + "/r/demo/releases/v1.0.0</loc>", | ||
| 151 | "<loc>" + base + "/r/demo/commits/main</loc>", | ||
| 152 | "<lastmod>", | ||
| 153 | } { | ||
| 154 | if !strings.Contains(body, want) { | ||
| 155 | t.Fatalf("repository sitemap is missing %q:\n%s", want, body) | ||
| 156 | } | ||
| 157 | } | ||
| 158 | |||
| 159 | var set struct { | ||
| 160 | URLs []struct { | ||
| 161 | Loc string `xml:"loc"` | ||
| 162 | } `xml:"url"` | ||
| 163 | } | ||
| 164 | if err := xml.Unmarshal([]byte(body), &set); err != nil { | ||
| 165 | t.Fatalf("repository sitemap is not XML: %v\n%s", err, body) | ||
| 166 | } | ||
| 167 | if len(set.URLs) < 9 { | ||
| 168 | t.Fatalf("repository sitemap has %d URLs, want the repository, list, issue, change and release pages:\n%s", len(set.URLs), body) | ||
| 169 | } | ||
| 170 | } | ||