From 598f5cdb0f7cc53454d1bc8110e95f9b3a3fa713 Mon Sep 17 00:00:00 2001 From: Anirudh Oppiliappan Date: Fri, 20 Feb 2026 10:11:18 +0000 Subject: [PATCH] appview/{db,models}: domain claims models and crud ops Signed-off-by: Anirudh Oppiliappan --- appview/db/db.go | 23 +++++++++++++++++++++++ appview/db/sites.go | 198 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ appview/models/sites.go | 20 ++++++++++++++++++++ 3 file(s) changed, 241 insertion(s)(+), 0 deletion(s)(-) diff --git a/appview/db/db.go b/appview/db/db.go --- a/appview/db/db.go +++ b/appview/db/db.go @@ -609,6 +609,13 @@ quote_count integer not null default 0 ); + create table if not exists domain_claims ( + id integer primary key autoincrement, + did text not null unique, + domain text not null unique, + deleted text -- timestamp when the domain was released/unclaimed; null means actively claimed + ); + create table if not exists migrations ( id integer primary key autoincrement, name text unique @@ -1251,6 +1258,22 @@ -- rename new table alter table profile_stats_new rename to profile_stats; + `) + return err + }) + + orm.RunMigration(conn, logger, "add-repo-sites-table", func(tx *sql.Tx) error { + _, err := tx.Exec(` + create table if not exists repo_sites ( + id integer primary key autoincrement, + repo_at text not null unique, + branch text not null, + dir text not null default '/', + is_index integer not null default 0, + created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), + updated text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), + foreign key (repo_at) references repos(at_uri) on delete cascade + ); `) return err }) diff --git a/appview/db/sites.go b/appview/db/sites.go new file mode 100644 --- /dev/null +++ b/appview/db/sites.go @@ -0,0 +1,198 @@ +package db + +import ( + "database/sql" + "errors" + "fmt" + "time" + + "tangled.org/core/appview/models" +) + +var ( + ErrDomainTaken = errors.New("domain is already claimed by another user") + ErrDomainCooldown = errors.New("domain is in a 30-day cooldown period after being released") + ErrAlreadyClaimed = errors.New("you already have an active domain claim; release it before claiming a new one") +) + +func scanClaim(row *sql.Row) (*models.DomainClaim, error) { + var claim models.DomainClaim + var deletedStr sql.NullString + + if err := row.Scan(&claim.ID, &claim.Did, &claim.Domain, &deletedStr); err != nil { + return nil, err + } + + if deletedStr.Valid { + t, err := time.Parse(time.RFC3339, deletedStr.String) + if err != nil { + return nil, fmt.Errorf("parsing deleted timestamp: %w", err) + } + claim.Deleted = &t + } + + return &claim, nil +} + +func GetDomainClaimByDomain(e Execer, domain string) (*models.DomainClaim, error) { + row := e.QueryRow(` + select id, did, domain, deleted + from domain_claims + where domain = ? + `, domain) + return scanClaim(row) +} + +func GetActiveDomainClaimForDid(e Execer, did string) (*models.DomainClaim, error) { + row := e.QueryRow(` + select id, did, domain, deleted + from domain_claims + where did = ? and deleted is null + `, did) + return scanClaim(row) +} + +func GetDomainClaimForDid(e Execer, did string) (*models.DomainClaim, error) { + row := e.QueryRow(` + select id, did, domain, deleted + from domain_claims + where did = ? + `, did) + return scanClaim(row) +} + +func ClaimDomain(e Execer, did, domain string) error { + const cooldown = 30 * 24 * time.Hour + + domainRow, err := GetDomainClaimByDomain(e, domain) + if err != nil && !errors.Is(err, sql.ErrNoRows) { + return fmt.Errorf("looking up domain: %w", err) + } + + if domainRow != nil { + if domainRow.Did == did { + if domainRow.Deleted == nil { + return nil + } + if time.Since(*domainRow.Deleted) < cooldown { + return ErrDomainCooldown + } + _, err = e.Exec(` + update domain_claims set deleted = null where did = ? and domain = ? + `, did, domain) + return err + } + + if domainRow.Deleted == nil { + return ErrDomainTaken + } + if time.Since(*domainRow.Deleted) < cooldown { + return ErrDomainCooldown + } + + if _, err = e.Exec(`delete from domain_claims where domain = ?`, domain); err != nil { + return fmt.Errorf("clearing expired domain row: %w", err) + } + } + + didRow, err := GetDomainClaimForDid(e, did) + if err != nil && !errors.Is(err, sql.ErrNoRows) { + return fmt.Errorf("looking up DID claim: %w", err) + } + + if didRow == nil { + _, err = e.Exec(` + insert into domain_claims (did, domain) values (?, ?) + `, did, domain) + return err + } + + if didRow.Deleted == nil { + return ErrAlreadyClaimed + } + + _, err = e.Exec(` + update domain_claims set domain = ?, deleted = null where did = ? + `, domain, did) + return err +} + +func ReleaseDomain(e Execer, did, domain string) error { + result, err := e.Exec(` + update domain_claims + set deleted = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') + where did = ? and domain = ? and deleted is null + `, did, domain) + if err != nil { + return err + } + + n, err := result.RowsAffected() + if err != nil { + return err + } + if n == 0 { + return errors.New("domain not found or not actively claimed by this account") + } + return nil +} + +// GetRepoSiteConfig returns the site configuration for a repo, or nil if not configured. +func GetRepoSiteConfig(e Execer, repoAt string) (*models.RepoSite, error) { + row := e.QueryRow(` + select id, repo_at, branch, dir, is_index, created, updated + from repo_sites + where repo_at = ? + `, repoAt) + + var s models.RepoSite + var isIndex int + var createdStr, updatedStr string + + err := row.Scan(&s.ID, &s.RepoAt, &s.Branch, &s.Dir, &isIndex, &createdStr, &updatedStr) + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, err + } + + s.IsIndex = isIndex != 0 + + s.Created, err = time.Parse(time.RFC3339, createdStr) + if err != nil { + return nil, fmt.Errorf("parsing created timestamp: %w", err) + } + + s.Updated, err = time.Parse(time.RFC3339, updatedStr) + if err != nil { + return nil, fmt.Errorf("parsing updated timestamp: %w", err) + } + + return &s, nil +} + +// SetRepoSiteConfig inserts or replaces the site configuration for a repo. +func SetRepoSiteConfig(e Execer, repoAt, branch, dir string, isIndex bool) error { + isIndexInt := 0 + if isIndex { + isIndexInt = 1 + } + + _, err := e.Exec(` + insert into repo_sites (repo_at, branch, dir, is_index, updated) + values (?, ?, ?, ?, strftime('%Y-%m-%dT%H:%M:%SZ', 'now')) + on conflict(repo_at) do update set + branch = excluded.branch, + dir = excluded.dir, + is_index = excluded.is_index, + updated = excluded.updated + `, repoAt, branch, dir, isIndexInt) + return err +} + +// DeleteRepoSiteConfig removes the site configuration for a repo. +func DeleteRepoSiteConfig(e Execer, repoAt string) error { + _, err := e.Exec(`delete from repo_sites where repo_at = ?`, repoAt) + return err +} diff --git a/appview/models/sites.go b/appview/models/sites.go new file mode 100644 --- /dev/null +++ b/appview/models/sites.go @@ -0,0 +1,20 @@ +package models + +import "time" + +type DomainClaim struct { + ID int64 + Did string + Domain string + Deleted *time.Time +} + +type RepoSite struct { + ID int64 + RepoAt string + Branch string + Dir string + IsIndex bool + Created time.Time + Updated time.Time +} -- tangled.sh