Serveur TypeScript (Fastify 5) permettant de déposer des fichiers médias
dans des dossiers nommés, via une page web de glisser-déposer.
- Authentification OpenID Connect gérée côté serveur (Authorization Code +
PKCE, client confidentiel, openid-client) ; session via cookie chiffré
(@fastify/secure-session).
- Configuration dans un fichier JSON (schéma fourni) ; l'environnement ne
porte que le pointeur MEDIADROP_CONFIG.
- Cibles de dépôt nommées : liste { name, label, dir }, sélection par
?target=, première cible par défaut.
- Upload multipart en streaming : nom de fichier dérivé côté serveur (pas
de path traversal), liste blanche de types MIME, plafond de taille.
- TypeScript strict, ESLint + Prettier sans warning, 46 tests Vitest.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
381 lines
11 KiB
HTML
381 lines
11 KiB
HTML
<!doctype html>
|
|
<html lang="fr">
|
|
<head>
|
|
<meta charset="utf-8" />
|
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
<title>mediaDrop</title>
|
|
<style>
|
|
:root {
|
|
color-scheme: light dark;
|
|
--bg: #f6f7f9;
|
|
--card: #ffffff;
|
|
--border: #d7dbe0;
|
|
--text: #1c1f23;
|
|
--muted: #6b7280;
|
|
--accent: #2563eb;
|
|
--ok: #15803d;
|
|
--err: #b91c1c;
|
|
}
|
|
@media (prefers-color-scheme: dark) {
|
|
:root {
|
|
--bg: #14171a;
|
|
--card: #1d2125;
|
|
--border: #33393f;
|
|
--text: #e8eaed;
|
|
--muted: #9aa3ad;
|
|
--accent: #60a5fa;
|
|
--ok: #4ade80;
|
|
--err: #f87171;
|
|
}
|
|
}
|
|
* {
|
|
box-sizing: border-box;
|
|
}
|
|
body {
|
|
margin: 0;
|
|
padding: 2rem 1rem;
|
|
font:
|
|
15px/1.5 system-ui,
|
|
-apple-system,
|
|
'Segoe UI',
|
|
Roboto,
|
|
sans-serif;
|
|
background: var(--bg);
|
|
color: var(--text);
|
|
}
|
|
main {
|
|
max-width: 640px;
|
|
margin: 0 auto;
|
|
display: grid;
|
|
gap: 1.25rem;
|
|
}
|
|
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: 1rem;
|
|
}
|
|
.authrow {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
gap: 1rem;
|
|
flex-wrap: wrap;
|
|
}
|
|
.btn {
|
|
display: inline-block;
|
|
font: inherit;
|
|
padding: 0.5rem 0.9rem;
|
|
border-radius: 8px;
|
|
border: 1px solid var(--accent);
|
|
background: var(--accent);
|
|
color: #fff;
|
|
text-decoration: none;
|
|
cursor: pointer;
|
|
}
|
|
.btn.secondary {
|
|
background: transparent;
|
|
color: var(--accent);
|
|
}
|
|
form.inline {
|
|
margin: 0;
|
|
}
|
|
label {
|
|
display: block;
|
|
font-weight: 600;
|
|
margin-bottom: 0.4rem;
|
|
}
|
|
select {
|
|
width: 100%;
|
|
padding: 0.5rem 0.6rem;
|
|
border: 1px solid var(--border);
|
|
border-radius: 8px;
|
|
background: var(--bg);
|
|
color: var(--text);
|
|
font: inherit;
|
|
}
|
|
#dropzone {
|
|
border: 2px dashed var(--border);
|
|
border-radius: 10px;
|
|
padding: 2.5rem 1rem;
|
|
text-align: center;
|
|
color: var(--muted);
|
|
cursor: pointer;
|
|
transition:
|
|
border-color 0.15s,
|
|
background 0.15s,
|
|
opacity 0.15s;
|
|
}
|
|
#dropzone.hover {
|
|
border-color: var(--accent);
|
|
background: color-mix(in srgb, var(--accent) 8%, transparent);
|
|
color: var(--text);
|
|
}
|
|
#dropzone.disabled {
|
|
opacity: 0.5;
|
|
cursor: not-allowed;
|
|
}
|
|
#list {
|
|
display: grid;
|
|
gap: 0.6rem;
|
|
}
|
|
.item {
|
|
border: 1px solid var(--border);
|
|
border-radius: 8px;
|
|
padding: 0.6rem 0.75rem;
|
|
display: grid;
|
|
gap: 0.4rem;
|
|
}
|
|
.item .row {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
gap: 1rem;
|
|
font-size: 0.9rem;
|
|
}
|
|
.item .name {
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
}
|
|
.bar {
|
|
height: 6px;
|
|
border-radius: 999px;
|
|
background: var(--border);
|
|
overflow: hidden;
|
|
}
|
|
.bar > span {
|
|
display: block;
|
|
height: 100%;
|
|
width: 0;
|
|
background: var(--accent);
|
|
transition: width 0.15s;
|
|
}
|
|
.status.ok {
|
|
color: var(--ok);
|
|
}
|
|
.status.err {
|
|
color: var(--err);
|
|
}
|
|
.muted {
|
|
color: var(--muted);
|
|
}
|
|
#banner {
|
|
color: var(--err);
|
|
font-size: 0.9rem;
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<main>
|
|
<header>
|
|
<h1>mediaDrop</h1>
|
|
<p class="sub">Connectez-vous, puis glissez des fichiers médias pour les déposer.</p>
|
|
</header>
|
|
|
|
<div class="card">
|
|
<div class="authrow">
|
|
<span id="authState" class="muted">Chargement…</span>
|
|
<span>
|
|
<a id="loginLink" class="btn" href="/auth/login" hidden>Se connecter</a>
|
|
<form id="logoutForm" class="inline" method="post" action="/auth/logout" hidden>
|
|
<button class="btn secondary" type="submit">Se déconnecter</button>
|
|
</form>
|
|
</span>
|
|
</div>
|
|
<div id="banner" hidden></div>
|
|
</div>
|
|
|
|
<div id="targetCard" class="card" hidden>
|
|
<label for="targetSelect">Dossier de dépôt</label>
|
|
<select id="targetSelect"></select>
|
|
</div>
|
|
|
|
<div id="dropzone" class="card disabled">
|
|
<strong>Déposez vos fichiers ici</strong><br />
|
|
<span class="muted">ou cliquez pour parcourir</span>
|
|
<input id="picker" type="file" multiple hidden />
|
|
</div>
|
|
|
|
<div id="list"></div>
|
|
</main>
|
|
|
|
<script>
|
|
const authState = document.getElementById('authState');
|
|
const banner = document.getElementById('banner');
|
|
const loginLink = document.getElementById('loginLink');
|
|
const logoutForm = document.getElementById('logoutForm');
|
|
const targetCard = document.getElementById('targetCard');
|
|
const targetSelect = document.getElementById('targetSelect');
|
|
const dropzone = document.getElementById('dropzone');
|
|
const picker = document.getElementById('picker');
|
|
const list = document.getElementById('list');
|
|
|
|
let authenticated = false;
|
|
let currentTarget = null;
|
|
|
|
function showBanner(message) {
|
|
banner.textContent = message;
|
|
banner.hidden = false;
|
|
}
|
|
|
|
const loginParam = new URLSearchParams(location.search).get('login');
|
|
if (loginParam === 'error') showBanner('La connexion a échoué. Réessayez.');
|
|
else if (loginParam === 'expired') showBanner('La session de connexion a expiré. Réessayez.');
|
|
if (loginParam) history.replaceState({}, '', location.pathname);
|
|
|
|
function render(me) {
|
|
authenticated = Boolean(me && me.authenticated);
|
|
loginLink.hidden = authenticated;
|
|
logoutForm.hidden = !authenticated;
|
|
dropzone.classList.toggle('disabled', !authenticated);
|
|
authState.textContent = authenticated ? 'Connecté : ' + me.name : 'Non connecté';
|
|
if (authenticated) loadTargets();
|
|
else targetCard.hidden = true;
|
|
}
|
|
|
|
function loadTargets() {
|
|
fetch('/targets', { headers: { accept: 'application/json' } })
|
|
.then((r) => (r.ok ? r.json() : []))
|
|
.then((targets) => {
|
|
targetSelect.replaceChildren();
|
|
targets.forEach((target) => {
|
|
const option = document.createElement('option');
|
|
option.value = target.name;
|
|
option.textContent = target.label;
|
|
targetSelect.appendChild(option);
|
|
});
|
|
currentTarget = targets.length ? targets[0].name : null;
|
|
targetCard.hidden = targets.length < 2;
|
|
})
|
|
.catch(() => {
|
|
currentTarget = null;
|
|
targetCard.hidden = true;
|
|
});
|
|
}
|
|
|
|
targetSelect.addEventListener('change', () => {
|
|
currentTarget = targetSelect.value;
|
|
});
|
|
|
|
fetch('/auth/me', { headers: { accept: 'application/json' } })
|
|
.then((r) => r.json())
|
|
.then(render)
|
|
.catch(() => render({ authenticated: false }));
|
|
|
|
function formatSize(bytes) {
|
|
const units = ['o', 'Ko', 'Mo', 'Go'];
|
|
let value = bytes;
|
|
let unit = 0;
|
|
while (value >= 1024 && unit < units.length - 1) {
|
|
value /= 1024;
|
|
unit += 1;
|
|
}
|
|
return value.toFixed(value < 10 && unit > 0 ? 1 : 0) + ' ' + units[unit];
|
|
}
|
|
|
|
function addItem(file) {
|
|
const item = document.createElement('div');
|
|
item.className = 'item';
|
|
item.innerHTML =
|
|
'<div class="row"><span class="name"></span><span class="muted"></span></div>' +
|
|
'<div class="bar"><span></span></div>' +
|
|
'<div class="row"><span class="status muted">En attente…</span></div>';
|
|
item.querySelector('.name').textContent = file.name;
|
|
item.querySelector('.row .muted').textContent = formatSize(file.size);
|
|
list.prepend(item);
|
|
return { bar: item.querySelector('.bar > span'), status: item.querySelector('.status') };
|
|
}
|
|
|
|
function setStatus(ui, text, kind) {
|
|
ui.status.textContent = text;
|
|
ui.status.className = 'status ' + (kind || 'muted');
|
|
}
|
|
|
|
function upload(file) {
|
|
const ui = addItem(file);
|
|
|
|
const form = new FormData();
|
|
form.append('file', file);
|
|
|
|
const xhr = new XMLHttpRequest();
|
|
const url = currentTarget
|
|
? '/upload?target=' + encodeURIComponent(currentTarget)
|
|
: '/upload';
|
|
xhr.open('POST', url);
|
|
|
|
xhr.upload.addEventListener('progress', (event) => {
|
|
if (event.lengthComputable) {
|
|
ui.bar.style.width = (event.loaded / event.total) * 100 + '%';
|
|
}
|
|
});
|
|
|
|
xhr.addEventListener('load', () => {
|
|
let body = {};
|
|
try {
|
|
body = JSON.parse(xhr.responseText);
|
|
} catch {
|
|
body = {};
|
|
}
|
|
if (xhr.status >= 200 && xhr.status < 300) {
|
|
ui.bar.style.width = '100%';
|
|
setStatus(ui, 'Déposé : ' + (body.storedName || 'OK'), 'ok');
|
|
} else if (xhr.status === 401) {
|
|
render({ authenticated: false });
|
|
showBanner('Session expirée — reconnectez-vous.');
|
|
setStatus(ui, 'Non authentifié', 'err');
|
|
} else {
|
|
setStatus(ui, 'Erreur ' + xhr.status + ' : ' + (body.message || 'échec'), 'err');
|
|
}
|
|
});
|
|
|
|
xhr.addEventListener('error', () => setStatus(ui, 'Erreur réseau', 'err'));
|
|
|
|
setStatus(ui, 'Envoi…', 'muted');
|
|
xhr.send(form);
|
|
}
|
|
|
|
function handleFiles(files) {
|
|
if (!authenticated) {
|
|
showBanner('Connectez-vous avant de déposer des fichiers.');
|
|
return;
|
|
}
|
|
banner.hidden = true;
|
|
for (const file of files) upload(file);
|
|
}
|
|
|
|
dropzone.addEventListener('click', () => {
|
|
if (authenticated) picker.click();
|
|
});
|
|
picker.addEventListener('change', () => {
|
|
handleFiles(picker.files);
|
|
picker.value = '';
|
|
});
|
|
['dragenter', 'dragover'].forEach((type) => {
|
|
dropzone.addEventListener(type, (event) => {
|
|
event.preventDefault();
|
|
if (authenticated) dropzone.classList.add('hover');
|
|
});
|
|
});
|
|
['dragleave', 'drop'].forEach((type) => {
|
|
dropzone.addEventListener(type, (event) => {
|
|
event.preventDefault();
|
|
if (type === 'dragleave' && dropzone.contains(event.relatedTarget)) return;
|
|
dropzone.classList.remove('hover');
|
|
});
|
|
});
|
|
dropzone.addEventListener('drop', (event) => {
|
|
if (event.dataTransfer && event.dataTransfer.files.length) {
|
|
handleFiles(event.dataTransfer.files);
|
|
}
|
|
});
|
|
</script>
|
|
</body>
|
|
</html>
|