Mirror of @tangled.org/core. Running on a Raspberry Pi Zero 2 (Please be gentle).

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" ··· 52 50 logger *slog.Logger 53 51 serviceAuth *serviceauth.ServiceAuth 54 52 validator *validator.Validator 53 + cfClient *cloudflare.Client 55 54 } 56 55 57 56 func New( ··· 67 64 enforcer *rbac.Enforcer, 68 65 logger *slog.Logger, 69 66 validator *validator.Validator, 67 + cfClient *cloudflare.Client, 70 68 ) *Repo { 71 - return &Repo{oauth: oauth, 69 + return &Repo{ 70 + oauth: oauth, 72 71 repoResolver: repoResolver, 73 72 pages: pages, 74 73 idResolver: idResolver, ··· 81 76 enforcer: enforcer, 82 77 logger: logger, 83 78 validator: validator, 79 + cfClient: cfClient, 84 80 } 85 81 } 86 82
+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" ··· 174 170 175 171 case "hooks": 176 172 rp.Webhooks(w, r) 173 + 174 + case "sites": 175 + rp.sitesSettings(w, r) 177 176 } 177 + } 178 + 179 + func (rp *Repo) sitesSettings(w http.ResponseWriter, r *http.Request) { 180 + l := rp.logger.With("handler", "sitesSettings") 181 + 182 + f, err := rp.repoResolver.Resolve(r) 183 + if err != nil { 184 + l.Error("failed to get repo and knot", "err", err) 185 + return 186 + } 187 + user := rp.oauth.GetMultiAccountUser(r) 188 + 189 + scheme := "http" 190 + if !rp.config.Core.Dev { 191 + scheme = "https" 192 + } 193 + host := fmt.Sprintf("%s://%s", scheme, f.Knot) 194 + xrpcc := &indigoxrpc.Client{Host: host} 195 + 196 + repo := fmt.Sprintf("%s/%s", f.Did, f.Name) 197 + xrpcBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo) 198 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 199 + l.Error("failed to call XRPC repo.branches", "err", xrpcerr) 200 + rp.pages.Error503(w) 201 + return 202 + } 203 + 204 + var result types.RepoBranchesResponse 205 + if err := json.Unmarshal(xrpcBytes, &result); err != nil { 206 + l.Error("failed to decode XRPC response", "err", err) 207 + rp.pages.Error503(w) 208 + return 209 + } 210 + 211 + siteConfig, err := db.GetRepoSiteConfig(rp.db, f.RepoAt().String()) 212 + if err != nil { 213 + l.Error("failed to get site config", "err", err) 214 + rp.pages.Error503(w) 215 + return 216 + } 217 + 218 + ownerClaim, err := db.GetActiveDomainClaimForDid(rp.db, f.Did) 219 + if err != nil { 220 + l.Error("failed to get owner domain claim", "err", err) 221 + // non-fatal — just show no claim 222 + ownerClaim = nil 223 + } 224 + 225 + deploys, err := db.GetSiteDeploys(rp.db, f.RepoAt().String(), 20) 226 + if err != nil { 227 + l.Error("failed to get site deploys", "err", err) 228 + // non-fatal 229 + deploys = nil 230 + } 231 + 232 + indexSiteTakenBy, err := db.GetIndexRepoAtForDid(rp.db, f.Did, f.RepoAt().String()) 233 + if err != nil { 234 + l.Error("failed to get index site owner", "err", err) 235 + // non-fatal 236 + indexSiteTakenBy = "" 237 + } 238 + 239 + rp.pages.RepoSiteSettings(w, pages.RepoSiteSettingsParams{ 240 + LoggedInUser: user, 241 + RepoInfo: rp.repoResolver.GetRepoInfo(r, user), 242 + Branches: result.Branches, 243 + SiteConfig: siteConfig, 244 + OwnerClaim: ownerClaim, 245 + Deploys: deploys, 246 + IndexSiteTakenBy: indexSiteTakenBy, 247 + }) 248 + } 249 + 250 + func (rp *Repo) SaveRepoSiteConfig(w http.ResponseWriter, r *http.Request) { 251 + l := rp.logger.With("handler", "SaveRepoSiteConfig") 252 + 253 + noticeId := "repo-sites-error" 254 + 255 + f, err := rp.repoResolver.Resolve(r) 256 + if err != nil { 257 + l.Error("failed to get repo and knot", "err", err) 258 + rp.pages.Notice(w, noticeId, "Failed to load repository.") 259 + return 260 + } 261 + 262 + branch := strings.TrimSpace(r.FormValue("branch")) 263 + if branch == "" { 264 + rp.pages.Notice(w, noticeId, "Branch cannot be empty.") 265 + return 266 + } 267 + 268 + dir := strings.TrimSpace(r.FormValue("dir")) 269 + if dir == "" { 270 + dir = "/" 271 + } 272 + 273 + // Normalise: always starts with /, no trailing slash (except root), no ".." 274 + dir = path.Clean("/" + dir) 275 + if dir != "/" && strings.Contains(dir, "..") { 276 + rp.pages.Notice(w, noticeId, "Invalid directory path.") 277 + return 278 + } 279 + 280 + isIndex := r.FormValue("is_index") == "true" 281 + 282 + if err := db.SetRepoSiteConfig(rp.db, f.RepoAt().String(), branch, dir, isIndex); err != nil { 283 + l.Error("failed to save site config", "err", err) 284 + rp.pages.Notice(w, noticeId, "Failed to save site configuration.") 285 + return 286 + } 287 + 288 + // Trigger an initial deploy asynchronously so the handler returns promptly. 289 + // Skip entirely if there is no active domain claim — the site cannot be served anyway. 290 + ownerClaim, _ := db.GetActiveDomainClaimForDid(rp.db, f.Did) 291 + if ownerClaim == nil { 292 + rp.logger.Info("skipping deploy: no active domain claim", "repo", f.DidSlashRepo()) 293 + } else if rp.cfClient.Enabled() { 294 + scheme := "http" 295 + if !rp.config.Core.Dev { 296 + scheme = "https" 297 + } 298 + knotHost := fmt.Sprintf("%s://%s", scheme, f.Knot) 299 + 300 + go func() { 301 + ctx := context.Background() 302 + 303 + deploy := &models.SiteDeploy{ 304 + RepoAt: f.RepoAt().String(), 305 + Branch: branch, 306 + Dir: dir, 307 + Trigger: models.SiteDeployTriggerConfigChange, 308 + } 309 + 310 + deployErr := sites.Deploy(ctx, rp.cfClient, knotHost, f.Did, f.Name, branch, dir) 311 + if deployErr != nil { 312 + l.Error("sites: initial R2 sync failed", "repo", f.DidSlashRepo(), "err", deployErr) 313 + deploy.Status = models.SiteDeployStatusFailure 314 + deploy.Error = deployErr.Error() 315 + } else { 316 + deploy.Status = models.SiteDeployStatusSuccess 317 + } 318 + 319 + if err := db.AddSiteDeploy(rp.db, deploy); err != nil { 320 + l.Error("sites: failed to record deploy", "repo", f.DidSlashRepo(), "err", err) 321 + } 322 + 323 + if deployErr == nil { 324 + if err := sites.PutDomainMapping(ctx, rp.cfClient, ownerClaim.Domain, f.Did, f.Name, isIndex); err != nil { 325 + l.Error("sites: KV write failed", "domain", ownerClaim.Domain, "err", err) 326 + } 327 + rp.logger.Info("site deployed to r2", "repo", f.DidSlashRepo(), "is_index", isIndex) 328 + } 329 + }() 330 + } else { 331 + rp.logger.Warn("cloudflare integration is disabled; site won't be deployed", "repo", f.DidSlashRepo()) 332 + } 333 + 334 + rp.pages.HxRefresh(w) 335 + } 336 + 337 + func (rp *Repo) DeleteRepoSiteConfig(w http.ResponseWriter, r *http.Request) { 338 + l := rp.logger.With("handler", "DeleteRepoSiteConfig") 339 + 340 + noticeId := "repo-sites-error" 341 + 342 + f, err := rp.repoResolver.Resolve(r) 343 + if err != nil { 344 + l.Error("failed to get repo and knot", "err", err) 345 + rp.pages.Notice(w, noticeId, "Failed to load repository.") 346 + return 347 + } 348 + 349 + // Fetch the current config before deleting so we know the isIndex flag for 350 + // the KV key and the domain mapping to clean up. 351 + existingConfig, _ := db.GetRepoSiteConfig(rp.db, f.RepoAt().String()) 352 + 353 + if err := db.DeleteRepoSiteConfig(rp.db, f.RepoAt().String()); err != nil { 354 + l.Error("failed to delete site config", "err", err) 355 + rp.pages.Notice(w, noticeId, "Failed to remove site configuration.") 356 + return 357 + } 358 + 359 + // Clean up R2 objects and KV entry asynchronously. 360 + if rp.cfClient.Enabled() && existingConfig != nil { 361 + ownerClaim, _ := db.GetActiveDomainClaimForDid(rp.db, f.Did) 362 + 363 + go func() { 364 + ctx := context.Background() 365 + if err := sites.Delete(ctx, rp.cfClient, f.Did, f.Name); err != nil { 366 + l.Error("sites: R2 delete failed", "repo", f.DidSlashRepo(), "err", err) 367 + } 368 + if ownerClaim != nil { 369 + if err := sites.DeleteDomainMapping(ctx, rp.cfClient, ownerClaim.Domain, f.Name); err != nil { 370 + l.Error("sites: KV delete failed", "domain", ownerClaim.Domain, "err", err) 371 + } 372 + } 373 + }() 374 + } 375 + 376 + rp.pages.HxRefresh(w) 178 377 } 179 378 180 379 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 { ··· 333 334 animation: fadeOut 0.25s ease-out forwards; 334 335 } 335 336 } 336 - 337 337 } 338 338 339 339 /* Background */ ··· 1009 1011 } 1010 1012 1011 1013 actor-typeahead { 1012 - --color-background: #ffffff; 1013 - --color-border: #d1d5db; 1014 - --color-shadow: #000000; 1015 - --color-hover: #f9fafb; 1016 - --color-avatar-fallback: #e5e7eb; 1017 - --radius: 0.0; 1018 - --padding-menu: 0.0rem; 1019 - z-index: 1000; 1014 + --color-background: #ffffff; 1015 + --color-border: #d1d5db; 1016 + --color-shadow: #000000; 1017 + --color-hover: #f9fafb; 1018 + --color-avatar-fallback: #e5e7eb; 1019 + --radius: 0; 1020 + --padding-menu: 0rem; 1021 + z-index: 1000; 1020 1022 } 1021 1023 1022 1024 actor-typeahead::part(handle) { 1023 - color: #111827; 1025 + color: #111827; 1024 1026 } 1025 1027 1026 1028 actor-typeahead::part(menu) { 1027 - box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1); 1029 + box-shadow: 1030 + 0 4px 6px -1px rgb(0 0 0 / 0.1), 1031 + 0 2px 4px -2px rgb(0 0 0 / 0.1); 1028 1032 } 1029 1033 1030 1034 @media (prefers-color-scheme: dark) { 1031 - actor-typeahead { 1032 - --color-background: #1f2937; 1033 - --color-border: #4b5563; 1034 - --color-shadow: #000000; 1035 - --color-hover: #374151; 1036 - --color-avatar-fallback: #4b5563; 1037 - } 1035 + actor-typeahead { 1036 + --color-background: #1f2937; 1037 + --color-border: #4b5563; 1038 + --color-shadow: #000000; 1039 + --color-hover: #374151; 1040 + --color-avatar-fallback: #4b5563; 1041 + } 1038 1042 1039 - actor-typeahead::part(handle) { 1040 - color: #f9fafb; 1041 - } 1043 + actor-typeahead::part(handle) { 1044 + color: #f9fafb; 1045 + } 1042 1046 }