mediaDrop/public/index.html
Nicolas b85607b096 Dépôt de dossiers + très gros fichiers
Dépôt de dossiers :
- flag `allowDirectories` (défaut false) par cible dans config.json + schéma.
- POST /upload accepte `?path=<chemin relatif>` : l'arborescence est recréée
  sous `dir`, nom d'origine conservé, fichier écrasé si présent.
- src/lib/paths.ts : assainissement segment par segment (pas de `..`, pas de
  séparateur) + vérification de confinement dans storage.ts -> aucune sortie
  possible de la cible. Refus 400 si la cible n'autorise pas les dossiers.
- GET /targets expose `allowDirectories`.
- Page : glisser-déposer d'un dossier (parcours récursif webkitGetAsEntry) +
  bouton « parcourir un dossier », visibles selon la cible.

Très gros fichiers (~10 Go) :
- `requestTimeout: 0` / `connectionTimeout: 0` : aucun timeout serveur imposé.
- écriture disque avec un tampon de 1 Mio.
- doc : `maxFileSizeMb` à l'échelle Go, réglages reverse proxy.

Tests : +paths.test.ts, +placement (storage), +dossier & gros fichier (upload) ;
60 tests.

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

470 lines
15 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;
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 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="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 list = document.getElementById('list');
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 addItem(file, label) {
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 = label || 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, relativePath) {
const ui = addItem(file, relativePath);
const form = new FormData();
form.append('file', file);
const params = new URLSearchParams();
if (currentTarget) params.set('target', currentTarget);
if (relativePath) params.set('path', relativePath);
const query = params.toString();
const xhr = new XMLHttpRequest();
xhr.open('POST', query ? '/upload?' + query : '/upload');
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);
}
// 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) upload(entry.file, entry.path);
}
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>