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