Monorepo for Tangled

appview/{repo,pages}: git sites settings ui, deploy and delete handlers

Signed-off-by: Anirudh Oppiliappan <anirudh@tangled.org>

anirudh.fi efb26f13 f93c5f2d

verified
+671 -29
+1
appview/pages/funcmap.go
··· 475 475 {"Name": "access", "Icon": "users"}, 476 476 {"Name": "pipelines", "Icon": "layers-2"}, 477 477 {"Name": "hooks", "Icon": "webhook"}, 478 + {"Name": "sites", "Icon": "globe"}, 478 479 }, 479 480 } 480 481 },
+18
appview/pages/pages.go
··· 1016 1016 return tpl.ExecuteTemplate(w, "repo/settings/fragments/webhookDeliveries", params) 1017 1017 } 1018 1018 1019 + type RepoSiteSettingsParams struct { 1020 + LoggedInUser *oauth.MultiAccountUser 1021 + RepoInfo repoinfo.RepoInfo 1022 + Active string 1023 + Tab string 1024 + Branches []types.Branch 1025 + SiteConfig *models.RepoSite 1026 + OwnerClaim *models.DomainClaim 1027 + Deploys []models.SiteDeploy 1028 + IndexSiteTakenBy string // repo_at of another repo that already holds is_index, or "" 1029 + } 1030 + 1031 + func (p *Pages) RepoSiteSettings(w io.Writer, params RepoSiteSettingsParams) error { 1032 + params.Active = "settings" 1033 + params.Tab = "sites" 1034 + return p.executeRepo("repo/settings/sites", w, params) 1035 + } 1036 + 1019 1037 type RepoIssuesParams struct { 1020 1038 LoggedInUser *oauth.MultiAccountUser 1021 1039 RepoInfo repoinfo.RepoInfo
+268
appview/pages/templates/repo/settings/sites.html
··· 1 + {{ define "title" }}{{ .Tab }} settings &middot; {{ .RepoInfo.FullName }}{{ end }} 2 + 3 + {{ define "repoContent" }} 4 + <section class="w-full grid grid-cols-1 md:grid-cols-4 gap-2"> 5 + <div class="col-span-1"> 6 + {{ template "repo/settings/fragments/sidebar" . }} 7 + </div> 8 + <div class="col-span-1 md:col-span-3 flex flex-col gap-6 p-2"> 9 + {{ template "repoSiteSettings" . }} 10 + </div> 11 + </section> 12 + {{ end }} 13 + 14 + {{ define "repoSiteSettings" }} 15 + <div class="grid grid-cols-1 md:grid-cols-3 gap-4 items-start"> 16 + <div class="col-span-1 md:col-span-2"> 17 + <h2 class="text-sm pb-2 uppercase font-bold">Git Sites</h2> 18 + <p class="text-gray-500 dark:text-gray-400"> 19 + Serve a static site directly from this repository. 20 + Choose a branch and the directory containing your <code>index.html</code>. 21 + Only repository owners can configure sites. 22 + </p> 23 + </div> 24 + </div> 25 + 26 + {{ if and .SiteConfig .OwnerClaim }} 27 + {{ if .SiteConfig.IsIndex }} 28 + <div class="flex items-center gap-2 px-3 py-2 rounded border border-green-200 dark:border-green-800 bg-green-50 dark:bg-green-900/20 text-sm text-green-800 dark:text-green-300"> 29 + {{ i "circle-check" "size-4 shrink-0" }} 30 + live at <a class="underline font-mono" href="https://{{ .OwnerClaim.Domain }}">{{ .OwnerClaim.Domain }}</a> 31 + </div> 32 + {{ else }} 33 + <div class="flex items-center gap-2 px-3 py-2 rounded border border-green-200 dark:border-green-800 bg-green-50 dark:bg-green-900/20 text-sm text-green-800 dark:text-green-300"> 34 + {{ i "circle-check" "size-4 shrink-0" }} 35 + live at <a class="underline font-mono" href="https://{{ .OwnerClaim.Domain }}/{{ .RepoInfo.Name }}">{{ .OwnerClaim.Domain }}/{{ .RepoInfo.Name }}</a> 36 + </div> 37 + {{ end }} 38 + {{ else if and .SiteConfig (not .OwnerClaim) }} 39 + <div class="flex items-center gap-2 px-3 py-2 rounded border border-amber-200 dark:border-amber-800 bg-amber-50 dark:bg-amber-900/20 text-sm text-amber-800 dark:text-amber-300"> 40 + {{ i "triangle-alert" "size-4 shrink-0" }} 41 + site is configured but not live &mdash; <a class="underline" href="/settings/sites">claim a domain</a> to publish it. 42 + </div> 43 + {{ else if and (not .SiteConfig) .OwnerClaim }} 44 + <div class="flex items-center gap-2 px-3 py-2 rounded border border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800/50 text-sm text-gray-600 dark:text-gray-400"> 45 + {{ i "circle-dashed" "size-4 shrink-0" }} 46 + not enabled &mdash; configure a branch below to publish to <span class="font-mono">{{ .OwnerClaim.Domain }}</span>. 47 + </div> 48 + {{ else }} 49 + <div class="flex items-center gap-2 px-3 py-2 rounded border border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800/50 text-sm text-gray-600 dark:text-gray-400"> 50 + {{ i "circle-dashed" "size-4 shrink-0" }} 51 + not enabled &mdash; configure a branch below and <a class="underline" href="/settings/sites">claim a domain</a> to publish. 52 + </div> 53 + {{ end }} 54 + 55 + <form 56 + hx-put="/{{ $.RepoInfo.FullName }}/settings/sites" 57 + hx-indicator="#sites-spinner" 58 + hx-swap="none" 59 + class="flex flex-col gap-4" 60 + > 61 + <fieldset class="flex flex-col gap-4" {{ if not .RepoInfo.Roles.IsOwner }}disabled{{ end }}> 62 + 63 + <div class="grid grid-cols-1 md:grid-cols-3 gap-4 items-center"> 64 + <div class="col-span-1 md:col-span-2"> 65 + <h2 class="text-sm pb-2 uppercase font-bold">Branch</h2> 66 + <p class="text-gray-500 dark:text-gray-400"> 67 + The branch to build and deploy the site from. 68 + </p> 69 + </div> 70 + <div class="col-span-1 md:col-span-1 md:justify-self-end"> 71 + <select 72 + id="sites-branch" 73 + name="branch" 74 + required 75 + class="p-1 border border-gray-200 bg-white dark:bg-gray-800 dark:text-white dark:border-gray-700"> 76 + <option value="" disabled {{ if not .SiteConfig }}selected{{ end }}> 77 + Choose a branch 78 + </option> 79 + {{ range .Branches }} 80 + <option value="{{ .Name }}" 81 + {{ if and $.SiteConfig (eq .Name $.SiteConfig.Branch) }}selected{{ end }}> 82 + {{ .Name }}{{ if .IsDefault }} (default){{ end }} 83 + </option> 84 + {{ end }} 85 + </select> 86 + </div> 87 + </div> 88 + 89 + <div class="grid grid-cols-1 md:grid-cols-3 gap-4 items-center"> 90 + <div class="col-span-1 md:col-span-2"> 91 + <h2 class="text-sm pb-2 uppercase font-bold">Deploy directory</h2> 92 + <p class="text-gray-500 dark:text-gray-400"> 93 + Path within the repository that contains your <code>index.html</code>. 94 + Use <code>/</code> for the root, or a subdirectory like <code>/docs</code>. 95 + </p> 96 + </div> 97 + <div class="col-span-1 md:col-span-1 md:justify-self-end"> 98 + <input 99 + type="text" 100 + id="sites-dir" 101 + name="dir" 102 + placeholder="/" 103 + value="{{ if .SiteConfig }}{{ .SiteConfig.Dir }}{{ else }}/{{ end }}" 104 + pattern="\/.*" 105 + class="font-mono w-full" 106 + /> 107 + </div> 108 + </div> 109 + 110 + <div class="flex flex-col gap-2"> 111 + <h2 class="text-sm pb-2 uppercase font-bold">Site type</h2> 112 + <p class="text-gray-500 dark:text-gray-400"> 113 + An <strong>index site</strong> is served at the root of your sites domain. 114 + A <strong>sub-path site</strong> is served under the repository name. 115 + </p> 116 + <div class="flex flex-col sm:flex-row gap-3 pt-1"> 117 + <label class="flex items-start gap-2 flex-1 border rounded p-3 118 + {{ if and .IndexSiteTakenBy (not (and .SiteConfig .SiteConfig.IsIndex)) }} 119 + border-gray-100 dark:border-gray-800 opacity-50 cursor-not-allowed 120 + {{ else }} 121 + border-gray-200 dark:border-gray-700 cursor-pointer 122 + {{ end }}"> 123 + <input 124 + type="radio" 125 + name="is_index" 126 + value="true" 127 + class="mt-0.5" 128 + {{ if and .SiteConfig .SiteConfig.IsIndex }}checked{{ end }} 129 + {{ if not .SiteConfig }}checked{{ end }} 130 + {{ if and .IndexSiteTakenBy (not (and .SiteConfig .SiteConfig.IsIndex)) }}disabled{{ end }} 131 + /> 132 + <div> 133 + <span class="font-medium">index site</span> 134 + <p class="text-xs text-gray-500 dark:text-gray-400 mt-2"> 135 + {{ if .OwnerClaim }} 136 + <code>{{ .OwnerClaim.Domain }}</code> 137 + {{ else }} 138 + e.g. <code>you.tngl.page</code> 139 + {{ end }} 140 + </p> 141 + {{ if and .IndexSiteTakenBy (not (and .SiteConfig .SiteConfig.IsIndex)) }} 142 + <p class="text-xs text-amber-600 dark:text-amber-400 mt-1"> 143 + already used by <code>{{ .IndexSiteTakenBy }}</code> 144 + </p> 145 + {{ end }} 146 + </div> 147 + </label> 148 + <label class="flex items-start gap-2 cursor-pointer flex-1 border border-gray-200 dark:border-gray-700 rounded p-3"> 149 + <input 150 + type="radio" 151 + name="is_index" 152 + value="false" 153 + class="mt-0.5" 154 + {{ if and .SiteConfig (not .SiteConfig.IsIndex) }}checked{{ end }} 155 + /> 156 + <div> 157 + <span class="font-medium">sub-path site</span> 158 + <p class="text-xs text-gray-500 dark:text-gray-400 mt-2"> 159 + {{ if .OwnerClaim }} 160 + <code>{{ .OwnerClaim.Domain }}/{{ $.RepoInfo.Name }}</code> 161 + {{ else }} 162 + e.g. <code>you.tngl.page/{{ $.RepoInfo.Name }}</code> 163 + {{ end }} 164 + </p> 165 + </div> 166 + </label> 167 + </div> 168 + </div> 169 + 170 + <div id="repo-sites-error" class="text-red-500 dark:text-red-400"></div> 171 + 172 + <div class="flex justify-end items-center gap-2"> 173 + <button 174 + type="submit" 175 + class="btn-create flex items-center gap-2 group"> 176 + {{ i "save" "size-4" }} 177 + save 178 + <span id="sites-spinner" class="group"> 179 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 180 + </span> 181 + </button> 182 + </div> 183 + 184 + </fieldset> 185 + </form> 186 + 187 + {{ if .SiteConfig }} 188 + <div class="grid grid-cols-1 md:grid-cols-3 gap-4 items-center"> 189 + <div class="col-span-1 md:col-span-2"> 190 + <h2 class="text-sm pb-2 uppercase text-red-500 dark:text-red-400 font-bold">Disable Site</h2> 191 + <p class="text-red-500 dark:text-red-400"> 192 + Removes the site configuration for this repository. The site will no longer be served. 193 + </p> 194 + </div> 195 + <div class="col-span-1 md:col-span-1 md:justify-self-end"> 196 + <form 197 + hx-delete="/{{ $.RepoInfo.FullName }}/settings/sites" 198 + hx-confirm="Disable site for {{ $.RepoInfo.Name }}? The configuration will be removed." 199 + hx-swap="none"> 200 + <button 201 + type="submit" 202 + class="btn group flex items-center gap-2 text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300" 203 + {{ if not .RepoInfo.Roles.IsOwner }}disabled{{ end }}> 204 + {{ i "trash-2" "size-4" }} 205 + disable 206 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 207 + </button> 208 + </form> 209 + </div> 210 + </div> 211 + {{ end }} 212 + 213 + <div class="flex flex-col gap-3"> 214 + <h2 class="text-sm uppercase font-bold">Recent Deploys</h2> 215 + {{ if .Deploys }} 216 + <div class="flex flex-col divide-y divide-gray-200 dark:divide-gray-700 border border-gray-200 dark:border-gray-700 rounded"> 217 + {{ range .Deploys }} 218 + <div class="flex flex-col gap-1 p-3 text-sm"> 219 + <div class="flex items-center gap-2"> 220 + {{ if eq .Status "success" }} 221 + <span class="inline-flex items-center gap-1 px-2 py-0.5 text-xs rounded bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200"> 222 + {{ i "circle-check" "size-3" }} 223 + success 224 + </span> 225 + {{ else }} 226 + <span class="inline-flex items-center gap-1 px-2 py-0.5 text-xs rounded bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200"> 227 + {{ i "circle-x" "size-3" }} 228 + failed 229 + </span> 230 + {{ end }} 231 + {{ if eq .Trigger "push" }} 232 + <span class="inline-flex items-center gap-1 px-2 py-0.5 text-xs rounded bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200"> 233 + {{ i "git-commit-horizontal" "size-3" }} 234 + push 235 + </span> 236 + {{ else if eq .Trigger "config_change" }} 237 + <span class="inline-flex items-center gap-1 px-2 py-0.5 text-xs rounded bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300"> 238 + {{ i "settings" "size-3" }} 239 + config change 240 + </span> 241 + {{ end }} 242 + <span class="font-mono text-xs text-gray-600 dark:text-gray-400"> 243 + {{ .Branch }}{{ if ne .Dir "/" }}{{ .Dir }}{{ end }} 244 + </span> 245 + {{ if .CommitSHA }} 246 + <span class="font-mono text-xs text-gray-400 dark:text-gray-500"> 247 + {{ slice .CommitSHA 0 7 }} 248 + </span> 249 + {{ end }} 250 + <span class="ml-auto text-xs text-gray-400 dark:text-gray-500 whitespace-nowrap"> 251 + {{ template "repo/fragments/shortTimeAgo" .CreatedAt }} 252 + </span> 253 + </div> 254 + {{ if .Error }} 255 + <div class="mt-1 text-xs font-mono text-red-600 dark:text-red-400 bg-red-50 dark:bg-red-900/20 rounded p-2 break-all"> 256 + {{ .Error }} 257 + </div> 258 + {{ end }} 259 + </div> 260 + {{ end }} 261 + </div> 262 + {{ else }} 263 + <div class="flex items-center justify-center p-6 text-gray-500 dark:text-gray-400 border border-gray-200 dark:border-gray-700 rounded"> 264 + no deploys yet 265 + </div> 266 + {{ end }} 267 + </div> 268 + {{ end }}
+138
appview/pages/templates/user/settings/sites.html
··· 1 + {{ define "title" }}{{ .Tab }} settings{{ end }} 2 + 3 + {{ define "content" }} 4 + <div class="p-6"> 5 + <p class="text-xl font-bold dark:text-white">Settings</p> 6 + </div> 7 + <div class="bg-white dark:bg-gray-800 p-6 rounded relative w-full mx-auto drop-shadow-sm dark:text-white"> 8 + <section class="w-full grid grid-cols-1 md:grid-cols-4 gap-6"> 9 + <div class="col-span-1"> 10 + {{ template "user/settings/fragments/sidebar" . }} 11 + </div> 12 + <div class="col-span-1 md:col-span-3 flex flex-col gap-6"> 13 + {{ template "sitesSettings" . }} 14 + </div> 15 + </section> 16 + </div> 17 + {{ end }} 18 + 19 + {{ define "sitesSettings" }} 20 + <div class="grid grid-cols-1 md:grid-cols-3 gap-4 items-center"> 21 + <div class="col-span-1 md:col-span-2"> 22 + <h2 class="text-sm pb-2 uppercase font-bold">Git Sites</h2> 23 + {{ if .IsTnglHandle }} 24 + <p class="text-gray-500 dark:text-gray-400"> 25 + Since your handle is on <code>tngl.sh</code>, it doubles as your sites domain&mdash;your site will be served from that subdomain automatically. 26 + </p> 27 + {{ else }} 28 + <p class="text-gray-500 dark:text-gray-400"> 29 + Claim a subdomain of <code>{{ .SitesDomain }}</code> to serve a repository as a static site. 30 + Each account may hold one domain at a time. A released domain enters a 30-day cooldown before it can be claimed again. 31 + </p> 32 + {{ end }} 33 + </div> 34 + {{ if not .Claim }} 35 + <div class="col-span-1 md:col-span-1 md:justify-self-end"> 36 + {{ template "claimDomainButton" . }} 37 + </div> 38 + {{ end }} 39 + </div> 40 + 41 + <div class="flex flex-col rounded border border-gray-200 dark:border-gray-700 divide-y divide-gray-200 dark:divide-gray-700 w-full"> 42 + {{ if .Claim }} 43 + {{ template "activeClaim" . }} 44 + {{ else }} 45 + <div class="flex items-center justify-center p-2 text-gray-500"> 46 + no domain claimed yet 47 + </div> 48 + {{ end }} 49 + </div> 50 + 51 + 52 + {{ if .Claim }} 53 + <p class="text-gray-500 dark:text-gray-400"> 54 + To deploy your site on this domain, <a href="https://docs.tangled.org/TODO">read the docs</a>. 55 + </p> 56 + {{ end }} 57 + {{ end }} 58 + 59 + {{ define "activeClaim" }} 60 + <div class="flex items-center justify-between p-2"> 61 + <div class="flex items-center gap-3"> 62 + {{ i "globe" "size-4 text-gray-400 dark:text-gray-500 flex-shrink-0" }} 63 + <div class="flex items-center gap-2"> 64 + <span class="font-mono">{{ .Claim.Domain }}</span> 65 + <span class="inline-flex items-center gap-1 text-xs bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200 px-2 py-1 rounded">{{ i "circle-check" "size-3" }} active</span> 66 + </div> 67 + </div> 68 + <form 69 + hx-delete="/settings/sites" 70 + hx-confirm="Release {{ .Claim.Domain }}? The domain will enter a 30-day cooldown before it can be claimed by anyone else." 71 + hx-swap="none" 72 + > 73 + <input type="hidden" name="domain" value="{{ .Claim.Domain }}" /> 74 + <button 75 + type="submit" 76 + class="btn flex items-center gap-2 text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300"> 77 + {{ i "trash-2" "size-4" }} 78 + release 79 + </button> 80 + </form> 81 + </div> 82 + {{ end }} 83 + 84 + {{ define "claimDomainButton" }} 85 + <button 86 + class="btn flex items-center gap-2" 87 + popovertarget="claim-domain-modal" 88 + popovertargetaction="toggle"> 89 + {{ i "plus" "size-4" }} 90 + claim domain 91 + </button> 92 + <div 93 + id="claim-domain-modal" 94 + popover 95 + class="bg-white w-full md:w-96 dark:bg-gray-800 p-4 rounded border border-gray-200 dark:border-gray-700 drop-shadow dark:text-white backdrop:bg-gray-400/50 dark:backdrop:bg-gray-800/50"> 96 + {{ template "claimDomainModal" . }} 97 + </div> 98 + {{ end }} 99 + 100 + {{ define "claimDomainModal" }} 101 + <form 102 + hx-put="/settings/sites" 103 + hx-indicator="#claim-spinner" 104 + hx-swap="none" 105 + class="flex flex-col gap-2" 106 + > 107 + <label class="uppercase p-0">claim a subdomain</label> 108 + <p class="text-gray-500 dark:text-gray-400">Choose a subdomain under <code>{{ .SitesDomain }}</code>. Only lowercase letters, digits, and hyphens are allowed.</p> 109 + <div class="flex items-stretch rounded border border-gray-200 dark:border-gray-600 overflow-hidden focus-within:ring-1 focus-within:ring-blue-500 dark:bg-gray-700"> 110 + <input 111 + type="text" 112 + name="subdomain" 113 + required 114 + placeholder="yourname" 115 + pattern="[a-z0-9][a-z0-9\-]{0,61}[a-z0-9]" 116 + class="flex-1 px-2 py-1.5 bg-transparent dark:text-white border-0 focus:outline-none focus:ring-0 min-w-0" 117 + /> 118 + <span class="px-2 py-1.5 bg-gray-100 dark:bg-gray-600 text-gray-500 dark:text-gray-300 select-none whitespace-nowrap border-l border-gray-200 dark:border-gray-600">.{{ .SitesDomain }}</span> 119 + </div> 120 + <div class="flex gap-2 pt-2"> 121 + <button 122 + type="button" 123 + popovertarget="claim-domain-modal" 124 + popovertargetaction="hide" 125 + class="btn w-1/2 flex items-center gap-2 text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300"> 126 + {{ i "x" "size-4" }} cancel 127 + </button> 128 + <button type="submit" class="btn w-1/2 flex items-center"> 129 + <span class="inline-flex gap-2 items-center">{{ i "plus" "size-4" }} claim</span> 130 + <span id="claim-spinner" class="group"> 131 + {{ i "loader-circle" "ml-2 w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 132 + </span> 133 + </button> 134 + </div> 135 + <div id="settings-sites-error" class="text-red-500 dark:text-red-400"></div> 136 + <div id="settings-sites-success" class="text-green-500 dark:text-green-400"></div> 137 + </form> 138 + {{ end }}
+7 -1
appview/repo/repo.go
··· 12 12 "strings" 13 13 "time" 14 14 15 + "tangled.org/core/appview/cloudflare" 16 + 15 17 "tangled.org/core/api/tangled" 16 18 "tangled.org/core/appview/config" 17 19 "tangled.org/core/appview/db" ··· 50 52 logger *slog.Logger 51 53 serviceAuth *serviceauth.ServiceAuth 52 54 validator *validator.Validator 55 + cfClient *cloudflare.Client 53 56 } 54 57 55 58 func New( ··· 64 67 enforcer *rbac.Enforcer, 65 68 logger *slog.Logger, 66 69 validator *validator.Validator, 70 + cfClient *cloudflare.Client, 67 71 ) *Repo { 68 - return &Repo{oauth: oauth, 72 + return &Repo{ 73 + oauth: oauth, 69 74 repoResolver: repoResolver, 70 75 pages: pages, 71 76 idResolver: idResolver, ··· 76 81 enforcer: enforcer, 77 82 logger: logger, 78 83 validator: validator, 84 + cfClient: cfClient, 79 85 } 80 86 } 81 87
+4
appview/repo/router.go
··· 87 87 r.Put("/branches/default", rp.SetDefaultBranch) 88 88 r.Put("/secrets", rp.Secrets) 89 89 r.Delete("/secrets", rp.Secrets) 90 + r.With(mw.RepoPermissionMiddleware("repo:owner")).Route("/sites", func(r chi.Router) { 91 + r.Put("/", rp.SaveRepoSiteConfig) 92 + r.Delete("/", rp.DeleteRepoSiteConfig) 93 + }) 90 94 r.With(mw.RepoPermissionMiddleware("repo:owner")).Route("/hooks", func(r chi.Router) { 91 95 r.Get("/", rp.Webhooks) 92 96 r.Post("/", rp.AddWebhook)
+207
appview/repo/settings.go
··· 1 1 package repo 2 2 3 3 import ( 4 + "context" 4 5 "encoding/json" 5 6 "fmt" 6 7 "net/http" 8 + "path" 7 9 "slices" 8 10 "strings" 9 11 "time" 10 12 11 13 "tangled.org/core/api/tangled" 14 + 12 15 "tangled.org/core/appview/db" 13 16 "tangled.org/core/appview/models" 14 17 "tangled.org/core/appview/oauth" 15 18 "tangled.org/core/appview/pages" 19 + "tangled.org/core/appview/sites" 16 20 xrpcclient "tangled.org/core/appview/xrpcclient" 17 21 "tangled.org/core/orm" 18 22 "tangled.org/core/types" ··· 170 174 171 175 case "hooks": 172 176 rp.Webhooks(w, r) 177 + 178 + case "sites": 179 + rp.sitesSettings(w, r) 173 180 } 181 + } 182 + 183 + func (rp *Repo) sitesSettings(w http.ResponseWriter, r *http.Request) { 184 + l := rp.logger.With("handler", "sitesSettings") 185 + 186 + f, err := rp.repoResolver.Resolve(r) 187 + if err != nil { 188 + l.Error("failed to get repo and knot", "err", err) 189 + return 190 + } 191 + user := rp.oauth.GetMultiAccountUser(r) 192 + 193 + scheme := "http" 194 + if !rp.config.Core.Dev { 195 + scheme = "https" 196 + } 197 + host := fmt.Sprintf("%s://%s", scheme, f.Knot) 198 + xrpcc := &indigoxrpc.Client{Host: host} 199 + 200 + repo := fmt.Sprintf("%s/%s", f.Did, f.Name) 201 + xrpcBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo) 202 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 203 + l.Error("failed to call XRPC repo.branches", "err", xrpcerr) 204 + rp.pages.Error503(w) 205 + return 206 + } 207 + 208 + var result types.RepoBranchesResponse 209 + if err := json.Unmarshal(xrpcBytes, &result); err != nil { 210 + l.Error("failed to decode XRPC response", "err", err) 211 + rp.pages.Error503(w) 212 + return 213 + } 214 + 215 + siteConfig, err := db.GetRepoSiteConfig(rp.db, f.RepoAt().String()) 216 + if err != nil { 217 + l.Error("failed to get site config", "err", err) 218 + rp.pages.Error503(w) 219 + return 220 + } 221 + 222 + ownerClaim, err := db.GetActiveDomainClaimForDid(rp.db, f.Did) 223 + if err != nil { 224 + l.Error("failed to get owner domain claim", "err", err) 225 + // non-fatal — just show no claim 226 + ownerClaim = nil 227 + } 228 + 229 + deploys, err := db.GetSiteDeploys(rp.db, f.RepoAt().String(), 20) 230 + if err != nil { 231 + l.Error("failed to get site deploys", "err", err) 232 + // non-fatal 233 + deploys = nil 234 + } 235 + 236 + indexSiteTakenBy, err := db.GetIndexRepoAtForDid(rp.db, f.Did, f.RepoAt().String()) 237 + if err != nil { 238 + l.Error("failed to get index site owner", "err", err) 239 + // non-fatal 240 + indexSiteTakenBy = "" 241 + } 242 + 243 + rp.pages.RepoSiteSettings(w, pages.RepoSiteSettingsParams{ 244 + LoggedInUser: user, 245 + RepoInfo: rp.repoResolver.GetRepoInfo(r, user), 246 + Branches: result.Branches, 247 + SiteConfig: siteConfig, 248 + OwnerClaim: ownerClaim, 249 + Deploys: deploys, 250 + IndexSiteTakenBy: indexSiteTakenBy, 251 + }) 252 + } 253 + 254 + func (rp *Repo) SaveRepoSiteConfig(w http.ResponseWriter, r *http.Request) { 255 + l := rp.logger.With("handler", "SaveRepoSiteConfig") 256 + 257 + noticeId := "repo-sites-error" 258 + 259 + f, err := rp.repoResolver.Resolve(r) 260 + if err != nil { 261 + l.Error("failed to get repo and knot", "err", err) 262 + rp.pages.Notice(w, noticeId, "Failed to load repository.") 263 + return 264 + } 265 + 266 + branch := strings.TrimSpace(r.FormValue("branch")) 267 + if branch == "" { 268 + rp.pages.Notice(w, noticeId, "Branch cannot be empty.") 269 + return 270 + } 271 + 272 + dir := strings.TrimSpace(r.FormValue("dir")) 273 + if dir == "" { 274 + dir = "/" 275 + } 276 + 277 + // Normalise: always starts with /, no trailing slash (except root), no ".." 278 + dir = path.Clean("/" + dir) 279 + if dir != "/" && strings.Contains(dir, "..") { 280 + rp.pages.Notice(w, noticeId, "Invalid directory path.") 281 + return 282 + } 283 + 284 + isIndex := r.FormValue("is_index") == "true" 285 + 286 + if err := db.SetRepoSiteConfig(rp.db, f.RepoAt().String(), branch, dir, isIndex); err != nil { 287 + l.Error("failed to save site config", "err", err) 288 + rp.pages.Notice(w, noticeId, "Failed to save site configuration.") 289 + return 290 + } 291 + 292 + // Trigger an initial deploy asynchronously so the handler returns promptly. 293 + // Skip entirely if there is no active domain claim — the site cannot be served anyway. 294 + ownerClaim, _ := db.GetActiveDomainClaimForDid(rp.db, f.Did) 295 + if ownerClaim == nil { 296 + rp.logger.Info("skipping deploy: no active domain claim", "repo", f.DidSlashRepo()) 297 + } else if rp.cfClient.Enabled() { 298 + scheme := "http" 299 + if !rp.config.Core.Dev { 300 + scheme = "https" 301 + } 302 + knotHost := fmt.Sprintf("%s://%s", scheme, f.Knot) 303 + 304 + go func() { 305 + ctx := context.Background() 306 + 307 + deploy := &models.SiteDeploy{ 308 + RepoAt: f.RepoAt().String(), 309 + Branch: branch, 310 + Dir: dir, 311 + Trigger: models.SiteDeployTriggerConfigChange, 312 + } 313 + 314 + deployErr := sites.Deploy(ctx, rp.cfClient, knotHost, f.Did, f.Name, branch, dir) 315 + if deployErr != nil { 316 + l.Error("sites: initial R2 sync failed", "repo", f.DidSlashRepo(), "err", deployErr) 317 + deploy.Status = models.SiteDeployStatusFailure 318 + deploy.Error = deployErr.Error() 319 + } else { 320 + deploy.Status = models.SiteDeployStatusSuccess 321 + } 322 + 323 + if err := db.AddSiteDeploy(rp.db, deploy); err != nil { 324 + l.Error("sites: failed to record deploy", "repo", f.DidSlashRepo(), "err", err) 325 + } 326 + 327 + if deployErr == nil { 328 + if err := sites.PutDomainMapping(ctx, rp.cfClient, ownerClaim.Domain, f.Did, f.Name, isIndex); err != nil { 329 + l.Error("sites: KV write failed", "domain", ownerClaim.Domain, "err", err) 330 + } 331 + rp.logger.Info("site deployed to r2", "repo", f.DidSlashRepo(), "is_index", isIndex) 332 + } 333 + }() 334 + } else { 335 + rp.logger.Warn("cloudflare integration is disabled; site won't be deployed", "repo", f.DidSlashRepo()) 336 + } 337 + 338 + rp.pages.HxRefresh(w) 339 + } 340 + 341 + func (rp *Repo) DeleteRepoSiteConfig(w http.ResponseWriter, r *http.Request) { 342 + l := rp.logger.With("handler", "DeleteRepoSiteConfig") 343 + 344 + noticeId := "repo-sites-error" 345 + 346 + f, err := rp.repoResolver.Resolve(r) 347 + if err != nil { 348 + l.Error("failed to get repo and knot", "err", err) 349 + rp.pages.Notice(w, noticeId, "Failed to load repository.") 350 + return 351 + } 352 + 353 + // Fetch the current config before deleting so we know the isIndex flag for 354 + // the KV key and the domain mapping to clean up. 355 + existingConfig, _ := db.GetRepoSiteConfig(rp.db, f.RepoAt().String()) 356 + 357 + if err := db.DeleteRepoSiteConfig(rp.db, f.RepoAt().String()); err != nil { 358 + l.Error("failed to delete site config", "err", err) 359 + rp.pages.Notice(w, noticeId, "Failed to remove site configuration.") 360 + return 361 + } 362 + 363 + // Clean up R2 objects and KV entry asynchronously. 364 + if rp.cfClient.Enabled() && existingConfig != nil { 365 + ownerClaim, _ := db.GetActiveDomainClaimForDid(rp.db, f.Did) 366 + 367 + go func() { 368 + ctx := context.Background() 369 + if err := sites.Delete(ctx, rp.cfClient, f.Did, f.Name); err != nil { 370 + l.Error("sites: R2 delete failed", "repo", f.DidSlashRepo(), "err", err) 371 + } 372 + if ownerClaim != nil { 373 + if err := sites.DeleteDomainMapping(ctx, rp.cfClient, ownerClaim.Domain, f.Name); err != nil { 374 + l.Error("sites: KV delete failed", "domain", ownerClaim.Domain, "err", err) 375 + } 376 + } 377 + }() 378 + } 379 + 380 + rp.pages.HxRefresh(w) 174 381 } 175 382 176 383 func (rp *Repo) generalSettings(w http.ResponseWriter, r *http.Request) {
+28 -28
input.css
··· 90 90 } 91 91 92 92 label { 93 - @apply block text-gray-900 text-sm font-bold py-2 uppercase dark:text-gray-100; 93 + @apply block text-gray-900 text-sm py-2 dark:text-gray-100; 94 94 } 95 - input, textarea { 96 - @apply 97 - block rounded p-3 95 + input, 96 + textarea { 97 + @apply block rounded p-3 98 98 bg-gray-50 dark:bg-gray-800 dark:text-white 99 99 border border-gray-300 dark:border-gray-600 100 100 focus:outline-none focus:ring-1 focus:ring-gray-400 dark:focus:ring-gray-500; ··· 104 104 } 105 105 106 106 code { 107 - @apply font-mono rounded bg-gray-100 dark:bg-gray-700 text-black dark:text-white; 107 + @apply p-1 font-mono rounded bg-gray-100 dark:bg-gray-700 text-black dark:text-white; 108 108 } 109 109 } 110 110 ··· 126 126 } 127 127 128 128 .btn-flat { 129 - @apply relative z-10 inline-flex min-h-[30px] cursor-pointer items-center justify-center 129 + @apply relative z-10 inline-flex min-h-[30px] cursor-pointer items-center justify-center 130 130 bg-transparent px-2 pb-[0.2rem] text-sm text-gray-900 131 131 before:absolute before:inset-0 before:-z-10 before:block before:rounded 132 132 before:border before:border-gray-200 before:bg-white ··· 277 277 details[data-callout] > summary::-webkit-details-marker { 278 278 display: none; 279 279 } 280 - 281 280 } 282 281 @layer utilities { 283 282 .error { ··· 334 333 animation: fadeOut 0.25s ease-out forwards; 335 334 } 336 335 } 337 - 338 336 } 339 337 340 338 /* Background */ ··· 1011 1009 } 1012 1010 1013 1011 actor-typeahead { 1014 - --color-background: #ffffff; 1015 - --color-border: #d1d5db; 1016 - --color-shadow: #000000; 1017 - --color-hover: #f9fafb; 1018 - --color-avatar-fallback: #e5e7eb; 1019 - --radius: 0.0; 1020 - --padding-menu: 0.0rem; 1021 - z-index: 1000; 1012 + --color-background: #ffffff; 1013 + --color-border: #d1d5db; 1014 + --color-shadow: #000000; 1015 + --color-hover: #f9fafb; 1016 + --color-avatar-fallback: #e5e7eb; 1017 + --radius: 0; 1018 + --padding-menu: 0rem; 1019 + z-index: 1000; 1022 1020 } 1023 1021 1024 1022 actor-typeahead::part(handle) { 1025 - color: #111827; 1023 + color: #111827; 1026 1024 } 1027 1025 1028 1026 actor-typeahead::part(menu) { 1029 - box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1); 1027 + box-shadow: 1028 + 0 4px 6px -1px rgb(0 0 0 / 0.1), 1029 + 0 2px 4px -2px rgb(0 0 0 / 0.1); 1030 1030 } 1031 1031 1032 1032 @media (prefers-color-scheme: dark) { 1033 - actor-typeahead { 1034 - --color-background: #1f2937; 1035 - --color-border: #4b5563; 1036 - --color-shadow: #000000; 1037 - --color-hover: #374151; 1038 - --color-avatar-fallback: #4b5563; 1039 - } 1033 + actor-typeahead { 1034 + --color-background: #1f2937; 1035 + --color-border: #4b5563; 1036 + --color-shadow: #000000; 1037 + --color-hover: #374151; 1038 + --color-avatar-fallback: #4b5563; 1039 + } 1040 1040 1041 - actor-typeahead::part(handle) { 1042 - color: #f9fafb; 1043 - } 1041 + actor-typeahead::part(handle) { 1042 + color: #f9fafb; 1043 + } 1044 1044 }