chunks enabled.

This commit is contained in:
MEGASOL\simon.adams
2025-09-16 09:34:43 +02:00
parent 17feda0d2f
commit 69aa1c031e
7 changed files with 600 additions and 34 deletions

View File

@@ -1,5 +1,6 @@
// Frontend logic (mobile-safe picker; no settings UI)
const sessionId = (crypto && crypto.randomUUID) ? crypto.randomUUID() : (Math.random().toString(36).slice(2));
let CFG = { chunked_uploads_enabled: false, chunk_size_mb: 95 };
// Detect invite token from URL path /invite/{token}
let INVITE_TOKEN = null;
try {
@@ -40,6 +41,21 @@ function updateThemeIcon() {
initDarkMode();
// --- Load minimal config ---
(async function loadConfig(){
try{
const r = await fetch('/api/config');
if (r.ok) {
const j = await r.json();
if (j && typeof j === 'object') {
CFG.chunked_uploads_enabled = !!j.chunked_uploads_enabled;
const n = parseInt(j.chunk_size_mb, 10);
if (!Number.isNaN(n) && n > 0) CFG.chunk_size_mb = n;
}
}
}catch{}
})();
// --- helpers ---
function human(bytes){
if (!bytes) return '0 B';
@@ -156,27 +172,10 @@ async function runQueue(){
render();
inflight++;
try{
const form = new FormData();
form.append('file', next.file);
form.append('item_id', next.id);
form.append('session_id', sessionId);
form.append('last_modified', next.file.lastModified || '');
if (INVITE_TOKEN) form.append('invite_token', INVITE_TOKEN);
const res = await fetch('/api/upload', { method:'POST', body: form });
const body = await res.json().catch(()=>({}));
if(!res.ok && next.status!=='error'){
next.status='error';
next.message = body.error || 'Upload failed';
render();
} else if (res.ok) {
// Fallback finalize on HTTP success in case WS final message is missed
const statusText = (body && body.status) ? String(body.status) : '';
const isDuplicate = /duplicate/i.test(statusText);
next.status = isDuplicate ? 'duplicate' : 'done';
next.message = statusText || (isDuplicate ? 'Duplicate' : 'Uploaded');
next.progress = 100;
render();
try { showBanner(isDuplicate ? `Duplicate: ${next.name}` : `Uploaded: ${next.name}`, isDuplicate ? 'warn' : 'ok'); } catch {}
if (CFG.chunked_uploads_enabled && next.file.size > (CFG.chunk_size_mb * 1024 * 1024)) {
await uploadChunked(next);
} else {
await uploadWhole(next);
}
}catch(err){
next.status='error';
@@ -190,6 +189,94 @@ async function runQueue(){
for(let i=0;i<3;i++) runNext();
}
async function uploadWhole(next){
const form = new FormData();
form.append('file', next.file);
form.append('item_id', next.id);
form.append('session_id', sessionId);
form.append('last_modified', next.file.lastModified || '');
if (INVITE_TOKEN) form.append('invite_token', INVITE_TOKEN);
const res = await fetch('/api/upload', { method:'POST', body: form });
const body = await res.json().catch(()=>({}));
if(!res.ok && next.status!=='error'){
next.status='error';
next.message = body.error || 'Upload failed';
render();
} else if (res.ok) {
const statusText = (body && body.status) ? String(body.status) : '';
const isDuplicate = /duplicate/i.test(statusText);
next.status = isDuplicate ? 'duplicate' : 'done';
next.message = statusText || (isDuplicate ? 'Duplicate' : 'Uploaded');
next.progress = 100;
render();
try { showBanner(isDuplicate ? `Duplicate: ${next.name}` : `Uploaded: ${next.name}`, isDuplicate ? 'warn' : 'ok'); } catch {}
}
}
async function uploadChunked(next){
const chunkBytes = Math.max(1, CFG.chunk_size_mb|0) * 1024 * 1024;
const total = Math.ceil(next.file.size / chunkBytes) || 1;
// init
try {
await fetch('/api/upload/chunk/init', { method:'POST', headers:{'Content-Type':'application/json','Accept':'application/json'}, body: JSON.stringify({
item_id: next.id,
session_id: sessionId,
name: next.file.name,
size: next.file.size,
last_modified: next.file.lastModified || '',
invite_token: INVITE_TOKEN || '',
content_type: next.file.type || 'application/octet-stream'
}) });
} catch {}
// upload parts
let uploaded = 0;
for (let i=0;i<total;i++){
const start = i * chunkBytes;
const end = Math.min(next.file.size, start + chunkBytes);
const blob = next.file.slice(start, end);
const fd = new FormData();
fd.append('item_id', next.id);
fd.append('session_id', sessionId);
fd.append('chunk_index', String(i));
fd.append('total_chunks', String(total));
if (INVITE_TOKEN) fd.append('invite_token', INVITE_TOKEN);
fd.append('chunk', blob, `${next.file.name}.part${i}`);
const r = await fetch('/api/upload/chunk', { method:'POST', body: fd });
if (!r.ok) {
const j = await r.json().catch(()=>({}));
throw new Error(j.error || `Chunk ${i} failed`);
}
uploaded++;
// Approximate progress until final server-side upload takes over
next.status = 'uploading';
next.progress = Math.min(90, Math.floor((uploaded/total) * 60) + 20); // stay under 100 until WS finish
render();
}
// complete
const rc = await fetch('/api/upload/chunk/complete', { method:'POST', headers:{'Content-Type':'application/json','Accept':'application/json'}, body: JSON.stringify({
item_id: next.id,
session_id: sessionId,
name: next.file.name,
last_modified: next.file.lastModified || '',
invite_token: INVITE_TOKEN || '',
content_type: next.file.type || 'application/octet-stream',
total_chunks: total
}) });
const body = await rc.json().catch(()=>({}));
if (!rc.ok && next.status!=='error'){
next.status='error';
next.message = body.error || 'Upload failed';
render();
} else if (rc.ok) {
const statusText = (body && body.status) ? String(body.status) : '';
const isDuplicate = /duplicate/i.test(statusText);
next.status = isDuplicate ? 'duplicate' : 'done';
next.message = statusText || (isDuplicate ? 'Duplicate' : 'Uploaded');
next.progress = 100;
render();
}
}
// --- DOM refs ---
const dz = document.getElementById('dropzone');
const fi = document.getElementById('fileInput');

View File

@@ -53,6 +53,16 @@
</div>
</section>
<!-- Password gate (hidden unless required) -->
<section id="pwGate" class="hidden rounded-2xl border bg-white dark:bg-gray-800 dark:border-gray-700 p-4 space-y-3">
<div class="text-sm">This link is protected. Enter the password to continue.</div>
<div class="flex flex-col sm:flex-row gap-2">
<input id="pwInput" type="password" placeholder="Password" class="flex-1 rounded-lg border px-3 py-3 bg-white dark:bg-gray-900 dark:border-gray-700" />
<button id="btnPw" class="rounded-xl bg-black text-white px-4 py-3 dark:bg-white dark:text-black">Unlock</button>
</div>
<div id="pwError" class="hidden text-sm text-red-600 dark:text-red-400"></div>
</section>
<!-- Dropzone and queue copied from index.html -->
<section id="dropzone" class="rounded-2xl border-2 border-dashed p-8 md:p-10 text-center bg-white dark:bg-gray-800 dark:border-gray-600">
<div id="dropHint" class="mx-auto h-12 w-12 opacity-70 hidden md:block">
@@ -120,14 +130,46 @@
document.getElementById('liExpires').textContent = j.expiresAt ? fmt(j.expiresAt) : 'No expiry';
document.getElementById('liClaimed').textContent = j.claimed ? 'Yes' : 'No';
document.getElementById('liStatus').textContent = j.active ? 'Active' : 'Inactive';
const dz = document.getElementById('dropzone');
const fi = document.getElementById('fileInput');
const itemsEl = document.getElementById('items');
const pwGate = document.getElementById('pwGate');
if (j.passwordRequired && !j.authorized) {
// Show password gate and disable uploader until authorized
pwGate.classList.remove('hidden');
dz.classList.add('opacity-50');
if (fi) fi.disabled = true;
itemsEl.innerHTML = '<div class="text-sm text-gray-500">Enter the password above to enable uploads.</div>';
// Wire unlock button
const pwInput = document.getElementById('pwInput');
const btnPw = document.getElementById('btnPw');
const pwError = document.getElementById('pwError');
const doAuth = async () => {
pwError.classList.add('hidden');
const pw = (pwInput && pwInput.value) ? pwInput.value.trim() : '';
if (!pw) { pwError.textContent = 'Please enter a password.'; pwError.classList.remove('hidden'); return; }
try {
const rr = await fetch(`/api/invite/${token}/auth`, { method:'POST', headers:{'Content-Type':'application/json','Accept':'application/json'}, body: JSON.stringify({ password: pw }) });
const jj = await rr.json().catch(()=>({}));
if (!rr.ok || !jj.authorized) { pwError.textContent = 'Invalid password.'; pwError.classList.remove('hidden'); return; }
pwGate.classList.add('hidden');
dz.classList.remove('opacity-50');
if (fi) fi.disabled = false;
itemsEl.innerHTML = '';
try { showBanner('Password accepted. You can upload now.', 'ok'); } catch {}
} catch (e) {
pwError.textContent = 'Error verifying password.';
pwError.classList.remove('hidden');
}
};
if (btnPw) btnPw.onclick = doAuth;
if (pwInput) pwInput.addEventListener('keydown', (e)=>{ if (e.key==='Enter') { e.preventDefault(); doAuth(); } });
}
if (!j.active) {
// Disable dropzone
const dz = document.getElementById('dropzone');
const fi = document.getElementById('fileInput');
dz.classList.add('opacity-50');
fi.disabled = true;
const items = document.getElementById('items');
items.innerHTML = '<div class="text-sm text-gray-500">This link is not active.</div>';
itemsEl.innerHTML = '<div class="text-sm text-gray-500">This link is not active.</div>';
}
} catch {}
})();

View File

@@ -57,6 +57,11 @@
<div class="text-sm font-medium mb-1">Expires in days</div>
<input id="days" type="number" min="0" placeholder="Leave empty for no expiry" class="w-full rounded-lg border px-3 py-3 bg-white dark:bg-gray-900 dark:border-gray-700" />
</div>
<div>
<div class="text-sm font-medium mb-1">Password (optional)</div>
<input id="password" type="password" placeholder="Set a password for this link" class="w-full rounded-lg border px-3 py-3 bg-white dark:bg-gray-900 dark:border-gray-700" />
<div class="mt-1 text-xs text-gray-500">Recipients must enter this password before uploading.</div>
</div>
</div>
<div>
<button id="btnCreate" class="w-full sm:w-auto rounded-xl bg-black text-white px-5 py-3 dark:bg-white dark:text-black">Create link</button>
@@ -96,6 +101,7 @@
const linkOut = document.getElementById('linkOut');
const btnCopy = document.getElementById('btnCopy');
const qrImg = document.getElementById('qrImg');
const passwordInput = document.getElementById('password');
function showResult(kind, text){
result.className = 'rounded-xl border p-3 text-sm space-y-2 ' + (kind==='ok' ? 'border-green-200 bg-green-50 text-green-700 dark:bg-green-900 dark:border-green-700 dark:text-green-300' : 'border-red-200 bg-red-50 text-red-700 dark:bg-red-900 dark:border-red-700 dark:text-red-300');
@@ -145,6 +151,8 @@
const d = days.value.trim();
if (d) payload.expiresDays = parseInt(d, 10);
if (albumId) payload.albumId = albumId; else if (albumName) payload.albumName = albumName;
const pw = (passwordInput && passwordInput.value) ? passwordInput.value.trim() : '';
if (pw) payload.password = pw;
try{
const r = await fetch('/api/invites', { method:'POST', headers:{'Content-Type':'application/json','Accept':'application/json'}, body: JSON.stringify(payload) });
const j = await r.json().catch(()=>({}));