feat(pages): serve static sites from user subdomains
11 files changed, +458 −12
- INSTALL.md modified +2 −0
- README.md modified +7 −0
- deploy/nginx.conf modified +28 −1
- forge/admin.go modified +3 −2
- forge/feature_test.go modified +169 −0
- forge/flow_test.go modified +2 −2
- forge/pages.go added +216 −0
- forge/rename.go modified +2 −2
- forge/store.go modified +15 −3
- forge/web.go modified +13 −1
- web/templates/admin.tmpl modified +1 −1
INSTALL.md modified
| @@ -62,6 +62,8 @@ certbot certonly --nginx -d git.example.org | ||
| 62 | 62 | nginx -t && systemctl reload nginx |
| 63 | 63 | ``` |
| 64 | 64 | |
| 65 | +Pages need a wildcard DNS record and certificate for `*.git.example.org` along with a second server block that proxies those names to the same port, which `deploy/nginx.conf` already includes, and without it user sites are simply unreachable. | |
| 66 | + | |
| 65 | 67 | Whatever proxy you use has to forward `Host` and `X-Forwarded-Proto`, set `X-Forwarded-For` to the client address, turn request buffering off and allow large bodies, otherwise bigger HTTPS pushes are cut short. The server takes the last address in `X-Forwarded-For`, so a client cannot pick its own rate limit bucket. |
| 66 | 68 | |
| 67 | 69 | ## SSH |
README.md modified
| @@ -22,6 +22,7 @@ The web interface is then on `127.0.0.1:3200` and wants a reverse proxy with TLS | ||
| 22 | 22 | - Organisations, collaborators, issues, labels, stars, forks, branch protection and per owner disk quotas enforced during the push |
| 23 | 23 | - Profiles with a pinned repository list, an activity graph, followers and a README from your `~you/you` repository |
| 24 | 24 | - Imports from GitHub, GitLab, Gitea or Forgejo over https, with the option to credit commits made under your old identity to your account |
| 25 | +- Static sites served from `<user>.<host>`, built from a `pages` repository or the `pages` branch of any public repository | |
| 25 | 26 | - Reports to admins and per account blocking, with hourly limits on new issues, pull requests and repositories |
| 26 | 27 | - SSH and PGP commit verification, TOTP, access tokens and Atom feeds for commits, issues and pull requests |
| 27 | 28 | - Site search over repositories and people with language facets |
| @@ -46,6 +47,12 @@ git clone [email protected]:~you/repo | ||
| 46 | 47 | |
| 47 | 48 | SSH pushes need a key added under settings and HTTPS pushes take an access token or your password. To contribute to something you do not own, fork it, push a branch to the fork and open a pull request from that branch. |
| 48 | 49 | |
| 50 | +### Pages | |
| 51 | + | |
| 52 | +A public repository named `pages` is served at `https://you.git.example.org/` from its `pages` branch when one exists and from the default branch otherwise, and any other public repository with a `pages` branch appears beneath it at `/name/`. Paths without an extension also try `.html` and directories serve their `index.html`, while a missing file serves the site's own `404.html` with a 404 status when the site provides one. Symlinks and submodules are never followed and files over 32 MB are refused, accounts younger than a day have no site at all and admins can turn any site off from the admin page. | |
| 53 | + | |
| 54 | +The pages origin never sets or reads cookies and the main site only uses `__Host-` cookies, so a script running on a user site can neither read a session from the main host nor plant a cookie that the main host would later accept. | |
| 55 | + | |
| 49 | 56 | ## Development |
| 50 | 57 | |
| 51 | 58 | ``` |
deploy/nginx.conf modified
| @@ -3,7 +3,7 @@ limit_req_zone $binary_remote_addr zone=gitpocket:10m rate=10r/s; | ||
| 3 | 3 | server { |
| 4 | 4 | listen 80; |
| 5 | 5 | listen [::]:80; |
| 6 | - server_name git.example.org; | |
| 6 | + server_name git.example.org *.git.example.org; | |
| 7 | 7 | location / { return 301 https://$host$request_uri; } |
| 8 | 8 | } |
| 9 | 9 | |
| @@ -39,3 +39,30 @@ server { | ||
| 39 | 39 | proxy_set_header Host $host; |
| 40 | 40 | } |
| 41 | 41 | } |
| 42 | + | |
| 43 | +server { | |
| 44 | + listen 443 ssl; | |
| 45 | + listen [::]:443 ssl; | |
| 46 | + http2 on; | |
| 47 | + server_name *.git.example.org; | |
| 48 | + ssl_certificate /etc/letsencrypt/live/gitpocket-pages/fullchain.pem; | |
| 49 | + ssl_certificate_key /etc/letsencrypt/live/gitpocket-pages/privkey.pem; | |
| 50 | + ssl_protocols TLSv1.2 TLSv1.3; | |
| 51 | + client_max_body_size 1m; | |
| 52 | + add_header Strict-Transport-Security "max-age=31536000" always; | |
| 53 | + gzip on; | |
| 54 | + gzip_proxied any; | |
| 55 | + gzip_vary on; | |
| 56 | + gzip_types text/css text/plain text/javascript application/javascript image/svg+xml application/json application/xml; | |
| 57 | + location / { | |
| 58 | + limit_req zone=gitpocket burst=60 nodelay; | |
| 59 | + limit_req_status 429; | |
| 60 | + limit_except GET HEAD { deny all; } | |
| 61 | + proxy_pass http://127.0.0.1:3200; | |
| 62 | + proxy_http_version 1.1; | |
| 63 | + proxy_set_header Host $host; | |
| 64 | + proxy_set_header X-Forwarded-Proto https; | |
| 65 | + proxy_set_header X-Forwarded-For $remote_addr; | |
| 66 | + proxy_set_header Cookie ""; | |
| 67 | + } | |
| 68 | +} | |
forge/admin.go modified
| @@ -143,11 +143,12 @@ type account struct { | ||
| 143 | 143 | Admin bool |
| 144 | 144 | Banned bool |
| 145 | 145 | Org bool |
| 146 | + Pages bool | |
| 146 | 147 | Repos int |
| 147 | 148 | } |
| 148 | 149 | |
| 149 | 150 | func (s *Store) Accounts() []account { |
| 150 | - rows, err := s.db.Query(`select u.id, u.name, u.created, u.admin, u.banned, u.org, (select count(*) from repos r where r.owner = u.id) from users u order by u.created desc limit 1000`) | |
| 151 | + rows, err := s.db.Query(`select u.id, u.name, u.created, u.admin, u.banned, u.org, u.pages, (select count(*) from repos r where r.owner = u.id) from users u order by u.created desc limit 1000`) | |
| 151 | 152 | if err != nil { |
| 152 | 153 | return nil |
| 153 | 154 | } |
| @@ -156,7 +157,7 @@ func (s *Store) Accounts() []account { | ||
| 156 | 157 | for rows.Next() { |
| 157 | 158 | var a account |
| 158 | 159 | var created int64 |
| 159 | - if rows.Scan(&a.ID, &a.Name, &created, &a.Admin, &a.Banned, &a.Org, &a.Repos) == nil { | |
| 160 | + if rows.Scan(&a.ID, &a.Name, &created, &a.Admin, &a.Banned, &a.Org, &a.Pages, &a.Repos) == nil { | |
| 160 | 161 | a.Created = time.Unix(created, 0) |
| 161 | 162 | out = append(out, a) |
| 162 | 163 | } |
forge/feature_test.go modified
| @@ -2,12 +2,181 @@ package forge | ||
| 2 | 2 | |
| 3 | 3 | import ( |
| 4 | 4 | "net/http" |
| 5 | + "net/http/httptest" | |
| 5 | 6 | "net/url" |
| 7 | + "os/exec" | |
| 8 | + "path/filepath" | |
| 6 | 9 | "strconv" |
| 7 | 10 | "strings" |
| 8 | 11 | "testing" |
| 12 | + "time" | |
| 9 | 13 | ) |
| 10 | 14 | |
| 15 | +func pagesGet(h http.Handler, host, path string) *httptest.ResponseRecorder { | |
| 16 | + r := httptest.NewRequest("GET", path, nil) | |
| 17 | + r.Host = host | |
| 18 | + r.RemoteAddr = "192.0.2.1:1000" | |
| 19 | + w := httptest.NewRecorder() | |
| 20 | + h.ServeHTTP(w, r) | |
| 21 | + return w | |
| 22 | +} | |
| 23 | + | |
| 24 | +func addEntry(t *testing.T, g Git, branch, mode, name, content string) string { | |
| 25 | + t.Helper() | |
| 26 | + parent, _ := g.Resolve("refs/heads/" + branch) | |
| 27 | + blob, err := g.run([]byte(content), "hash-object", "-w", "--stdin") | |
| 28 | + if err != nil { | |
| 29 | + t.Fatal(err) | |
| 30 | + } | |
| 31 | + index := filepath.Join(t.TempDir(), "index") | |
| 32 | + env := []string{"GIT_INDEX_FILE=" + index} | |
| 33 | + if parent != "" { | |
| 34 | + g.runEnv(env, nil, "read-tree", parent) | |
| 35 | + } | |
| 36 | + if _, err := g.runEnv(env, nil, "update-index", "--add", "--cacheinfo", mode+","+strings.TrimSpace(string(blob))+","+name); err != nil { | |
| 37 | + t.Fatal(err) | |
| 38 | + } | |
| 39 | + tree, _ := g.runEnv(env, nil, "write-tree") | |
| 40 | + args := []string{"commit-tree", strings.TrimSpace(string(tree))} | |
| 41 | + if parent != "" { | |
| 42 | + args = append(args, "-p", parent) | |
| 43 | + } | |
| 44 | + out, err := g.runEnv(ident("T", "[email protected]", timeNow()), []byte("add "+name), args...) | |
| 45 | + if err != nil { | |
| 46 | + t.Fatal(err) | |
| 47 | + } | |
| 48 | + hash := strings.TrimSpace(string(out)) | |
| 49 | + g.run(nil, "update-ref", "refs/heads/"+branch, hash) | |
| 50 | + return hash | |
| 51 | +} | |
| 52 | + | |
| 53 | +func newRepo(t *testing.T, s *Server, owner User, name string, vis int) (Repo, Git) { | |
| 54 | + t.Helper() | |
| 55 | + repo, err := s.store.CreateRepo(owner, name, "", vis, "") | |
| 56 | + if err != nil { | |
| 57 | + t.Fatal(err) | |
| 58 | + } | |
| 59 | + g := Git{Dir: s.repoDir(repo)} | |
| 60 | + if err := initRepo(g.Dir); err != nil { | |
| 61 | + t.Fatal(err) | |
| 62 | + } | |
| 63 | + return repo, g | |
| 64 | +} | |
| 65 | + | |
| 66 | +func age(t *testing.T, s *Server, u User) { | |
| 67 | + t.Helper() | |
| 68 | + if _, err := s.store.db.Exec(`update users set created = ? where id = ?`, time.Now().Add(-48*time.Hour).Unix(), u.ID); err != nil { | |
| 69 | + t.Fatal(err) | |
| 70 | + } | |
| 71 | +} | |
| 72 | + | |
| 73 | +func TestPages(t *testing.T) { | |
| 74 | + if _, err := exec.LookPath("git"); err != nil { | |
| 75 | + t.Skip("git not installed") | |
| 76 | + } | |
| 77 | + s, h := testServer(t) | |
| 78 | + alice, _ := s.store.CreateUser("alice", "correct horse") | |
| 79 | + age(t, s, alice) | |
| 80 | + _, g := newRepo(t, s, alice, "pages", Public) | |
| 81 | + main := g.Head() | |
| 82 | + addEntry(t, g, main, "100644", "index.html", "<h1>home</h1>") | |
| 83 | + addEntry(t, g, main, "100644", "about.html", "about me") | |
| 84 | + addEntry(t, g, main, "100644", "style.css", "body{}") | |
| 85 | + addEntry(t, g, main, "100644", "docs/index.html", "docs") | |
| 86 | + addEntry(t, g, main, "120000", "link", "/etc/passwd") | |
| 87 | + addEntry(t, g, main, "100644", "404.html", "missing") | |
| 88 | + host := "alice.example.org" | |
| 89 | + cases := []struct { | |
| 90 | + path, body, ctype string | |
| 91 | + code int | |
| 92 | + }{ | |
| 93 | + {"/", "<h1>home</h1>", "text/html", 200}, | |
| 94 | + {"/index.html", "<h1>home</h1>", "text/html", 200}, | |
| 95 | + {"/about", "about me", "text/html", 200}, | |
| 96 | + {"/style.css", "body{}", "text/css", 200}, | |
| 97 | + {"/docs/", "docs", "text/html", 200}, | |
| 98 | + {"/link", "missing", "text/html", 404}, | |
| 99 | + {"/nope", "missing", "text/html", 404}, | |
| 100 | + {"/../../../etc/passwd", "", "", 404}, | |
| 101 | + {"/%2e%2e/%2e%2e/etc/passwd", "", "", 404}, | |
| 102 | + } | |
| 103 | + for _, c := range cases { | |
| 104 | + w := pagesGet(h, host, c.path) | |
| 105 | + if w.Code != c.code || (c.body != "" && w.Body.String() != c.body) || !strings.HasPrefix(w.Header().Get("Content-Type"), c.ctype) { | |
| 106 | + t.Errorf("%s: %d %q %s", c.path, w.Code, w.Body.String(), w.Header().Get("Content-Type")) | |
| 107 | + } | |
| 108 | + if len(w.Result().Cookies()) > 0 || w.Header().Get("X-Content-Type-Options") != "nosniff" { | |
| 109 | + t.Errorf("%s: cookies or missing nosniff", c.path) | |
| 110 | + } | |
| 111 | + } | |
| 112 | + if w := pagesGet(h, host, "/docs"); w.Code != http.StatusMovedPermanently || w.Header().Get("Location") != "/docs/" { | |
| 113 | + t.Errorf("directory without slash: %d %s", w.Code, w.Header().Get("Location")) | |
| 114 | + } | |
| 115 | + r := httptest.NewRequest("POST", "/", nil) | |
| 116 | + r.Host = host | |
| 117 | + w := httptest.NewRecorder() | |
| 118 | + h.ServeHTTP(w, r) | |
| 119 | + if w.Code != http.StatusMethodNotAllowed { | |
| 120 | + t.Errorf("post to pages: %d", w.Code) | |
| 121 | + } | |
| 122 | + | |
| 123 | + _, pg := newRepo(t, s, alice, "proj", Public) | |
| 124 | + addEntry(t, pg, pg.Head(), "100644", "README.md", "readme") | |
| 125 | + addEntry(t, pg, "pages", "100644", "index.html", "project site") | |
| 126 | + if w := pagesGet(h, host, "/proj/"); w.Body.String() != "project site" { | |
| 127 | + t.Errorf("project site: %d %q", w.Code, w.Body.String()) | |
| 128 | + } | |
| 129 | + if w := pagesGet(h, host, "/proj"); w.Code != http.StatusMovedPermanently { | |
| 130 | + t.Errorf("project root redirect: %d", w.Code) | |
| 131 | + } | |
| 132 | + _, secret := newRepo(t, s, alice, "secret", Private) | |
| 133 | + addEntry(t, secret, "pages", "100644", "index.html", "private site") | |
| 134 | + if w := pagesGet(h, host, "/secret/"); strings.Contains(w.Body.String(), "private site") { | |
| 135 | + t.Error("private repository served as pages") | |
| 136 | + } | |
| 137 | + | |
| 138 | + for _, host := range []string{"www.example.org", "a.b.example.org", "x_y.example.org"} { | |
| 139 | + if w := pagesGet(h, host, "/"); w.Code != 404 { | |
| 140 | + t.Errorf("%s: %d", host, w.Code) | |
| 141 | + } | |
| 142 | + } | |
| 143 | + if w := pagesGet(h, "alice.example.org.", "/"); w.Body.String() != "<h1>home</h1>" { | |
| 144 | + t.Error("trailing dot host not served as pages") | |
| 145 | + } | |
| 146 | + if w := pagesGet(h, "example.org", "/"); w.Header().Get("Content-Security-Policy") == "" { | |
| 147 | + t.Error("main site lost its headers") | |
| 148 | + } | |
| 149 | + | |
| 150 | + bob, _ := s.store.CreateUser("bob", "correct horse") | |
| 151 | + _, bg := newRepo(t, s, bob, "pages", Public) | |
| 152 | + addEntry(t, bg, bg.Head(), "100644", "index.html", "fresh") | |
| 153 | + if w := pagesGet(h, "bob.example.org", "/"); w.Code != 404 { | |
| 154 | + t.Errorf("new account served pages: %d", w.Code) | |
| 155 | + } | |
| 156 | + age(t, s, bob) | |
| 157 | + if w := pagesGet(h, "bob.example.org", "/"); w.Code != 200 { | |
| 158 | + t.Errorf("aged account not served: %d", w.Code) | |
| 159 | + } | |
| 160 | + s.store.SetPages("bob", false) | |
| 161 | + if w := pagesGet(h, "bob.example.org", "/"); w.Code != 404 { | |
| 162 | + t.Errorf("disabled pages served: %d", w.Code) | |
| 163 | + } | |
| 164 | + s.store.SetPages("bob", true) | |
| 165 | + s.store.SetBanned("bob", true) | |
| 166 | + if w := pagesGet(h, "bob.example.org", "/"); w.Code != 404 { | |
| 167 | + t.Errorf("banned account served pages: %d", w.Code) | |
| 168 | + } | |
| 169 | +} | |
| 170 | + | |
| 171 | +func TestReservedNames(t *testing.T) { | |
| 172 | + s, _ := testServer(t) | |
| 173 | + for _, name := range []string{"www", "mail", "pages"} { | |
| 174 | + if _, err := s.store.CreateUser(name, "correct horse"); err == nil { | |
| 175 | + t.Errorf("registered reserved name %s", name) | |
| 176 | + } | |
| 177 | + } | |
| 178 | +} | |
| 179 | + | |
| 11 | 180 | func TestModeration(t *testing.T) { |
| 12 | 181 | s, h := testServer(t) |
| 13 | 182 | alice, _ := s.store.CreateUser("alice", "correct horse") |
forge/flow_test.go modified
| @@ -248,8 +248,8 @@ func TestSignupEmail(t *testing.T) { | ||
| 248 | 248 | |
| 249 | 249 | func TestAdminSections(t *testing.T) { |
| 250 | 250 | s, h := testServer(t) |
| 251 | - admin, _ := s.store.CreateUser("root", "correct horse") | |
| 252 | - s.store.SetAdmin("root", true) | |
| 251 | + admin, _ := s.store.CreateUser("boss", "correct horse") | |
| 252 | + s.store.SetAdmin("boss", true) | |
| 253 | 253 | id, _ := s.store.CreateSession(admin.ID) |
| 254 | 254 | for _, sec := range []string{"overview", "people", "signup", "projects", "storage", "reports", "bogus"} { |
| 255 | 255 | if w := get(h, "/admin?s="+sec, id); w.Code != 200 { |
forge/pages.go added
| @@ -0,0 +1,216 @@ | ||
| 1 | +package forge | |
| 2 | + | |
| 3 | +import ( | |
| 4 | + "bytes" | |
| 5 | + "mime" | |
| 6 | + "net" | |
| 7 | + "net/http" | |
| 8 | + "path" | |
| 9 | + "regexp" | |
| 10 | + "strings" | |
| 11 | + "time" | |
| 12 | +) | |
| 13 | + | |
| 14 | +const ( | |
| 15 | + pagesBranch = "pages" | |
| 16 | + pagesRepo = "pages" | |
| 17 | + maxPage = 32 << 20 | |
| 18 | + pagesMinAge = 24 * time.Hour | |
| 19 | +) | |
| 20 | + | |
| 21 | +var pagesLabel = regexp.MustCompile(`^[a-z0-9]([a-z0-9-]{0,37}[a-z0-9])?$`) | |
| 22 | + | |
| 23 | +var pageTypes = map[string]string{ | |
| 24 | + ".html": "text/html; charset=utf-8", | |
| 25 | + ".htm": "text/html; charset=utf-8", | |
| 26 | + ".css": "text/css; charset=utf-8", | |
| 27 | + ".js": "text/javascript; charset=utf-8", | |
| 28 | + ".mjs": "text/javascript; charset=utf-8", | |
| 29 | + ".json": "application/json", | |
| 30 | + ".map": "application/json", | |
| 31 | + ".txt": "text/plain; charset=utf-8", | |
| 32 | + ".md": "text/plain; charset=utf-8", | |
| 33 | + ".xml": "application/xml", | |
| 34 | + ".svg": "image/svg+xml", | |
| 35 | + ".png": "image/png", | |
| 36 | + ".jpg": "image/jpeg", | |
| 37 | + ".jpeg": "image/jpeg", | |
| 38 | + ".gif": "image/gif", | |
| 39 | + ".webp": "image/webp", | |
| 40 | + ".avif": "image/avif", | |
| 41 | + ".ico": "image/x-icon", | |
| 42 | + ".woff": "font/woff", | |
| 43 | + ".woff2": "font/woff2", | |
| 44 | + ".ttf": "font/ttf", | |
| 45 | + ".otf": "font/otf", | |
| 46 | + ".wasm": "application/wasm", | |
| 47 | + ".pdf": "application/pdf", | |
| 48 | + ".mp4": "video/mp4", | |
| 49 | + ".webm": "video/webm", | |
| 50 | + ".mp3": "audio/mpeg", | |
| 51 | + ".ogg": "audio/ogg", | |
| 52 | + ".wav": "audio/wav", | |
| 53 | +} | |
| 54 | + | |
| 55 | +func pageType(name string) string { | |
| 56 | + ext := strings.ToLower(path.Ext(name)) | |
| 57 | + if t, ok := pageTypes[ext]; ok { | |
| 58 | + return t | |
| 59 | + } | |
| 60 | + if t := mime.TypeByExtension(ext); t != "" { | |
| 61 | + return t | |
| 62 | + } | |
| 63 | + return "application/octet-stream" | |
| 64 | +} | |
| 65 | + | |
| 66 | +func bareHost(host string) string { | |
| 67 | + host = strings.ToLower(host) | |
| 68 | + if h, _, err := net.SplitHostPort(host); err == nil { | |
| 69 | + host = h | |
| 70 | + } | |
| 71 | + return strings.TrimSuffix(host, ".") | |
| 72 | +} | |
| 73 | + | |
| 74 | +func (s *Server) pagesOwner(host string) (label string, sub bool) { | |
| 75 | + host = bareHost(host) | |
| 76 | + label, sub = strings.CutSuffix(host, "."+s.host) | |
| 77 | + if !sub || !pagesLabel.MatchString(label) || reservedUsers[label] { | |
| 78 | + return "", sub | |
| 79 | + } | |
| 80 | + return label, true | |
| 81 | +} | |
| 82 | + | |
| 83 | +type pageSite struct { | |
| 84 | + repo Repo | |
| 85 | + g Git | |
| 86 | + commit string | |
| 87 | + prefix string | |
| 88 | +} | |
| 89 | + | |
| 90 | +func (s *Server) pagesEnabled(owner User) bool { | |
| 91 | + return time.Since(owner.Created) >= pagesMinAge && s.store.PagesAllowed(owner.ID) | |
| 92 | +} | |
| 93 | + | |
| 94 | +func (s *Server) pageSite(owner User, name string, branchOnly bool) (pageSite, bool) { | |
| 95 | + repo, err := s.store.RepoByPath(owner.Name, name) | |
| 96 | + if err != nil || repo.Private { | |
| 97 | + return pageSite{}, false | |
| 98 | + } | |
| 99 | + g := Git{Dir: s.repoDir(repo)} | |
| 100 | + commit, err := g.Resolve("refs/heads/" + pagesBranch) | |
| 101 | + if err != nil && !branchOnly { | |
| 102 | + commit, err = g.Resolve("HEAD") | |
| 103 | + } | |
| 104 | + if err != nil { | |
| 105 | + return pageSite{}, false | |
| 106 | + } | |
| 107 | + return pageSite{repo: repo, g: g, commit: commit}, true | |
| 108 | +} | |
| 109 | + | |
| 110 | +func (g Git) pageEntry(commit, filePath string) (mode, oid string, ok bool) { | |
| 111 | + out, err := g.run(nil, "ls-tree", "-z", "--full-tree", commit, "--", filePath) | |
| 112 | + if err != nil || len(out) == 0 { | |
| 113 | + return "", "", false | |
| 114 | + } | |
| 115 | + meta, name, found := strings.Cut(strings.TrimSuffix(string(out), "\x00"), "\t") | |
| 116 | + f := strings.Fields(meta) | |
| 117 | + if !found || name != filePath || len(f) != 3 { | |
| 118 | + return "", "", false | |
| 119 | + } | |
| 120 | + return f[0], f[2], true | |
| 121 | +} | |
| 122 | + | |
| 123 | +func (s *Server) servePages(w http.ResponseWriter, r *http.Request, label string) { | |
| 124 | + h := w.Header() | |
| 125 | + h.Set("X-Content-Type-Options", "nosniff") | |
| 126 | + h.Set("Referrer-Policy", "strict-origin-when-cross-origin") | |
| 127 | + if r.Method != "GET" && r.Method != "HEAD" { | |
| 128 | + h.Set("Allow", "GET, HEAD") | |
| 129 | + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) | |
| 130 | + return | |
| 131 | + } | |
| 132 | + if !s.requests.allow(clientIP(r)) { | |
| 133 | + h.Set("Retry-After", "10") | |
| 134 | + http.Error(w, "too many requests, slow down", http.StatusTooManyRequests) | |
| 135 | + return | |
| 136 | + } | |
| 137 | + notFound := func() { http.Error(w, "page not found", http.StatusNotFound) } | |
| 138 | + owner, err := s.store.UserByName(label) | |
| 139 | + if err != nil || s.store.IsBanned(owner.ID) || !s.pagesEnabled(owner) { | |
| 140 | + notFound() | |
| 141 | + return | |
| 142 | + } | |
| 143 | + rel, ok := cleanPath(r.URL.Path) | |
| 144 | + if !ok { | |
| 145 | + notFound() | |
| 146 | + return | |
| 147 | + } | |
| 148 | + var site pageSite | |
| 149 | + first, rest, _ := strings.Cut(rel, "/") | |
| 150 | + if first != "" && first != pagesRepo && validName(first) { | |
| 151 | + if sub, found := s.pageSite(owner, first, true); found { | |
| 152 | + if rest == "" && !strings.HasSuffix(r.URL.Path, "/") { | |
| 153 | + http.Redirect(w, r, "/"+first+"/", http.StatusMovedPermanently) | |
| 154 | + return | |
| 155 | + } | |
| 156 | + site, rel = sub, rest | |
| 157 | + site.prefix = "/" + first | |
| 158 | + } | |
| 159 | + } | |
| 160 | + if site.commit == "" { | |
| 161 | + root, found := s.pageSite(owner, pagesRepo, false) | |
| 162 | + if !found { | |
| 163 | + notFound() | |
| 164 | + return | |
| 165 | + } | |
| 166 | + site = root | |
| 167 | + } | |
| 168 | + file := rel | |
| 169 | + if file == "" || strings.HasSuffix(r.URL.Path, "/") { | |
| 170 | + file = path.Join(file, "index.html") | |
| 171 | + } | |
| 172 | + mode, oid, found := site.g.pageEntry(site.commit, file) | |
| 173 | + if found && mode == "040000" { | |
| 174 | + http.Redirect(w, r, site.prefix+"/"+rel+"/", http.StatusMovedPermanently) | |
| 175 | + return | |
| 176 | + } | |
| 177 | + if !found && path.Ext(file) == "" { | |
| 178 | + mode, oid, found = site.g.pageEntry(site.commit, file+".html") | |
| 179 | + file += ".html" | |
| 180 | + } | |
| 181 | + status := http.StatusOK | |
| 182 | + if !found || (mode != "100644" && mode != "100755") { | |
| 183 | + mode, oid, found = site.g.pageEntry(site.commit, "404.html") | |
| 184 | + if !found || (mode != "100644" && mode != "100755") { | |
| 185 | + notFound() | |
| 186 | + return | |
| 187 | + } | |
| 188 | + file, status = "404.html", http.StatusNotFound | |
| 189 | + } | |
| 190 | + size := site.g.Size(site.commit, file) | |
| 191 | + if size > maxPage { | |
| 192 | + http.Error(w, "file too large for pages", http.StatusRequestEntityTooLarge) | |
| 193 | + return | |
| 194 | + } | |
| 195 | + if size > 1<<20 && !s.heavy.allow(clientIP(r)) { | |
| 196 | + h.Set("Retry-After", "10") | |
| 197 | + http.Error(w, "too many requests, slow down", http.StatusTooManyRequests) | |
| 198 | + return | |
| 199 | + } | |
| 200 | + data, err := site.g.run(nil, "cat-file", "blob", oid) | |
| 201 | + if err != nil { | |
| 202 | + notFound() | |
| 203 | + return | |
| 204 | + } | |
| 205 | + h.Set("Content-Type", pageType(file)) | |
| 206 | + h.Set("Cache-Control", "public, max-age=60") | |
| 207 | + if status != http.StatusOK { | |
| 208 | + w.WriteHeader(status) | |
| 209 | + if r.Method == "GET" { | |
| 210 | + w.Write(data) | |
| 211 | + } | |
| 212 | + return | |
| 213 | + } | |
| 214 | + h.Set("ETag", `"`+oid+`"`) | |
| 215 | + http.ServeContent(w, r, "", time.Time{}, bytes.NewReader(data)) | |
| 216 | +} | |
forge/rename.go modified
| @@ -17,8 +17,8 @@ create table if not exists renames ( | ||
| 17 | 17 | ` |
| 18 | 18 | |
| 19 | 19 | func (s *Store) RenameUser(id int64, old, name string) error { |
| 20 | - if !validName(name) { | |
| 21 | - return errors.New("Names are lowercase letters, digits, dot, dash or underscore, at most 39 characters") | |
| 20 | + if err := checkName(name); err != nil { | |
| 21 | + return err | |
| 22 | 22 | } |
| 23 | 23 | tx, err := s.db.Begin() |
| 24 | 24 | if err != nil { |
forge/store.go modified
| @@ -307,10 +307,22 @@ func validName(name string) bool { | ||
| 307 | 307 | return nameRe.MatchString(name) && !strings.HasSuffix(name, ".git") |
| 308 | 308 | } |
| 309 | 309 | |
| 310 | -func checkCredentials(name, password string) error { | |
| 310 | +var reservedUsers = map[string]bool{"www": true, "mail": true, "git": true, "api": true, "static": true, "pages": true, "ssh": true, "smtp": true, "imap": true, "pop": true, "mx": true, "ns1": true, "ns2": true, "autoconfig": true, "autodiscover": true, "status": true, "docs": true, "help": true, "support": true, "security": true, "abuse": true, "postmaster": true, "webmaster": true, "hostmaster": true, "root": true, "login": true, "cdn": true} | |
| 311 | + | |
| 312 | +func checkName(name string) error { | |
| 311 | 313 | if !validName(name) { |
| 312 | 314 | return errors.New("Names are lowercase letters, digits, dot, dash or underscore, at most 39 characters") |
| 313 | 315 | } |
| 316 | + if reservedUsers[name] { | |
| 317 | + return errors.New("That name is reserved") | |
| 318 | + } | |
| 319 | + return nil | |
| 320 | +} | |
| 321 | + | |
| 322 | +func checkCredentials(name, password string) error { | |
| 323 | + if err := checkName(name); err != nil { | |
| 324 | + return err | |
| 325 | + } | |
| 314 | 326 | if len(password) < 8 { |
| 315 | 327 | return errors.New("Password must be at least 8 characters") |
| 316 | 328 | } |
| @@ -798,8 +810,8 @@ func (s *Store) IsMember(org, user int64) bool { | ||
| 798 | 810 | } |
| 799 | 811 | |
| 800 | 812 | func (s *Store) CreateOrg(name string) (User, error) { |
| 801 | - if !validName(name) { | |
| 802 | - return User{}, errors.New("Names are lowercase letters, digits, dot, dash or underscore, at most 39 characters") | |
| 813 | + if err := checkName(name); err != nil { | |
| 814 | + return User{}, err | |
| 803 | 815 | } |
| 804 | 816 | now := time.Now() |
| 805 | 817 | res, err := s.db.Exec(`insert into users(name, hash, created, org) values (?, '!', ?, 1)`, name, now.Unix()) |
forge/web.go modified
| @@ -300,7 +300,19 @@ func (s *Server) handler() http.Handler { | ||
| 300 | 300 | mux.HandleFunc("POST /block", s.block) |
| 301 | 301 | mux.HandleFunc("/", s.route) |
| 302 | 302 | cop := http.NewCrossOriginProtection() |
| 303 | - return secure(s.throttle(cop.Handler(mux))) | |
| 303 | + site := secure(s.throttle(cop.Handler(mux))) | |
| 304 | + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 305 | + if label, sub := s.pagesOwner(r.Host); sub { | |
| 306 | + if label == "" { | |
| 307 | + w.Header().Set("X-Content-Type-Options", "nosniff") | |
| 308 | + http.Error(w, "page not found", http.StatusNotFound) | |
| 309 | + return | |
| 310 | + } | |
| 311 | + s.servePages(w, r, label) | |
| 312 | + return | |
| 313 | + } | |
| 314 | + site.ServeHTTP(w, r) | |
| 315 | + }) | |
| 304 | 316 | } |
| 305 | 317 | |
| 306 | 318 | func (s *Server) index(w http.ResponseWriter, r *http.Request) { |
web/templates/admin.tmpl modified
| @@ -25,7 +25,7 @@ | ||
| 25 | 25 | <td class="muted">{{if .Org}}organization{{else if .Admin}}admin{{end}}{{if .Banned}} banned{{end}}</td> |
| 26 | 26 | <td class="muted">{{.Repos}} repos</td> |
| 27 | 27 | <td class="when">joined {{.Created.Format "2 Jan 2006"}}</td> |
| 28 | -<td>{{if ne .Name $.User.Name}}<form method="post" action="/admin" class="inline"><input type="hidden" name="s" value="people"><input type="hidden" name="q" value="{{$d.Query}}"><input type="hidden" name="name" value="{{.Name}}">{{if not .Org}}{{if .Admin}}<button class="btn small" name="action" value="unadmin">Revoke admin</button>{{else}}<button class="btn small" name="action" value="admin">Make admin</button>{{end}}{{end}}{{if .Banned}}<button class="btn small" name="action" value="unban">Unban</button>{{else}}<button class="btn small danger" name="action" value="ban">Ban</button>{{end}}</form>{{end}}</td> | |
| 28 | +<td>{{if ne .Name $.User.Name}}<form method="post" action="/admin" class="inline"><input type="hidden" name="s" value="people"><input type="hidden" name="q" value="{{$d.Query}}"><input type="hidden" name="name" value="{{.Name}}">{{if not .Org}}{{if .Admin}}<button class="btn small" name="action" value="unadmin">Revoke admin</button>{{else}}<button class="btn small" name="action" value="admin">Make admin</button>{{end}}{{end}}{{if .Pages}}<button class="btn small" name="action" value="nopages">Disable pages</button>{{else}}<button class="btn small" name="action" value="pages">Allow pages</button>{{end}}{{if .Banned}}<button class="btn small" name="action" value="unban">Unban</button>{{else}}<button class="btn small danger" name="action" value="ban">Ban</button>{{end}}</form>{{end}}</td> | |
| 29 | 29 | </tr>{{else}}<tr><td class="muted">Nobody matches.</td></tr>{{end}}</table> |
| 30 | 30 | {{end}} |
| 31 | 31 | |