feat(pages): serve sites on verified custom domains
11 files changed, +414 −3
- INSTALL.md modified +8 −0
- README.md modified +3 −1
- deploy/domains.sh added +79 −0
- deploy/gitpocket-domains.service added +7 −0
- deploy/gitpocket-domains.timer added +9 −0
- forge/cli.go modified +9 −0
- forge/domain.go added +227 −0
- forge/feature_test.go modified +44 −0
- forge/store.go modified +1 −1
- forge/web.go modified +8 −1
- web/templates/settings.tmpl modified +19 −0
INSTALL.md modified
| @@ -64,6 +64,14 @@ nginx -t && systemctl reload nginx | ||
| 64 | 64 | |
| 65 | 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 | 66 | |
| 67 | +Custom domains need the host to issue certificates since the server itself never terminates TLS, so install the script below with its timer to read the verified domains every five minutes and write them into `/etc/nginx/conf.d/gitpocket-domains.conf`. The script runs certbot over a webroot challenge for each new domain and reloads nginx only when that file has actually changed. | |
| 68 | + | |
| 69 | +``` | |
| 70 | +install -m 755 deploy/domains.sh /usr/local/sbin/gitpocket-domains | |
| 71 | +cp deploy/gitpocket-domains.service deploy/gitpocket-domains.timer /etc/systemd/system/ | |
| 72 | +systemctl enable --now gitpocket-domains.timer | |
| 73 | +``` | |
| 74 | + | |
| 67 | 75 | 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. |
| 68 | 76 | |
| 69 | 77 | ## SSH |
README.md modified
| @@ -24,7 +24,7 @@ The web interface is then on `127.0.0.1:3200` and wants a reverse proxy with TLS | ||
| 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 | 25 | - Pull mirrors that fetch a source on a schedule and stay read only and push mirrors that copy every branch and tag to another host after each change |
| 26 | 26 | - Pull requests opened by pushing to `refs/for/<branch>` with no fork needed |
| 27 | -- Static sites served from `<user>.<host>`, built from a `pages` repository or the `pages` branch of any public repository | |
| 27 | +- Static sites served from `<user>.<host>` or a domain of your own, built from a `pages` repository or the `pages` branch of any public repository | |
| 28 | 28 | - Reports to admins and per account blocking, with hourly limits on new issues, pull requests and repositories |
| 29 | 29 | - SSH and PGP commit verification, TOTP, access tokens and Atom feeds for commits, issues and pull requests |
| 30 | 30 | - Site search over repositories and people with language facets |
| @@ -60,6 +60,8 @@ Pushing again with the same topic updates that pull request and records a new re | ||
| 60 | 60 | |
| 61 | 61 | 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. |
| 62 | 62 | |
| 63 | +A site can also answer on a domain of your own once you claim it under settings, publish the TXT record `_gitpocket-pages.<domain>` with the token shown there and point the domain at the server through a CNAME to your subdomain or matching A and AAAA records. Verification checks both records and a daily recheck drops any domain whose TXT record has disappeared, while `deploy/domains.sh` on the host issues the certificate and writes the nginx server block for every verified domain. | |
| 64 | + | |
| 63 | 65 | 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. |
| 64 | 66 | |
| 65 | 67 | ### Mirrors |
deploy/domains.sh added
| @@ -0,0 +1,79 @@ | ||
| 1 | +#!/bin/sh | |
| 2 | +set -eu | |
| 3 | +bin=${GITPOCKET_BIN:-/usr/local/bin/gitpocket} | |
| 4 | +root=${GITPOCKET_ROOT:-/srv/gitpocket} | |
| 5 | +user=${GITPOCKET_USER:-git} | |
| 6 | +port=${GITPOCKET_PORT:-3200} | |
| 7 | +conf=${GITPOCKET_NGINX:-/etc/nginx/conf.d/gitpocket-domains.conf} | |
| 8 | +webroot=/var/www/gitpocket-acme | |
| 9 | +state=/var/lib/gitpocket-domains | |
| 10 | +mkdir -p "$webroot" "$state" | |
| 11 | + | |
| 12 | +domains=$(runuser -u "$user" -- "$bin" -root "$root" domains | grep -E '^[a-z0-9.-]+$' || true) | |
| 13 | + | |
| 14 | +render() { | |
| 15 | + [ -n "$domains" ] || return 0 | |
| 16 | + printf 'server {\n listen 80;\n listen [::]:80;\n server_name' | |
| 17 | + for d in $domains; do printf ' %s' "$d"; done | |
| 18 | + printf ';\n location /.well-known/acme-challenge/ { root %s; }\n location / { return 301 https://$host$request_uri; }\n}\n' "$webroot" | |
| 19 | + for d in $domains; do | |
| 20 | + [ -f "/etc/letsencrypt/live/gitpocket-$d/fullchain.pem" ] || continue | |
| 21 | + cat <<CONF | |
| 22 | +server { | |
| 23 | + listen 443 ssl; | |
| 24 | + listen [::]:443 ssl; | |
| 25 | + http2 on; | |
| 26 | + server_name $d; | |
| 27 | + ssl_certificate /etc/letsencrypt/live/gitpocket-$d/fullchain.pem; | |
| 28 | + ssl_certificate_key /etc/letsencrypt/live/gitpocket-$d/privkey.pem; | |
| 29 | + ssl_protocols TLSv1.2 TLSv1.3; | |
| 30 | + client_max_body_size 1m; | |
| 31 | + add_header Strict-Transport-Security "max-age=31536000" always; | |
| 32 | + gzip on; | |
| 33 | + gzip_proxied any; | |
| 34 | + gzip_vary on; | |
| 35 | + gzip_types text/css text/plain text/javascript application/javascript image/svg+xml application/json application/xml; | |
| 36 | + location / { | |
| 37 | + limit_except GET HEAD { deny all; } | |
| 38 | + proxy_pass http://127.0.0.1:$port; | |
| 39 | + proxy_http_version 1.1; | |
| 40 | + proxy_set_header Host \$host; | |
| 41 | + proxy_set_header X-Forwarded-Proto https; | |
| 42 | + proxy_set_header X-Forwarded-For \$remote_addr; | |
| 43 | + proxy_set_header Cookie ""; | |
| 44 | + } | |
| 45 | +} | |
| 46 | +CONF | |
| 47 | + done | |
| 48 | +} | |
| 49 | + | |
| 50 | +apply() { | |
| 51 | + next=$(mktemp) | |
| 52 | + render > "$next" | |
| 53 | + if [ -f "$conf" ] && cmp -s "$next" "$conf"; then | |
| 54 | + rm -f "$next" | |
| 55 | + return 0 | |
| 56 | + fi | |
| 57 | + [ -f "$conf" ] && cp "$conf" "$conf.old" | |
| 58 | + mv "$next" "$conf" | |
| 59 | + chmod 644 "$conf" | |
| 60 | + if nginx -t 2>/dev/null; then | |
| 61 | + systemctl reload nginx | |
| 62 | + else | |
| 63 | + echo "nginx rejected $conf, restoring" >&2 | |
| 64 | + if [ -f "$conf.old" ]; then mv "$conf.old" "$conf"; else rm -f "$conf"; fi | |
| 65 | + exit 1 | |
| 66 | + fi | |
| 67 | +} | |
| 68 | + | |
| 69 | +apply | |
| 70 | +for d in $domains; do | |
| 71 | + [ -f "/etc/letsencrypt/live/gitpocket-$d/fullchain.pem" ] && continue | |
| 72 | + if [ -f "$state/$d" ] && [ -n "$(find "$state/$d" -mmin -60)" ]; then continue; fi | |
| 73 | + if certbot certonly -n --quiet --webroot -w "$webroot" --cert-name "gitpocket-$d" -d "$d"; then | |
| 74 | + rm -f "$state/$d" | |
| 75 | + else | |
| 76 | + touch "$state/$d" | |
| 77 | + fi | |
| 78 | +done | |
| 79 | +apply | |
deploy/gitpocket-domains.service added
| @@ -0,0 +1,7 @@ | ||
| 1 | +[Unit] | |
| 2 | +Description=Issue certificates for gitpocket custom domains | |
| 3 | +After=network-online.target nginx.service | |
| 4 | + | |
| 5 | +[Service] | |
| 6 | +Type=oneshot | |
| 7 | +ExecStart=/usr/local/sbin/gitpocket-domains | |
deploy/gitpocket-domains.timer added
| @@ -0,0 +1,9 @@ | ||
| 1 | +[Unit] | |
| 2 | +Description=Check gitpocket custom domains | |
| 3 | + | |
| 4 | +[Timer] | |
| 5 | +OnBootSec=2min | |
| 6 | +OnUnitActiveSec=5min | |
| 7 | + | |
| 8 | +[Install] | |
| 9 | +WantedBy=timers.target | |
forge/cli.go modified
| @@ -52,6 +52,14 @@ func Main() { | ||
| 52 | 52 | os.Exit(2) |
| 53 | 53 | } |
| 54 | 54 | os.Exit(hook(*root, args[1])) |
| 55 | + case "domains": | |
| 56 | + store, err := openStore(filepath.Join(*root, "gitpocket.db"), true) | |
| 57 | + if err != nil { | |
| 58 | + log.Fatal(err) | |
| 59 | + } | |
| 60 | + for _, d := range store.VerifiedDomains() { | |
| 61 | + fmt.Println(d.Name) | |
| 62 | + } | |
| 55 | 63 | case "shell": |
| 56 | 64 | if len(args) != 2 { |
| 57 | 65 | os.Exit(2) |
| @@ -236,6 +244,7 @@ func serve(root string, args []string) { | ||
| 236 | 244 | store := mustStore(root) |
| 237 | 245 | site := newServer(store, root, *host, *name, *sshUser) |
| 238 | 246 | go site.mirrorLoop() |
| 247 | + go site.domainLoop() | |
| 239 | 248 | srv := &http.Server{ |
| 240 | 249 | Addr: *listen, |
| 241 | 250 | Handler: site.handler(), |
forge/domain.go added
| @@ -0,0 +1,227 @@ | ||
| 1 | +package forge | |
| 2 | + | |
| 3 | +import ( | |
| 4 | + "context" | |
| 5 | + "errors" | |
| 6 | + "fmt" | |
| 7 | + "log" | |
| 8 | + "net" | |
| 9 | + "net/http" | |
| 10 | + "net/url" | |
| 11 | + "regexp" | |
| 12 | + "slices" | |
| 13 | + "strings" | |
| 14 | + "time" | |
| 15 | +) | |
| 16 | + | |
| 17 | +const domainSchema = ` | |
| 18 | +create table if not exists domains ( | |
| 19 | + user integer primary key references users(id) on delete cascade, | |
| 20 | + domain text not null unique, | |
| 21 | + token text not null, | |
| 22 | + verified integer not null default 0, | |
| 23 | + checked integer not null default 0, | |
| 24 | + created integer not null | |
| 25 | +); | |
| 26 | +` | |
| 27 | + | |
| 28 | +const ( | |
| 29 | + domainPrefix = "_gitpocket-pages." | |
| 30 | + domainRecheck = 24 * time.Hour | |
| 31 | +) | |
| 32 | + | |
| 33 | +var domainLabel = regexp.MustCompile(`^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$`) | |
| 34 | + | |
| 35 | +type Domain struct { | |
| 36 | + User int64 | |
| 37 | + Name string | |
| 38 | + Token string | |
| 39 | + Verified bool | |
| 40 | + Checked time.Time | |
| 41 | +} | |
| 42 | + | |
| 43 | +func (d Domain) Record() string { return domainPrefix + d.Name } | |
| 44 | + | |
| 45 | +func (s *Server) cleanDomain(raw string) (string, error) { | |
| 46 | + d := strings.TrimSuffix(strings.ToLower(strings.TrimSpace(raw)), ".") | |
| 47 | + d = strings.TrimPrefix(strings.TrimPrefix(d, "https://"), "http://") | |
| 48 | + if d == "" { | |
| 49 | + return "", errors.New("Enter a domain") | |
| 50 | + } | |
| 51 | + if len(d) > 253 || net.ParseIP(d) != nil || !strings.Contains(d, ".") { | |
| 52 | + return "", errors.New("That is not a domain name") | |
| 53 | + } | |
| 54 | + for _, label := range strings.Split(d, ".") { | |
| 55 | + if !domainLabel.MatchString(label) { | |
| 56 | + return "", errors.New("That is not a domain name") | |
| 57 | + } | |
| 58 | + } | |
| 59 | + if d == s.host || strings.HasSuffix(d, "."+s.host) { | |
| 60 | + return "", errors.New("Subdomains of " + s.host + " are already served") | |
| 61 | + } | |
| 62 | + return d, nil | |
| 63 | +} | |
| 64 | + | |
| 65 | +func scanDomain(scan func(...any) error) (Domain, error) { | |
| 66 | + var d Domain | |
| 67 | + var checked int64 | |
| 68 | + err := scan(&d.User, &d.Name, &d.Token, &d.Verified, &checked) | |
| 69 | + d.Checked = time.Unix(checked, 0) | |
| 70 | + return d, err | |
| 71 | +} | |
| 72 | + | |
| 73 | +func (s *Store) UserDomain(user int64) (Domain, bool) { | |
| 74 | + d, err := scanDomain(s.db.QueryRow(`select user, domain, token, verified, checked from domains where user = ?`, user).Scan) | |
| 75 | + return d, err == nil | |
| 76 | +} | |
| 77 | + | |
| 78 | +func (s *Store) DomainOwner(domain string) (User, bool) { | |
| 79 | + var id int64 | |
| 80 | + if err := s.db.QueryRow(`select user from domains where domain = ? and verified = 1`, domain).Scan(&id); err != nil { | |
| 81 | + return User{}, false | |
| 82 | + } | |
| 83 | + u, err := s.userByID(id) | |
| 84 | + return u, err == nil | |
| 85 | +} | |
| 86 | + | |
| 87 | +func (s *Store) VerifiedDomains() []Domain { | |
| 88 | + rows, err := s.db.Query(`select user, domain, token, verified, checked from domains where verified = 1 order by domain`) | |
| 89 | + if err != nil { | |
| 90 | + return nil | |
| 91 | + } | |
| 92 | + defer rows.Close() | |
| 93 | + var out []Domain | |
| 94 | + for rows.Next() { | |
| 95 | + if d, err := scanDomain(rows.Scan); err == nil { | |
| 96 | + out = append(out, d) | |
| 97 | + } | |
| 98 | + } | |
| 99 | + return out | |
| 100 | +} | |
| 101 | + | |
| 102 | +func (s *Store) ClaimDomain(user int64, domain string) error { | |
| 103 | + _, err := s.db.Exec(`insert into domains(user, domain, token, created) values (?, ?, ?, ?) | |
| 104 | + on conflict(user) do update set domain = excluded.domain, token = excluded.token, verified = 0, checked = 0`, | |
| 105 | + user, domain, "gitpocket-verify="+random(16), time.Now().Unix()) | |
| 106 | + if err != nil && strings.Contains(err.Error(), "UNIQUE") { | |
| 107 | + return errors.New("Another account already uses that domain") | |
| 108 | + } | |
| 109 | + return err | |
| 110 | +} | |
| 111 | + | |
| 112 | +func (s *Store) DropDomain(user int64) { | |
| 113 | + s.db.Exec(`delete from domains where user = ?`, user) | |
| 114 | +} | |
| 115 | + | |
| 116 | +func (s *Store) MarkDomain(user int64, verified bool) { | |
| 117 | + s.db.Exec(`update domains set verified = ?, checked = ? where user = ?`, verified, time.Now().Unix(), user) | |
| 118 | +} | |
| 119 | + | |
| 120 | +func (s *Server) checkDomain(d Domain) error { | |
| 121 | + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) | |
| 122 | + defer cancel() | |
| 123 | + txt, err := net.DefaultResolver.LookupTXT(ctx, d.Record()) | |
| 124 | + if err != nil || !slices.Contains(txt, d.Token) { | |
| 125 | + return fmt.Errorf("The TXT record %s does not contain %s yet", d.Record(), d.Token) | |
| 126 | + } | |
| 127 | + want, err := net.DefaultResolver.LookupIPAddr(ctx, s.host) | |
| 128 | + if err != nil || len(want) == 0 { | |
| 129 | + return errors.New("Could not resolve " + s.host) | |
| 130 | + } | |
| 131 | + got, err := net.DefaultResolver.LookupIPAddr(ctx, d.Name) | |
| 132 | + if err != nil || len(got) == 0 { | |
| 133 | + return fmt.Errorf("%s does not resolve yet", d.Name) | |
| 134 | + } | |
| 135 | + for _, ip := range got { | |
| 136 | + if !slices.ContainsFunc(want, func(w net.IPAddr) bool { return w.IP.Equal(ip.IP) }) { | |
| 137 | + return fmt.Errorf("%s resolves to %s, point it at %s instead", d.Name, ip.IP, s.host) | |
| 138 | + } | |
| 139 | + } | |
| 140 | + return nil | |
| 141 | +} | |
| 142 | + | |
| 143 | +func (s *Server) domainLoop() { | |
| 144 | + for { | |
| 145 | + for _, d := range s.store.VerifiedDomains() { | |
| 146 | + if time.Since(d.Checked) < domainRecheck { | |
| 147 | + continue | |
| 148 | + } | |
| 149 | + err := s.checkDomain(d) | |
| 150 | + s.store.MarkDomain(d.User, err == nil) | |
| 151 | + if err != nil { | |
| 152 | + log.Printf("domain %s dropped: %v", d.Name, err) | |
| 153 | + } | |
| 154 | + } | |
| 155 | + time.Sleep(time.Hour) | |
| 156 | + } | |
| 157 | +} | |
| 158 | + | |
| 159 | +func (s *Server) setDomain(w http.ResponseWriter, r *http.Request) { | |
| 160 | + u := s.require(w, r) | |
| 161 | + if u == nil { | |
| 162 | + return | |
| 163 | + } | |
| 164 | + back := "/settings#pages" | |
| 165 | + fail := func(msg string) { | |
| 166 | + http.Redirect(w, r, "/settings?error="+url.QueryEscape(msg)+"#pages", http.StatusSeeOther) | |
| 167 | + } | |
| 168 | + switch r.FormValue("action") { | |
| 169 | + case "claim": | |
| 170 | + d, err := s.cleanDomain(r.FormValue("domain")) | |
| 171 | + if err != nil { | |
| 172 | + fail(err.Error()) | |
| 173 | + return | |
| 174 | + } | |
| 175 | + if err := s.store.ClaimDomain(u.ID, d); err != nil { | |
| 176 | + fail(err.Error()) | |
| 177 | + return | |
| 178 | + } | |
| 179 | + case "verify": | |
| 180 | + d, ok := s.store.UserDomain(u.ID) | |
| 181 | + if !ok { | |
| 182 | + fail("Add a domain first") | |
| 183 | + return | |
| 184 | + } | |
| 185 | + if s.uploads.blocked("domain:" + u.Name) { | |
| 186 | + fail("Too many checks, try again later") | |
| 187 | + return | |
| 188 | + } | |
| 189 | + s.uploads.fail("domain:" + u.Name) | |
| 190 | + err := s.checkDomain(d) | |
| 191 | + s.store.MarkDomain(u.ID, err == nil) | |
| 192 | + if err != nil { | |
| 193 | + fail(err.Error()) | |
| 194 | + return | |
| 195 | + } | |
| 196 | + case "remove": | |
| 197 | + s.store.DropDomain(u.ID) | |
| 198 | + default: | |
| 199 | + fail("Unknown action") | |
| 200 | + return | |
| 201 | + } | |
| 202 | + http.Redirect(w, r, back, http.StatusSeeOther) | |
| 203 | +} | |
| 204 | + | |
| 205 | +func domainView(s *Server, u *User) map[string]any { | |
| 206 | + view := map[string]any{"Enabled": s.pagesEnabled(*u)} | |
| 207 | + if pagesLabel.MatchString(u.Name) && !reservedUsers[u.Name] { | |
| 208 | + view["Subdomain"] = u.Name + "." + s.host | |
| 209 | + } | |
| 210 | + if d, ok := s.store.UserDomain(u.ID); ok { | |
| 211 | + view["Domain"] = d | |
| 212 | + } | |
| 213 | + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) | |
| 214 | + defer cancel() | |
| 215 | + if ips, err := net.DefaultResolver.LookupIPAddr(ctx, s.host); err == nil { | |
| 216 | + var addrs [][2]string | |
| 217 | + for _, ip := range ips { | |
| 218 | + kind := "A" | |
| 219 | + if ip.IP.To4() == nil { | |
| 220 | + kind = "AAAA" | |
| 221 | + } | |
| 222 | + addrs = append(addrs, [2]string{kind, ip.IP.String()}) | |
| 223 | + } | |
| 224 | + view["Addresses"] = addrs | |
| 225 | + } | |
| 226 | + return view | |
| 227 | +} | |
forge/feature_test.go modified
| @@ -490,3 +490,47 @@ func TestModeration(t *testing.T) { | ||
| 490 | 490 | } |
| 491 | 491 | |
| 492 | 492 | func itoa(n int64) string { return strconv.FormatInt(n, 10) } |
| 493 | + | |
| 494 | +func TestDomains(t *testing.T) { | |
| 495 | + if _, err := exec.LookPath("git"); err != nil { | |
| 496 | + t.Skip("git not installed") | |
| 497 | + } | |
| 498 | + s, h := testServer(t) | |
| 499 | + for _, bad := range []string{"", "localhost", "127.0.0.1", "example.org", "x.example.org", "a..b", "-a.com", "a_b.com", strings.Repeat("a", 64) + ".com"} { | |
| 500 | + if _, err := s.cleanDomain(bad); err == nil { | |
| 501 | + t.Errorf("accepted %q", bad) | |
| 502 | + } | |
| 503 | + } | |
| 504 | + if d, err := s.cleanDomain(" HTTPS://Blog.Example.COM. "); err != nil || d != "blog.example.com" { | |
| 505 | + t.Errorf("clean: %q %v", d, err) | |
| 506 | + } | |
| 507 | + alice, _ := s.store.CreateUser("alice", "correct horse") | |
| 508 | + bob, _ := s.store.CreateUser("bob", "correct horse") | |
| 509 | + age(t, s, alice) | |
| 510 | + _, g := newRepo(t, s, alice, "pages", Public) | |
| 511 | + addEntry(t, g, g.Head(), "100644", "index.html", "alice blog") | |
| 512 | + if err := s.store.ClaimDomain(alice.ID, "blog.example.com"); err != nil { | |
| 513 | + t.Fatal(err) | |
| 514 | + } | |
| 515 | + if err := s.store.ClaimDomain(bob.ID, "blog.example.com"); err == nil { | |
| 516 | + t.Error("two accounts claimed one domain") | |
| 517 | + } | |
| 518 | + if w := pagesGet(h, "blog.example.com", "/"); strings.Contains(w.Body.String(), "alice blog") { | |
| 519 | + t.Error("unverified domain served") | |
| 520 | + } | |
| 521 | + own, _ := s.store.CreateSession(alice.ID) | |
| 522 | + if body := get(h, "/settings", own).Body.String(); !strings.Contains(body, "_gitpocket-pages.blog.example.com") { | |
| 523 | + t.Error("settings do not show the verification record") | |
| 524 | + } | |
| 525 | + s.store.MarkDomain(alice.ID, true) | |
| 526 | + if w := pagesGet(h, "Blog.Example.com.", "/"); w.Body.String() != "alice blog" || len(w.Result().Cookies()) > 0 { | |
| 527 | + t.Errorf("verified domain: %d %q", w.Code, w.Body.String()) | |
| 528 | + } | |
| 529 | + if w := post(h, "/settings/pages", url.Values{"action": {"claim"}, "domain": {"alice.example.org"}}, own); !strings.Contains(w.Header().Get("Location"), "error") { | |
| 530 | + t.Error("claimed a subdomain of the host") | |
| 531 | + } | |
| 532 | + post(h, "/settings/pages", url.Values{"action": {"remove"}}, own) | |
| 533 | + if _, ok := s.store.DomainOwner("blog.example.com"); ok { | |
| 534 | + t.Error("removed domain still served") | |
| 535 | + } | |
| 536 | +} | |
forge/store.go modified
| @@ -279,7 +279,7 @@ func openStore(path string, readonly bool) (*Store, error) { | ||
| 279 | 279 | if _, err := db.Exec(schema); err != nil { |
| 280 | 280 | return nil, err |
| 281 | 281 | } |
| 282 | - for _, ddl := range []string{pullSchema, collabSchema, extraSchema, adminSchema, inboxSchema, followSchema, renameSchema, moderationSchema, mirrorSchema} { | |
| 282 | + for _, ddl := range []string{pullSchema, collabSchema, extraSchema, adminSchema, inboxSchema, followSchema, renameSchema, moderationSchema, mirrorSchema, domainSchema} { | |
| 283 | 283 | if _, err := db.Exec(ddl); err != nil { |
| 284 | 284 | return nil, err |
| 285 | 285 | } |
forge/web.go modified
| @@ -298,10 +298,17 @@ func (s *Server) handler() http.Handler { | ||
| 298 | 298 | mux.HandleFunc("GET /report", s.report) |
| 299 | 299 | mux.HandleFunc("POST /report", s.report) |
| 300 | 300 | mux.HandleFunc("POST /block", s.block) |
| 301 | + mux.HandleFunc("POST /settings/pages", s.setDomain) | |
| 301 | 302 | mux.HandleFunc("/", s.route) |
| 302 | 303 | cop := http.NewCrossOriginProtection() |
| 303 | 304 | site := secure(s.throttle(cop.Handler(mux))) |
| 304 | 305 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| 306 | + if host := bareHost(r.Host); host != s.host && !strings.HasSuffix(host, "."+s.host) { | |
| 307 | + if owner, ok := s.store.DomainOwner(host); ok { | |
| 308 | + s.servePages(w, r, owner.Name) | |
| 309 | + return | |
| 310 | + } | |
| 311 | + } | |
| 305 | 312 | if label, sub := s.pagesOwner(r.Host); sub { |
| 306 | 313 | if label == "" { |
| 307 | 314 | w.Header().Set("X-Content-Type-Options", "nosniff") |
| @@ -633,7 +640,7 @@ func (s *Server) settings(w http.ResponseWriter, r *http.Request) { | ||
| 633 | 640 | } |
| 634 | 641 | used := usage(filepath.Join(s.root, "repos", u.Name)) |
| 635 | 642 | s.render(w, r, "settings", page{Title: "Settings", User: u, Error: r.URL.Query().Get("error"), |
| 636 | - Data: map[string]any{"Settings": "profile", "Profile": s.store.Profile(u.ID), "Used": used, "Quota": readQuota(s.root), "Account": s.store.Account(u.ID), "Mail": s.mailer != nil, "Sent": r.URL.Query().Get("sent"), "Blocked": s.store.BlockedUsers(u.ID)}}) | |
| 643 | + Data: map[string]any{"Settings": "profile", "Profile": s.store.Profile(u.ID), "Used": used, "Quota": readQuota(s.root), "Account": s.store.Account(u.ID), "Mail": s.mailer != nil, "Sent": r.URL.Query().Get("sent"), "Blocked": s.store.BlockedUsers(u.ID), "Domain": domainView(s, u)}}) | |
| 637 | 644 | } |
| 638 | 645 | |
| 639 | 646 | func (s *Server) security(w http.ResponseWriter, r *http.Request) { |
web/templates/settings.tmpl modified
| @@ -46,6 +46,25 @@ | ||
| 46 | 46 | <label>Bio <textarea name="bio" rows="6" maxlength="4000" placeholder="Markdown supported, paste or drop images to attach them" data-md>{{$p.Bio}}</textarea></label> |
| 47 | 47 | <div class="actions"><button class="btn primary">Save changes ▸</button></div> |
| 48 | 48 | </form> |
| 49 | +<h3 class="rule" id="pages">Pages</h3> | |
| 50 | +{{with .Data.Domain}}{{$d := .}} | |
| 51 | +<p class="muted small">A public repository called <code>pages</code> is published as your site, from its <code>pages</code> branch when it has one.{{with .Subdomain}} It lives at <a href="https://{{.}}/">{{.}}</a>.{{end}}{{if not .Enabled}} Sites go live once an account is a day old and while an admin has not turned them off.{{end}}</p> | |
| 52 | +{{with .Domain}} | |
| 53 | +<p>Custom domain <code>{{.Name}}</code> {{if .Verified}}<span class="muted">(verified, serving your site)</span>{{else}}<span class="muted">(waiting for DNS)</span>{{end}}</p> | |
| 54 | +{{if not .Verified}}<table class="keys"> | |
| 55 | +<tr><td><code>TXT</code></td><td><code>{{.Record}}</code></td><td><code>{{.Token}}</code></td></tr> | |
| 56 | +{{if $d.Subdomain}}<tr><td><code>CNAME</code></td><td><code>{{.Name}}</code></td><td><code>{{$d.Subdomain}}</code> <span class="muted small">for a subdomain</span></td></tr>{{end}} | |
| 57 | +{{$name := .Name}}{{range $d.Addresses}}<tr><td><code>{{index . 0}}</code></td><td><code>{{$name}}</code></td><td><code>{{index . 1}}</code></td></tr>{{end}} | |
| 58 | +</table> | |
| 59 | +<p class="muted small">Add the TXT record, then point the domain at this server with either the CNAME or the address records, unproxied. HTTPS is issued automatically a few minutes after verification.</p>{{end}} | |
| 60 | +<form method="post" action="/settings/pages" class="inline">{{if not .Verified}}<button class="btn primary" name="action" value="verify">Verify ▸</button>{{end}}<button class="btn danger" name="action" value="remove">Remove domain</button></form> | |
| 61 | +{{else}} | |
| 62 | +<form method="post" action="/settings/pages" class="stack wide"> | |
| 63 | +<input type="hidden" name="action" value="claim"> | |
| 64 | +<label>Custom domain <input name="domain" placeholder="blog.example.org" autocapitalize="none" spellcheck="false"></label> | |
| 65 | +<div class="actions left"><button class="btn">Use this domain ▸</button></div> | |
| 66 | +</form> | |
| 67 | +{{end}}{{end}} | |
| 49 | 68 | <h3 class="rule" id="blocked">Blocked accounts</h3> |
| 50 | 69 | <p class="muted small">Blocked accounts cannot open issues or pull requests, comment, star or fork your repositories, or follow you.</p> |
| 51 | 70 | {{if .Data.Blocked}}<table class="keys">{{range .Data.Blocked}}<tr><td><a href="/~{{.Name}}">~{{.Name}}</a></td><td><form method="post" action="/block" class="inline"><input type="hidden" name="name" value="{{.Name}}"><input type="hidden" name="back" value="settings"><button class="btn small" name="action" value="unblock">Unblock</button></form></td></tr>{{end}}</table>{{else}}<p class="muted">Nobody.</p>{{end}} |