Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
- **`<link rel="canonical">` 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
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down
31 changes: 25 additions & 6 deletions src/server.js
Original file line number Diff line number Diff line change
Expand Up @@ -297,6 +297,16 @@ function escapeHtmlServer(text) {
.replace(/'/g, '&#39;');
}

// Build a <link rel="canonical"> 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 ` <link rel="canonical" href="${escapeHtmlServer(url)}">`;
}

// 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() {
Expand Down Expand Up @@ -528,6 +538,9 @@ function servePublicIndex(req, res) {
html = html.replace('<head>', `<head>\n${trackingCode}`);
}

// Inject canonical link derived from the request (honors reverse-proxy headers)
html = html.replace('</head>', `${buildCanonicalTag(req)}\n</head>`);

// Inject default dataset ID and language info (no DATASET_PREVIEW = no preview banner)
const siblings = getDatasetSiblings(defaultDataset);
const datasetTheme = data.theme || gatherTheme();
Expand Down Expand Up @@ -568,6 +581,9 @@ function servePublicIndex(req, res) {
html = html.replace('<head>', `<head>\n${trackingCode}`);
}

// Inject canonical link derived from the request (honors reverse-proxy headers)
html = html.replace('</head>', `${buildCanonicalTag(req)}\n</head>`);

// Inject theme so the public page can apply font/gradient before paint
const fallbackTheme = gatherTheme();
const themeScript = `<script>window.DATASET_THEME = ${JSON.stringify(fallbackTheme)};</script>`;
Expand Down Expand Up @@ -598,18 +614,21 @@ function serveDatasetPage(req, res, lang) {
const ogTags = `\n <meta property="og:title" content="${name} - CV (${dataset.name})">\n <meta property="og:description" content="${description.replace(/"/g, '&quot;')}">\n <meta property="og:type" content="profile">`;
html = html.replace(/<meta name="description" content="[^"]*">/, `<meta name="description" content="${description.replace(/"/g, '&quot;')}">${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(/<meta name="robots"[^>]*>/, '<meta name="robots" id="metaRobots" content="noindex, nofollow">');
} else {
html = html.replace('</head>', `${buildCanonicalTag(req)}\n</head>`);
}

// Inject dataset context with language info and exact ID
const siblings = getDatasetSiblings(dataset);
const datasetTheme = data.theme || gatherTheme();
const datasetScript = `<script>window.DATASET_ID = ${dataset.id}; window.DATASET_SLUG = "${dataset.slug}"; window.DATASET_LANG = "${dsLang}"; window.DATASET_THEME = ${JSON.stringify(datasetTheme)};${siblings.length > 1 ? ` window.DATASET_SIBLINGS = ${JSON.stringify(siblings)};` : ''}</script>`;
html = html.replace('</head>', `${datasetScript}</head>`);

// 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(/<meta name="robots"[^>]*>/, '<meta name="robots" id="metaRobots" content="noindex, nofollow">');
}

// Inject tracking code right after <head> (server-side for GA verification)
const trackingCode = getTrackingCode();
if (trackingCode && !isTrackingConsentRequired()) {
Expand Down
87 changes: 87 additions & 0 deletions tests/backend.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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, /<link rel="canonical" href="http:\/\/cv\.example\.com\/">/);
});

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, /<link rel="canonical" href="https:\/\/cv\.example\.com\/">/);
});

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, /<link rel="canonical"/);
assert.match(text, /<meta name="robots"[^>]*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(`<link rel="canonical" href="http://cv\\.example\\.com/v/${created.slug}">`);
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 = '<script>window.__cvTrackingFlag = "yes";</script>';

Expand Down
2 changes: 1 addition & 1 deletion version.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
{
"version": "1.49.3",
"version": "1.49.4",
"changelog": "https://github.com/vincentmakes/cv-manager/blob/main/CHANGELOG.md"
}
Loading