mediaDrop/public/index.html
Nicolas 1b429049c2
Some checks failed
Publier l'image Docker / test (push) Successful in 19s
Publier l'image Docker / publish (push) Failing after 9s
Upload fragmenté et reprenable pour les gros fichiers
Serveur :
- POST /upload/init -> session { id, chunkSize (16 Mo), chunkCount }
- PUT /upload/:id/:index -> écrit le morceau à son offset (octet-stream brut,
  bufferisé, parser dédié) ; renvoie { received, total }
- POST /upload/:id/complete -> vérifie tous les morceaux + la taille, renomme
  le .mediadrop-part vers le chemin final ; 201 + métadonnées
- DELETE /upload/:id -> abandon, supprime le fichier partiel
- src/lib/upload-sessions.ts : registre en mémoire, purge après 30 min
  d'inactivité, 100 sessions max
- src/lib/targets.ts : resolveTarget / resolvePlacement extraits et partagés
- writeChunkAt / createEmptyFile / finalizeFile / removeFile dans storage.ts
- limites, allowDirectories, jokers MIME et confinement dans la cible
  s'appliquent aussi au chemin fragmenté

Page :
- bascule automatique en fragmenté au-delà de 32 Mo : 3 morceaux en parallèle,
  3 réessais par morceau, progression et annulation agrégées
- makeReporter partagé (débit + ETA) ; job.cancel unifié (simple / fragmenté /
  en file d'attente)

Tests : +tests/upload-chunked.test.ts (9), +writeChunkAt (storage) ; 73 tests.
Vérifié en live : 40 Mio -> 3 morceaux // -> octets exacts, DELETE nettoie.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-10 18:25:27 +02:00

763 lines
24 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;
}
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;
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">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 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 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 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 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 = 2;
const CHUNK_THRESHOLD = 32 * 1024 * 1024; // au-delà : upload fragmenté et reprenable
const CHUNK_PARALLEL = 3;
const CHUNK_RETRIES = 3;
let authenticated = false;
let currentTarget = null;
let currentTargetAllowsDirs = false;
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;
dirRow.hidden = true;
}
}
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 = !authenticated || !currentTargetAllowsDirs;
}
function loadTargets() {
fetch('/targets', { headers: { accept: 'application/json' } })
.then((r) => (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);
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', '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');
}
// File d'attente : au plus MAX_PARALLEL transferts simultanés.
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() {
while (active.size < MAX_PARALLEL && queue.length > 0) {
startUpload(queue.shift());
}
updateQueueRow();
}
function enqueue(file, relativePath) {
const ui = addItem(file, relativePath);
const job = { file, relativePath, ui };
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();
}
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) {
render({ authenticated: false });
showBanner('Session expirée — reconnectez-vous.');
setStatus(ui, 'Non authentifié', 'err');
} 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();
let aborted = false;
let failed = false;
let sessionId = null;
const dropSession = () => {
if (sessionId) fetch('/upload/' + sessionId, { method: 'DELETE' }).catch(() => {});
};
job.cancel = () => {
aborted = true;
for (const x of inflight) x.abort();
dropSession();
finish(job);
ui.progress.textContent = '';
setStatus(ui, 'Annulé', 'err');
};
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) {
finish(job);
applyResult(job, res.status, meta);
return;
}
} catch {
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 emit = () => report(loadedPer.reduce((a, b) => a + b, 0));
let nextIndex = 0;
const sendOnce = (index) =>
new Promise((resolve) => {
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();
}
});
const settle = (r) => {
inflight.delete(xhr);
resolve(r);
};
xhr.addEventListener('load', () => {
if (xhr.status >= 200 && xhr.status < 300) {
loadedPer[index] = end - start;
emit();
settle({ ok: true });
} else {
settle({ ok: false, status: xhr.status });
}
});
xhr.addEventListener('error', () => settle({ ok: false, status: 0 }));
xhr.addEventListener('abort', () => settle({ ok: false, aborted: true }));
xhr.send(file.slice(start, end));
});
async function worker() {
while (!aborted && !failed && nextIndex < chunkCount) {
const index = nextIndex;
nextIndex += 1;
let r = { ok: false };
for (let attempt = 0; attempt <= CHUNK_RETRIES; attempt += 1) {
r = await sendOnce(index);
if (r.ok || r.aborted || r.status === 401) break;
await new Promise((res) => setTimeout(res, 400 * (attempt + 1)));
if (aborted) return;
}
if (r.ok) continue;
if (r.aborted) return;
failed = true;
if (r.status === 401) applyResult(job, 401, {});
else
setStatus(ui, 'Erreur morceau ' + index + ' (' + (r.status || 'réseau') + ')', 'err');
return;
}
}
const workers = [];
for (let i = 0; i < Math.min(CHUNK_PARALLEL, chunkCount); i += 1) workers.push(worker());
await Promise.all(workers);
if (aborted) return;
if (failed) {
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) {
if (!authenticated) {
showBanner('Connectez-vous avant de déposer des fichiers.');
return;
}
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', () => {
if (authenticated) 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();
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 (!authenticated || !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>