From 749095b35936b7aef8eed56e99f619c806c63ba2 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 6 May 2026 12:37:47 +0000 Subject: [PATCH] Add to public CV pages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Crawlers were free to index the same CV under multiple URL variants (http vs https, alternate hostnames behind a reverse proxy, query-string permutations). The public root, the live-CV fallback, and indexable /v/:slug pages now emit a canonical link derived from the request, honoring X-Forwarded-Proto / X-Forwarded-Host the same way sitemap.xml and robots.txt already do — so no extra config is required. Slug pages that resolve to noindex intentionally omit the tag. https://claude.ai/code/session_01EGykcv9Yvem218n13TqYhz --- CHANGELOG.md | 5 +++ package-lock.json | 4 +- package.json | 2 +- src/server.js | 31 ++++++++++++--- tests/backend.test.js | 87 +++++++++++++++++++++++++++++++++++++++++++ version.json | 2 +- 6 files changed, 121 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0fe54cb..dbba376 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,11 @@ All notable changes to CV Manager will be documented in this file. Format follows [Keep a Changelog](https://keepachangelog.com/), versioning follows [Semantic Versioning](https://semver.org/). +## [1.49.4] - 2026-05-06 + +### Added +- **`` on public CV pages.** Crawlers were free to index the same CV under multiple URL variants (http vs https, with/without trailing query strings, alternate hostnames behind reverse proxies). The public root and the live-CV fallback now emit a canonical link derived from the request — honoring `X-Forwarded-Proto` and `X-Forwarded-Host` the same way `/sitemap.xml` and `/robots.txt` already do, so no extra config is required. `/v/:slug` pages emit a canonical only when `slugsIndex` is enabled; when slugs are `noindex`, the tag is intentionally omitted. Implemented as a single `buildCanonicalTag(req)` helper in `src/server.js` reused at all three SSR sites. + ## [1.49.3] - 2026-05-05 ### Fixed diff --git a/package-lock.json b/package-lock.json index 1687070..88175a4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "cv-manager", - "version": "1.49.3", + "version": "1.49.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "cv-manager", - "version": "1.49.3", + "version": "1.49.4", "dependencies": { "archiver": "^7.0.1", "better-sqlite3": "^9.4.3", diff --git a/package.json b/package.json index 011169e..b7ded9d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "cv-manager", - "version": "1.49.3", + "version": "1.49.4", "description": "Professional CV Management System", "main": "src/server.js", "scripts": { diff --git a/src/server.js b/src/server.js index bb4d2f8..f130d81 100644 --- a/src/server.js +++ b/src/server.js @@ -297,6 +297,16 @@ function escapeHtmlServer(text) { .replace(/'/g, '''); } +// Build a tag from the request, honoring reverse-proxy +// headers (X-Forwarded-Proto / X-Forwarded-Host) the same way sitemap.xml / +// robots.txt do. Uses req.path so query strings and fragments are stripped. +function buildCanonicalTag(req) { + const protocol = req.headers['x-forwarded-proto'] || req.protocol || 'https'; + const host = req.headers['x-forwarded-host'] || req.headers.host || 'localhost'; + const url = `${protocol}://${host}${req.path}`; + return ` `; +} + // Pull the current live CV into the same shape as a saved-dataset blob so // the SSR helper has one input format to deal with. function gatherLiveCvData() { @@ -528,6 +538,9 @@ function servePublicIndex(req, res) { html = html.replace('', `\n${trackingCode}`); } + // Inject canonical link derived from the request (honors reverse-proxy headers) + html = html.replace('', `${buildCanonicalTag(req)}\n`); + // Inject default dataset ID and language info (no DATASET_PREVIEW = no preview banner) const siblings = getDatasetSiblings(defaultDataset); const datasetTheme = data.theme || gatherTheme(); @@ -568,6 +581,9 @@ function servePublicIndex(req, res) { html = html.replace('', `\n${trackingCode}`); } + // Inject canonical link derived from the request (honors reverse-proxy headers) + html = html.replace('', `${buildCanonicalTag(req)}\n`); + // Inject theme so the public page can apply font/gradient before paint const fallbackTheme = gatherTheme(); const themeScript = ``; @@ -598,18 +614,21 @@ function serveDatasetPage(req, res, lang) { const ogTags = `\n \n \n `; html = html.replace(//, `${ogTags}`); + // Apply noindex if slugsIndex setting is not enabled; only emit canonical when indexable + const slugsIndexSetting = db.prepare('SELECT value FROM settings WHERE key = ?').get('slugsIndex'); + const slugIsIndexable = slugsIndexSetting && slugsIndexSetting.value === 'true'; + if (!slugIsIndexable) { + html = html.replace(/]*>/, ''); + } else { + html = html.replace('', `${buildCanonicalTag(req)}\n`); + } + // Inject dataset context with language info and exact ID const siblings = getDatasetSiblings(dataset); const datasetTheme = data.theme || gatherTheme(); const datasetScript = ``; html = html.replace('', `${datasetScript}`); - // Apply noindex if slugsIndex setting is not enabled - const slugsIndexSetting = db.prepare('SELECT value FROM settings WHERE key = ?').get('slugsIndex'); - if (!slugsIndexSetting || slugsIndexSetting.value !== 'true') { - html = html.replace(/]*>/, ''); - } - // Inject tracking code right after (server-side for GA verification) const trackingCode = getTrackingCode(); if (trackingCode && !isTrackingConsentRequired()) { diff --git a/tests/backend.test.js b/tests/backend.test.js index ddf6e8c..c50c97a 100644 --- a/tests/backend.test.js +++ b/tests/backend.test.js @@ -2634,6 +2634,93 @@ describe('Backend API', () => { }); }); + describe('Canonical link injection', () => { + it('emits canonical from request host on public root', async () => { + // Node's fetch reserves the Host header, so simulate the deployed-host + // case via X-Forwarded-Host (the realistic reverse-proxy path). + const res = await fetch(PUBLIC_URL, { + headers: { 'X-Forwarded-Host': 'cv.example.com' }, + }); + assert.strictEqual(res.status, 200); + const text = await res.text(); + assert.match(text, //); + }); + + it('honors X-Forwarded-Proto and X-Forwarded-Host', async () => { + const res = await fetch(PUBLIC_URL, { + headers: { + 'X-Forwarded-Proto': 'https', + 'X-Forwarded-Host': 'cv.example.com', + }, + }); + assert.strictEqual(res.status, 200); + const text = await res.text(); + assert.match(text, //); + }); + + it('omits canonical on /v/:slug when slugsIndex is disabled', async () => { + // Default state — slugsIndex unset + const createRes = await fetch(`${BASE_URL}/api/datasets`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: 'Canonical NoIndex' }), + }); + const created = await createRes.json(); + await fetch(`${BASE_URL}/api/datasets/${created.id}/save`, { method: 'POST' }); + await fetch(`${BASE_URL}/api/datasets/${created.id}/public`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ is_public: true }), + }); + + const res = await fetch(`${PUBLIC_URL}/v/${created.slug}`, { + headers: { 'X-Forwarded-Host': 'cv.example.com' }, + }); + assert.strictEqual(res.status, 200); + const text = await res.text(); + assert.doesNotMatch(text, /]*content="noindex/); + + await fetch(`${BASE_URL}/api/datasets/${created.id}`, { method: 'DELETE' }); + }); + + it('emits canonical on /v/:slug when slugsIndex is enabled', async () => { + await fetch(`${BASE_URL}/api/settings/slugsIndex`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ value: 'true' }), + }); + + const createRes = await fetch(`${BASE_URL}/api/datasets`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: 'Canonical Indexable' }), + }); + const created = await createRes.json(); + await fetch(`${BASE_URL}/api/datasets/${created.id}/save`, { method: 'POST' }); + await fetch(`${BASE_URL}/api/datasets/${created.id}/public`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ is_public: true }), + }); + + const res = await fetch(`${PUBLIC_URL}/v/${created.slug}`, { + headers: { 'X-Forwarded-Host': 'cv.example.com' }, + }); + assert.strictEqual(res.status, 200); + const text = await res.text(); + const expected = new RegExp(``); + assert.match(text, expected); + + await fetch(`${BASE_URL}/api/datasets/${created.id}`, { method: 'DELETE' }); + await fetch(`${BASE_URL}/api/settings/slugsIndex`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ value: 'false' }), + }); + }); + }); + describe('Tracking consent gating', () => { const SNIPPET = ''; diff --git a/version.json b/version.json index 8d566e1..4d635a4 100644 --- a/version.json +++ b/version.json @@ -1,4 +1,4 @@ { - "version": "1.49.3", + "version": "1.49.4", "changelog": "https://github.com/vincentmakes/cv-manager/blob/main/CHANGELOG.md" }