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 } // GetRepoSiteConfigsForDid returns all site configurations for repos owned by a DID. // RepoName is populated on each returned RepoSite. func GetRepoSiteConfigsForDid(e Execer, did string) ([]*models.RepoSite, error) { rows, err := e.Query(` select rs.id, rs.repo_at, r.name, rs.branch, rs.dir, rs.is_index, rs.created, rs.updated from repo_sites rs join repos r on r.at_uri = rs.repo_at where r.did = ? `, did) if err != nil { return nil, err } defer rows.Close() var sites []*models.RepoSite for rows.Next() { var s models.RepoSite var isIndex int var createdStr, updatedStr string if err := rows.Scan(&s.ID, &s.RepoAt, &s.RepoName, &s.Branch, &s.Dir, &isIndex, &createdStr, &updatedStr); 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) } sites = append(sites, &s) } return sites, rows.Err() } // DeleteRepoSiteConfigsForDid removes all site configurations for repos owned by a DID. func DeleteRepoSiteConfigsForDid(e Execer, did string) error { _, err := e.Exec(` delete from repo_sites where repo_at in ( select at_uri from repos where did = ? ) `, did) return err } // GetIndexRepoAtForDid returns the repo_at of the repo that currently holds // is_index=1 for the given DID, excluding excludeRepoAt (the current repo). // Returns "", nil if no other repo is the index site. func GetIndexRepoAtForDid(e Execer, did, excludeRepoAt string) (string, error) { row := e.QueryRow(` select rs.repo_at from repo_sites rs join repos r on r.at_uri = rs.repo_at where r.did = ? and rs.is_index = 1 and rs.repo_at != ? limit 1 `, did, excludeRepoAt) var repoAt string err := row.Scan(&repoAt) if errors.Is(err, sql.ErrNoRows) { return "", nil } if err != nil { return "", err } return repoAt, nil }