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 }