pages: serve at pages.grove.host/{owner}/{repo} by default

- PagesDeployer: deploy to _pages/{owner}/{repo}/ when no custom domain
- Caddyfile: add pages.{DOMAIN} block for path-based file serving
- Settings UI: show default pages.grove.host URL immediately on enable
- Custom domains still work via :443 catch-all with on-demand TLS
Anton Kaminsky25d agoff50d0358885parent 0542c45
4 files changed+86-43
api/src/routes/repos.ts
@@ -473,18 +473,19 @@
473473 setClauses.push("updated_at = datetime('now')");
474474 values.push(repoRow.id);
475475
476 // Undeploy old domain if pages disabled or domain changed
476 // Undeploy old deploy path if pages disabled or domain changed
477477 const pagesDeployer = (app as any).pagesDeployer;
478 if (pagesDeployer && repoRow.pages_domain) {
478 const oldDeployInfo = pagesDeployer?.getDeployPath(repoRow.name);
479 if (pagesDeployer && oldDeployInfo) {
479480 if (updates.pages_enabled === false ||
480481 (updates.pages_domain !== undefined && updates.pages_domain !== repoRow.pages_domain)) {
481 pagesDeployer.undeploy(repoRow.pages_domain);
482 pagesDeployer.undeploy(oldDeployInfo.path);
482483 }
483484 }
484485
485486 db.prepare(`UPDATE repos SET ${setClauses.join(", ")} WHERE id = ?`).run(...values);
486487
487 // Trigger initial pages deploy if enabled
488 // Trigger pages deploy if enabled
488489 if (pagesDeployer && (updates.pages_enabled === true || updates.pages_domain !== undefined)) {
489490 void pagesDeployer.deploy(repoRow.name, repoRow.default_branch ?? "main").catch(
490491 (err: any) => app.log.error({ err, repo: repoRow.name }, "Initial pages deploy failed")
491492
api/src/services/pages-deployer.ts
@@ -16,32 +16,43 @@
1616 }
1717
1818 /**
19 * Deploy a repo's pages content to disk.
20 * Archives the repo from bridge and extracts to sitesDir/{domain}/.
21 * Uses atomic rename so Caddy never sees a half-written site.
19 * Get the deploy path for a repo's pages.
20 * Custom domain: sitesDir/{domain}/ (Caddy serves via {host} matching)
21 * Default: sitesDir/_pages/{owner}/{repo}/ (Caddy serves via path-based routing on pages.grove.host)
2222 */
23 async deploy(repoName: string, ref: string): Promise<void> {
23 getDeployPath(repoName: string): { path: string; isCustomDomain: boolean } | null {
2424 const repo = this.db
2525 .prepare(
26 `SELECT * FROM repos WHERE name = ? AND pages_enabled = 1`
26 `SELECT r.pages_domain, r.pages_enabled, rwo.owner_name, r.name
27 FROM repos r
28 JOIN repos_with_owner rwo ON rwo.id = r.id
29 WHERE r.name = ? AND r.pages_enabled = 1`
2730 )
2831 .get(repoName) as any;
2932
30 if (!repo) return;
33 if (!repo) return null;
3134
32 const domain = repo.pages_domain;
33 if (!domain) {
34 this.logger.info(
35 { repo: repoName },
36 "Pages enabled but no domain set, skipping deploy"
37 );
38 return;
35 if (repo.pages_domain) {
36 return { path: `${this.sitesDir}/${repo.pages_domain}`, isCustomDomain: true };
3937 }
4038
41 this.logger.info({ repo: repoName, domain, ref }, "Deploying pages");
39 return { path: `${this.sitesDir}/_pages/${repo.owner_name}/${repo.name}`, isCustomDomain: false };
40 }
41
42 /**
43 * Deploy a repo's pages content to disk.
44 * Archives the repo from bridge and extracts to the deploy path.
45 * Uses atomic rename so Caddy never sees a half-written site.
46 */
47 async deploy(repoName: string, ref: string): Promise<void> {
48 const deployInfo = this.getDeployPath(repoName);
49 if (!deployInfo) return;
50
51 const { path: destDir } = deployInfo;
52
53 this.logger.info({ repo: repoName, dest: destDir, ref }, "Deploying pages");
4254
43 const tmpDir = `${this.sitesDir}/.tmp-${domain}-${Date.now()}`;
44 const destDir = `${this.sitesDir}/${domain}`;
55 const tmpDir = `${destDir}.tmp-${Date.now()}`;
4556
4657 try {
4758 const url = `${this.bridgeUrl}/repos/${repoName}/archive/${ref}`;
@@ -61,13 +72,15 @@
6172 if (existsSync(destDir)) {
6273 renameSync(destDir, oldDir);
6374 }
75 // Ensure parent directory exists for nested paths
76 mkdirSync(destDir.substring(0, destDir.lastIndexOf("/")), { recursive: true });
6477 renameSync(tmpDir, destDir);
6578 if (existsSync(oldDir)) {
6679 rmSync(oldDir, { recursive: true, force: true });
6780 }
6881
6982 this.logger.info(
70 { repo: repoName, domain },
83 { repo: repoName, dest: destDir },
7184 "Pages deployed successfully"
7285 );
7386 } catch (err) {
@@ -78,16 +91,15 @@
7891 }
7992 }
8093
81 /** Remove a site's deployed files. */
82 undeploy(domain: string): void {
83 const destDir = `${this.sitesDir}/${domain}`;
84 if (existsSync(destDir)) {
85 rmSync(destDir, { recursive: true, force: true });
86 this.logger.info({ domain }, "Pages undeployed");
94 /** Remove a site's deployed files by deploy path. */
95 undeploy(deployPath: string): void {
96 if (existsSync(deployPath)) {
97 rmSync(deployPath, { recursive: true, force: true });
98 this.logger.info({ path: deployPath }, "Pages undeployed");
8799 }
88100 }
89101
90 /** Check if a domain is configured for any repo (used by Caddy's on-demand TLS). */
102 /** Check if a custom domain is configured for any repo (used by Caddy's on-demand TLS). */
91103 isDomainConfigured(domain: string): boolean {
92104 const row = this.db
93105 .prepare(
94106
hub/Caddyfile
@@ -140,7 +140,18 @@
140140 }
141141}
142142
143# Grove Pages — serve static sites from repos with custom domains
143# Grove Pages — path-based default hosting at pages.grove.host/{owner}/{repo}
144pages.{$DOMAIN} {
145 root * /srv/pages/sites/_pages
146 file_server browse
147
148 header {
149 X-Content-Type-Options nosniff
150 X-Frame-Options SAMEORIGIN
151 }
152}
153
154# Grove Pages — custom domains
144155:443 {
145156 tls {
146157 on_demand
147158
web/app/[owner]/[repo]/(tabs)/settings/page.tsx
@@ -243,8 +243,40 @@
243243
244244 {!!repoData?.pages_enabled && (
245245 <div className="py-3">
246 {(() => {
247 const defaultUrl = `https://pages.grove.host/${repoData?.owner_name}/${repoData?.name}`;
248 const customUrl = repoData?.pages_domain ? `https://${repoData.pages_domain}` : null;
249 return (
250 <div className="text-xs mb-3" style={{ color: "var(--text-muted)" }}>
251 <p>
252 Serving at{" "}
253 <a
254 href={defaultUrl}
255 target="_blank"
256 rel="noopener noreferrer"
257 style={{ color: "var(--accent)" }}
258 >
259 {defaultUrl}
260 </a>
261 </p>
262 {customUrl && (
263 <p className="mt-1">
264 Custom domain:{" "}
265 <a
266 href={customUrl}
267 target="_blank"
268 rel="noopener noreferrer"
269 style={{ color: "var(--accent)" }}
270 >
271 {customUrl}
272 </a>
273 </p>
274 )}
275 </div>
276 );
277 })()}
246278 <p className="text-sm mb-1" style={{ color: "var(--text-primary)" }}>
247 Custom domain
279 Custom domain (optional)
248280 </p>
249281 <p className="text-xs mb-2" style={{ color: "var(--text-muted)" }}>
250282 Point your domain&apos;s A record to{" "}
@@ -269,19 +301,6 @@
269301 Save
270302 </Button>
271303 </div>
272 {repoData?.pages_domain && (
273 <p className="text-xs mt-2" style={{ color: "var(--text-muted)" }}>
274 Currently serving at{" "}
275 <a
276 href={`https://${repoData.pages_domain}`}
277 target="_blank"
278 rel="noopener noreferrer"
279 style={{ color: "var(--accent)" }}
280 >
281 https://{repoData.pages_domain}
282 </a>
283 </p>
284 )}
285304 </div>
286305 )}
287306 </div>
288307