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>
470 lines
15 KiB
HTML
470 lines
15 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 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>
|