-
Notifications
You must be signed in to change notification settings - Fork 7
Self-hosted: static SEO files, canonical policy and responsive media #1463
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 1 commit
620b839
d9317ff
b49d9d1
5cd3c75
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -7,19 +7,23 @@ | |
| import { promises as fs } from 'fs'; | ||
| import path from 'path'; | ||
| import { withAdvisoryLock } from '../db/client'; | ||
| import { escapeHtml } from '../utils/escape-html'; | ||
| import { TenantService, type Tenant } from './tenant-service'; | ||
| import { | ||
| buildRobotsTxt, | ||
| buildRssXml, | ||
| buildSitemapXml, | ||
| canonicalHomeUrl, | ||
| fetchTenantPosts, | ||
| SEO_FRESH_MS, | ||
| } from './seo-files'; | ||
|
|
||
| const CONFIG_DIR = process.env.CONFIG_DIR || '/app/configs'; | ||
|
|
||
| /** Escape a string for safe interpolation into HTML text and attribute values. */ | ||
| export function escapeHtml(value: string): string { | ||
| return value | ||
| .replace(/&/g, '&') | ||
| .replace(/</g, '<') | ||
| .replace(/>/g, '>') | ||
| .replace(/"/g, '"') | ||
| .replace(/'/g, '''); | ||
| } | ||
| // Re-exported for existing consumers; the implementation lives in its own | ||
| // util so seo-files can use it without importing this module back | ||
| // (config-service consumes seo-files below). | ||
| export { escapeHtml } from '../utils/escape-html'; | ||
|
|
||
| // Per-tenant write chains: every config-file write for a tenant runs strictly after the | ||
| // previous one, so the periodic sync and a concurrent tenant update can never interleave. | ||
|
|
@@ -178,6 +182,11 @@ export const ConfigService = { | |
| ] | ||
| : []), | ||
| `<meta name="twitter:card" content="${ogImage ? 'summary_large_image' : 'summary'}" />`, | ||
| // Custom-domain tenants canonicalize to themselves, subdomain tenants | ||
| // to the ecency.com SSR page (see canonicalHomeUrl); the feed link is | ||
| // in the SSI snippet so crawlers see it, not only JS runtimes. | ||
| `<link rel="canonical" href="${escapeHtml(canonicalHomeUrl(tenant))}" />`, | ||
| `<link rel="alternate" type="application/rss+xml" title="${title}" href="${escapeHtml(TenantService.getBlogUrl(tenant) + '/rss.xml')}" />`, | ||
| `<link rel="icon" href="${favicon}" />`, | ||
| '', | ||
| ].join('\n'); | ||
|
|
@@ -188,6 +197,56 @@ export const ConfigService = { | |
| return path.join(CONFIG_DIR, username.toLowerCase() + '.meta.html'); | ||
| }, | ||
|
|
||
| /** Paths of a tenant's static SEO files (robots, sitemap, rss). */ | ||
| getSeoPaths(username: string): { robots: string; sitemap: string; rss: string } { | ||
| const base = path.join(CONFIG_DIR, username.toLowerCase()); | ||
| return { | ||
| robots: `${base}.robots.txt`, | ||
| sitemap: `${base}.sitemap.xml`, | ||
| rss: `${base}.rss.xml`, | ||
| }; | ||
| }, | ||
|
|
||
| /** | ||
| * Regenerate a tenant's static SEO files when they are stale. Called from | ||
| * the sync pass inside the per-tenant lock. Freshness is mtime-based, so | ||
| * after an unchanged regeneration the files are touched: writeIfChanged | ||
| * deliberately skips identical writes, and without the touch every later | ||
| * pass would refetch the feed for a blog that has not posted. | ||
| */ | ||
| async writeSeoFilesIfStale(tenant: Tenant): Promise<void> { | ||
| const paths = this.getSeoPaths(tenant.username); | ||
| const all = [paths.robots, paths.sitemap, paths.rss]; | ||
|
|
||
| let fresh = true; | ||
| for (const p of all) { | ||
| try { | ||
| const stat = await fs.stat(p); | ||
| if (Date.now() - stat.mtimeMs > SEO_FRESH_MS) { | ||
| fresh = false; | ||
|
qodo-code-review[bot] marked this conversation as resolved.
|
||
| break; | ||
| } | ||
| } catch { | ||
| fresh = false; | ||
| break; | ||
| } | ||
| } | ||
| if (fresh) return; | ||
|
|
||
| // One bounded chain page; a failure here is caught by the sync pass's | ||
| // per-tenant isolation and yesterday's files keep serving. | ||
| const posts = await fetchTenantPosts(tenant); | ||
| await fs.mkdir(CONFIG_DIR, { recursive: true }); | ||
| await this.writeIfChanged(paths.robots, buildRobotsTxt(tenant)); | ||
| await this.writeIfChanged(paths.sitemap, buildSitemapXml(tenant, posts)); | ||
| await this.writeIfChanged(paths.rss, buildRssXml(tenant, posts)); | ||
|
qodo-code-review[bot] marked this conversation as resolved.
qodo-free-for-open-source-projects[bot] marked this conversation as resolved.
|
||
|
|
||
| const now = new Date(); | ||
| for (const p of all) { | ||
| await fs.utimes(p, now, now).catch(() => {}); | ||
| } | ||
| }, | ||
|
|
||
| /** | ||
| * Delete config file for a tenant. Serialized through the same per-tenant lock as | ||
| * writes, so an in-flight sync write cannot resurrect the file after deletion. | ||
|
|
@@ -198,7 +257,14 @@ export const ConfigService = { | |
|
|
||
| /** The actual unlink; only ever invoked under the per-tenant lock. */ | ||
| async removeConfigFileUnlocked(username: string): Promise<void> { | ||
| for (const filePath of [this.getConfigPath(username), this.getMetaPath(username)]) { | ||
| const seo = this.getSeoPaths(username); | ||
| for (const filePath of [ | ||
| this.getConfigPath(username), | ||
| this.getMetaPath(username), | ||
| seo.robots, | ||
| seo.sitemap, | ||
| seo.rss, | ||
| ]) { | ||
| try { | ||
| await fs.unlink(filePath); | ||
| console.log('[ConfigService] Deleted config:', filePath); | ||
|
|
@@ -253,6 +319,9 @@ export const ConfigService = { | |
| for (const f of files) { | ||
| if (f === 'default.json') continue; | ||
| if (f.endsWith('.meta.html')) names.add(f.slice(0, -'.meta.html'.length)); | ||
| else if (f.endsWith('.robots.txt')) names.add(f.slice(0, -'.robots.txt'.length)); | ||
| else if (f.endsWith('.sitemap.xml')) names.add(f.slice(0, -'.sitemap.xml'.length)); | ||
| else if (f.endsWith('.rss.xml')) names.add(f.slice(0, -'.rss.xml'.length)); | ||
| else if (f.endsWith('.json')) names.add(f.slice(0, -'.json'.length)); | ||
| } | ||
| return [...names]; | ||
|
|
@@ -298,6 +367,7 @@ export const ConfigService = { | |
| const fresh = await TenantService.getByUsername(tenant.username); | ||
| if (!fresh || !isPublishableTenant(fresh)) return; | ||
| await this.writeConfigFile(fresh); | ||
| await this.writeSeoFilesIfStale(fresh); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When the Hive RPC is slow or unavailable, every stale tenant can block here for the full 10-second SEO timeout while Useful? React with 👍 / 👎.
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Confirmed and fixed in d9317ff: the SEO refresh runs on its own five-minute loop with its own single flight and a four-worker pool, fully decoupled from the serial config pass, so an RPC outage can no longer delay config publication and cleanup.
qodo-code-review[bot] marked this conversation as resolved.
Outdated
|
||
| }); | ||
| } catch (err) { | ||
| failed++; | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.