This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

core / appview / xrpc / sites.go
10 kB 330 lines
1package xrpc 2 3import ( 4 "context" 5 "database/sql" 6 "encoding/json" 7 "errors" 8 "net/http" 9 "path" 10 "strings" 11 12 "github.com/bluesky-social/indigo/atproto/syntax" 13 "tangled.org/core/api/tangled" 14 "tangled.org/core/appview/db" 15 "tangled.org/core/appview/models" 16 "tangled.org/core/appview/sites" 17 "tangled.org/core/appview/state/userutil" 18 xrpcerr "tangled.org/core/xrpc/errors" 19) 20 21func (x *Xrpc) SiteGetDomainClaim(w http.ResponseWriter, r *http.Request) { 22 l := x.Logger.With("handler", "SiteGetDomainClaim") 23 24 did, ok := actorDid(r) 25 if !ok { 26 writeError(w, xrpcerr.MissingActorDidError, http.StatusForbidden) 27 return 28 } 29 30 claim, err := db.GetActiveDomainClaimForDid(x.DB, did) 31 if err != nil && !errors.Is(err, sql.ErrNoRows) { 32 l.Error("failed to get domain claim", "err", err) 33 writeError(w, errInternal, http.StatusInternalServerError) 34 return 35 } 36 37 out := &tangled.TempSiteGetDomainClaim_Output{} 38 if claim != nil { 39 out.Domain = &claim.Domain 40 } 41 x.writeJSON(w, out) 42} 43 44func (x *Xrpc) SiteClaimDomain(w http.ResponseWriter, r *http.Request) { 45 l := x.Logger.With("handler", "SiteClaimDomain") 46 47 did, ok := actorDid(r) 48 if !ok { 49 writeError(w, xrpcerr.MissingActorDidError, http.StatusForbidden) 50 return 51 } 52 53 var input tangled.TempSiteClaimDomain_Input 54 if err := json.NewDecoder(r.Body).Decode(&input); err != nil { 55 writeError(w, errBadRequestBody, http.StatusBadRequest) 56 return 57 } 58 59 subdomain := strings.TrimSpace(input.Subdomain) 60 if len(subdomain) < 4 { 61 writeError(w, xrpcErrorTag("InvalidSubdomain", "Subdomain must be at least 4 characters long."), http.StatusBadRequest) 62 return 63 } 64 if !userutil.IsValidSubdomain(subdomain) { 65 writeError(w, xrpcErrorTag("InvalidSubdomain", "Use only lowercase letters, digits, and hyphens; cannot start or end with a hyphen."), http.StatusBadRequest) 66 return 67 } 68 if userutil.HasSlur(subdomain) { 69 writeError(w, xrpcErrorTag("InvalidSubdomain", "That subdomain is not allowed."), http.StatusBadRequest) 70 return 71 } 72 73 sitesDomain := x.Config.Sites.Domain 74 if subdomain == sitesDomain { 75 writeError(w, xrpcErrorTag("InvalidSubdomain", "Cannot claim the root domain."), http.StatusBadRequest) 76 return 77 } 78 fullDomain := subdomain + "." + sitesDomain 79 80 if err := db.ClaimDomain(x.DB, did, fullDomain); err != nil { 81 switch { 82 case errors.Is(err, db.ErrDomainTaken): 83 writeError(w, xrpcErrorTag("DomainTaken", "That subdomain is already claimed by another user."), http.StatusConflict) 84 case errors.Is(err, db.ErrDomainCooldown): 85 writeError(w, xrpcErrorTag("DomainCooldown", "That subdomain was recently released and is in a cooldown period. Please try again later."), http.StatusConflict) 86 case errors.Is(err, db.ErrAlreadyClaimed): 87 writeError(w, xrpcErrorTag("AlreadyClaimed", "You already have a domain claimed. Release it before claiming a new one."), http.StatusConflict) 88 default: 89 l.Error("claiming domain", "err", err) 90 writeError(w, errInternal, http.StatusInternalServerError) 91 } 92 return 93 } 94 95 w.WriteHeader(http.StatusOK) 96} 97 98func (x *Xrpc) SiteReleaseDomain(w http.ResponseWriter, r *http.Request) { 99 l := x.Logger.With("handler", "SiteReleaseDomain") 100 101 did, ok := actorDid(r) 102 if !ok { 103 writeError(w, xrpcerr.MissingActorDidError, http.StatusForbidden) 104 return 105 } 106 107 var input tangled.TempSiteReleaseDomain_Input 108 if err := json.NewDecoder(r.Body).Decode(&input); err != nil { 109 writeError(w, errBadRequestBody, http.StatusBadRequest) 110 return 111 } 112 113 domain := strings.TrimSpace(input.Domain) 114 if domain == "" { 115 writeError(w, badRequestError("Domain cannot be empty."), http.StatusBadRequest) 116 return 117 } 118 119 // a tngl.sh handle's sites domain is auto-claimed at signup and handle-bound 120 if isTngl, err := x.isTnglHandle(r.Context(), did); err != nil { 121 l.Error("resolving identity", "err", err) 122 writeError(w, errInternal, http.StatusInternalServerError) 123 return 124 } else if isTngl { 125 writeError(w, xrpcErrorTag("HandleBoundDomain", "Your tngl.sh domain is tied to your handle and cannot be released."), http.StatusBadRequest) 126 return 127 } 128 129 if err := db.ReleaseDomain(x.DB, did, domain); err != nil { 130 l.Error("releasing domain", "err", err) 131 writeError(w, xrpcErrorTag("DomainNotFound", "Unable to release domain; ensure it belongs to your account."), http.StatusNotFound) 132 return 133 } 134 135 // clean up all site data for this did asynchronously 136 if x.Cloudflare != nil && x.Cloudflare.Enabled() { 137 siteConfigs, err := db.GetRepoSiteConfigsForDid(x.DB, did) 138 if err != nil { 139 l.Error("fetching site configs for cleanup", "err", err) 140 } 141 if err := db.DeleteRepoSiteConfigsForDid(x.DB, did); err != nil { 142 l.Error("deleting site configs from db", "err", err) 143 } 144 145 go func() { 146 ctx := context.Background() 147 for _, sc := range siteConfigs { 148 if err := sites.Delete(ctx, x.Cloudflare, did, sc.RepoRkey); err != nil { 149 l.Error("R2 delete failed", "did", did, "repo", sc.RepoRkey, "err", err) 150 } 151 } 152 if err := sites.DeleteAllDomainMappings(ctx, x.Cloudflare, domain); err != nil { 153 l.Error("KV delete failed", "domain", domain, "err", err) 154 } 155 }() 156 } 157 158 w.WriteHeader(http.StatusOK) 159} 160 161func (x *Xrpc) SiteGetRepoSiteConfig(w http.ResponseWriter, r *http.Request) { 162 l := x.Logger.With("handler", "SiteGetRepoSiteConfig") 163 164 repo, xerr, status := x.resolveOwnedRepo(r, r.URL.Query().Get("repoDid")) 165 if xerr != nil { 166 writeError(w, *xerr, status) 167 return 168 } 169 170 config, err := db.GetRepoSiteConfig(x.DB, repo.RepoDid) 171 if err != nil { 172 l.Error("failed to get repo site config", "err", err) 173 writeError(w, errInternal, http.StatusInternalServerError) 174 return 175 } 176 177 out := &tangled.TempRepoGetSiteConfig_Output{} 178 if config != nil { 179 out.Config = &tangled.TempRepoGetSiteConfig_SiteConfig{ 180 Branch: config.Branch, 181 Dir: config.Dir, 182 IsIndex: config.IsIndex, 183 } 184 } 185 x.writeJSON(w, out) 186} 187 188func (x *Xrpc) SiteUpdateRepoSiteConfig(w http.ResponseWriter, r *http.Request) { 189 l := x.Logger.With("handler", "SiteUpdateRepoSiteConfig") 190 191 var input tangled.TempRepoUpdateSiteConfig_Input 192 if err := json.NewDecoder(r.Body).Decode(&input); err != nil { 193 writeError(w, errBadRequestBody, http.StatusBadRequest) 194 return 195 } 196 197 repo, xerr, status := x.resolveOwnedRepo(r, input.RepoDid) 198 if xerr != nil { 199 writeError(w, *xerr, status) 200 return 201 } 202 203 branch := strings.TrimSpace(input.Branch) 204 if branch == "" { 205 writeError(w, badRequestError("Branch cannot be empty."), http.StatusBadRequest) 206 return 207 } 208 209 dir := strings.TrimSpace(input.Dir) 210 if dir == "" { 211 dir = "/" 212 } 213 dir = path.Clean("/" + dir) 214 if dir != "/" && strings.Contains(dir, "..") { 215 writeError(w, badRequestError("Invalid directory path."), http.StatusBadRequest) 216 return 217 } 218 219 isIndex := input.IsIndex != nil && *input.IsIndex 220 221 // check the claim before persisting, so a failed call leaves no state 222 ownerClaim, _ := db.GetActiveDomainClaimForDid(x.DB, repo.Did) 223 if ownerClaim == nil { 224 writeError(w, xrpcErrorTag("NoDomainClaim", "The account does not have an active domain claim."), http.StatusBadRequest) 225 return 226 } 227 228 if err := db.SetRepoSiteConfig(x.DB, repo.RepoDid, branch, dir, isIndex); err != nil { 229 l.Error("failed to save site config", "err", err) 230 writeError(w, errInternal, http.StatusInternalServerError) 231 return 232 } 233 234 if x.Cloudflare != nil && x.Cloudflare.Enabled() { 235 go x.deploySite(repo, branch, dir, isIndex, ownerClaim.Domain) 236 } else { 237 l.Warn("cloudflare integration disabled; site won't be deployed", "repo", repo.RepoIdentifier()) 238 } 239 240 w.WriteHeader(http.StatusOK) 241} 242 243func (x *Xrpc) SiteDisableRepoSite(w http.ResponseWriter, r *http.Request) { 244 l := x.Logger.With("handler", "SiteDisableRepoSite") 245 246 var input tangled.TempRepoDisableSite_Input 247 if err := json.NewDecoder(r.Body).Decode(&input); err != nil { 248 writeError(w, errBadRequestBody, http.StatusBadRequest) 249 return 250 } 251 252 repo, xerr, status := x.resolveOwnedRepo(r, input.RepoDid) 253 if xerr != nil { 254 writeError(w, *xerr, status) 255 return 256 } 257 258 existingConfig, _ := db.GetRepoSiteConfig(x.DB, repo.RepoDid) 259 if existingConfig == nil { 260 writeError(w, xrpcErrorTag("SiteNotFound", "No site configuration exists for this repository."), http.StatusNotFound) 261 return 262 } 263 264 if err := db.DeleteRepoSiteConfig(x.DB, repo.RepoDid); err != nil { 265 l.Error("failed to delete site config", "err", err) 266 writeError(w, errInternal, http.StatusInternalServerError) 267 return 268 } 269 270 if x.Cloudflare != nil && x.Cloudflare.Enabled() { 271 ownerClaim, _ := db.GetActiveDomainClaimForDid(x.DB, repo.Did) 272 go func() { 273 ctx := context.Background() 274 if err := sites.Delete(ctx, x.Cloudflare, repo.Did, repo.Rkey); err != nil { 275 l.Error("R2 delete failed", "repo", repo.RepoIdentifier(), "err", err) 276 } 277 if ownerClaim != nil { 278 if err := sites.DeleteDomainMapping(ctx, x.Cloudflare, ownerClaim.Domain, repo.Name); err != nil { 279 l.Error("KV delete failed", "domain", ownerClaim.Domain, "err", err) 280 } 281 } 282 }() 283 } 284 285 w.WriteHeader(http.StatusOK) 286} 287 288// deploySite syncs a repo's site to r2 and writes the domain mapping, mirroring 289// the appview's SaveRepoSiteConfig deploy path 290func (x *Xrpc) deploySite(repo *models.Repo, branch, dir string, isIndex bool, domain string) { 291 l := x.Logger.With("handler", "deploySite", "repo", repo.RepoIdentifier()) 292 ctx := context.Background() 293 294 deploy := &models.SiteDeploy{ 295 RepoDid: syntax.DID(repo.RepoDid), 296 Branch: branch, 297 Dir: dir, 298 Trigger: models.SiteDeployTriggerConfigChange, 299 } 300 301 deployErr := sites.Deploy(ctx, x.Cloudflare, x.Config, repo, branch, dir) 302 if deployErr != nil { 303 l.Error("initial R2 sync failed", "err", deployErr) 304 deploy.Status = models.SiteDeployStatusFailure 305 deploy.Error = deployErr.Error() 306 } else { 307 deploy.Status = models.SiteDeployStatusSuccess 308 } 309 310 if err := db.AddSiteDeploy(x.DB, deploy); err != nil { 311 l.Error("failed to record deploy", "err", err) 312 } 313 314 if deployErr == nil { 315 if err := sites.PutDomainMapping(ctx, x.Cloudflare, domain, repo.Did, repo.Name, repo.Rkey, isIndex); err != nil { 316 l.Error("KV write failed", "domain", domain, "err", err) 317 } 318 } 319} 320 321// isTnglHandle reports whether the account's handle sits under the PDS user 322// domain (e.g. *.tngl.sh). Such users have a handle-bound sites domain that was 323// auto-claimed at signup and must not be released. 324func (x *Xrpc) isTnglHandle(ctx context.Context, did string) (bool, error) { 325 ident, err := x.IdResolver.ResolveIdent(ctx, did) 326 if err != nil { 327 return false, err 328 } 329 return strings.HasSuffix(ident.Handle.String(), x.Config.Pds.UserDomain), nil 330}