Commit 96f9270d24
Verified · cmc ci/build: success
cmd/gitbay/main.go +2 −1
| @@ -259,7 +259,8 @@ func repoCmd() *cobra.Command { | ||
| 259 | 259 | pass("sync", "schedule an immediate sync", passOpts{server: []string{"repo", "mirror", "sync"}, needsRepo: true}), |
| 260 | 260 | ), |
| 261 | 261 | group("domain", "custom domains for the pages branch", |
| 262 | pass("add", "serve pages on a domain: <domain>", passOpts{server: []string{"repo", "domain", "add"}, needsRepo: true}), | |
| 262 | pass("add", "claim a domain (verify with a DNS TXT record): <domain>", passOpts{server: []string{"repo", "domain", "add"}, needsRepo: true}), | |
| 263 | pass("verify", "check the DNS challenge and activate a claim: <domain>", passOpts{server: []string{"repo", "domain", "verify"}, needsRepo: true}), | |
| 263 | 264 | pass("list", "list custom pages domains", passOpts{server: []string{"repo", "domain", "list"}, needsRepo: true}), |
| 264 | 265 | pass("remove", "remove a custom pages domain: <domain>", passOpts{server: []string{"repo", "domain", "remove"}, needsRepo: true}), |
| 265 | 266 | ), |
e2e/pages_test.go +100 −2
| @@ -1,13 +1,17 @@ | ||
| 1 | 1 | package e2e |
| 2 | 2 | |
| 3 | 3 | import ( |
| 4 | "encoding/json" | |
| 4 | 5 | "fmt" |
| 5 | 6 | "io" |
| 7 | "net" | |
| 6 | 8 | "net/http" |
| 7 | 9 | "os" |
| 8 | 10 | "path/filepath" |
| 9 | 11 | "strings" |
| 12 | "sync/atomic" | |
| 10 | 13 | "testing" |
| 14 | "time" | |
| 11 | 15 | ) |
| 12 | 16 | |
| 13 | 17 | // pagesGet fetches a path with a pages Host header against the instance. |
| @@ -29,7 +33,57 @@ func (i *instance) pagesGet(t *testing.T, host, path string) (*http.Response, st | ||
| 29 | 33 | return resp, string(body) |
| 30 | 34 | } |
| 31 | 35 | |
| 36 | // fakeDNS answers every TXT query with the string in txt (none when empty), | |
| 37 | // standing in for the challenge record during domain verification. | |
| 38 | func fakeDNS(t *testing.T, txt *atomic.Value) string { | |
| 39 | t.Helper() | |
| 40 | pc, err := net.ListenPacket("udp", "127.0.0.1:0") | |
| 41 | if err != nil { | |
| 42 | t.Fatal(err) | |
| 43 | } | |
| 44 | t.Cleanup(func() { pc.Close() }) | |
| 45 | go func() { | |
| 46 | buf := make([]byte, 512) | |
| 47 | for { | |
| 48 | n, addr, err := pc.ReadFrom(buf) | |
| 49 | if err != nil { | |
| 50 | return | |
| 51 | } | |
| 52 | q := buf[:n] | |
| 53 | if len(q) < 12 { | |
| 54 | continue | |
| 55 | } | |
| 56 | i := 12 | |
| 57 | for i < len(q) && q[i] != 0 { | |
| 58 | i += int(q[i]) + 1 | |
| 59 | } | |
| 60 | i += 5 // name terminator + qtype + qclass | |
| 61 | if i > len(q) { | |
| 62 | continue | |
| 63 | } | |
| 64 | val, _ := txt.Load().(string) | |
| 65 | resp := []byte{q[0], q[1], 0x81, 0x80, 0, 1, 0, 0, 0, 0, 0, 0} | |
| 66 | if val != "" { | |
| 67 | resp[7] = 1 | |
| 68 | } | |
| 69 | resp = append(resp, q[12:i]...) | |
| 70 | if val != "" { | |
| 71 | resp = append(resp, 0xC0, 0x0C, 0, 16, 0, 1, 0, 0, 0, 60) | |
| 72 | rdata := append([]byte{byte(len(val))}, val...) | |
| 73 | resp = append(resp, byte(len(rdata)>>8), byte(len(rdata))) | |
| 74 | resp = append(resp, rdata...) | |
| 75 | } | |
| 76 | pc.WriteTo(resp, addr) | |
| 77 | } | |
| 78 | }() | |
| 79 | return pc.LocalAddr().String() | |
| 80 | } | |
| 81 | ||
| 32 | 82 | func TestPages(t *testing.T) { |
| 83 | var challenge atomic.Value | |
| 84 | challenge.Store("") | |
| 85 | t.Setenv("GITBAY_DNS_SERVER", fakeDNS(t, &challenge)) | |
| 86 | t.Setenv("GITBAY_DOMAIN_PENDING_TTL", "5s") | |
| 33 | 87 | inst := startInstanceWith(t, "[pages]\ndomain = \"p.test\"\n") |
| 34 | 88 | aliceKey := inst.newKey(t, "alice") |
| 35 | 89 | inst.admin(t, "admin", "user", "create", "alice", "--key", aliceKey+".pub") |
| @@ -129,9 +183,37 @@ func TestPages(t *testing.T) { | ||
| 129 | 183 | if _, _, code := inst.ssh(t, bobKey, "", "repo", "domain", "add", "alice/site", "docs.example.org"); code != 4 { |
| 130 | 184 | t.Fatal("non-admin claimed a domain") |
| 131 | 185 | } |
| 132 | if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "domain", "add", "alice/site", "docs.example.org"); code != 0 { | |
| 186 | out, errOut, code := inst.ssh(t, aliceKey, "", "repo", "domain", "add", "alice/site", "docs.example.org", "--json") | |
| 187 | if code != 0 { | |
| 133 | 188 | t.Fatalf("domain add: %s", errOut) |
| 134 | 189 | } |
| 190 | var addEnv struct { | |
| 191 | Data struct { | |
| 192 | ChallengeValue string `json:"challenge_value"` | |
| 193 | } `json:"data"` | |
| 194 | } | |
| 195 | if err := json.Unmarshal([]byte(out), &addEnv); err != nil || addEnv.Data.ChallengeValue == "" { | |
| 196 | t.Fatalf("no challenge in add output: %s", out) | |
| 197 | } | |
| 198 | // Pending claims hold the name but serve nothing. | |
| 199 | if _, body = inst.pagesGet(t, "docs.example.org", "/"); strings.Contains(body, "project site") { | |
| 200 | t.Fatal("pending claim already serves") | |
| 201 | } | |
| 202 | if _, errOut, code = inst.ssh(t, bobKey, "", "repo", "create", "bob/held"); code != 0 { | |
| 203 | t.Fatalf("bob repo: %s", errOut) | |
| 204 | } | |
| 205 | if _, _, code = inst.ssh(t, bobKey, "", "repo", "domain", "add", "bob/held", "docs.example.org"); code != 2 { | |
| 206 | t.Fatal("pending claim did not hold the name") | |
| 207 | } | |
| 208 | // Verification: wrong record refused, right record activates. | |
| 209 | challenge.Store("gitbay-domain-verify=nope") | |
| 210 | if _, _, code = inst.ssh(t, aliceKey, "", "repo", "domain", "verify", "alice/site", "docs.example.org"); code != 4 { | |
| 211 | t.Fatal("wrong TXT accepted") | |
| 212 | } | |
| 213 | challenge.Store(addEnv.Data.ChallengeValue) | |
| 214 | if _, errOut, code = inst.ssh(t, aliceKey, "", "repo", "domain", "verify", "alice/site", "docs.example.org"); code != 0 { | |
| 215 | t.Fatalf("verify: %s", errOut) | |
| 216 | } | |
| 135 | 217 | // The whole path maps into the repo's pages branch, no /<repo>/ prefix. |
| 136 | 218 | resp, body = inst.pagesGet(t, "docs.example.org", "/") |
| 137 | 219 | if resp.StatusCode != 200 || !strings.Contains(body, "project site") { |
| @@ -161,7 +243,7 @@ func TestPages(t *testing.T) { | ||
| 161 | 243 | t.Fatal("private repo got a domain") |
| 162 | 244 | } |
| 163 | 245 | // repo show lists it; removal stops serving. |
| 164 | out, _, _ := inst.ssh(t, aliceKey, "", "repo", "show", "alice/site") | |
| 246 | out, _, _ = inst.ssh(t, aliceKey, "", "repo", "show", "alice/site") | |
| 165 | 247 | if !strings.Contains(out, "pages domains: docs.example.org") { |
| 166 | 248 | t.Fatalf("repo show missing domains:\n%s", out) |
| 167 | 249 | } |
| @@ -173,4 +255,20 @@ func TestPages(t *testing.T) { | ||
| 173 | 255 | if _, body = inst.pagesGet(t, "docs.example.org", "/"); strings.Contains(body, "project site") { |
| 174 | 256 | t.Fatal("removed domain still serves") |
| 175 | 257 | } |
| 258 | ||
| 259 | // Expired pending claims free the name; live ones hold it. | |
| 260 | if _, errOut, code = inst.ssh(t, aliceKey, "", "repo", "domain", "add", "alice/site", "exp.example.org"); code != 0 { | |
| 261 | t.Fatalf("expiry claim: %s", errOut) | |
| 262 | } | |
| 263 | if _, _, code = inst.ssh(t, bobKey, "", "repo", "domain", "add", "bob/held", "exp.example.org"); code != 2 { | |
| 264 | t.Fatal("live pending claim did not hold the name") | |
| 265 | } | |
| 266 | time.Sleep(5500 * time.Millisecond) // one TTL (GITBAY_DOMAIN_PENDING_TTL=5s) plus slack | |
| 267 | if _, errOut, code = inst.ssh(t, bobKey, "", "repo", "domain", "add", "bob/held", "exp.example.org"); code != 0 { | |
| 268 | t.Fatalf("expired claim still held the name: %s", errOut) | |
| 269 | } | |
| 270 | // The original claimant's expired claim is gone, not resurrectable. | |
| 271 | if _, _, code = inst.ssh(t, aliceKey, "", "repo", "domain", "verify", "alice/site", "exp.example.org"); code != 3 { | |
| 272 | t.Fatal("expired claim still verifiable") | |
| 273 | } | |
| 176 | 274 | } |
internal/control/pagescmd.go +118 −7
| @@ -1,11 +1,17 @@ | ||
| 1 | 1 | package control |
| 2 | 2 | |
| 3 | 3 | import ( |
| 4 | "context" | |
| 5 | "crypto/rand" | |
| 6 | "encoding/hex" | |
| 4 | 7 | "errors" |
| 5 | 8 | "fmt" |
| 6 | 9 | "io" |
| 10 | "net" | |
| 11 | "os" | |
| 7 | 12 | "regexp" |
| 8 | 13 | "strings" |
| 14 | "time" | |
| 9 | 15 | |
| 10 | 16 | "gitbay.org/gitbay/internal/policy" |
| 11 | 17 | "gitbay.org/gitbay/internal/protocol" |
| @@ -14,17 +20,33 @@ import ( | ||
| 14 | 20 | |
| 15 | 21 | func init() { |
| 16 | 22 | register(Command{Path: []string{"repo", "domain", "add"}, |
| 17 | Summary: "serve pages on a custom domain: repo domain add <owner/name> <domain>", Run: runDomainAdd}) | |
| 23 | Summary: "claim a custom pages domain (verify with a DNS TXT record): repo domain add <owner/name> <domain>", Run: runDomainAdd}) | |
| 24 | register(Command{Path: []string{"repo", "domain", "verify"}, | |
| 25 | Summary: "check the DNS challenge and activate a claim: repo domain verify <owner/name> <domain>", Run: runDomainVerify}) | |
| 18 | 26 | register(Command{Path: []string{"repo", "domain", "remove"}, |
| 19 | 27 | Summary: "remove a custom pages domain: repo domain remove <owner/name> <domain>", Run: runDomainRemove}) |
| 20 | 28 | register(Command{Path: []string{"repo", "domain", "list"}, |
| 21 | 29 | Summary: "list custom pages domains: repo domain list <owner/name>", ReadOnly: true, Run: runDomainList}) |
| 22 | 30 | } |
| 23 | 31 | |
| 32 | // challengeLabel prefixes the domain for the ownership TXT record. | |
| 33 | const challengeLabel = "_gitbay-challenge." | |
| 34 | ||
| 24 | 35 | // hostnamePat is a conservative DNS hostname: dot-separated labels, |
| 25 | 36 | // lowercase, at least two labels. |
| 26 | 37 | var hostnamePat = regexp.MustCompile(`^([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,}$`) |
| 27 | 38 | |
| 39 | // pendingTTL is how long an unverified claim holds a domain. The env | |
| 40 | // override exists for tests. | |
| 41 | func pendingTTL() int { | |
| 42 | if v := os.Getenv("GITBAY_DOMAIN_PENDING_TTL"); v != "" { | |
| 43 | if d, err := time.ParseDuration(v); err == nil { | |
| 44 | return int(d.Seconds()) | |
| 45 | } | |
| 46 | } | |
| 47 | return 7 * 24 * 3600 | |
| 48 | } | |
| 49 | ||
| 28 | 50 | func validatePageDomain(c *Ctx, domain string) error { |
| 29 | 51 | if !hostnamePat.MatchString(domain) { |
| 30 | 52 | return fmt.Errorf("invalid domain %q: lowercase hostname like docs.example.org", domain) |
| @@ -38,6 +60,10 @@ func validatePageDomain(c *Ctx, domain string) error { | ||
| 38 | 60 | return nil |
| 39 | 61 | } |
| 40 | 62 | |
| 63 | func challengeRecord(domain, token string) (name, value string) { | |
| 64 | return challengeLabel + domain, "gitbay-domain-verify=" + token | |
| 65 | } | |
| 66 | ||
| 41 | 67 | func runDomainAdd(c *Ctx, args []string) int { |
| 42 | 68 | if len(args) != 2 { |
| 43 | 69 | return c.fail(protocol.ExitUsage, "usage: repo domain add <owner/name> <domain>") |
| @@ -53,15 +79,85 @@ func runDomainAdd(c *Ctx, args []string) int { | ||
| 53 | 79 | if repo.Visibility != "public" { |
| 54 | 80 | return c.fail(protocol.ExitUsage, "pages serve public repositories only; %s is private", repo.Path()) |
| 55 | 81 | } |
| 56 | if err := c.Store.AddPageDomain(domain, repo.ID); err != nil { | |
| 82 | buf := make([]byte, 16) | |
| 83 | rand.Read(buf) | |
| 84 | token := hex.EncodeToString(buf) | |
| 85 | if err := c.Store.AddPageDomain(domain, repo.ID, c.User.ID, token, pendingTTL()); err != nil { | |
| 57 | 86 | if errors.Is(err, store.ErrExists) { |
| 58 | 87 | // Not naming the holder: domain claims must not enumerate repos. |
| 59 | 88 | return c.fail(protocol.ExitUsage, "%s is already claimed on this instance", domain) |
| 60 | 89 | } |
| 61 | 90 | return c.fail(protocol.ExitFailure, "%v", err) |
| 62 | 91 | } |
| 63 | return c.emit(map[string]string{"domain": domain}, func(w io.Writer) { | |
| 64 | fmt.Fprintf(w, "%s now serves %s's pages branch — point its DNS (A/AAAA) at this server\n", domain, repo.Path()) | |
| 92 | name, value := challengeRecord(domain, token) | |
| 93 | days := pendingTTL() / 86400 | |
| 94 | return c.emit(map[string]any{ | |
| 95 | "domain": domain, "state": "pending", | |
| 96 | "challenge_name": name, "challenge_value": value, | |
| 97 | }, func(w io.Writer) { | |
| 98 | fmt.Fprintf(w, "%s claimed, pending ownership proof. Create this DNS record:\n\n %s\tTXT\t%q\n\nthen run: repo domain verify %s %s\nUnverified claims expire after %d days.\n", | |
| 99 | domain, name, value, repo.Path(), domain, days) | |
| 100 | }) | |
| 101 | } | |
| 102 | ||
| 103 | // lookupTXT resolves the challenge record. GITBAY_DNS_SERVER (host:port) | |
| 104 | // overrides the system resolver so tests can answer the challenge. | |
| 105 | func lookupTXT(name string) ([]string, error) { | |
| 106 | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) | |
| 107 | defer cancel() | |
| 108 | r := net.DefaultResolver | |
| 109 | if srv := os.Getenv("GITBAY_DNS_SERVER"); srv != "" { | |
| 110 | r = &net.Resolver{PreferGo: true, Dial: func(ctx context.Context, network, address string) (net.Conn, error) { | |
| 111 | var d net.Dialer | |
| 112 | return d.DialContext(ctx, "udp", srv) | |
| 113 | }} | |
| 114 | } | |
| 115 | return r.LookupTXT(ctx, name) | |
| 116 | } | |
| 117 | ||
| 118 | func runDomainVerify(c *Ctx, args []string) int { | |
| 119 | if len(args) != 2 { | |
| 120 | return c.fail(protocol.ExitUsage, "usage: repo domain verify <owner/name> <domain>") | |
| 121 | } | |
| 122 | repo, code := resolveRepo(c, args[0], policy.CanAdmin) | |
| 123 | if code >= 0 { | |
| 124 | return code | |
| 125 | } | |
| 126 | domain := strings.ToLower(args[1]) | |
| 127 | claim, err := c.Store.PageDomainClaim(domain, repo.ID) | |
| 128 | if err != nil { | |
| 129 | return c.fail(protocol.ExitNotFound, "%s has no claim on %s", repo.Path(), domain) | |
| 130 | } | |
| 131 | if claim.Verified() { | |
| 132 | return c.emit(map[string]string{"domain": domain, "state": "verified"}, func(w io.Writer) { | |
| 133 | fmt.Fprintf(w, "%s is already verified\n", domain) | |
| 134 | }) | |
| 135 | } | |
| 136 | if c.Store.PageDomainExpired(claim, pendingTTL()) { | |
| 137 | c.Store.RemovePageDomain(domain, repo.ID) | |
| 138 | return c.fail(protocol.ExitUsage, "the claim on %s expired; run repo domain add again", domain) | |
| 139 | } | |
| 140 | name, want := challengeRecord(domain, claim.Token) | |
| 141 | records, err := lookupTXT(name) | |
| 142 | if err != nil { | |
| 143 | return c.fail(protocol.ExitFailure, "looking up %s: %v", name, err) | |
| 144 | } | |
| 145 | found := false | |
| 146 | for _, r := range records { | |
| 147 | if strings.TrimSpace(r) == want { | |
| 148 | found = true | |
| 149 | break | |
| 150 | } | |
| 151 | } | |
| 152 | if !found { | |
| 153 | return c.fail(protocol.ExitDenied, "%s does not carry the expected record %q", name, want) | |
| 154 | } | |
| 155 | if err := c.Store.VerifyPageDomain(domain, repo.ID); err != nil { | |
| 156 | return c.fail(protocol.ExitFailure, "%v", err) | |
| 157 | } | |
| 158 | c.Store.Audit(c.User.ID, "pages.domain_verified", map[string]any{"repo": repo.ID, "domain": domain}) | |
| 159 | return c.emit(map[string]string{"domain": domain, "state": "verified"}, func(w io.Writer) { | |
| 160 | fmt.Fprintf(w, "%s verified — it now serves %s's pages branch; point its A/AAAA records at this server\n", domain, repo.Path()) | |
| 65 | 161 | }) |
| 66 | 162 | } |
| 67 | 163 | |
| @@ -97,9 +193,24 @@ func runDomainList(c *Ctx, args []string) int { | ||
| 97 | 193 | if err != nil { |
| 98 | 194 | return c.fail(protocol.ExitFailure, "%v", err) |
| 99 | 195 | } |
| 100 | return c.emit(ds, func(w io.Writer) { | |
| 101 | for _, d := range ds { | |
| 102 | fmt.Fprintln(w, d) | |
| 196 | type out struct { | |
| 197 | Domain string `json:"domain"` | |
| 198 | State string `json:"state"` | |
| 199 | Verified string `json:"verified_at,omitempty"` | |
| 200 | } | |
| 201 | var list []out | |
| 202 | for _, d := range ds { | |
| 203 | state := "pending" | |
| 204 | if d.Verified() { | |
| 205 | state = "verified" | |
| 206 | } else if c.Store.PageDomainExpired(d, pendingTTL()) { | |
| 207 | state = "expired" | |
| 208 | } | |
| 209 | list = append(list, out{d.Domain, state, d.VerifiedAt}) | |
| 210 | } | |
| 211 | return c.emit(list, func(w io.Writer) { | |
| 212 | for _, d := range list { | |
| 213 | fmt.Fprintf(w, "%s\t%s\n", d.Domain, d.State) | |
| 103 | 214 | } |
| 104 | 215 | }) |
| 105 | 216 | } |
internal/control/repo.go +8 −1
| @@ -252,7 +252,14 @@ func runRepoShow(c *Ctx, args []string) int { | ||
| 252 | 252 | if err != nil { |
| 253 | 253 | return c.fail(protocol.ExitFailure, "%v", err) |
| 254 | 254 | } |
| 255 | domains, _ := c.Store.ListPageDomains(repo.ID) | |
| 255 | var domains []string | |
| 256 | if ds, err := c.Store.ListPageDomains(repo.ID); err == nil { | |
| 257 | for _, pd := range ds { | |
| 258 | if pd.Verified() { | |
| 259 | domains = append(domains, pd.Domain) | |
| 260 | } | |
| 261 | } | |
| 262 | } | |
| 256 | 263 | d := out{repo.Path(), desc, repo.Settings.Website, repo.Visibility, repo.DefaultBranch, |
| 257 | 264 | repo.Settings.ProtectedBranches, repo.Settings.Archived, topics, domains, nil} |
| 258 | 265 | // Mirror status is admin-only, like repo mirror list. The token never |
internal/store/migrations/0024_domain_verify.down.sql added +3
| @@ -0,0 +1,3 @@ | ||
| 1 | ALTER TABLE page_domains DROP COLUMN token; | |
| 2 | ALTER TABLE page_domains DROP COLUMN user_id; | |
| 3 | ALTER TABLE page_domains DROP COLUMN verified_at; | |
internal/store/migrations/0024_domain_verify.up.sql added +6
| @@ -0,0 +1,6 @@ | ||
| 1 | - DNS-challenge verification for custom pages domains. Existing claims | |
| 2 | - predate verification and stay serving: they are grandfathered verified. | |
| 3 | ALTER TABLE page_domains ADD COLUMN token TEXT NOT NULL DEFAULT ''; | |
| 4 | ALTER TABLE page_domains ADD COLUMN user_id INTEGER NOT NULL DEFAULT 0; | |
| 5 | ALTER TABLE page_domains ADD COLUMN verified_at TEXT NOT NULL DEFAULT ''; | |
| 6 | UPDATE page_domains SET verified_at = created_at WHERE verified_at = ''; | |
internal/store/pagedomains.go +78 −11
| @@ -5,10 +5,32 @@ import ( | ||
| 5 | 5 | "errors" |
| 6 | 6 | ) |
| 7 | 7 | |
| 8 | // AddPageDomain claims a domain for a repo's pages site. The primary key | |
| 9 | // makes claims exclusive instance-wide. | |
| 10 | func (s *Store) AddPageDomain(domain string, repoID int64) error { | |
| 11 | _, err := s.DB.Exec("INSERT INTO page_domains (domain, repo_id) VALUES (?, ?)", domain, repoID) | |
| 8 | // PageDomain is one custom-domain claim. A claim starts pending — it holds | |
| 9 | // the domain but serves nothing — and activates when the DNS challenge | |
| 10 | // verifies. Pending claims expire so a squatted claim frees itself. | |
| 11 | type PageDomain struct { | |
| 12 | Domain string | |
| 13 | RepoID int64 | |
| 14 | UserID int64 | |
| 15 | Token string | |
| 16 | CreatedAt string | |
| 17 | VerifiedAt string | |
| 18 | } | |
| 19 | ||
| 20 | func (d PageDomain) Verified() bool { return d.VerifiedAt != "" } | |
| 21 | ||
| 22 | // AddPageDomain claims a domain for a repo. Expired pending claims (any | |
| 23 | // repo's) are cleared first, so abandonment frees the name; live claims | |
| 24 | // make the insert fail with ErrExists. | |
| 25 | func (s *Store) AddPageDomain(domain string, repoID, userID int64, token string, ttlSeconds int) error { | |
| 26 | if _, err := s.DB.Exec( | |
| 27 | "DELETE FROM page_domains WHERE domain = ? AND verified_at = '' AND strftime('%s','now') - strftime('%s', created_at) > ?", | |
| 28 | domain, ttlSeconds); err != nil { | |
| 29 | return err | |
| 30 | } | |
| 31 | _, err := s.DB.Exec( | |
| 32 | "INSERT INTO page_domains (domain, repo_id, user_id, token) VALUES (?, ?, ?, ?)", | |
| 33 | domain, repoID, userID, token) | |
| 12 | 34 | if err != nil && isUniqueErr(err) { |
| 13 | 35 | return ErrExists |
| 14 | 36 | } |
| @@ -26,16 +48,59 @@ func (s *Store) RemovePageDomain(domain string, repoID int64) error { | ||
| 26 | 48 | return nil |
| 27 | 49 | } |
| 28 | 50 | |
| 29 | func (s *Store) ListPageDomains(repoID int64) ([]string, error) { | |
| 30 | rows, err := s.DB.Query("SELECT domain FROM page_domains WHERE repo_id = ? ORDER BY domain", repoID) | |
| 51 | const pageDomainSelect = "SELECT domain, repo_id, user_id, token, created_at, verified_at FROM page_domains" | |
| 52 | ||
| 53 | func scanPageDomain(row interface{ Scan(...any) error }) (PageDomain, error) { | |
| 54 | var d PageDomain | |
| 55 | err := row.Scan(&d.Domain, &d.RepoID, &d.UserID, &d.Token, &d.CreatedAt, &d.VerifiedAt) | |
| 56 | return d, err | |
| 57 | } | |
| 58 | ||
| 59 | // PageDomainClaim returns a repo's claim on a domain, verified or pending. | |
| 60 | func (s *Store) PageDomainClaim(domain string, repoID int64) (PageDomain, error) { | |
| 61 | d, err := scanPageDomain(s.DB.QueryRow( | |
| 62 | pageDomainSelect+" WHERE domain = ? AND repo_id = ?", domain, repoID)) | |
| 63 | if errors.Is(err, sql.ErrNoRows) { | |
| 64 | return d, ErrNotFound | |
| 65 | } | |
| 66 | return d, err | |
| 67 | } | |
| 68 | ||
| 69 | // PageDomainExpired reports whether a pending claim has outlived the TTL. | |
| 70 | func (s *Store) PageDomainExpired(d PageDomain, ttlSeconds int) bool { | |
| 71 | if d.Verified() { | |
| 72 | return false | |
| 73 | } | |
| 74 | var expired bool | |
| 75 | s.DB.QueryRow( | |
| 76 | "SELECT strftime('%s','now') - strftime('%s', ?) > ?", d.CreatedAt, ttlSeconds).Scan(&expired) | |
| 77 | return expired | |
| 78 | } | |
| 79 | ||
| 80 | // VerifyPageDomain activates a pending claim. | |
| 81 | func (s *Store) VerifyPageDomain(domain string, repoID int64) error { | |
| 82 | res, err := s.DB.Exec( | |
| 83 | "UPDATE page_domains SET verified_at = strftime('%Y-%m-%dT%H:%M:%SZ','now') WHERE domain = ? AND repo_id = ?", | |
| 84 | domain, repoID) | |
| 85 | if err != nil { | |
| 86 | return err | |
| 87 | } | |
| 88 | if n, _ := res.RowsAffected(); n == 0 { | |
| 89 | return ErrNotFound | |
| 90 | } | |
| 91 | return nil | |
| 92 | } | |
| 93 | ||
| 94 | func (s *Store) ListPageDomains(repoID int64) ([]PageDomain, error) { | |
| 95 | rows, err := s.DB.Query(pageDomainSelect+" WHERE repo_id = ? ORDER BY domain", repoID) | |
| 31 | 96 | if err != nil { |
| 32 | 97 | return nil, err |
| 33 | 98 | } |
| 34 | 99 | defer rows.Close() |
| 35 | var out []string | |
| 100 | var out []PageDomain | |
| 36 | 101 | for rows.Next() { |
| 37 | var d string | |
| 38 | if err := rows.Scan(&d); err != nil { | |
| 102 | d, err := scanPageDomain(rows) | |
| 103 | if err != nil { | |
| 39 | 104 | return nil, err |
| 40 | 105 | } |
| 41 | 106 | out = append(out, d) |
| @@ -43,10 +108,12 @@ func (s *Store) ListPageDomains(repoID int64) ([]string, error) { | ||
| 43 | 108 | return out, rows.Err() |
| 44 | 109 | } |
| 45 | 110 | |
| 46 | // PageDomainRepo resolves a request host to the repo serving it. | |
| 111 | // PageDomainRepo resolves a request host to the repo serving it. Only | |
| 112 | // verified claims serve. | |
| 47 | 113 | func (s *Store) PageDomainRepo(domain string) (Repo, error) { |
| 48 | 114 | var repoID int64 |
| 49 | err := s.DB.QueryRow("SELECT repo_id FROM page_domains WHERE domain = ?", domain).Scan(&repoID) | |
| 115 | err := s.DB.QueryRow( | |
| 116 | "SELECT repo_id FROM page_domains WHERE domain = ? AND verified_at != ''", domain).Scan(&repoID) | |
| 50 | 117 | if errors.Is(err, sql.ErrNoRows) { |
| 51 | 118 | return Repo{}, ErrNotFound |
| 52 | 119 | } |