mediaDrop/public/upload.html
Nicolas 0962c2cc1f
All checks were successful
Publier l'image Docker / test (push) Successful in 22s
Publier l'image Docker / publish (push) Successful in 12s
Page de connexion distincte avant la page de dépôt
`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>
2026-09-13 20:49:21 +02:00

875 lines
28 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!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;
}
[hidden] {
display: none !important;
}
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);
}
.authrow form {
margin: 0;
display: flex;
}
.authrow .btn {
white-space: nowrap;
}
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);
}
#dropArea {
display: grid;
gap: 1.25rem;
}
#list {
display: grid;
gap: 0.6rem;
max-height: 55vh;
overflow-y: auto;
overscroll-behavior: contain;
}
#list:not(:empty) {
padding-right: 0.25rem;
}
.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;
align-items: center;
gap: 1rem;
font-size: 0.9rem;
}
.item .name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.item .detail {
color: var(--muted);
white-space: nowrap;
font-variant-numeric: tabular-nums;
}
.cancel {
flex: none;
font: inherit;
line-height: 1;
padding: 0.15rem 0.4rem;
border-radius: 6px;
border: 1px solid var(--border);
background: transparent;
color: var(--muted);
cursor: pointer;
}
.cancel:hover {
color: var(--err);
border-color: var(--err);
}
#queueRow {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
}
.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">Glissez des fichiers médias pour les déposer.</p>
</header>
<div class="card">
<div class="authrow">
<span class="muted">Connecté : {{USER_NAME}}</span>
<form method="post" action="/auth/logout">
<button class="btn secondary" type="submit">Se déconnecter</button>
</form>
</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="dropArea">
<div id="dropzone" class="card">
<strong>Déposez fichiers ou dossiers ici</strong><br />
<span class="muted">ou cliquez pour parcourir</span>
<input id="picker" type="file" multiple hidden />
</div>
<div id="dirRow" class="card" hidden>
<button id="dirBtn" type="button" class="btn secondary">Parcourir un dossier…</button>
<span class="muted"> — ou glissez un dossier dans la zone ci-dessus</span>
<input id="dirPicker" type="file" webkitdirectory multiple hidden />
</div>
</div>
<div id="queueRow" class="card" hidden>
<span id="queueInfo" class="muted"></span>
<button id="queueCancel" type="button" class="btn secondary">Tout annuler</button>
</div>
<div id="list"></div>
</main>
<script>
const banner = document.getElementById('banner');
const targetCard = document.getElementById('targetCard');
const targetSelect = document.getElementById('targetSelect');
const dropzone = document.getElementById('dropzone');
const picker = document.getElementById('picker');
const dirRow = document.getElementById('dirRow');
const dirBtn = document.getElementById('dirBtn');
const dirPicker = document.getElementById('dirPicker');
const queueRow = document.getElementById('queueRow');
const queueInfo = document.getElementById('queueInfo');
const queueCancel = document.getElementById('queueCancel');
const list = document.getElementById('list');
const MAX_PARALLEL = 3; // dépôts directs (petits fichiers) simultanés
const MAX_CHUNKED_JOBS = 32; // fichiers fragmentés actifs à la fois (< 100 côté serveur)
const CHUNK_THRESHOLD = 32 * 1024 * 1024; // au-delà : upload fragmenté et reprenable
let CHUNK_POOL = 30; // budget global de morceaux en vol ; remplacé par GET /config
const CHUNK_RETRIES = 3;
let currentTarget = null;
let currentTargetAllowsDirs = false;
function showBanner(message) {
banner.textContent = message;
banner.hidden = false;
}
// La session a expiré pendant l'utilisation de la page : la route `GET /`
// sert la page de connexion tant qu'elle n'est pas renouvelée.
function goToLogin() {
location.href = '/?login=expired';
}
let targets = [];
function applyTargetSelection() {
const target = targets.find((t) => t.name === targetSelect.value) || targets[0] || null;
currentTarget = target ? target.name : null;
currentTargetAllowsDirs = Boolean(target && target.allowDirectories);
dirRow.hidden = !currentTargetAllowsDirs;
// 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).
const mimes = target && target.allowedMimeTypes;
picker.accept =
Array.isArray(mimes) && !mimes.includes('*') && !mimes.includes('*/*')
? mimes.join(',')
: '';
}
function loadTargets() {
fetch('/targets', { headers: { accept: 'application/json' } })
.then((r) => {
if (r.status === 401) {
goToLogin();
return [];
}
return r.ok ? r.json() : [];
})
.then((data) => {
targets = Array.isArray(data) ? data : [];
targetSelect.replaceChildren();
targets.forEach((target) => {
const option = document.createElement('option');
option.value = target.name;
option.textContent = target.label;
targetSelect.appendChild(option);
});
targetCard.hidden = targets.length < 2;
applyTargetSelection();
})
.catch(() => {
targets = [];
currentTarget = null;
currentTargetAllowsDirs = false;
targetCard.hidden = true;
dirRow.hidden = true;
});
}
targetSelect.addEventListener('change', applyTargetSelection);
loadTargets();
function formatSize(bytes) {
const units = ['o', 'Ko', 'Mo', 'Go', 'To'];
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 formatEta(seconds) {
if (!isFinite(seconds) || seconds < 0) return '';
if (seconds < 90) return '~' + Math.round(seconds) + ' s';
if (seconds < 5400) return '~' + Math.round(seconds / 60) + ' min';
return '~' + (seconds / 3600).toFixed(1) + ' h';
}
function addItem(file, label) {
const item = document.createElement('div');
item.className = 'item';
item.innerHTML =
'<div class="row">' +
'<span class="name"></span>' +
'<span class="detail"><span class="total"></span> ' +
'<button type="button" class="cancel" hidden>Annuler</button></span>' +
'</div>' +
'<div class="bar"><span></span></div>' +
'<div class="row"><span class="status muted">En attente…</span>' +
'<span class="detail progress"></span></div>';
item.querySelector('.name').textContent = label || file.name;
item.querySelector('.total').textContent = formatSize(file.size);
list.prepend(item);
return {
bar: item.querySelector('.bar > span'),
status: item.querySelector('.status'),
progress: item.querySelector('.progress'),
cancelBtn: item.querySelector('.cancel'),
};
}
function setStatus(ui, text, kind) {
ui.status.textContent = text;
ui.status.className = 'status ' + (kind || 'muted');
}
const queue = [];
const active = new Set();
function updateQueueRow() {
const running = active.size;
const waiting = queue.length;
if (running + waiting === 0) {
queueRow.hidden = true;
return;
}
queueRow.hidden = false;
queueInfo.textContent =
running + ' en cours' + (waiting ? ' · ' + waiting + ' en attente' : '');
}
function pump() {
let simple = 0;
let chunked = 0;
for (const j of active) {
if (j.chunked) chunked += 1;
else simple += 1;
}
const kept = [];
for (const job of queue) {
if (job.chunked ? chunked < MAX_CHUNKED_JOBS : simple < MAX_PARALLEL) {
if (job.chunked) chunked += 1;
else simple += 1;
startUpload(job);
} else {
kept.push(job);
}
}
queue.length = 0;
for (const job of kept) queue.push(job);
updateQueueRow();
}
function enqueue(file, relativePath) {
const ui = addItem(file, relativePath);
const job = { file, relativePath, ui, chunked: file.size > CHUNK_THRESHOLD };
queue.push(job);
ui.cancelBtn.hidden = false;
ui.cancelBtn.onclick = () => {
const i = queue.indexOf(job);
if (i < 0) return;
queue.splice(i, 1);
ui.cancelBtn.hidden = true;
setStatus(ui, 'Annulé', 'err');
updateQueueRow();
};
updateQueueRow();
}
// Pool global de morceaux en vol, réparti équitablement entre les fichiers
// fragmentés actifs. Chaque fichier est plafonné à sa part équitable
// (ceil(CHUNK_POOL / nb de fichiers actifs)) : 1 fichier => tout le pool,
// N fichiers => une part chacun. Un fichier encore en cours d'initialisation
// (`priming`) compte dans la part pour lui réserver un slot ; à égalité de
// morceaux en vol, le fichier le moins servi passe en premier.
const chunkPool = {
free: CHUNK_POOL,
entries: [],
add(entry) {
this.entries.push(entry);
this.tick();
},
remove(entry) {
const i = this.entries.indexOf(entry);
if (i >= 0) this.entries.splice(i, 1);
this.tick();
},
tick() {
while (this.free > 0) {
const contenders = this.entries.filter(
(e) => !e.stopped && (e.pending.length > 0 || e.priming),
);
if (contenders.length === 0) return;
const share = Math.max(1, Math.ceil(CHUNK_POOL / contenders.length));
let best = null;
for (const e of this.entries) {
if (e.stopped || e.pending.length === 0 || e.inflight >= share) continue;
if (!best || e.inflight < best.inflight) best = e;
}
if (!best) return;
const target = best;
const index = target.pending.shift();
target.inflight += 1;
this.free -= 1;
target.send(index).finally(() => {
target.inflight -= 1;
this.free += 1;
this.tick();
});
}
},
};
// Taille du pool réglée côté serveur (config.json -> GET /config). Ajuste
// `free` du même delta : sûr même si des morceaux sont déjà en vol.
fetch('/config', { headers: { accept: 'application/json' } })
.then((r) => (r.ok ? r.json() : null))
.then((cfg) => {
const n = cfg && Number(cfg.chunkPoolSize);
if (Number.isInteger(n) && n > 0 && n !== CHUNK_POOL) {
chunkPool.free += n - CHUNK_POOL;
CHUNK_POOL = n;
chunkPool.tick();
}
})
.catch(() => {});
function makeReporter(ui, total) {
let started = 0;
let lastAt = 0;
let lastLoaded = 0;
let speed = 0;
return function report(loaded) {
const now = performance.now();
if (!started) {
started = now;
lastAt = now;
}
const dt = (now - lastAt) / 1000;
if (dt >= 0.25) {
const inst = (loaded - lastLoaded) / dt;
speed = speed ? speed * 0.7 + inst * 0.3 : inst;
lastAt = now;
lastLoaded = loaded;
}
const pct = total ? Math.floor((loaded / total) * 100) : 0;
ui.bar.style.width = pct + '%';
const eta = speed > 0 && total ? formatEta((total - loaded) / speed) : '';
ui.progress.textContent =
formatSize(loaded) +
' / ' +
formatSize(total) +
' · ' +
pct +
' %' +
(speed > 0 ? ' · ' + formatSize(speed) + '/s' : '') +
(eta ? ' · ' + eta : '');
};
}
function uploadQuery(relativePath) {
const p = new URLSearchParams();
if (currentTarget) p.set('target', currentTarget);
if (relativePath) p.set('path', relativePath);
const s = p.toString();
return s ? '?' + s : '';
}
function finish(job) {
active.delete(job);
job.ui.cancelBtn.hidden = true;
pump();
}
function applyResult(job, status, body) {
const ui = job.ui;
if (status >= 200 && status < 300) {
ui.bar.style.width = '100%';
ui.progress.textContent = formatSize(job.file.size);
setStatus(ui, 'Déposé : ' + (body.storedName || 'OK'), 'ok');
} else if (status === 401) {
goToLogin();
} else {
setStatus(ui, 'Erreur ' + status + ' : ' + (body.message || 'échec'), 'err');
}
}
function startUpload(job) {
active.add(job);
job.ui.cancelBtn.hidden = false;
job.ui.cancelBtn.onclick = () => job.cancel && job.cancel();
setStatus(job.ui, 'Envoi…', 'muted');
if (job.file.size > CHUNK_THRESHOLD) runChunked(job);
else runSimple(job);
}
function runSimple(job) {
const { file, relativePath, ui } = job;
const report = makeReporter(ui, file.size);
const form = new FormData();
form.append('file', file);
const xhr = new XMLHttpRequest();
job.cancel = () => xhr.abort();
xhr.open('POST', '/upload' + uploadQuery(relativePath));
xhr.upload.addEventListener('progress', (e) => {
if (e.lengthComputable) report(e.loaded);
});
xhr.addEventListener('load', () => {
finish(job);
let body = {};
try {
body = JSON.parse(xhr.responseText);
} catch {
body = {};
}
applyResult(job, xhr.status, body);
});
xhr.addEventListener('error', () => {
finish(job);
setStatus(ui, 'Erreur réseau', 'err');
});
xhr.addEventListener('abort', () => {
finish(job);
ui.progress.textContent = '';
setStatus(ui, 'Annulé', 'err');
});
xhr.send(form);
}
async function runChunked(job) {
const { file, relativePath, ui } = job;
const report = makeReporter(ui, file.size);
const inflight = new Set(); // XHR en vol (pour l'annulation)
let aborted = false;
let sessionId = null;
let resolveAll = null;
const finished = new Promise((r) => {
resolveAll = r;
});
let settled = false;
const finalize = (outcome) => {
if (!settled) {
settled = true;
resolveAll(outcome);
}
};
const dropSession = () => {
if (sessionId) fetch('/upload/' + sessionId, { method: 'DELETE' }).catch(() => {});
};
// Inscrit dans le pool dès maintenant, avant l'init réseau : le
// planificateur compte ce fichier dans la part équitable (`priming`) et
// lui réserve un slot même si son init n'a pas encore répondu.
const entry = { inflight: 0, stopped: false, priming: true, pending: [], send: null };
chunkPool.add(entry);
job.cancel = () => {
aborted = true;
entry.stopped = true;
entry.pending.length = 0;
for (const x of inflight) x.abort();
chunkPool.remove(entry);
dropSession();
finish(job);
ui.progress.textContent = '';
setStatus(ui, 'Annulé', 'err');
finalize('abort');
};
let meta;
try {
const res = await fetch('/upload/init' + uploadQuery(relativePath), {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
name: file.name,
size: file.size,
type: file.type || 'application/octet-stream',
}),
});
meta = await res.json().catch(() => ({}));
if (!res.ok) {
entry.stopped = true;
chunkPool.remove(entry);
finish(job);
applyResult(job, res.status, meta);
return;
}
} catch {
entry.stopped = true;
chunkPool.remove(entry);
finish(job);
setStatus(ui, 'Erreur réseau (init)', 'err');
return;
}
if (aborted) return;
sessionId = meta.id;
const chunkSize = meta.chunkSize;
const chunkCount = meta.chunkCount;
const loadedPer = new Array(chunkCount).fill(0);
const attempts = new Array(chunkCount).fill(0);
const emit = () => report(loadedPer.reduce((a, b) => a + b, 0));
let doneCount = 0;
function retry(index, status, releasePermit) {
if (aborted || entry.stopped) {
releasePermit();
return;
}
attempts[index] += 1;
if (attempts[index] > CHUNK_RETRIES) {
entry.stopped = true;
entry.pending.length = 0;
setStatus(ui, 'Erreur morceau ' + index + ' (' + (status || 'réseau') + ')', 'err');
finalize('fail');
releasePermit();
return;
}
setTimeout(() => {
if (!aborted && !entry.stopped) {
entry.pending.unshift(index);
chunkPool.tick();
}
}, 400 * attempts[index]);
releasePermit(); // libère le permis ; le morceau est re-planifié après le backoff
}
entry.pending = Array.from({ length: chunkCount }, (_, i) => i);
entry.priming = false;
entry.send = (index) =>
new Promise((releasePermit) => {
const start = index * chunkSize;
const end = Math.min(start + chunkSize, file.size);
const xhr = new XMLHttpRequest();
inflight.add(xhr);
xhr.open('PUT', '/upload/' + sessionId + '/' + index);
xhr.setRequestHeader('content-type', 'application/octet-stream');
xhr.upload.addEventListener('progress', (e) => {
if (e.lengthComputable) {
loadedPer[index] = e.loaded;
emit();
}
});
xhr.addEventListener('load', () => {
inflight.delete(xhr);
if (xhr.status >= 200 && xhr.status < 300) {
loadedPer[index] = end - start;
emit();
doneCount += 1;
if (doneCount === chunkCount) finalize('ok');
releasePermit();
} else if (xhr.status === 401) {
entry.stopped = true;
entry.pending.length = 0;
applyResult(job, 401, {});
finalize('fail');
releasePermit();
} else {
retry(index, xhr.status, releasePermit);
}
});
xhr.addEventListener('error', () => {
inflight.delete(xhr);
retry(index, 0, releasePermit);
});
xhr.addEventListener('abort', () => {
inflight.delete(xhr);
releasePermit();
});
xhr.send(file.slice(start, end));
});
chunkPool.tick();
const outcome = await finished;
chunkPool.remove(entry);
if (outcome === 'abort') return;
if (outcome === 'fail') {
for (const x of inflight) x.abort();
finish(job);
dropSession();
return;
}
try {
const res = await fetch('/upload/' + sessionId + '/complete', { method: 'POST' });
const body = await res.json().catch(() => ({}));
finish(job);
applyResult(job, res.status, body);
} catch {
finish(job);
setStatus(ui, 'Erreur réseau (finalisation)', 'err');
}
}
function cancelAll() {
while (queue.length) {
const job = queue.shift();
job.ui.cancelBtn.hidden = true;
setStatus(job.ui, 'Annulé', 'err');
}
for (const job of Array.from(active)) if (job.cancel) job.cancel();
updateQueueRow();
}
queueCancel.addEventListener('click', cancelAll);
// items : tableau de { file, path } — path '' = fichier simple, sinon dépôt de dossier.
function handleBatch(items) {
let batch = items;
if (!currentTargetAllowsDirs && batch.some((entry) => entry.path)) {
showBanner('La cible sélectionnée n’accepte pas le dépôt de dossiers.');
batch = batch.filter((entry) => !entry.path);
} else {
banner.hidden = true;
}
for (const entry of batch) enqueue(entry.file, entry.path);
pump();
}
function fromFileList(fileList, useRelativePath) {
return Array.from(fileList, (file) => ({
file,
path: useRelativePath ? file.webkitRelativePath || '' : '',
}));
}
function readAllEntries(reader) {
return new Promise((resolve, reject) => {
const acc = [];
const step = () =>
reader.readEntries((batch) => {
if (batch.length === 0) resolve(acc);
else {
acc.push(...batch);
step();
}
}, reject);
step();
});
}
async function walkEntry(entry, prefix, out) {
if (entry.isFile) {
const file = await new Promise((res, rej) => entry.file(res, rej));
out.push({ file, path: prefix ? prefix + entry.name : '' });
} else if (entry.isDirectory) {
const children = await readAllEntries(entry.createReader());
for (const child of children) await walkEntry(child, prefix + entry.name + '/', out);
}
}
async function collectDropItems(dataTransfer) {
const entries = [];
for (const item of Array.from(dataTransfer.items || [])) {
const entry = item.webkitGetAsEntry ? item.webkitGetAsEntry() : null;
if (entry) entries.push(entry);
}
if (entries.length === 0) {
return fromFileList(dataTransfer.files || [], false);
}
const out = [];
for (const entry of entries) await walkEntry(entry, '', out);
return out;
}
dropzone.addEventListener('click', () => picker.click());
picker.addEventListener('change', () => {
handleBatch(fromFileList(picker.files, false));
picker.value = '';
});
dirBtn.addEventListener('click', () => dirPicker.click());
dirPicker.addEventListener('change', () => {
handleBatch(fromFileList(dirPicker.files, true));
dirPicker.value = '';
});
['dragenter', 'dragover'].forEach((type) => {
dropzone.addEventListener(type, (event) => {
event.preventDefault();
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) return;
collectDropItems(event.dataTransfer)
.then((items) => {
if (items.length) handleBatch(items);
})
.catch(() => showBanner('Impossible de lire le contenu déposé.'));
});
</script>
</body>
</html>