···609609 quote_count integer not null default 0610610 );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 claimed617617+ );618618+612619 create table if not exists migrations (613620 id integer primary key autoincrement,614621 name text unique···1258125112591252 -- rename new table12601253 alter table profile_stats_new rename to profile_stats;12541254+ `)12551255+ return err12561256+ })12571257+12581258+ orm.RunMigration(conn, logger, "add-repo-sites-table", func(tx *sql.Tx) error {12591259+ _, err := tx.Exec(`12601260+ create table if not exists repo_sites (12611261+ id integer primary key autoincrement,12621262+ repo_at text not null unique,12631263+ branch text not null,12641264+ dir text not null default '/',12651265+ is_index integer not null default 0,12661266+ created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),12671267+ updated text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),12681268+ foreign key (repo_at) references repos(at_uri) on delete cascade12691269+ );12611270 `)12621271 return err12631272 })
+198
appview/db/sites.go
···11+package db22+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.DomainClaim2020+ var deletedStr sql.NullString2121+2222+ if err := row.Scan(&claim.ID, &claim.Did, &claim.Domain, &deletedStr); err != nil {2323+ return nil, err2424+ }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 = &t3232+ }3333+3434+ return &claim, nil3535+}3636+3737+func GetDomainClaimByDomain(e Execer, domain string) (*models.DomainClaim, error) {3838+ row := e.QueryRow(`3939+ select id, did, domain, deleted4040+ from domain_claims4141+ 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, deleted4949+ from domain_claims5050+ where did = ? and deleted is null5151+ `, 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, deleted5858+ from domain_claims5959+ 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.Hour6666+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 nil7676+ }7777+ if time.Since(*domainRow.Deleted) < cooldown {7878+ return ErrDomainCooldown7979+ }8080+ _, err = e.Exec(`8181+ update domain_claims set deleted = null where did = ? and domain = ?8282+ `, did, domain)8383+ return err8484+ }8585+8686+ if domainRow.Deleted == nil {8787+ return ErrDomainTaken8888+ }8989+ if time.Since(*domainRow.Deleted) < cooldown {9090+ return ErrDomainCooldown9191+ }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 err108108+ }109109+110110+ if didRow.Deleted == nil {111111+ return ErrAlreadyClaimed112112+ }113113+114114+ _, err = e.Exec(`115115+ update domain_claims set domain = ?, deleted = null where did = ?116116+ `, domain, did)117117+ return err118118+}119119+120120+func ReleaseDomain(e Execer, did, domain string) error {121121+ result, err := e.Exec(`122122+ update domain_claims123123+ set deleted = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')124124+ where did = ? and domain = ? and deleted is null125125+ `, did, domain)126126+ if err != nil {127127+ return err128128+ }129129+130130+ n, err := result.RowsAffected()131131+ if err != nil {132132+ return err133133+ }134134+ if n == 0 {135135+ return errors.New("domain not found or not actively claimed by this account")136136+ }137137+ return nil138138+}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, updated144144+ from repo_sites145145+ where repo_at = ?146146+ `, repoAt)147147+148148+ var s models.RepoSite149149+ var isIndex int150150+ var createdStr, updatedStr string151151+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, nil155155+ }156156+ if err != nil {157157+ return nil, err158158+ }159159+160160+ s.IsIndex = isIndex != 0161161+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, nil173173+}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 := 0178178+ if isIndex {179179+ isIndexInt = 1180180+ }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 set186186+ branch = excluded.branch,187187+ dir = excluded.dir,188188+ is_index = excluded.is_index,189189+ updated = excluded.updated190190+ `, repoAt, branch, dir, isIndexInt)191191+ return err192192+}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 err198198+}
+20
appview/models/sites.go
···11+package models22+33+import "time"44+55+type DomainClaim struct {66+ ID int6477+ Did string88+ Domain string99+ Deleted *time.Time1010+}1111+1212+type RepoSite struct {1313+ ID int641414+ RepoAt string1515+ Branch string1616+ Dir string1717+ IsIndex bool1818+ Created time.Time1919+ Updated time.Time2020+}