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 }}
+
+
+
+ {{ 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" }}
+
+
+
+
+ {{ 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" }}
+
+ {{ i "plus" "size-4" }}
+ claim domain
+
+
+ {{ template "claimDomainModal" . }}
+
+{{ end }}
+
+{{ define "claimDomainModal" }}
+
+{{ 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;
+ }
}