This repository has no description
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}