···6464 r.Put("/", s.updateNotificationPreferences)6565 })66666767+ r.Route("/sites", func(r chi.Router) {6868+ r.Get("/", s.sitesSettings)6969+ r.Put("/", s.claimSitesDomain)7070+ r.Delete("/", s.releaseSitesDomain)7171+ })7272+6773 return r7474+}7575+7676+func (s *Settings) sitesSettings(w http.ResponseWriter, r *http.Request) {7777+ user := s.OAuth.GetMultiAccountUser(r)7878+ did := s.OAuth.GetDid(r)7979+8080+ claim, err := db.GetActiveDomainClaimForDid(s.Db, did)8181+ if err != nil {8282+ s.Logger.Error("failed to get domain claim", "err", err)8383+ claim = nil8484+ }8585+8686+ // determine whether the active account has a tngl.sh handle, in which8787+ // case their sites domain is automatically their handle domain.8888+ pdsDomain := strings.TrimPrefix(s.Config.Pds.Host, "https://")8989+ pdsDomain = strings.TrimPrefix(pdsDomain, "http://")9090+ isTnglHandle := false9191+ for _, acc := range user.Accounts {9292+ if acc.Did == did && strings.HasSuffix(acc.Handle, "."+pdsDomain) {9393+ isTnglHandle = true9494+ break9595+ }9696+ }9797+9898+ s.Pages.UserSiteSettings(w, pages.UserSiteSettingsParams{9999+ LoggedInUser: user,100100+ Claim: claim,101101+ SitesDomain: s.Config.Sites.Domain,102102+ IsTnglHandle: isTnglHandle,103103+ })104104+}105105+106106+func (s *Settings) claimSitesDomain(w http.ResponseWriter, r *http.Request) {107107+ did := s.OAuth.GetDid(r)108108+109109+ subdomain := strings.TrimSpace(r.FormValue("subdomain"))110110+ if subdomain == "" {111111+ s.Pages.Notice(w, "settings-sites-error", "Subdomain cannot be empty.")112112+ return113113+ }114114+115115+ if !isValidSubdomain(subdomain) {116116+ s.Pages.Notice(w, "settings-sites-error", "Invalid subdomain. Use only lowercase letters, digits, and hyphens. Cannot start or end with a hyphen.")117117+ return118118+ }119119+120120+ sitesDomain := s.Config.Sites.Domain121121+122122+ if subdomain == sitesDomain {123123+ s.Pages.Notice(w, "settings-sites-error", fmt.Sprintf("You cannot claim the root domain %q.", sitesDomain))124124+ return125125+ }126126+127127+ fullDomain := subdomain + "." + sitesDomain128128+129129+ if err := db.ClaimDomain(s.Db, did, fullDomain); err != nil {130130+ switch {131131+ case errors.Is(err, db.ErrDomainTaken):132132+ s.Pages.Notice(w, "settings-sites-error", "That domain is already claimed by another user.")133133+ case errors.Is(err, db.ErrDomainCooldown):134134+ s.Pages.Notice(w, "settings-sites-error", "That domain was recently released and is in a 30-day cooldown period. Please try again later.")135135+ case errors.Is(err, db.ErrAlreadyClaimed):136136+ s.Pages.Notice(w, "settings-sites-error", "You already have a domain claimed. Release it before claiming a new one.")137137+ default:138138+ s.Logger.Error("claiming domain", "err", err)139139+ s.Pages.Notice(w, "settings-sites-error", "Unable to claim domain at this moment. Try again later.")140140+ }141141+ return142142+ }143143+144144+ s.Pages.HxRefresh(w)145145+}146146+147147+func (s *Settings) releaseSitesDomain(w http.ResponseWriter, r *http.Request) {148148+ did := s.OAuth.GetDid(r)149149+ domain := strings.TrimSpace(r.FormValue("domain"))150150+151151+ if domain == "" {152152+ s.Pages.Notice(w, "settings-sites-error", "Domain cannot be empty.")153153+ return154154+ }155155+156156+ pdsDomain := strings.TrimPrefix(s.Config.Pds.Host, "https://")157157+ pdsDomain = strings.TrimPrefix(pdsDomain, "http://")158158+ user := s.OAuth.GetMultiAccountUser(r)159159+ for _, acc := range user.Accounts {160160+ if acc.Did == did && strings.HasSuffix(acc.Handle, "."+pdsDomain) {161161+ if strings.HasSuffix(domain, "."+pdsDomain) {162162+ s.Pages.Notice(w, "settings-sites-error", "Your tngl.sh domain is tied to your handle and cannot be released here.")163163+ return164164+ }165165+ }166166+ }167167+168168+ if err := db.ReleaseDomain(s.Db, did, domain); err != nil {169169+ s.Logger.Error("releasing domain", "err", err)170170+ s.Pages.Notice(w, "settings-sites-error", "Unable to release domain. Make sure it belongs to your account.")171171+ return172172+ }173173+174174+ // Clean up all site data for this DID asynchronously.175175+ if s.CfClient.Enabled() {176176+ siteConfigs, err := db.GetRepoSiteConfigsForDid(s.Db, did)177177+ if err != nil {178178+ s.Logger.Error("releaseSitesDomain: fetching site configs for cleanup", "err", err)179179+ }180180+181181+ if err := db.DeleteRepoSiteConfigsForDid(s.Db, did); err != nil {182182+ s.Logger.Error("releaseSitesDomain: deleting site configs from db", "err", err)183183+ }184184+185185+ go func() {186186+ ctx := context.Background()187187+188188+ // Delete each repo's R2 objects.189189+ for _, sc := range siteConfigs {190190+ if err := sites.Delete(ctx, s.CfClient, did, sc.RepoName); err != nil {191191+ s.Logger.Error("releaseSitesDomain: R2 delete failed", "did", did, "repo", sc.RepoName, "err", err)192192+ }193193+ }194194+195195+ // Delete the single KV entry for the domain.196196+ if err := sites.DeleteAllDomainMappings(ctx, s.CfClient, domain); err != nil {197197+ s.Logger.Error("releaseSitesDomain: KV delete failed", "domain", domain, "err", err)198198+ }199199+ }()200200+ }201201+202202+ s.Pages.HxLocation(w, "/settings/sites")203203+}204204+205205+// isValidSubdomain checks that a subdomain label uses only lowercase letters,206206+// digits, and hyphens, and does not start or end with a hyphen.207207+func isValidSubdomain(s string) bool {208208+ if len(s) == 0 || len(s) > 63 {209209+ return false210210+ }211211+ if s[0] == '-' || s[len(s)-1] == '-' {212212+ return false213213+ }214214+ for _, c := range s {215215+ if !((c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '-') {216216+ return false217217+ }218218+ }219219+ return true68220}6922170222func (s *Settings) profileSettings(w http.ResponseWriter, r *http.Request) {