From efb26f139928414927ea46c3f0780ec2f47d3b55 Mon Sep 17 00:00:00 2001 From: Anirudh Oppiliappan Date: Fri, 20 Feb 2026 12:11:18 +0200 Subject: [PATCH] appview/{repo,pages}: git sites settings ui, deploy and delete handlers Signed-off-by: Anirudh Oppiliappan --- appview/pages/funcmap.go | 1 + appview/pages/pages.go | 18 ++ .../pages/templates/repo/settings/sites.html | 268 ++++++++++++++++++ .../pages/templates/user/settings/sites.html | 138 +++++++++ appview/repo/repo.go | 8 +- appview/repo/router.go | 4 + appview/repo/settings.go | 207 ++++++++++++++ input.css | 56 ++-- 8 files changed, 671 insertions(+), 29 deletions(-) create mode 100644 appview/pages/templates/repo/settings/sites.html create mode 100644 appview/pages/templates/user/settings/sites.html diff --git a/appview/pages/funcmap.go b/appview/pages/funcmap.go index 92aa3fec..7a2ed11c 100644 --- a/appview/pages/funcmap.go +++ b/appview/pages/funcmap.go @@ -475,6 +475,7 @@ func (p *Pages) funcMap() template.FuncMap { {"Name": "access", "Icon": "users"}, {"Name": "pipelines", "Icon": "layers-2"}, {"Name": "hooks", "Icon": "webhook"}, + {"Name": "sites", "Icon": "globe"}, }, } }, diff --git a/appview/pages/pages.go b/appview/pages/pages.go index 55ace852..1c28ebab 100644 --- a/appview/pages/pages.go +++ b/appview/pages/pages.go @@ -1016,6 +1016,24 @@ func (p *Pages) WebhookDeliveriesList(w io.Writer, params WebhookDeliveriesListP return tpl.ExecuteTemplate(w, "repo/settings/fragments/webhookDeliveries", params) } +type RepoSiteSettingsParams struct { + LoggedInUser *oauth.MultiAccountUser + RepoInfo repoinfo.RepoInfo + Active string + Tab string + Branches []types.Branch + SiteConfig *models.RepoSite + OwnerClaim *models.DomainClaim + Deploys []models.SiteDeploy + IndexSiteTakenBy string // repo_at of another repo that already holds is_index, or "" +} + +func (p *Pages) RepoSiteSettings(w io.Writer, params RepoSiteSettingsParams) error { + params.Active = "settings" + params.Tab = "sites" + return p.executeRepo("repo/settings/sites", w, params) +} + type RepoIssuesParams struct { LoggedInUser *oauth.MultiAccountUser RepoInfo repoinfo.RepoInfo diff --git a/appview/pages/templates/repo/settings/sites.html b/appview/pages/templates/repo/settings/sites.html new file mode 100644 index 00000000..1ac98026 --- /dev/null +++ b/appview/pages/templates/repo/settings/sites.html @@ -0,0 +1,268 @@ +{{ define "title" }}{{ .Tab }} settings · {{ .RepoInfo.FullName }}{{ end }} + +{{ define "repoContent" }} +
+
+ {{ template "repo/settings/fragments/sidebar" . }} +
+
+ {{ template "repoSiteSettings" . }} +
+
+{{ end }} + +{{ define "repoSiteSettings" }} +
+
+

Git Sites

+

+ Serve a static site directly from this repository. + Choose a branch and the directory containing your index.html. + Only repository owners can configure sites. +

+
+
+ + {{ if and .SiteConfig .OwnerClaim }} + {{ if .SiteConfig.IsIndex }} +
+ {{ i "circle-check" "size-4 shrink-0" }} + live at {{ .OwnerClaim.Domain }} +
+ {{ else }} +
+ {{ i "circle-check" "size-4 shrink-0" }} + live at {{ .OwnerClaim.Domain }}/{{ .RepoInfo.Name }} +
+ {{ end }} + {{ else if and .SiteConfig (not .OwnerClaim) }} +
+ {{ i "triangle-alert" "size-4 shrink-0" }} + site is configured but not live — claim a domain to publish it. +
+ {{ else if and (not .SiteConfig) .OwnerClaim }} +
+ {{ i "circle-dashed" "size-4 shrink-0" }} + not enabled — configure a branch below to publish to {{ .OwnerClaim.Domain }}. +
+ {{ else }} +
+ {{ i "circle-dashed" "size-4 shrink-0" }} + not enabled — configure a branch below and claim a domain to publish. +
+ {{ end }} + +
+
+ +
+
+

Branch

+

+ The branch to build and deploy the site from. +

+
+
+ +
+
+ +
+
+

Deploy directory

+

+ Path within the repository that contains your index.html. + Use / for the root, or a subdirectory like /docs. +

+
+
+ +
+
+ +
+

Site type

+

+ An index site is served at the root of your sites domain. + A sub-path site is served under the repository name. +

+
+ + +
+
+ +
+ +
+ +
+ +
+
+ + {{ if .SiteConfig }} +
+
+

Disable Site

+

+ Removes the site configuration for this repository. The site will no longer be served. +

+
+
+
+ +
+
+
+ {{ end }} + +
+

Recent Deploys

+ {{ if .Deploys }} +
+ {{ range .Deploys }} +
+
+ {{ if eq .Status "success" }} + + {{ i "circle-check" "size-3" }} + success + + {{ else }} + + {{ i "circle-x" "size-3" }} + failed + + {{ end }} + {{ if eq .Trigger "push" }} + + {{ i "git-commit-horizontal" "size-3" }} + push + + {{ else if eq .Trigger "config_change" }} + + {{ i "settings" "size-3" }} + config change + + {{ end }} + + {{ .Branch }}{{ if ne .Dir "/" }}{{ .Dir }}{{ end }} + + {{ if .CommitSHA }} + + {{ slice .CommitSHA 0 7 }} + + {{ end }} + + {{ template "repo/fragments/shortTimeAgo" .CreatedAt }} + +
+ {{ if .Error }} +
+ {{ .Error }} +
+ {{ end }} +
+ {{ end }} +
+ {{ else }} +
+ no deploys yet +
+ {{ end }} +
+{{ end }} diff --git a/appview/pages/templates/user/settings/sites.html b/appview/pages/templates/user/settings/sites.html new file mode 100644 index 00000000..d5f68b13 --- /dev/null +++ b/appview/pages/templates/user/settings/sites.html @@ -0,0 +1,138 @@ +{{ define "title" }}{{ .Tab }} settings{{ end }} + +{{ define "content" }} +
+

Settings

+
+
+
+
+ {{ template "user/settings/fragments/sidebar" . }} +
+
+ {{ template "sitesSettings" . }} +
+
+
+{{ end }} + +{{ define "sitesSettings" }} +
+
+

Git Sites

+ {{ if .IsTnglHandle }} +

+ Since your handle is on tngl.sh, it doubles as your sites domain—your site will be served from that subdomain automatically. +

+ {{ else }} +

+ Claim a subdomain of {{ .SitesDomain }} to serve a repository as a static site. + Each account may hold one domain at a time. A released domain enters a 30-day cooldown before it can be claimed again. +

+ {{ end }} +
+ {{ if not .Claim }} +
+ {{ template "claimDomainButton" . }} +
+ {{ end }} +
+ +
+ {{ if .Claim }} + {{ template "activeClaim" . }} + {{ else }} +
+ no domain claimed yet +
+ {{ end }} +
+ + + {{ if .Claim }} +

+ To deploy your site on this domain, read the docs. +

+ {{ end }} +{{ end }} + +{{ define "activeClaim" }} +
+
+ {{ i "globe" "size-4 text-gray-400 dark:text-gray-500 flex-shrink-0" }} +
+ {{ .Claim.Domain }} + {{ i "circle-check" "size-3" }} active +
+
+
+ + +
+
+{{ end }} + +{{ define "claimDomainButton" }} + +
+ {{ template "claimDomainModal" . }} +
+{{ end }} + +{{ define "claimDomainModal" }} +
+ +

Choose a subdomain under {{ .SitesDomain }}. Only lowercase letters, digits, and hyphens are allowed.

+
+ + .{{ .SitesDomain }} +
+
+ + +
+
+
+
+{{ end }} \ No newline at end of file diff --git a/appview/repo/repo.go b/appview/repo/repo.go index f04f9061..81f46173 100644 --- a/appview/repo/repo.go +++ b/appview/repo/repo.go @@ -12,6 +12,8 @@ import ( "strings" "time" + "tangled.org/core/appview/cloudflare" + "tangled.org/core/api/tangled" "tangled.org/core/appview/config" "tangled.org/core/appview/db" @@ -50,6 +52,7 @@ type Repo struct { logger *slog.Logger serviceAuth *serviceauth.ServiceAuth validator *validator.Validator + cfClient *cloudflare.Client } func New( @@ -64,8 +67,10 @@ func New( enforcer *rbac.Enforcer, logger *slog.Logger, validator *validator.Validator, + cfClient *cloudflare.Client, ) *Repo { - return &Repo{oauth: oauth, + return &Repo{ + oauth: oauth, repoResolver: repoResolver, pages: pages, idResolver: idResolver, @@ -76,6 +81,7 @@ func New( enforcer: enforcer, logger: logger, validator: validator, + cfClient: cfClient, } } diff --git a/appview/repo/router.go b/appview/repo/router.go index 2da7d4dd..dd7eb151 100644 --- a/appview/repo/router.go +++ b/appview/repo/router.go @@ -87,6 +87,10 @@ func (rp *Repo) Router(mw *middleware.Middleware) http.Handler { r.Put("/branches/default", rp.SetDefaultBranch) r.Put("/secrets", rp.Secrets) r.Delete("/secrets", rp.Secrets) + r.With(mw.RepoPermissionMiddleware("repo:owner")).Route("/sites", func(r chi.Router) { + r.Put("/", rp.SaveRepoSiteConfig) + r.Delete("/", rp.DeleteRepoSiteConfig) + }) r.With(mw.RepoPermissionMiddleware("repo:owner")).Route("/hooks", func(r chi.Router) { r.Get("/", rp.Webhooks) r.Post("/", rp.AddWebhook) diff --git a/appview/repo/settings.go b/appview/repo/settings.go index f01ae022..5e20092f 100644 --- a/appview/repo/settings.go +++ b/appview/repo/settings.go @@ -1,18 +1,22 @@ package repo import ( + "context" "encoding/json" "fmt" "net/http" + "path" "slices" "strings" "time" "tangled.org/core/api/tangled" + "tangled.org/core/appview/db" "tangled.org/core/appview/models" "tangled.org/core/appview/oauth" "tangled.org/core/appview/pages" + "tangled.org/core/appview/sites" xrpcclient "tangled.org/core/appview/xrpcclient" "tangled.org/core/orm" "tangled.org/core/types" @@ -170,7 +174,210 @@ func (rp *Repo) Settings(w http.ResponseWriter, r *http.Request) { case "hooks": rp.Webhooks(w, r) + + case "sites": + rp.sitesSettings(w, r) + } +} + +func (rp *Repo) sitesSettings(w http.ResponseWriter, r *http.Request) { + l := rp.logger.With("handler", "sitesSettings") + + f, err := rp.repoResolver.Resolve(r) + if err != nil { + l.Error("failed to get repo and knot", "err", err) + return + } + user := rp.oauth.GetMultiAccountUser(r) + + scheme := "http" + if !rp.config.Core.Dev { + scheme = "https" } + host := fmt.Sprintf("%s://%s", scheme, f.Knot) + xrpcc := &indigoxrpc.Client{Host: host} + + repo := fmt.Sprintf("%s/%s", f.Did, f.Name) + xrpcBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo) + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { + l.Error("failed to call XRPC repo.branches", "err", xrpcerr) + rp.pages.Error503(w) + return + } + + var result types.RepoBranchesResponse + if err := json.Unmarshal(xrpcBytes, &result); err != nil { + l.Error("failed to decode XRPC response", "err", err) + rp.pages.Error503(w) + return + } + + siteConfig, err := db.GetRepoSiteConfig(rp.db, f.RepoAt().String()) + if err != nil { + l.Error("failed to get site config", "err", err) + rp.pages.Error503(w) + return + } + + ownerClaim, err := db.GetActiveDomainClaimForDid(rp.db, f.Did) + if err != nil { + l.Error("failed to get owner domain claim", "err", err) + // non-fatal — just show no claim + ownerClaim = nil + } + + deploys, err := db.GetSiteDeploys(rp.db, f.RepoAt().String(), 20) + if err != nil { + l.Error("failed to get site deploys", "err", err) + // non-fatal + deploys = nil + } + + indexSiteTakenBy, err := db.GetIndexRepoAtForDid(rp.db, f.Did, f.RepoAt().String()) + if err != nil { + l.Error("failed to get index site owner", "err", err) + // non-fatal + indexSiteTakenBy = "" + } + + rp.pages.RepoSiteSettings(w, pages.RepoSiteSettingsParams{ + LoggedInUser: user, + RepoInfo: rp.repoResolver.GetRepoInfo(r, user), + Branches: result.Branches, + SiteConfig: siteConfig, + OwnerClaim: ownerClaim, + Deploys: deploys, + IndexSiteTakenBy: indexSiteTakenBy, + }) +} + +func (rp *Repo) SaveRepoSiteConfig(w http.ResponseWriter, r *http.Request) { + l := rp.logger.With("handler", "SaveRepoSiteConfig") + + noticeId := "repo-sites-error" + + f, err := rp.repoResolver.Resolve(r) + if err != nil { + l.Error("failed to get repo and knot", "err", err) + rp.pages.Notice(w, noticeId, "Failed to load repository.") + return + } + + branch := strings.TrimSpace(r.FormValue("branch")) + if branch == "" { + rp.pages.Notice(w, noticeId, "Branch cannot be empty.") + return + } + + dir := strings.TrimSpace(r.FormValue("dir")) + if dir == "" { + dir = "/" + } + + // Normalise: always starts with /, no trailing slash (except root), no ".." + dir = path.Clean("/" + dir) + if dir != "/" && strings.Contains(dir, "..") { + rp.pages.Notice(w, noticeId, "Invalid directory path.") + return + } + + isIndex := r.FormValue("is_index") == "true" + + if err := db.SetRepoSiteConfig(rp.db, f.RepoAt().String(), branch, dir, isIndex); err != nil { + l.Error("failed to save site config", "err", err) + rp.pages.Notice(w, noticeId, "Failed to save site configuration.") + return + } + + // Trigger an initial deploy asynchronously so the handler returns promptly. + // Skip entirely if there is no active domain claim — the site cannot be served anyway. + ownerClaim, _ := db.GetActiveDomainClaimForDid(rp.db, f.Did) + if ownerClaim == nil { + rp.logger.Info("skipping deploy: no active domain claim", "repo", f.DidSlashRepo()) + } else if rp.cfClient.Enabled() { + scheme := "http" + if !rp.config.Core.Dev { + scheme = "https" + } + knotHost := fmt.Sprintf("%s://%s", scheme, f.Knot) + + go func() { + ctx := context.Background() + + deploy := &models.SiteDeploy{ + RepoAt: f.RepoAt().String(), + Branch: branch, + Dir: dir, + Trigger: models.SiteDeployTriggerConfigChange, + } + + deployErr := sites.Deploy(ctx, rp.cfClient, knotHost, f.Did, f.Name, branch, dir) + if deployErr != nil { + l.Error("sites: initial R2 sync failed", "repo", f.DidSlashRepo(), "err", deployErr) + deploy.Status = models.SiteDeployStatusFailure + deploy.Error = deployErr.Error() + } else { + deploy.Status = models.SiteDeployStatusSuccess + } + + if err := db.AddSiteDeploy(rp.db, deploy); err != nil { + l.Error("sites: failed to record deploy", "repo", f.DidSlashRepo(), "err", err) + } + + if deployErr == nil { + if err := sites.PutDomainMapping(ctx, rp.cfClient, ownerClaim.Domain, f.Did, f.Name, isIndex); err != nil { + l.Error("sites: KV write failed", "domain", ownerClaim.Domain, "err", err) + } + rp.logger.Info("site deployed to r2", "repo", f.DidSlashRepo(), "is_index", isIndex) + } + }() + } else { + rp.logger.Warn("cloudflare integration is disabled; site won't be deployed", "repo", f.DidSlashRepo()) + } + + rp.pages.HxRefresh(w) +} + +func (rp *Repo) DeleteRepoSiteConfig(w http.ResponseWriter, r *http.Request) { + l := rp.logger.With("handler", "DeleteRepoSiteConfig") + + noticeId := "repo-sites-error" + + f, err := rp.repoResolver.Resolve(r) + if err != nil { + l.Error("failed to get repo and knot", "err", err) + rp.pages.Notice(w, noticeId, "Failed to load repository.") + return + } + + // Fetch the current config before deleting so we know the isIndex flag for + // the KV key and the domain mapping to clean up. + existingConfig, _ := db.GetRepoSiteConfig(rp.db, f.RepoAt().String()) + + if err := db.DeleteRepoSiteConfig(rp.db, f.RepoAt().String()); err != nil { + l.Error("failed to delete site config", "err", err) + rp.pages.Notice(w, noticeId, "Failed to remove site configuration.") + return + } + + // Clean up R2 objects and KV entry asynchronously. + if rp.cfClient.Enabled() && existingConfig != nil { + ownerClaim, _ := db.GetActiveDomainClaimForDid(rp.db, f.Did) + + go func() { + ctx := context.Background() + if err := sites.Delete(ctx, rp.cfClient, f.Did, f.Name); err != nil { + l.Error("sites: R2 delete failed", "repo", f.DidSlashRepo(), "err", err) + } + if ownerClaim != nil { + if err := sites.DeleteDomainMapping(ctx, rp.cfClient, ownerClaim.Domain, f.Name); err != nil { + l.Error("sites: KV delete failed", "domain", ownerClaim.Domain, "err", err) + } + } + }() + } + + rp.pages.HxRefresh(w) } func (rp *Repo) generalSettings(w http.ResponseWriter, r *http.Request) { diff --git a/input.css b/input.css index d0a7510b..af9825dd 100644 --- a/input.css +++ b/input.css @@ -90,11 +90,11 @@ } label { - @apply block text-gray-900 text-sm font-bold py-2 uppercase dark:text-gray-100; + @apply block text-gray-900 text-sm py-2 dark:text-gray-100; } - input, textarea { - @apply - block rounded p-3 + input, + textarea { + @apply block rounded p-3 bg-gray-50 dark:bg-gray-800 dark:text-white border border-gray-300 dark:border-gray-600 focus:outline-none focus:ring-1 focus:ring-gray-400 dark:focus:ring-gray-500; @@ -104,7 +104,7 @@ } code { - @apply font-mono rounded bg-gray-100 dark:bg-gray-700 text-black dark:text-white; + @apply p-1 font-mono rounded bg-gray-100 dark:bg-gray-700 text-black dark:text-white; } } @@ -126,7 +126,7 @@ } .btn-flat { - @apply relative z-10 inline-flex min-h-[30px] cursor-pointer items-center justify-center + @apply relative z-10 inline-flex min-h-[30px] cursor-pointer items-center justify-center bg-transparent px-2 pb-[0.2rem] text-sm text-gray-900 before:absolute before:inset-0 before:-z-10 before:block before:rounded before:border before:border-gray-200 before:bg-white @@ -277,7 +277,6 @@ details[data-callout] > summary::-webkit-details-marker { display: none; } - } @layer utilities { .error { @@ -334,7 +333,6 @@ animation: fadeOut 0.25s ease-out forwards; } } - } /* Background */ @@ -1011,34 +1009,36 @@ } actor-typeahead { - --color-background: #ffffff; - --color-border: #d1d5db; - --color-shadow: #000000; - --color-hover: #f9fafb; - --color-avatar-fallback: #e5e7eb; - --radius: 0.0; - --padding-menu: 0.0rem; - z-index: 1000; + --color-background: #ffffff; + --color-border: #d1d5db; + --color-shadow: #000000; + --color-hover: #f9fafb; + --color-avatar-fallback: #e5e7eb; + --radius: 0; + --padding-menu: 0rem; + z-index: 1000; } actor-typeahead::part(handle) { - color: #111827; + color: #111827; } actor-typeahead::part(menu) { - box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1); + box-shadow: + 0 4px 6px -1px rgb(0 0 0 / 0.1), + 0 2px 4px -2px rgb(0 0 0 / 0.1); } @media (prefers-color-scheme: dark) { - actor-typeahead { - --color-background: #1f2937; - --color-border: #4b5563; - --color-shadow: #000000; - --color-hover: #374151; - --color-avatar-fallback: #4b5563; - } + actor-typeahead { + --color-background: #1f2937; + --color-border: #4b5563; + --color-shadow: #000000; + --color-hover: #374151; + --color-avatar-fallback: #4b5563; + } - actor-typeahead::part(handle) { - color: #f9fafb; - } + actor-typeahead::part(handle) { + color: #f9fafb; + } } -- 2.51.2