diff --git a/redirects.caddy b/redirects.caddy index 17fe66c..7c59228 100644 --- a/redirects.caddy +++ b/redirects.caddy @@ -175,6 +175,8 @@ redir /docs/self-hosting/railway /actors/self-host/control-plane/{http.request.u redir /docs/self-hosting/railway/ /actors/self-host/control-plane/{http.request.uri.prefixed_query} 301 redir /docs/self-hosting/tls /actors/self-host/control-plane/{http.request.uri.prefixed_query} 301 redir /docs/self-hosting/tls/ /actors/self-host/control-plane/{http.request.uri.prefixed_query} 301 +redir /discord https://discord.gg/rivet-developer-network-822914074136018994{http.request.uri.prefixed_query} 301 +redir /discord/ https://discord.gg/rivet-developer-network-822914074136018994{http.request.uri.prefixed_query} 301 redir /docs/integrations/vercel-workflow /actors/integrations/workflow-sdk/{http.request.uri.prefixed_query} 301 redir /docs/integrations/vercel-workflow/ /actors/integrations/workflow-sdk/{http.request.uri.prefixed_query} 301 redir /integrations/vercel-workflow /actors/integrations/workflow-sdk/{http.request.uri.prefixed_query} 301 diff --git a/redirects.mjs b/redirects.mjs index 3eee401..00c4129 100644 --- a/redirects.mjs +++ b/redirects.mjs @@ -8,17 +8,19 @@ // targets should end in `/` to match the site's canonical trailing-slash form // and avoid a second redirect hop. // -// Absolute targets are supported but restricted to a single allowed host, so a -// bad entry can never point traffic at an arbitrary domain. See -// `EXTERNAL_REDIRECT_HOST` below and the matching check in -// `scripts/generate-caddy-redirects.mjs`. Nothing currently uses it: agentOS -// briefly had its own site and is now a vertical here. +// Absolute targets are supported but restricted to explicitly allowed hosts, +// so a bad entry can never point traffic at an arbitrary domain. See +// `EXTERNAL_REDIRECT_HOSTS` below and the matching check in +// `scripts/generate-caddy-redirects.mjs`. import { readdirSync } from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; const explicitRedirects = { + // Public community short link. Keep this slashless in authored links, while + // Caddy serves both variants so copied URLs cannot fall through to a 404. + '/discord': 'https://discord.gg/rivet-developer-network-822914074136018994', // Integrations moved out of the documentation URL hierarchy. '/docs/integrations/vercel-workflow': '/actors/integrations/workflow-sdk/', '/integrations/vercel-workflow': '/actors/integrations/workflow-sdk/', @@ -249,10 +251,10 @@ function legacyDocsRedirects() { export const redirects = { ...legacyDocsRedirects(), ...explicitRedirects }; -// External host that wildcard and absolute-URL redirect targets are restricted +// External hosts that wildcard and absolute-URL redirect targets are restricted // to. Used by both the Astro config and the Caddy generator so neither consumer // can accidentally emit a redirect to an arbitrary host. -export const EXTERNAL_REDIRECT_HOST = 'agentos-sdk.dev'; +export const EXTERNAL_REDIRECT_HOSTS = ['agentos-sdk.dev', 'discord.gg']; // Wildcard (prefix) redirects. Any request under `from` (at any depth) is sent // to `to`. diff --git a/scripts/check-seo.ts b/scripts/check-seo.ts index 9eef5b8..1baac6d 100644 --- a/scripts/check-seo.ts +++ b/scripts/check-seo.ts @@ -417,10 +417,11 @@ for (const file of htmlFiles) { } catch { errors.push(`${route}: canonical URL is invalid: ${href}`); } - if (canonical?.origin !== SITE_ORIGIN) { + if (!redirect && canonical?.origin !== SITE_ORIGIN) { errors.push(`${route}: canonical must use ${SITE_ORIGIN}`); } if ( + !redirect && canonical && ensureDirectorySlash(canonical.pathname) !== canonical.pathname ) { diff --git a/scripts/generate-caddy-redirects.mjs b/scripts/generate-caddy-redirects.mjs index bb73f46..a1e57a2 100644 --- a/scripts/generate-caddy-redirects.mjs +++ b/scripts/generate-caddy-redirects.mjs @@ -10,17 +10,17 @@ // The output file is gitignored and regenerated during the Docker build. import { writeFileSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; -import { EXTERNAL_REDIRECT_HOST, redirects, wildcardRedirects } from '../redirects.mjs'; +import { EXTERNAL_REDIRECT_HOSTS, redirects, wildcardRedirects } from '../redirects.mjs'; // Conservative charset for paths. Blocks whitespace and any character that // could break out of a `redir` argument or inject another Caddy directive. const SAFE_PATH = /^\/[A-Za-z0-9\-._~/]*$/; -// Absolute external targets are restricted to the single agentOS host with the -// same conservative path charset. This keeps the generator from ever emitting a -// redirect to an arbitrary host while still allowing the agentOS split-out. -const SAFE_EXTERNAL_TARGET = new RegExp( - `^https://${EXTERNAL_REDIRECT_HOST.replace(/\./g, '\\.')}(/[A-Za-z0-9\\-._~/]*)?$`, +// Absolute external targets are restricted to an explicit host allowlist with +// the same conservative path charset. This keeps the generator from ever +// emitting a redirect to an arbitrary host. +const SAFE_EXTERNAL_TARGETS = EXTERNAL_REDIRECT_HOSTS.map( + (host) => new RegExp(`^https://${host.replace(/\./g, '\\.')}(/[A-Za-z0-9\\-._~/]*)?$`), ); // Expands to `?query` when a query string is present, or an empty string when @@ -39,7 +39,7 @@ function assertSafeTarget(to) { if (typeof to !== 'string') { throw new Error(`unsafe target in redirect map: ${JSON.stringify(to)}`); } - if (SAFE_PATH.test(to) || SAFE_EXTERNAL_TARGET.test(to)) { + if (SAFE_PATH.test(to) || SAFE_EXTERNAL_TARGETS.some((pattern) => pattern.test(to))) { return; } throw new Error(`unsafe target in redirect map: ${JSON.stringify(to)}`); diff --git a/src/lib/internalHref.ts b/src/lib/internalHref.ts index 44ba21a..e1bd43b 100644 --- a/src/lib/internalHref.ts +++ b/src/lib/internalHref.ts @@ -1,4 +1,5 @@ const SITE_ORIGINS = new Set(["https://rivet.dev", "http://rivet.dev"]); +const EDGE_REDIRECT_PATHS = new Set(["/discord"]); function splitSuffix(value: string): { pathname: string; suffix: string } { const boundary = value.search(/[?#]/); @@ -8,6 +9,8 @@ function splitSuffix(value: string): { pathname: string; suffix: string } { } function canonicalizePath(pathname: string): string { + const barePath = pathname.length > 1 ? pathname.replace(/\/+$/, "") : pathname; + if (EDGE_REDIRECT_PATHS.has(barePath)) return barePath; if (!pathname || pathname === "/" || pathname.endsWith("/")) return pathname || "/"; const lastSegment = pathname.slice(pathname.lastIndexOf("/") + 1); return lastSegment.includes(".") ? pathname : `${pathname}/`; @@ -15,7 +18,8 @@ function canonicalizePath(pathname: string): string { /** * Returns the canonical trailing-slash form for site-owned directory links. - * Fragments, query strings, files, and external/protocol URLs are preserved. + * Edge-managed short links stay slashless; fragments, query strings, files, + * and external/protocol URLs are preserved. */ export function canonicalizeInternalHref(href?: string | null): string { if (!href) return href ?? "";