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