From f62ed9da61cac4d642619000eede12bfae27fac2 Mon Sep 17 00:00:00 2001 From: Baptiste LAFOURCADE Date: Wed, 22 Jul 2026 06:30:34 +0200 Subject: [PATCH] =?UTF-8?q?fix(verify):=20la=20page=20marche=20=C3=A0=20l'?= =?UTF-8?q?URL=20sans=20slash=20final=20(badge=20LinkedIn)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le badge pointe vers /u/ (sans slash). En relatif, la page chargeait /u/credential.jwt (404) -> badge affiché invalide. Calcule désormais tous les chemins (preuve, QR, téléchargements) depuis l'URL, robuste au slash final. Corrige aussi le serveur de démo (servait un dossier -> crash EISDIR). +4 tests. Co-Authored-By: Claude Opus 4.8 --- .github/scripts/demo.mjs | 11 ++++++----- site/verify-page.mjs | 34 ++++++++++++++++++++++++++-------- site/verify-page.test.mjs | 32 +++++++++++++++++++++++++++++++- 3 files changed, 63 insertions(+), 14 deletions(-) diff --git a/.github/scripts/demo.mjs b/.github/scripts/demo.mjs index 2936798..bbb2754 100644 --- a/.github/scripts/demo.mjs +++ b/.github/scripts/demo.mjs @@ -4,7 +4,7 @@ // // Usage : node .github/scripts/demo.mjs (puis ouvrir l'URL affichée) import { createServer } from 'node:http'; -import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs'; +import { readFileSync, writeFileSync, mkdirSync, existsSync, statSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import { dirname, join, extname } from 'node:path'; import { generateKeyPair, exportJWK, calculateJwkThumbprint, SignJWT } from 'jose'; @@ -63,10 +63,11 @@ const TYPES = { '.mjs': 'text/javascript', '.json': 'application/json', '.html': const { kid } = await build(); createServer((req, res) => { - let path = decodeURIComponent(req.url.split('?')[0]); - if (path.endsWith('/')) path += 'index.html'; - const file = join(root, path); - if (!file.startsWith(root) || !existsSync(file)) { res.writeHead(404); res.end('not found'); return; } + const path = decodeURIComponent(req.url.split('?')[0]); + let file = join(root, path); + // Un dossier (ex. /u/demo) -> sa page index.html. + if (existsSync(file) && statSync(file).isDirectory()) file = join(file, 'index.html'); + if (!file.startsWith(root) || !existsSync(file) || statSync(file).isDirectory()) { res.writeHead(404); res.end('not found'); return; } res.writeHead(200, { 'content-type': TYPES[extname(file)] || 'application/octet-stream' }); res.end(readFileSync(file)); }).listen(PORT, () => { diff --git a/site/verify-page.mjs b/site/verify-page.mjs index da7efa6..d43469c 100644 --- a/site/verify-page.mjs +++ b/site/verify-page.mjs @@ -16,8 +16,12 @@ const fmtDate = (iso) => { catch { return iso; } }; -/** Rend le résultat dans le conteneur. Pur DOM, testable via une racine injectée. */ -export function render(root, result) { +/** + * Rend le résultat dans le conteneur. `base` = dossier du membre (sans slash final), + * pour que QR et téléchargements marchent que l'URL ait un slash final ou non. + * Pur DOM, testable via une racine injectée. + */ +export function render(root, result, base = '.') { const label = LABELS[result.state] || LABELS[STATE.INVALID]; const d = result.details || {}; const rows = result.state === STATE.INVALID @@ -31,12 +35,12 @@ export function render(root, result) {
Valable jusqu'au
${fmtDate(d.validUntil)}
- QR code de vérification + QR code de vérification
Scanne pour vérifier

- ⤓ QR code - ⤓ Preuve brute (VC-JWT) + ⤓ QR code + ⤓ Preuve brute (VC-JWT)

`; root.className = `result ${label.tone}`; @@ -47,13 +51,27 @@ export function render(root, result) { Vérifier avec un outil tiers.

`; } -/** Point d'entrée navigateur : lit ./credential.jwt, vérifie, rend dans #app. */ +/** + * Dossier du membre déduit de l'URL, robuste au slash final : /u/handle ET + * /u/handle/ donnent tous deux la base /u/handle. (Le badge LinkedIn est sans slash.) + */ +export function memberBase(pathname) { + return String(pathname || '').replace(/\/+$/, '') || '.'; +} + +/** URL de la preuve du membre. */ +export function credentialUrl(pathname) { + return `${memberBase(pathname)}/credential.jwt`; +} + +/** Point d'entrée navigateur : lit la preuve, vérifie, rend dans #app. */ export async function mount(doc = document, fetchImpl = fetch) { const root = doc.getElementById('app'); + const base = memberBase(doc.location && doc.location.pathname); try { - const jwt = await (await fetchImpl('./credential.jwt')).text(); + const jwt = await (await fetchImpl(`${base}/credential.jwt`)).text(); const result = await verifyBadge(jwt.trim()); - render(root, result); + render(root, result, base); } catch { render(root, { state: STATE.INVALID, reason: 'Aucune preuve trouvée à cette adresse.' }); } diff --git a/site/verify-page.test.mjs b/site/verify-page.test.mjs index 580ce42..c15f638 100644 --- a/site/verify-page.test.mjs +++ b/site/verify-page.test.mjs @@ -1,8 +1,29 @@ import { describe, it } from 'node:test'; import assert from 'node:assert/strict'; -import { render, mount } from './verify-page.mjs'; +import { render, mount, credentialUrl, memberBase } from './verify-page.mjs'; import { STATE } from './verify.mjs'; +describe('memberBase', () => { + it('rend la base sans slash final (URL du badge LinkedIn)', () => { + assert.equal(memberBase('/u/octocat'), '/u/octocat'); + }); + + it('retire le slash final', () => { + assert.equal(memberBase('/u/octocat/'), '/u/octocat'); + }); + + it('retombe sur le répertoire courant si le chemin est vide', () => { + assert.equal(memberBase(''), '.'); + }); +}); + +describe('credentialUrl', () => { + it('pointe vers la preuve du membre, avec ou sans slash final', () => { + assert.equal(credentialUrl('/u/octocat'), '/u/octocat/credential.jwt'); + assert.equal(credentialUrl('/u/octocat/'), '/u/octocat/credential.jwt'); + }); +}); + const fakeElement = () => ({ className: '', innerHTML: '' }); describe('render', () => { @@ -53,6 +74,15 @@ describe('render', () => { assert.match(root.innerHTML, /href="\.\/credential\.jwt" download/); }); + it('utilise la base fournie pour QR et preuve (chemins absolus, URL sans slash)', () => { + const root = fakeElement(); + + render(root, { state: STATE.VALID, details: { handle: 'jd' } }, '/u/jd'); + + assert.match(root.innerHTML, /src="\/u\/jd\/qr\.svg"/); + assert.match(root.innerHTML, /href="\/u\/jd\/credential\.jwt" download/); + }); + it('n\'affiche ni QR ni téléchargements pour un badge invalide', () => { const root = fakeElement();