~gitpocket/gitpocket

7

feat(pages): serve sites on verified custom domains

commit
bae926725bd43e86c83216adf292c2eed3c8c6c6 browse tree
parent
afe731a9da0ddd2e2b088666b904c7cd965f48ff
author
~james <354576047+[email protected]>
signature
Signed by: ~james SHA256:KlzJ0P0gvJcnOYpaxD5yCUw52ImHttTKRjVAEnnYnKg
date
2026-11-14 09:56 UTC

11 files changed, +414 −3

INSTALL.md modified

@@ -64,6 +64,14 @@ nginx -t && systemctl reload nginx
6464
6565 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.
6666
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+
6775 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.
6876
6977 ## 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
2424 - Imports from GitHub, GitLab, Gitea or Forgejo over https, with the option to credit commits made under your old identity to your account
2525 - 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
2626 - 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
2828 - Reports to admins and per account blocking, with hourly limits on new issues, pull requests and repositories
2929 - SSH and PGP commit verification, TOTP, access tokens and Atom feeds for commits, issues and pull requests
3030 - 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
6060
6161 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.
6262
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+
6365 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.
6466
6567 ### 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() {
5252 os.Exit(2)
5353 }
5454 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+ }
5563 case "shell":
5664 if len(args) != 2 {
5765 os.Exit(2)
@@ -236,6 +244,7 @@ func serve(root string, args []string) {
236244 store := mustStore(root)
237245 site := newServer(store, root, *host, *name, *sshUser)
238246 go site.mirrorLoop()
247+ go site.domainLoop()
239248 srv := &http.Server{
240249 Addr: *listen,
241250 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) {
490490 }
491491
492492 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) {
279279 if _, err := db.Exec(schema); err != nil {
280280 return nil, err
281281 }
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} {
283283 if _, err := db.Exec(ddl); err != nil {
284284 return nil, err
285285 }

forge/web.go modified

@@ -298,10 +298,17 @@ func (s *Server) handler() http.Handler {
298298 mux.HandleFunc("GET /report", s.report)
299299 mux.HandleFunc("POST /report", s.report)
300300 mux.HandleFunc("POST /block", s.block)
301+ mux.HandleFunc("POST /settings/pages", s.setDomain)
301302 mux.HandleFunc("/", s.route)
302303 cop := http.NewCrossOriginProtection()
303304 site := secure(s.throttle(cop.Handler(mux)))
304305 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+ }
305312 if label, sub := s.pagesOwner(r.Host); sub {
306313 if label == "" {
307314 w.Header().Set("X-Content-Type-Options", "nosniff")
@@ -633,7 +640,7 @@ func (s *Server) settings(w http.ResponseWriter, r *http.Request) {
633640 }
634641 used := usage(filepath.Join(s.root, "repos", u.Name))
635642 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)}})
637644 }
638645
639646 func (s *Server) security(w http.ResponseWriter, r *http.Request) {

web/templates/settings.tmpl modified

@@ -46,6 +46,25 @@
4646 <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>
4747 <div class="actions"><button class="btn primary">Save changes ▸</button></div>
4848 </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}}
4968 <h3 class="rule" id="blocked">Blocked accounts</h3>
5069 <p class="muted small">Blocked accounts cannot open issues or pull requests, comment, star or fork your repositories, or follow you.</p>
5170 {{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}}