1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
|
package forge
import (
"context"
"errors"
"fmt"
"log"
"net"
"net/http"
"net/url"
"regexp"
"slices"
"strings"
"time"
)
const domainSchema = `
create table if not exists domains (
user integer primary key references users(id) on delete cascade,
domain text not null unique,
token text not null,
verified integer not null default 0,
checked integer not null default 0,
created integer not null
);
`
const (
domainPrefix = "_gitpocket-pages."
domainRecheck = 24 * time.Hour
)
var domainLabel = regexp.MustCompile(`^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$`)
type Domain struct {
User int64
Name string
Token string
Verified bool
Checked time.Time
}
func (d Domain) Record() string { return domainPrefix + d.Name }
func (s *Server) cleanDomain(raw string) (string, error) {
d := strings.TrimSuffix(strings.ToLower(strings.TrimSpace(raw)), ".")
d = strings.TrimPrefix(strings.TrimPrefix(d, "https://"), "http://")
if d == "" {
return "", errors.New("Enter a domain")
}
if len(d) > 253 || net.ParseIP(d) != nil || !strings.Contains(d, ".") {
return "", errors.New("That is not a domain name")
}
for _, label := range strings.Split(d, ".") {
if !domainLabel.MatchString(label) {
return "", errors.New("That is not a domain name")
}
}
if d == s.host || strings.HasSuffix(d, "."+s.host) {
return "", errors.New("Subdomains of " + s.host + " are already served")
}
return d, nil
}
func scanDomain(scan func(...any) error) (Domain, error) {
var d Domain
var checked int64
err := scan(&d.User, &d.Name, &d.Token, &d.Verified, &checked)
d.Checked = time.Unix(checked, 0)
return d, err
}
func (s *Store) UserDomain(user int64) (Domain, bool) {
d, err := scanDomain(s.db.QueryRow(`select user, domain, token, verified, checked from domains where user = ?`, user).Scan)
return d, err == nil
}
func (s *Store) DomainOwner(domain string) (User, bool) {
var id int64
if err := s.db.QueryRow(`select user from domains where domain = ? and verified = 1`, domain).Scan(&id); err != nil {
return User{}, false
}
u, err := s.userByID(id)
return u, err == nil
}
func (s *Store) VerifiedDomains() []Domain {
rows, err := s.db.Query(`select user, domain, token, verified, checked from domains where verified = 1 order by domain`)
if err != nil {
return nil
}
defer rows.Close()
var out []Domain
for rows.Next() {
if d, err := scanDomain(rows.Scan); err == nil {
out = append(out, d)
}
}
return out
}
func (s *Store) ClaimDomain(user int64, domain string) error {
_, err := s.db.Exec(`insert into domains(user, domain, token, created) values (?, ?, ?, ?)
on conflict(user) do update set domain = excluded.domain, token = excluded.token, verified = 0, checked = 0`,
user, domain, "gitpocket-verify="+random(16), time.Now().Unix())
if err != nil && strings.Contains(err.Error(), "UNIQUE") {
return errors.New("Another account already uses that domain")
}
return err
}
func (s *Store) DropDomain(user int64) {
s.db.Exec(`delete from domains where user = ?`, user)
}
func (s *Store) MarkDomain(user int64, verified bool) {
s.db.Exec(`update domains set verified = ?, checked = ? where user = ?`, verified, time.Now().Unix(), user)
}
func (s *Server) checkDomain(d Domain) error {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
txt, err := net.DefaultResolver.LookupTXT(ctx, d.Record())
if err != nil || !slices.Contains(txt, d.Token) {
return fmt.Errorf("The TXT record %s does not contain %s yet", d.Record(), d.Token)
}
if got, err := net.DefaultResolver.LookupHost(ctx, d.Name); err != nil || len(got) == 0 {
return fmt.Errorf("%s does not resolve yet", d.Name)
}
return nil
}
func (s *Server) domainLoop() {
for {
for _, d := range s.store.VerifiedDomains() {
if time.Since(d.Checked) < domainRecheck {
continue
}
err := s.checkDomain(d)
s.store.MarkDomain(d.User, err == nil)
if err != nil {
log.Printf("domain %s dropped: %v", d.Name, err)
}
}
time.Sleep(time.Hour)
}
}
func (s *Server) setDomain(w http.ResponseWriter, r *http.Request) {
u := s.require(w, r)
if u == nil {
return
}
back := "/settings#pages"
fail := func(msg string) {
http.Redirect(w, r, "/settings?error="+url.QueryEscape(msg)+"#pages", http.StatusSeeOther)
}
switch r.FormValue("action") {
case "claim":
d, err := s.cleanDomain(r.FormValue("domain"))
if err != nil {
fail(err.Error())
return
}
if err := s.store.ClaimDomain(u.ID, d); err != nil {
fail(err.Error())
return
}
case "verify":
d, ok := s.store.UserDomain(u.ID)
if !ok {
fail("Add a domain first")
return
}
if s.uploads.blocked("domain:" + u.Name) {
fail("Too many checks, try again later")
return
}
s.uploads.fail("domain:" + u.Name)
err := s.checkDomain(d)
s.store.MarkDomain(u.ID, err == nil)
if err != nil {
fail(err.Error())
return
}
case "remove":
s.store.DropDomain(u.ID)
default:
fail("Unknown action")
return
}
http.Redirect(w, r, back, http.StatusSeeOther)
}
func domainView(s *Server, u *User) map[string]any {
view := map[string]any{"Enabled": s.pagesEnabled(*u), "Target": s.host}
if pagesLabel.MatchString(u.Name) && !reservedUsers[u.Name] {
view["Subdomain"] = u.Name + "." + s.host
view["Target"] = view["Subdomain"]
}
if d, ok := s.store.UserDomain(u.ID); ok {
view["Domain"] = d
}
return view
}
|