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
11 changes: 6 additions & 5 deletions .github/scripts/demo.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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, () => {
Expand Down
34 changes: 26 additions & 8 deletions site/verify-page.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -31,12 +35,12 @@ export function render(root, result) {
<dt>Valable jusqu'au</dt><dd>${fmtDate(d.validUntil)}</dd>
</dl>
<figure class="qr">
<img src="./qr.svg" alt="QR code de vérification" width="140" height="140">
<img src="${base}/qr.svg" alt="QR code de vérification" width="140" height="140">
<figcaption>Scanne pour vérifier</figcaption>
</figure>
<p class="downloads">
<a href="./qr.svg" download="qr.svg">⤓ QR code</a>
<a href="./credential.jwt" download="credential.jwt">⤓ Preuve brute (VC-JWT)</a>
<a href="${base}/qr.svg" download="qr.svg">⤓ QR code</a>
<a href="${base}/credential.jwt" download="credential.jwt">⤓ Preuve brute (VC-JWT)</a>
</p>`;

root.className = `result ${label.tone}`;
Expand All @@ -47,13 +51,27 @@ export function render(root, result) {
<a href="https://vc.1ed.tech" rel="noopener">Vérifier avec un outil tiers</a>.</p>`;
}

/** 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.' });
}
Expand Down
32 changes: 31 additions & 1 deletion site/verify-page.test.mjs
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand Down Expand Up @@ -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();

Expand Down
Loading