Page de connexion distincte avant la page de dépôt
All checks were successful
Publier l'image Docker / test (push) Successful in 22s
Publier l'image Docker / publish (push) Successful in 12s

`GET /` décide côté serveur, à chaque requête, laquelle des deux pages
servir : `public/login.html` (aucune session valide — juste le bouton
« Se connecter », aucune zone de dépôt dans le HTML) ou
`public/upload.html` (session valide — nom d'utilisateur injecté et
échappé, bouton « Se déconnecter »). Nouveau `src/routes/pages.ts`.

Simplifications côté page de dépôt, permises par cette garantie
serveur : plus de fetch `/auth/me` ni de bascule `authenticated` en
JS, plus de retrait/réinsertion de la zone de dépôt dans le DOM — elle
est toujours présente puisque la page n'est servie qu'aux sessions
valides. Une session qui expire en cours d'usage (401 sur /targets ou
/upload*) renvoie vers `/?login=expired`, qui affiche alors la vraie
page de connexion avec le message adapté.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Nicolas 2026-09-13 20:49:21 +02:00
parent 615d0fd45a
commit 0962c2cc1f
6 changed files with 320 additions and 91 deletions

View file

@ -1,10 +1,12 @@
# mediaDrop # mediaDrop
Petit serveur pour **déposer des fichiers médias** (images / vidéos / audio) sur une Petit serveur pour **déposer des fichiers médias** (images / vidéos / audio) sur une
machine : une page web de glisser-déposer et un endpoint `POST /upload`. machine : une page de connexion, une page de glisser-déposer, et un endpoint
Authentification **OpenID Connect gérée côté serveur** : le serveur effectue le flux `POST /upload`. Authentification **OpenID Connect gérée côté serveur** : le serveur
Authorization Code + PKCE, puis maintient une **session** (cookie chiffré). Les effectue le flux Authorization Code + PKCE, puis maintient une **session** (cookie
fichiers sont écrits dans un dossier local. Pas de base de données, pas de listing. chiffré). `GET /` sert l'une ou l'autre page selon la session — jamais les deux à la
fois. Les fichiers sont écrits dans un dossier local. Pas de base de données, pas de
listing.
## Prérequis ## Prérequis
@ -49,8 +51,9 @@ pointeur.
npm run dev npm run dev
``` ```
Ouvrir <http://localhost:3000/>, cliquer **Se connecter** (redirection vers le Ouvrir <http://localhost:3000/> : page de connexion, cliquer **Se connecter**
fournisseur), puis glisser des fichiers. (redirection vers le fournisseur) ; de retour sur `/`, la page de dépôt s'affiche —
il ne reste plus qu'à glisser des fichiers.
En production : En production :
@ -109,8 +112,8 @@ pouvant écrire les paquets de l'organisation.
## Endpoints ## Endpoints
| Route | Auth | Rôle | | Route | Auth | Rôle |
| -------------------------------------- | ------- | ------------------------------------------------------------------------ | | -------------------------------------- | ------- | ------------------------------------------------------------------------------- |
| `GET /` | non | Page de dépôt | | `GET /` | non | Page de connexion (`login.html`), ou de dépôt (`upload.html`) si session valide |
| `GET /health` | non | Sonde : `{ "status": "ok", "uptime": 12.3 }` | | `GET /health` | non | Sonde : `{ "status": "ok", "uptime": 12.3 }` |
| `GET /auth/login` | non | Démarre le flux OIDC (redirige vers le fournisseur) | | `GET /auth/login` | non | Démarre le flux OIDC (redirige vers le fournisseur) |
| `GET /auth/callback` | non | Fin du flux : ouvre la session, redirige vers `/` | | `GET /auth/callback` | non | Fin du flux : ouvre la session, redirige vers `/` |
@ -124,6 +127,12 @@ pouvant écrire les paquets de l'organisation.
| `POST /upload/:id/complete` | session | Assemble et finalise → `201` + métadonnées | | `POST /upload/:id/complete` | session | Assemble et finalise → `201` + métadonnées |
| `DELETE /upload/:id` | session | Abandonne (supprime le fichier partiel) | | `DELETE /upload/:id` | session | Abandonne (supprime le fichier partiel) |
`GET /` décide **côté serveur**, à chaque requête : sans session valide, `login.html`
(rien d'autre que le bouton **Se connecter**, aucune zone de dépôt dans le HTML) ;
avec une session valide, `upload.html` avec le nom d'utilisateur injecté (échappé) et
`Se déconnecter`. Une session qui expire pendant l'utilisation de la page (401 sur
`/targets` ou `/upload*`) renvoie le navigateur sur `/?login=expired`.
`POST /upload` — `?target=<nom>` choisit la cible (absent → **première** cible). `POST /upload` — `?target=<nom>` choisit la cible (absent → **première** cible).
`?path=<chemin relatif>` (URL-encodé) dépose dans un **sous-dossier** recréé sous `?path=<chemin relatif>` (URL-encodé) dépose dans un **sous-dossier** recréé sous
`dir`, en conservant le nom d'origine ; le fichier est **écrasé** s'il existe. `dir`, en conservant le nom d'origine ; le fichier est **écrasé** s'il existe.
@ -317,6 +326,7 @@ src/
errors.ts classes d'erreur (AppError + dérivées) errors.ts classes d'erreur (AppError + dérivées)
error-handler.ts gestionnaire d'erreurs global error-handler.ts gestionnaire d'erreurs global
routes/ routes/
pages.ts GET / (login.html ou upload.html selon la session, nom échappé)
auth.ts GET /auth/login, /auth/callback, /auth/me ; POST /auth/logout auth.ts GET /auth/login, /auth/callback, /auth/me ; POST /auth/logout
health.ts GET /health health.ts GET /health
upload.ts GET /targets ; POST /upload (dépôt direct) upload.ts GET /targets ; POST /upload (dépôt direct)
@ -329,5 +339,6 @@ src/
media-types.ts liste blanche des types + extensions + jokers media-types.ts liste blanche des types + extensions + jokers
paths.ts assainissement des chemins relatifs (dépôt de dossier) paths.ts assainissement des chemins relatifs (dépôt de dossier)
public/ public/
index.html page de dépôt (login = lien vers /auth/login, sans build) login.html page de connexion (bouton -> /auth/login, sans build)
upload.html page de dépôt, servie uniquement avec une session valide
``` ```

119
public/login.html Normal file
View file

@ -0,0 +1,119 @@
<!doctype html>
<html lang="fr">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>mediaDrop — Connexion</title>
<style>
:root {
color-scheme: light dark;
--bg: #f6f7f9;
--card: #ffffff;
--border: #d7dbe0;
--text: #1c1f23;
--muted: #6b7280;
--accent: #2563eb;
--err: #b91c1c;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #14171a;
--card: #1d2125;
--border: #33393f;
--text: #e8eaed;
--muted: #9aa3ad;
--accent: #60a5fa;
--err: #f87171;
}
}
* {
box-sizing: border-box;
}
[hidden] {
display: none !important;
}
body {
margin: 0;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 2rem 1rem;
font:
15px/1.5 system-ui,
-apple-system,
'Segoe UI',
Roboto,
sans-serif;
background: var(--bg);
color: var(--text);
}
main {
width: 100%;
max-width: 380px;
display: grid;
gap: 1.25rem;
text-align: center;
}
h1 {
margin: 0;
font-size: 1.4rem;
}
p.sub {
margin: 0.25rem 0 0;
color: var(--muted);
}
.card {
background: var(--card);
border: 1px solid var(--border);
border-radius: 10px;
padding: 1.5rem;
display: grid;
gap: 1rem;
justify-items: center;
}
.btn {
display: inline-block;
font: inherit;
font-weight: 600;
padding: 0.6rem 1.4rem;
border-radius: 8px;
border: 1px solid var(--accent);
background: var(--accent);
color: #fff;
text-decoration: none;
cursor: pointer;
}
#banner {
color: var(--err);
font-size: 0.9rem;
}
</style>
</head>
<body>
<main>
<header>
<h1>mediaDrop</h1>
<p class="sub">Connectez-vous pour déposer des fichiers médias.</p>
</header>
<div class="card">
<div id="banner" hidden></div>
<a class="btn" href="/auth/login">Se connecter</a>
</div>
</main>
<script>
const banner = document.getElementById('banner');
const loginParam = new URLSearchParams(location.search).get('login');
if (loginParam === 'error') {
banner.textContent = 'La connexion a échoué. Réessayez.';
banner.hidden = false;
} else if (loginParam === 'expired') {
banner.textContent = 'La session a expiré. Reconnectez-vous.';
banner.hidden = false;
}
if (loginParam) history.replaceState({}, '', location.pathname);
</script>
</body>
</html>

View file

@ -217,14 +217,13 @@
<main> <main>
<header> <header>
<h1>mediaDrop</h1> <h1>mediaDrop</h1>
<p class="sub">Connectez-vous, puis glissez des fichiers médias pour les déposer.</p> <p class="sub">Glissez des fichiers médias pour les déposer.</p>
</header> </header>
<div class="card"> <div class="card">
<div class="authrow"> <div class="authrow">
<span id="authState" class="muted">Chargement…</span> <span class="muted">Connecté : {{USER_NAME}}</span>
<a id="loginLink" class="btn" href="/auth/login" hidden>Se connecter</a> <form method="post" action="/auth/logout">
<form id="logoutForm" method="post" action="/auth/logout" hidden>
<button class="btn secondary" type="submit">Se déconnecter</button> <button class="btn secondary" type="submit">Se déconnecter</button>
</form> </form>
</div> </div>
@ -259,13 +258,9 @@
</main> </main>
<script> <script>
const authState = document.getElementById('authState');
const banner = document.getElementById('banner'); const banner = document.getElementById('banner');
const loginLink = document.getElementById('loginLink');
const logoutForm = document.getElementById('logoutForm');
const targetCard = document.getElementById('targetCard'); const targetCard = document.getElementById('targetCard');
const targetSelect = document.getElementById('targetSelect'); const targetSelect = document.getElementById('targetSelect');
const dropArea = document.getElementById('dropArea');
const dropzone = document.getElementById('dropzone'); const dropzone = document.getElementById('dropzone');
const picker = document.getElementById('picker'); const picker = document.getElementById('picker');
const dirRow = document.getElementById('dirRow'); const dirRow = document.getElementById('dirRow');
@ -282,42 +277,18 @@
let CHUNK_POOL = 30; // budget global de morceaux en vol ; remplacé par GET /config let CHUNK_POOL = 30; // budget global de morceaux en vol ; remplacé par GET /config
const CHUNK_RETRIES = 3; const CHUNK_RETRIES = 3;
let authenticated = false;
let currentTarget = null; let currentTarget = null;
let currentTargetAllowsDirs = false; let currentTargetAllowsDirs = false;
// Les zones de dépôt ne sont présentes dans le DOM que si l'on est connecté.
const dropAreaAnchor = queueRow;
dropArea.remove();
function setDropAreaPresent(present) {
if (present) {
if (!dropArea.isConnected) dropAreaAnchor.before(dropArea);
} else if (dropArea.isConnected) {
dropArea.remove();
}
}
function showBanner(message) { function showBanner(message) {
banner.textContent = message; banner.textContent = message;
banner.hidden = false; banner.hidden = false;
} }
const loginParam = new URLSearchParams(location.search).get('login'); // La session a expiré pendant l'utilisation de la page : la route `GET /`
if (loginParam === 'error') showBanner('La connexion a échoué. Réessayez.'); // sert la page de connexion tant qu'elle n'est pas renouvelée.
else if (loginParam === 'expired') showBanner('La session de connexion a expiré. Réessayez.'); function goToLogin() {
if (loginParam) history.replaceState({}, '', location.pathname); location.href = '/?login=expired';
function render(me) {
authenticated = Boolean(me && me.authenticated);
loginLink.hidden = authenticated;
logoutForm.hidden = !authenticated;
setDropAreaPresent(authenticated);
authState.textContent = authenticated ? 'Connecté : ' + me.name : 'Non connecté';
if (authenticated) {
loadTargets();
} else {
targetCard.hidden = true;
}
} }
let targets = []; let targets = [];
@ -326,7 +297,7 @@
const target = targets.find((t) => t.name === targetSelect.value) || targets[0] || null; const target = targets.find((t) => t.name === targetSelect.value) || targets[0] || null;
currentTarget = target ? target.name : null; currentTarget = target ? target.name : null;
currentTargetAllowsDirs = Boolean(target && target.allowDirectories); currentTargetAllowsDirs = Boolean(target && target.allowDirectories);
dirRow.hidden = !authenticated || !currentTargetAllowsDirs; dirRow.hidden = !currentTargetAllowsDirs;
// Filtre le sélecteur natif de fichiers selon les types acceptés par la cible // Filtre le sélecteur natif de fichiers selon les types acceptés par la cible
// (le serveur reste l'autorité ; l'attribut ne bloque pas le glisser-déposer). // (le serveur reste l'autorité ; l'attribut ne bloque pas le glisser-déposer).
const mimes = target && target.allowedMimeTypes; const mimes = target && target.allowedMimeTypes;
@ -338,7 +309,13 @@
function loadTargets() { function loadTargets() {
fetch('/targets', { headers: { accept: 'application/json' } }) fetch('/targets', { headers: { accept: 'application/json' } })
.then((r) => (r.ok ? r.json() : [])) .then((r) => {
if (r.status === 401) {
goToLogin();
return [];
}
return r.ok ? r.json() : [];
})
.then((data) => { .then((data) => {
targets = Array.isArray(data) ? data : []; targets = Array.isArray(data) ? data : [];
targetSelect.replaceChildren(); targetSelect.replaceChildren();
@ -361,11 +338,7 @@
} }
targetSelect.addEventListener('change', applyTargetSelection); targetSelect.addEventListener('change', applyTargetSelection);
loadTargets();
fetch('/auth/me', { headers: { accept: 'application/json' } })
.then((r) => r.json())
.then(render)
.catch(() => render({ authenticated: false }));
function formatSize(bytes) { function formatSize(bytes) {
const units = ['o', 'Ko', 'Mo', 'Go', 'To']; const units = ['o', 'Ko', 'Mo', 'Go', 'To'];
@ -578,9 +551,7 @@
ui.progress.textContent = formatSize(job.file.size); ui.progress.textContent = formatSize(job.file.size);
setStatus(ui, 'Déposé : ' + (body.storedName || 'OK'), 'ok'); setStatus(ui, 'Déposé : ' + (body.storedName || 'OK'), 'ok');
} else if (status === 401) { } else if (status === 401) {
render({ authenticated: false }); goToLogin();
showBanner('Session expirée — reconnectez-vous.');
setStatus(ui, 'Non authentifié', 'err');
} else { } else {
setStatus(ui, 'Erreur ' + status + ' : ' + (body.message || 'échec'), 'err'); setStatus(ui, 'Erreur ' + status + ' : ' + (body.message || 'échec'), 'err');
} }
@ -810,10 +781,6 @@
// items : tableau de { file, path } — path '' = fichier simple, sinon dépôt de dossier. // items : tableau de { file, path } — path '' = fichier simple, sinon dépôt de dossier.
function handleBatch(items) { function handleBatch(items) {
if (!authenticated) {
showBanner('Connectez-vous avant de déposer des fichiers.');
return;
}
let batch = items; let batch = items;
if (!currentTargetAllowsDirs && batch.some((entry) => entry.path)) { if (!currentTargetAllowsDirs && batch.some((entry) => entry.path)) {
showBanner('La cible sélectionnée n’accepte pas le dépôt de dossiers.'); showBanner('La cible sélectionnée n’accepte pas le dépôt de dossiers.');
@ -871,9 +838,7 @@
return out; return out;
} }
dropzone.addEventListener('click', () => { dropzone.addEventListener('click', () => picker.click());
if (authenticated) picker.click();
});
picker.addEventListener('change', () => { picker.addEventListener('change', () => {
handleBatch(fromFileList(picker.files, false)); handleBatch(fromFileList(picker.files, false));
picker.value = ''; picker.value = '';
@ -887,7 +852,7 @@
['dragenter', 'dragover'].forEach((type) => { ['dragenter', 'dragover'].forEach((type) => {
dropzone.addEventListener(type, (event) => { dropzone.addEventListener(type, (event) => {
event.preventDefault(); event.preventDefault();
if (authenticated) dropzone.classList.add('hover'); dropzone.classList.add('hover');
}); });
}); });
['dragleave', 'drop'].forEach((type) => { ['dragleave', 'drop'].forEach((type) => {
@ -898,7 +863,7 @@
}); });
}); });
dropzone.addEventListener('drop', (event) => { dropzone.addEventListener('drop', (event) => {
if (!authenticated || !event.dataTransfer) return; if (!event.dataTransfer) return;
collectDropItems(event.dataTransfer) collectDropItems(event.dataTransfer)
.then((items) => { .then((items) => {
if (items.length) handleBatch(items); if (items.length) handleBatch(items);

View file

@ -1,6 +1,3 @@
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import Fastify, { type FastifyInstance, type FastifyServerOptions } from 'fastify'; import Fastify, { type FastifyInstance, type FastifyServerOptions } from 'fastify';
import multipart from '@fastify/multipart'; import multipart from '@fastify/multipart';
import secureSession from '@fastify/secure-session'; import secureSession from '@fastify/secure-session';
@ -14,13 +11,9 @@ import { createOidcClient, type OidcClient } from './oidc.js';
import { authRoutes } from './routes/auth.js'; import { authRoutes } from './routes/auth.js';
import { chunkedUploadRoutes } from './routes/upload-chunked.js'; import { chunkedUploadRoutes } from './routes/upload-chunked.js';
import { healthRoutes } from './routes/health.js'; import { healthRoutes } from './routes/health.js';
import { pageRoutes } from './routes/pages.js';
import { uploadRoutes } from './routes/upload.js'; import { uploadRoutes } from './routes/upload.js';
const INDEX_HTML = readFileSync(
fileURLToPath(new URL('../public/index.html', import.meta.url)),
'utf8',
);
const SESSION_COOKIE_NAME = 'mediadrop_session'; const SESSION_COOKIE_NAME = 'mediadrop_session';
export interface BuildAppOptions { export interface BuildAppOptions {
@ -32,8 +25,8 @@ export interface BuildAppOptions {
/** /**
* Construit l'instance Fastify : session chiffrée, façade OIDC, plugins, dossier * Construit l'instance Fastify : session chiffrée, façade OIDC, plugins, dossier
* de dépôt, gestionnaire d'erreurs, page (`GET /`) et routes (`/auth/*`, * de dépôt, gestionnaire d'erreurs, page (`GET /` — connexion ou dépôt selon la
* `/health`, `/upload`). Exportée pour les tests. * session) et routes (`/auth/*`, `/health`, `/upload`). Exportée pour les tests.
*/ */
export async function buildApp( export async function buildApp(
config: AppConfig, config: AppConfig,
@ -107,10 +100,7 @@ export async function buildApp(
} }
registerErrorHandler(app); registerErrorHandler(app);
app.get('/', (_request, reply): void => { await app.register(pageRoutes);
reply.type('text/html; charset=utf-8').send(INDEX_HTML);
});
await app.register(authRoutes); await app.register(authRoutes);
await app.register(healthRoutes); await app.register(healthRoutes);
await app.register(uploadRoutes); await app.register(uploadRoutes);

47
src/routes/pages.ts Normal file
View file

@ -0,0 +1,47 @@
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import type { FastifyPluginCallback } from 'fastify';
import { getSessionUser } from '../session.js';
const LOGIN_HTML = readFileSync(
fileURLToPath(new URL('../../public/login.html', import.meta.url)),
'utf8',
);
const UPLOAD_HTML_TEMPLATE = readFileSync(
fileURLToPath(new URL('../../public/upload.html', import.meta.url)),
'utf8',
);
const USER_NAME_PLACEHOLDER = '{{USER_NAME}}';
const HTML_ESCAPES: Record<string, string> = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;',
};
/** Échappe une valeur avant insertion dans du HTML brut (nom d'utilisateur issu de l'IdP). */
function escapeHtml(value: string): string {
return value.replace(/[&<>"']/g, (char) => HTML_ESCAPES[char] ?? char);
}
/**
* `GET /` — page de connexion tant qu'aucune session valide n'existe, sinon
* page de dépôt (nom d'utilisateur injecté côté serveur, échappé).
*/
export const pageRoutes: FastifyPluginCallback = (app, _options, done) => {
app.get('/', (request, reply): void => {
const user = getSessionUser(request);
const html =
user === null
? LOGIN_HTML
: UPLOAD_HTML_TEMPLATE.replace(USER_NAME_PLACEHOLDER, escapeHtml(user.name));
reply.type('text/html; charset=utf-8').send(html);
});
done();
};

97
tests/pages.test.ts Normal file
View file

@ -0,0 +1,97 @@
import type { FastifyInstance } from 'fastify';
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest';
import { buildApp } from '../src/app.js';
import type { OidcConfig } from '../src/config.js';
import {
login,
makeConfig,
makeTempDir,
oneTarget,
removeDir,
SESSION_COOKIE,
startStubIdp,
type StubIdp,
} from './helpers.js';
let idp: StubIdp;
let app: FastifyInstance;
let uploadDir: string;
function oidcOverride(ref: StubIdp): OidcConfig {
return {
issuer: ref.issuer,
clientId: ref.clientId,
clientSecret: ref.clientSecret,
scopes: ['openid', 'profile', 'email'],
};
}
beforeAll(async () => {
idp = await startStubIdp();
});
afterAll(async () => {
await idp.close();
});
beforeEach(async () => {
uploadDir = await makeTempDir();
app = await buildApp(makeConfig({ targets: oneTarget(uploadDir), oidc: oidcOverride(idp) }), {
logger: false,
});
await app.ready();
});
afterEach(async () => {
await app.close();
await removeDir(uploadDir);
});
describe('GET /', () => {
it('sans session : sert la page de connexion, pas la page de dépôt', async () => {
const res = await app.inject({ method: 'GET', url: '/' });
expect(res.statusCode).toBe(200);
expect(res.headers['content-type']).toContain('text/html');
expect(res.body).toContain('href="/auth/login"');
expect(res.body).not.toContain('id="dropzone"');
expect(res.body).not.toContain('Se déconnecter');
});
it('avec session valide : sert la page de dépôt avec le nom injecté', async () => {
const cookie = await login(app, idp, { name: 'Alice' });
const res = await app.inject({
method: 'GET',
url: '/',
cookies: { [SESSION_COOKIE]: cookie },
});
expect(res.statusCode).toBe(200);
expect(res.body).toContain('Connecté : Alice');
expect(res.body).toContain('Se déconnecter');
expect(res.body).toContain('id="dropzone"');
expect(res.body).not.toContain('{{USER_NAME}}');
expect(res.body).not.toContain('href="/auth/login"');
});
it('échappe le nom d’utilisateur avant de l’insérer dans le HTML', async () => {
const cookie = await login(app, idp, { name: '<script>alert(1)</script>' });
const res = await app.inject({
method: 'GET',
url: '/',
cookies: { [SESSION_COOKIE]: cookie },
});
expect(res.body).not.toContain('<script>alert(1)</script>');
expect(res.body).toContain('&lt;script&gt;alert(1)&lt;/script&gt;');
});
it('session invalide (cookie illisible) : retombe sur la page de connexion', async () => {
const res = await app.inject({
method: 'GET',
url: '/',
cookies: { [SESSION_COOKIE]: 'cookie-bidon' },
});
expect(res.statusCode).toBe(200);
expect(res.body).toContain('href="/auth/login"');
expect(res.body).not.toContain('id="dropzone"');
});
});