···609609 quote_count integer not null default 0
610610 );
611611612612+ create table if not exists domain_claims (
613613+ id integer primary key autoincrement,
614614+ did text not null unique,
615615+ domain text not null unique,
616616+ deleted text -- timestamp when the domain was released/unclaimed; null means actively claimed
617617+ );
618618+612619 create table if not exists migrations (
613620 id integer primary key autoincrement,
614621 name text unique
···1251125812521259 -- rename new table
12531260 alter table profile_stats_new rename to profile_stats;
12611261+ `)
12621262+ return err
12631263+ })
12641264+12651265+ orm.RunMigration(conn, logger, "add-repo-sites-table", func(tx *sql.Tx) error {
12661266+ _, err := tx.Exec(`
12671267+ create table if not exists repo_sites (
12681268+ id integer primary key autoincrement,
12691269+ repo_at text not null unique,
12701270+ branch text not null,
12711271+ dir text not null default '/',
12721272+ is_index integer not null default 0,
12731273+ created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
12741274+ updated text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
12751275+ foreign key (repo_at) references repos(at_uri) on delete cascade
12761276+ );
12541277 `)
12551278 return err
12561279 })
+198
appview/db/sites.go
···11+package db
22+33+import (
44+ "database/sql"
55+ "errors"
66+ "fmt"
77+ "time"
88+99+ "tangled.org/core/appview/models"
1010+)
1111+1212+var (
1313+ ErrDomainTaken = errors.New("domain is already claimed by another user")
1414+ ErrDomainCooldown = errors.New("domain is in a 30-day cooldown period after being released")
1515+ ErrAlreadyClaimed = errors.New("you already have an active domain claim; release it before claiming a new one")
1616+)
1717+1818+func scanClaim(row *sql.Row) (*models.DomainClaim, error) {
1919+ var claim models.DomainClaim
2020+ var deletedStr sql.NullString
2121+2222+ if err := row.Scan(&claim.ID, &claim.Did, &claim.Domain, &deletedStr); err != nil {
2323+ return nil, err
2424+ }
2525+2626+ if deletedStr.Valid {
2727+ t, err := time.Parse(time.RFC3339, deletedStr.String)
2828+ if err != nil {
2929+ return nil, fmt.Errorf("parsing deleted timestamp: %w", err)
3030+ }
3131+ claim.Deleted = &t
3232+ }
3333+3434+ return &claim, nil
3535+}
3636+3737+func GetDomainClaimByDomain(e Execer, domain string) (*models.DomainClaim, error) {
3838+ row := e.QueryRow(`
3939+ select id, did, domain, deleted
4040+ from domain_claims
4141+ where domain = ?
4242+ `, domain)
4343+ return scanClaim(row)
4444+}
4545+4646+func GetActiveDomainClaimForDid(e Execer, did string) (*models.DomainClaim, error) {
4747+ row := e.QueryRow(`
4848+ select id, did, domain, deleted
4949+ from domain_claims
5050+ where did = ? and deleted is null
5151+ `, did)
5252+ return scanClaim(row)
5353+}
5454+5555+func GetDomainClaimForDid(e Execer, did string) (*models.DomainClaim, error) {
5656+ row := e.QueryRow(`
5757+ select id, did, domain, deleted
5858+ from domain_claims
5959+ where did = ?
6060+ `, did)
6161+ return scanClaim(row)
6262+}
6363+6464+func ClaimDomain(e Execer, did, domain string) error {
6565+ const cooldown = 30 * 24 * time.Hour
6666+6767+ domainRow, err := GetDomainClaimByDomain(e, domain)
6868+ if err != nil && !errors.Is(err, sql.ErrNoRows) {
6969+ return fmt.Errorf("looking up domain: %w", err)
7070+ }
7171+7272+ if domainRow != nil {
7373+ if domainRow.Did == did {
7474+ if domainRow.Deleted == nil {
7575+ return nil
7676+ }
7777+ if time.Since(*domainRow.Deleted) < cooldown {
7878+ return ErrDomainCooldown
7979+ }
8080+ _, err = e.Exec(`
8181+ update domain_claims set deleted = null where did = ? and domain = ?
8282+ `, did, domain)
8383+ return err
8484+ }
8585+8686+ if domainRow.Deleted == nil {
8787+ return ErrDomainTaken
8888+ }
8989+ if time.Since(*domainRow.Deleted) < cooldown {
9090+ return ErrDomainCooldown
9191+ }
9292+9393+ if _, err = e.Exec(`delete from domain_claims where domain = ?`, domain); err != nil {
9494+ return fmt.Errorf("clearing expired domain row: %w", err)
9595+ }
9696+ }
9797+9898+ didRow, err := GetDomainClaimForDid(e, did)
9999+ if err != nil && !errors.Is(err, sql.ErrNoRows) {
100100+ return fmt.Errorf("looking up DID claim: %w", err)
101101+ }
102102+103103+ if didRow == nil {
104104+ _, err = e.Exec(`
105105+ insert into domain_claims (did, domain) values (?, ?)
106106+ `, did, domain)
107107+ return err
108108+ }
109109+110110+ if didRow.Deleted == nil {
111111+ return ErrAlreadyClaimed
112112+ }
113113+114114+ _, err = e.Exec(`
115115+ update domain_claims set domain = ?, deleted = null where did = ?
116116+ `, domain, did)
117117+ return err
118118+}
119119+120120+func ReleaseDomain(e Execer, did, domain string) error {
121121+ result, err := e.Exec(`
122122+ update domain_claims
123123+ set deleted = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
124124+ where did = ? and domain = ? and deleted is null
125125+ `, did, domain)
126126+ if err != nil {
127127+ return err
128128+ }
129129+130130+ n, err := result.RowsAffected()
131131+ if err != nil {
132132+ return err
133133+ }
134134+ if n == 0 {
135135+ return errors.New("domain not found or not actively claimed by this account")
136136+ }
137137+ return nil
138138+}
139139+140140+// GetRepoSiteConfig returns the site configuration for a repo, or nil if not configured.
141141+func GetRepoSiteConfig(e Execer, repoAt string) (*models.RepoSite, error) {
142142+ row := e.QueryRow(`
143143+ select id, repo_at, branch, dir, is_index, created, updated
144144+ from repo_sites
145145+ where repo_at = ?
146146+ `, repoAt)
147147+148148+ var s models.RepoSite
149149+ var isIndex int
150150+ var createdStr, updatedStr string
151151+152152+ err := row.Scan(&s.ID, &s.RepoAt, &s.Branch, &s.Dir, &isIndex, &createdStr, &updatedStr)
153153+ if errors.Is(err, sql.ErrNoRows) {
154154+ return nil, nil
155155+ }
156156+ if err != nil {
157157+ return nil, err
158158+ }
159159+160160+ s.IsIndex = isIndex != 0
161161+162162+ s.Created, err = time.Parse(time.RFC3339, createdStr)
163163+ if err != nil {
164164+ return nil, fmt.Errorf("parsing created timestamp: %w", err)
165165+ }
166166+167167+ s.Updated, err = time.Parse(time.RFC3339, updatedStr)
168168+ if err != nil {
169169+ return nil, fmt.Errorf("parsing updated timestamp: %w", err)
170170+ }
171171+172172+ return &s, nil
173173+}
174174+175175+// SetRepoSiteConfig inserts or replaces the site configuration for a repo.
176176+func SetRepoSiteConfig(e Execer, repoAt, branch, dir string, isIndex bool) error {
177177+ isIndexInt := 0
178178+ if isIndex {
179179+ isIndexInt = 1
180180+ }
181181+182182+ _, err := e.Exec(`
183183+ insert into repo_sites (repo_at, branch, dir, is_index, updated)
184184+ values (?, ?, ?, ?, strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
185185+ on conflict(repo_at) do update set
186186+ branch = excluded.branch,
187187+ dir = excluded.dir,
188188+ is_index = excluded.is_index,
189189+ updated = excluded.updated
190190+ `, repoAt, branch, dir, isIndexInt)
191191+ return err
192192+}
193193+194194+// DeleteRepoSiteConfig removes the site configuration for a repo.
195195+func DeleteRepoSiteConfig(e Execer, repoAt string) error {
196196+ _, err := e.Exec(`delete from repo_sites where repo_at = ?`, repoAt)
197197+ return err
198198+}
+20
appview/models/sites.go
···11+package models
22+33+import "time"
44+55+type DomainClaim struct {
66+ ID int64
77+ Did string
88+ Domain string
99+ Deleted *time.Time
1010+}
1111+1212+type RepoSite struct {
1313+ ID int64
1414+ RepoAt string
1515+ Branch string
1616+ Dir string
1717+ IsIndex bool
1818+ Created time.Time
1919+ Updated time.Time
2020+}