~gitpocket/gitpocket

7

feat(pages): serve static sites from user subdomains

commit
f09bf278b027a2c3136d5ebdfeb8149937ca7790 browse tree
parent
ca96986a2b8a683049db1edfcd185b75870b05ee
author
~james <354576047+[email protected]>
signature
Signed by: ~james SHA256:KlzJ0P0gvJcnOYpaxD5yCUw52ImHttTKRjVAEnnYnKg
date
2026-11-14 01:19 UTC

11 files changed, +458 −12

INSTALL.md modified

@@ -62,6 +62,8 @@ certbot certonly --nginx -d git.example.org
6262 nginx -t && systemctl reload nginx
6363 ```
6464
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+
6567 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.
6668
6769 ## 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
2222 - Organisations, collaborators, issues, labels, stars, forks, branch protection and per owner disk quotas enforced during the push
2323 - Profiles with a pinned repository list, an activity graph, followers and a README from your `~you/you` repository
2424 - 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
2526 - Reports to admins and per account blocking, with hourly limits on new issues, pull requests and repositories
2627 - SSH and PGP commit verification, TOTP, access tokens and Atom feeds for commits, issues and pull requests
2728 - Site search over repositories and people with language facets
@@ -46,6 +47,12 @@ git clone [email protected]:~you/repo
4647
4748 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.
4849
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+
4956 ## Development
5057
5158 ```

deploy/nginx.conf modified

@@ -3,7 +3,7 @@ limit_req_zone $binary_remote_addr zone=gitpocket:10m rate=10r/s;
33 server {
44 listen 80;
55 listen [::]:80;
6- server_name git.example.org;
6+ server_name git.example.org *.git.example.org;
77 location / { return 301 https://$host$request_uri; }
88 }
99
@@ -39,3 +39,30 @@ server {
3939 proxy_set_header Host $host;
4040 }
4141 }
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 {
143143 Admin bool
144144 Banned bool
145145 Org bool
146+ Pages bool
146147 Repos int
147148 }
148149
149150 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`)
151152 if err != nil {
152153 return nil
153154 }
@@ -156,7 +157,7 @@ func (s *Store) Accounts() []account {
156157 for rows.Next() {
157158 var a account
158159 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 {
160161 a.Created = time.Unix(created, 0)
161162 out = append(out, a)
162163 }

forge/feature_test.go modified

@@ -2,12 +2,181 @@ package forge
22
33 import (
44 "net/http"
5+ "net/http/httptest"
56 "net/url"
7+ "os/exec"
8+ "path/filepath"
69 "strconv"
710 "strings"
811 "testing"
12+ "time"
913 )
1014
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+
11180 func TestModeration(t *testing.T) {
12181 s, h := testServer(t)
13182 alice, _ := s.store.CreateUser("alice", "correct horse")

forge/flow_test.go modified

@@ -248,8 +248,8 @@ func TestSignupEmail(t *testing.T) {
248248
249249 func TestAdminSections(t *testing.T) {
250250 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)
253253 id, _ := s.store.CreateSession(admin.ID)
254254 for _, sec := range []string{"overview", "people", "signup", "projects", "storage", "reports", "bogus"} {
255255 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 (
1717 `
1818
1919 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
2222 }
2323 tx, err := s.db.Begin()
2424 if err != nil {

forge/store.go modified

@@ -307,10 +307,22 @@ func validName(name string) bool {
307307 return nameRe.MatchString(name) && !strings.HasSuffix(name, ".git")
308308 }
309309
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 {
311313 if !validName(name) {
312314 return errors.New("Names are lowercase letters, digits, dot, dash or underscore, at most 39 characters")
313315 }
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+ }
314326 if len(password) < 8 {
315327 return errors.New("Password must be at least 8 characters")
316328 }
@@ -798,8 +810,8 @@ func (s *Store) IsMember(org, user int64) bool {
798810 }
799811
800812 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
803815 }
804816 now := time.Now()
805817 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 {
300300 mux.HandleFunc("POST /block", s.block)
301301 mux.HandleFunc("/", s.route)
302302 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+ })
304316 }
305317
306318 func (s *Server) index(w http.ResponseWriter, r *http.Request) {

web/templates/admin.tmpl modified

@@ -25,7 +25,7 @@
2525 <td class="muted">{{if .Org}}organization{{else if .Admin}}admin{{end}}{{if .Banned}} banned{{end}}</td>
2626 <td class="muted">{{.Repos}} repos</td>
2727 <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>
2929 </tr>{{else}}<tr><td class="muted">Nobody matches.</td></tr>{{end}}</table>
3030 {{end}}
3131