Add per-user upload quotas/permissions and share link system

- Per-user quota tracking (MB limit + uploadedBytes counter)
- Per-user allowed folders restriction (empty = all folders allowed)
- Admin permissions modal: quota config, folder checkboxes, usage reset
- Share links: create tokenized upload URLs with expiry, max-uses, folder
- Public share.html upload page (no auth required) with drag-drop + progress
- Backend routes: GET/PUT /api/users/:u/permissions, POST .../quota/reset
- Backend routes: GET/POST /api/sharelinks, DELETE .../token, GET /share/:token
- migrateData() ensures existing user records gain new fields on startup
- Frontend JS: loadUsers quota column, openPermissions modal, loadShareLinks

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Zac Gaetano 2026-04-06 19:58:29 -04:00
parent 12441b7bf4
commit c03b7ef491
4 changed files with 615 additions and 11 deletions

View file

@ -535,6 +535,7 @@ body::before{content:'';position:fixed;inset:0;background:radial-gradient(ellips
<div class="admin-tab" data-tab="ampp" onclick="switchAdminTab('ampp')">AMPP</div>
<div class="admin-tab" data-tab="extension" onclick="switchAdminTab('extension')">🧩 Extension</div>
<div class="admin-tab" data-tab="users" onclick="switchAdminTab('users')">Users</div>
<div class="admin-tab" data-tab="sharelinks" onclick="switchAdminTab('sharelinks')">🔗 Share Links</div>
<div class="admin-tab" data-tab="folders" onclick="switchAdminTab('folders')">Folders</div>
</div>
@ -660,7 +661,64 @@ body::before{content:'';position:fixed;inset:0;background:radial-gradient(ellips
<button class="btn-primary" onclick="createUser()">+ Create User</button>
<div class="status-msg" id="user-status"></div>
</div>
<table class="user-table"><thead><tr><th>Username</th><th>Role</th><th>Created</th><th>Actions</th></tr></thead><tbody id="user-tbody"></tbody></table>
<table class="user-table"><thead><tr><th>Username</th><th>Role</th><th>Quota</th><th>Created</th><th>Actions</th></tr></thead><tbody id="user-tbody"></tbody></table>
<!-- Permissions modal -->
<div id="perm-modal" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,.7);z-index:500;align-items:center;justify-content:center">
<div style="background:var(--bg-card);border:1px solid var(--border);border-radius:16px;padding:2rem;width:480px;max-width:95vw;max-height:90vh;overflow-y:auto">
<div class="section-title" id="perm-modal-title">Permissions</div>
<div class="form-group" style="margin-top:1rem">
<label class="form-label">Upload Quota (MB) <span style="font-weight:400;text-transform:none;letter-spacing:0;color:var(--text-dim)">— 0 = unlimited</span></label>
<input class="form-input" id="perm-quota" type="number" min="0" placeholder="0"/>
<div class="form-hint" id="perm-quota-used"></div>
</div>
<div class="form-group">
<label class="form-label">Allowed Folders <span style="font-weight:400;text-transform:none;letter-spacing:0;color:var(--text-dim)">— none checked = all folders allowed</span></label>
<div id="perm-folder-list" style="margin-top:.5rem;display:flex;flex-direction:column;gap:.35rem;max-height:200px;overflow-y:auto;padding:.5rem;background:var(--bg-secondary);border:1px solid var(--border);border-radius:8px"></div>
</div>
<div class="btn-row">
<button class="btn-primary" onclick="savePermissions()">💾 Save</button>
<button class="btn-secondary" onclick="document.getElementById('perm-modal').style.display='none'">Cancel</button>
<button class="btn-danger" onclick="resetQuota()" id="perm-reset-btn">Reset Usage</button>
</div>
<div class="status-msg" id="perm-status"></div>
</div>
</div>
</div>
<!-- Share Links -->
<div class="admin-panel" id="admin-sharelinks">
<div class="section-title">Share Links</div>
<p style="color:var(--text-secondary);font-size:.82rem;margin-bottom:1.5rem;line-height:1.6">Generate a shareable upload link for external users. No account needed — just send the link and they can upload directly to a specified folder.</p>
<div style="background:var(--bg-secondary);border:1px solid var(--border);border-radius:12px;padding:1.25rem;margin-bottom:1.5rem">
<div class="section-title" style="font-size:.75rem;margin-bottom:1rem">Create New Link</div>
<div class="form-group"><label class="form-label">Label</label><input class="form-input" id="sl-label" placeholder="e.g. Client Delivery — Episode 4"/></div>
<div class="form-row">
<div class="form-group">
<label class="form-label">Destination Folder</label>
<select class="form-input" id="sl-folder" style="cursor:pointer"><option value="">— Root (no folder) —</option></select>
</div>
<div class="form-group">
<label class="form-label">Expires In</label>
<select class="form-input" id="sl-expiry" style="cursor:pointer">
<option value="">Never</option>
<option value="1">1 hour</option>
<option value="24">24 hours</option>
<option value="72">3 days</option>
<option value="168">7 days</option>
<option value="720">30 days</option>
</select>
</div>
</div>
<div class="form-group">
<label class="form-label">Max Uses <span style="font-weight:400;text-transform:none;letter-spacing:0;color:var(--text-dim)">— 0 = unlimited</span></label>
<input class="form-input" id="sl-maxuses" type="number" min="0" placeholder="0"/>
</div>
<button class="btn-primary" onclick="createShareLink()">🔗 Generate Link</button>
<div class="status-msg" id="sl-create-status"></div>
</div>
<div class="section-title" style="font-size:.75rem;margin-bottom:.75rem">Active Links</div>
<div id="sl-list"><div style="color:var(--text-dim);font-size:.82rem">Loading…</div></div>
</div>
<!-- Folders -->
@ -792,7 +850,7 @@ function showApp() {
document.getElementById('header-user').textContent = currentUser;
if (currentRole === 'admin') {
document.querySelectorAll('.admin-only').forEach(e => e.classList.remove('hidden'));
loadS3Config(); loadRelayConfig(); loadAmppConfig(); loadUsers();
loadS3Config(); loadRelayConfig(); loadAmppConfig(); loadUsers(); loadShareLinks(); populateSlFolderSelect();
}
loadFolders();
loadAmppJobs();
@ -835,9 +893,10 @@ function switchAdminTab(name) {
t.classList.toggle('active', t.dataset.tab === name);
});
document.querySelectorAll('.admin-panel').forEach(p => p.classList.toggle('active', p.id === `admin-${name}`));
if (name === 'users') loadUsers();
if (name === 'folders') loadAdminFolders();
if (name === 'ampp') loadAmppConfig();
if (name === 'users') loadUsers();
if (name === 'folders') loadAdminFolders();
if (name === 'ampp') loadAmppConfig();
if (name === 'sharelinks') { loadShareLinks(); populateSlFolderSelect(); }
}
async function downloadExtension() {
@ -1200,8 +1259,19 @@ async function loadUsers() {
const d=await api('GET','/api/users'); if(!d.success)return;
const tbody=document.getElementById('user-tbody'); tbody.innerHTML='';
d.users.forEach(u=>{
const quotaLabel = u.quotaMB
? `${fmtBytes(u.uploadedBytes||0)} / ${u.quotaMB} MB`
: '<span style="color:var(--text-dim)">unlimited</span>';
const tr=document.createElement('tr');
tr.innerHTML=`<td><strong>${esc(u.username)}</strong></td><td><span class="role-badge ${u.role}">${u.role}</span></td><td style="color:var(--text-dim);font-size:.73rem;font-family:'JetBrains Mono',monospace">${u.created?new Date(u.created).toLocaleDateString():''}</td><td>${u.username!==currentUser?`<button class="btn-danger" style="padding:.22rem .55rem;font-size:.68rem" onclick="deleteUser('${esc(u.username)}')">Delete</button>`:'<span style="color:var(--text-dim);font-size:.73rem">(you)</span>'}</td>`;
tr.innerHTML=`
<td><strong>${esc(u.username)}</strong></td>
<td><span class="role-badge ${u.role}">${u.role}</span></td>
<td style="font-size:.73rem;font-family:'JetBrains Mono',monospace">${quotaLabel}</td>
<td style="color:var(--text-dim);font-size:.73rem;font-family:'JetBrains Mono',monospace">${u.created?new Date(u.created).toLocaleDateString():''}</td>
<td style="display:flex;gap:.35rem;flex-wrap:wrap">
<button class="btn-secondary" style="padding:.22rem .55rem;font-size:.68rem" onclick="openPermissions('${esc(u.username)}')">⚙ Perms</button>
${u.username!==currentUser?`<button class="btn-danger" style="padding:.22rem .55rem;font-size:.68rem" onclick="deleteUser('${esc(u.username)}')">Delete</button>`:'<span style="color:var(--text-dim);font-size:.73rem">(you)</span>'}
</td>`;
tbody.appendChild(tr);
});
} catch(_){}
@ -1220,6 +1290,162 @@ async function deleteUser(u) {
try{await api('DELETE',`/api/users/${encodeURIComponent(u)}`);showToast(`User "${u}" deleted`,'success');loadUsers();}catch(e){showToast(e.message,'error');}
}
// ============================================================
// PERMISSIONS MODAL
// ============================================================
let permCurrentUser = null;
async function openPermissions(username) {
permCurrentUser = username;
const modal = document.getElementById('perm-modal');
modal.style.display = 'flex';
document.getElementById('perm-modal-title').textContent = `Permissions — ${username}`;
document.getElementById('perm-status').className = 'status-msg';
document.getElementById('perm-status').textContent = '';
document.getElementById('perm-quota-used').textContent = 'Loading…';
document.getElementById('perm-folder-list').innerHTML = '<div style="color:var(--text-dim);font-size:.78rem">Loading…</div>';
try {
const [pd, fd] = await Promise.all([
api('GET', `/api/users/${encodeURIComponent(username)}/permissions`),
api('GET', '/api/folders')
]);
if (!pd.success) throw new Error(pd.error);
document.getElementById('perm-quota').value = pd.quotaMB || 0;
const usedMB = pd.uploadedBytes ? (pd.uploadedBytes / 1048576).toFixed(1) : '0';
const usedLabel = pd.quotaMB
? `Used: ${fmtBytes(pd.uploadedBytes||0)} of ${pd.quotaMB} MB`
: `Used: ${fmtBytes(pd.uploadedBytes||0)} (no limit)`;
document.getElementById('perm-quota-used').textContent = usedLabel;
const allFolders = flattenFolders(fd.tree || []);
const allowed = pd.allowedFolders || [];
const list = document.getElementById('perm-folder-list');
if (!allFolders.length) {
list.innerHTML = '<div style="color:var(--text-dim);font-size:.78rem">No folders configured yet.</div>';
} else {
list.innerHTML = allFolders.map(f => `
<label style="display:flex;align-items:center;gap:.5rem;cursor:pointer;font-size:.8rem;padding:.15rem 0">
<input type="checkbox" value="${esc(f)}" ${allowed.includes(f)?'checked':''} style="accent-color:var(--accent-bright);width:14px;height:14px"/>
<span style="font-family:'JetBrains Mono',monospace">${esc(f)}</span>
</label>`).join('');
}
} catch(e) {
document.getElementById('perm-folder-list').innerHTML = `<div style="color:var(--error);font-size:.78rem">Error: ${e.message}</div>`;
}
}
function flattenFolders(nodes, prefix='') {
const out = [];
for (const n of nodes) {
const full = prefix ? `${prefix}--${n.name}` : n.name;
out.push(full);
if (n.children && n.children.length) out.push(...flattenFolders(n.children, full));
}
return out;
}
async function savePermissions() {
const s = document.getElementById('perm-status');
s.className = 'status-msg loading'; s.textContent = 'Saving…';
const quota = parseInt(document.getElementById('perm-quota').value) || 0;
const checked = [...document.querySelectorAll('#perm-folder-list input[type=checkbox]:checked')].map(c => c.value);
try {
const d = await api('PUT', `/api/users/${encodeURIComponent(permCurrentUser)}/permissions`, { quotaMB: quota, allowedFolders: checked });
s.className = `status-msg ${d.success?'success':'error'}`;
s.textContent = d.success ? '✅ Saved' : `❌ ${d.error}`;
if (d.success) loadUsers();
} catch(e) { s.className='status-msg error'; s.textContent=`❌ ${e.message}`; }
}
async function resetQuota() {
if (!confirm(`Reset upload usage counter for "${permCurrentUser}"?`)) return;
const s = document.getElementById('perm-status');
s.className = 'status-msg loading'; s.textContent = 'Resetting…';
try {
const d = await api('POST', `/api/users/${encodeURIComponent(permCurrentUser)}/quota/reset`);
s.className = `status-msg ${d.success?'success':'error'}`;
s.textContent = d.success ? '✅ Usage reset to 0' : `❌ ${d.error}`;
if (d.success) { document.getElementById('perm-quota-used').textContent = 'Used: 0 B'; loadUsers(); }
} catch(e) { s.className='status-msg error'; s.textContent=`❌ ${e.message}`; }
}
function fmtBytes(b) {
if (b < 1024) return b + ' B';
if (b < 1048576) return (b/1024).toFixed(1) + ' KB';
if (b < 1073741824) return (b/1048576).toFixed(1) + ' MB';
return (b/1073741824).toFixed(2) + ' GB';
}
// ============================================================
// SHARE LINKS
// ============================================================
async function loadShareLinks() {
const list = document.getElementById('sl-list');
if (!list) return;
try {
const d = await api('GET', '/api/sharelinks');
if (!d.success) { list.innerHTML=`<div style="color:var(--error);font-size:.82rem">❌ ${d.error}</div>`; return; }
if (!d.links.length) { list.innerHTML='<div style="color:var(--text-dim);font-size:.82rem">No share links yet.</div>'; return; }
list.innerHTML = d.links.map(l => {
const url = `${location.origin}/share/${l.token}`;
const exp = l.expiresAt ? new Date(l.expiresAt).toLocaleString() : 'Never';
const uses = l.maxUses ? `${l.uses}/${l.maxUses}` : `${l.uses} uses`;
const expired = l.expiresAt && new Date(l.expiresAt) < new Date();
return `<div style="background:var(--bg-secondary);border:1px solid var(--border);border-radius:10px;padding:.9rem 1rem;margin-bottom:.6rem${expired?';opacity:.5':''}">
<div style="display:flex;align-items:flex-start;justify-content:space-between;gap:.5rem;flex-wrap:wrap">
<div>
<div style="font-weight:600;font-size:.85rem">${esc(l.label||'(no label)')}</div>
${l.folder?`<div style="font-size:.72rem;color:var(--accent-bright);font-family:'JetBrains Mono',monospace;margin-top:.1rem">📁 ${esc(l.folder)}</div>`:''}
<div style="font-size:.7rem;color:var(--text-dim);margin-top:.25rem">Expires: ${exp} &nbsp;·&nbsp; Uses: ${uses}${expired?' &nbsp;<span style="color:var(--error)">EXPIRED</span>':''}</div>
</div>
<div style="display:flex;gap:.35rem;flex-shrink:0">
<button class="btn-secondary" style="padding:.22rem .55rem;font-size:.68rem" onclick="copyShareLink('${l.token}')">📋 Copy</button>
<button class="btn-danger" style="padding:.22rem .55rem;font-size:.68rem" onclick="deleteShareLink('${l.token}')">Delete</button>
</div>
</div>
<div style="font-family:'JetBrains Mono',monospace;font-size:.67rem;color:var(--text-dim);margin-top:.5rem;word-break:break-all">${url}</div>
</div>`;
}).join('');
} catch(e) { list.innerHTML=`<div style="color:var(--error);font-size:.82rem">❌ ${e.message}</div>`; }
}
async function createShareLink() {
const s = document.getElementById('sl-create-status');
s.className = 'status-msg loading'; s.textContent = 'Creating…';
const body = {
label: document.getElementById('sl-label').value.trim(),
folder: document.getElementById('sl-folder').value,
expiresInHours: document.getElementById('sl-expiry').value || null,
maxUses: parseInt(document.getElementById('sl-maxuses').value) || 0
};
try {
const d = await api('POST', '/api/sharelinks', body);
if (!d.success) { s.className='status-msg error'; s.textContent=`❌ ${d.error}`; return; }
const url = `${location.origin}/share/${d.link.token}`;
s.className = 'status-msg success';
s.innerHTML = `✅ Link created! <br><span style="font-family:'JetBrains Mono',monospace;font-size:.75rem;word-break:break-all">${url}</span> <button class="btn-secondary" style="padding:.18rem .45rem;font-size:.68rem;margin-left:.5rem" onclick="navigator.clipboard.writeText('${url}').then(()=>showToast('Copied!','success'))">📋 Copy</button>`;
document.getElementById('sl-label').value = '';
document.getElementById('sl-folder').value = '';
document.getElementById('sl-expiry').value = '';
document.getElementById('sl-maxuses').value = '';
loadShareLinks();
} catch(e) { s.className='status-msg error'; s.textContent=`❌ ${e.message}`; }
}
async function deleteShareLink(token) {
if (!confirm('Delete this share link? It will immediately stop working.')) return;
try {
const d = await api('DELETE', `/api/sharelinks/${token}`);
if (d.success) { showToast('Share link deleted','success'); loadShareLinks(); }
else showToast(d.error,'error');
} catch(e) { showToast(e.message,'error'); }
}
function copyShareLink(token) {
const url = `${location.origin}/share/${token}`;
navigator.clipboard.writeText(url).then(()=>showToast('Link copied to clipboard!','success')).catch(()=>showToast('Could not copy','error'));
}
async function populateSlFolderSelect() {
const sel = document.getElementById('sl-folder');
if (!sel) return;
try {
const d = await api('GET', '/api/folders');
const folders = flattenFolders(d.tree || []);
sel.innerHTML = '<option value="">— Root (no folder) —</option>' + folders.map(f=>`<option value="${esc(f)}">${esc(f)}</option>`).join('');
} catch(_){}
}
// ============================================================
// FOLDERS ADMIN
// ============================================================

BIN
public/logo.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

198
public/share.html Normal file
View file

@ -0,0 +1,198 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width,initial-scale=1"/>
<title>Dragon Wind · Upload</title>
<link rel="icon" href="/dragon-icon.png" type="image/png"/>
<link rel="preconnect" href="https://fonts.googleapis.com"/>
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700;800&family=JetBrains+Mono:wght@400;600&display=swap" rel="stylesheet"/>
<style>
:root{
--bg:#03040a;--bg-card:#0d1117;--border:#1e2535;--border-bright:#2d3a50;
--text:#e8eaf0;--text-dim:#4a5568;--text-secondary:#8892a4;
--blue:#1e4bd8;--blue-bright:#3060ff;
--success:#22c55e;--success-bg:rgba(34,197,94,.08);
--error:#ef4444;--error-bg:rgba(239,68,68,.08);
--dragon:#e05c1a;--dragon-bright:#ff7733;
}
*{box-sizing:border-box;margin:0;padding:0}
body{background:var(--bg);color:var(--text);font-family:'Outfit',sans-serif;min-height:100vh;display:flex;flex-direction:column;align-items:center;justify-content:center;padding:2rem}
.card{width:100%;max-width:520px;background:var(--bg-card);border:1px solid var(--border);border-radius:20px;padding:2.5rem 2rem;box-shadow:0 24px 80px rgba(0,0,0,.5)}
.brand{display:flex;flex-direction:column;align-items:center;gap:.5rem;margin-bottom:2rem;text-align:center}
.brand-icon{width:64px;height:64px;object-fit:contain;filter:drop-shadow(0 4px 16px rgba(30,75,216,.4))}
.brand-title{font-size:1.3rem;font-weight:700;background:linear-gradient(135deg,#fff,#8892a4);-webkit-background-clip:text;-webkit-text-fill-color:transparent}
.brand-label{font-size:.78rem;color:var(--text-secondary);margin-top:.1rem}
.brand-folder{font-size:.7rem;color:var(--blue-bright);font-family:'JetBrains Mono',monospace;background:rgba(30,75,216,.1);border:1px solid rgba(30,75,216,.2);border-radius:5px;padding:.15rem .5rem;margin-top:.3rem}
.drop-zone{border:2px dashed var(--border-bright);border-radius:14px;padding:2.5rem 1.5rem;text-align:center;cursor:pointer;transition:all .2s;margin-bottom:1.2rem;position:relative}
.drop-zone.dragover{border-color:var(--blue-bright);background:rgba(30,75,216,.07)}
.drop-zone.has-files{border-color:var(--blue);background:rgba(30,75,216,.05)}
.drop-icon{font-size:2.2rem;margin-bottom:.75rem}
.drop-text{font-size:.9rem;color:var(--text-secondary)}
.drop-sub{font-size:.73rem;color:var(--text-dim);margin-top:.35rem}
#file-input{display:none}
.file-list{margin-bottom:1.2rem;display:flex;flex-direction:column;gap:.4rem;max-height:200px;overflow-y:auto}
.file-item{display:flex;align-items:center;justify-content:space-between;padding:.45rem .75rem;background:rgba(255,255,255,.03);border:1px solid var(--border);border-radius:8px;font-size:.78rem}
.file-item-name{color:var(--text);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;flex:1;margin-right:.5rem}
.file-item-size{color:var(--text-dim);font-family:'JetBrains Mono',monospace;font-size:.68rem;flex-shrink:0}
.file-item-rm{background:none;border:none;color:var(--text-dim);cursor:pointer;padding:0 .25rem;font-size:.9rem;line-height:1;flex-shrink:0}
.file-item-rm:hover{color:var(--error)}
.btn-upload{width:100%;padding:.85rem;font-family:'Outfit',sans-serif;font-size:.95rem;font-weight:700;color:#fff;background:linear-gradient(135deg,var(--blue),var(--blue-bright));border:none;border-radius:11px;cursor:pointer;transition:all .2s}
.btn-upload:hover:not(:disabled){transform:translateY(-1px);box-shadow:0 6px 24px rgba(30,75,216,.4)}
.btn-upload:disabled{opacity:.4;cursor:not-allowed;transform:none}
.progress-wrap{height:6px;background:var(--border);border-radius:3px;margin-top:1rem;overflow:hidden;display:none}
.progress-bar{height:100%;width:0;background:linear-gradient(90deg,var(--blue),var(--blue-bright));border-radius:3px;transition:width .3s}
.status{padding:.7rem 1rem;border-radius:9px;font-size:.82rem;font-weight:600;margin-top:.9rem;display:none;text-align:center}
.status.success{background:var(--success-bg);color:var(--success);border:1px solid rgba(34,197,94,.2);display:block}
.status.error{background:var(--error-bg);color:var(--error);border:1px solid rgba(239,68,68,.2);display:block}
.footer{margin-top:2rem;font-size:.65rem;color:var(--text-dim);text-align:center;line-height:1.7}
.footer strong{color:var(--text-secondary)}
.expired{text-align:center;padding:2rem 0}
.expired-icon{font-size:3rem;margin-bottom:1rem}
.expired-title{font-size:1.1rem;font-weight:700;color:var(--text);margin-bottom:.5rem}
.expired-msg{font-size:.83rem;color:var(--text-secondary)}
</style>
</head>
<body>
<div class="card" id="card">
<div style="text-align:center;padding:2rem 0" id="loading">
<div style="font-size:1.5rem;margin-bottom:.5rem">🌪️</div>
<div style="color:var(--text-dim);font-size:.85rem">Loading…</div>
</div>
</div>
<div class="footer">
Built by <strong>Zac Gaetano</strong> &amp; <strong>Wild Dragon LLC</strong> &nbsp;·&nbsp;
In partnership with <strong>Broadcast Management Group</strong>
</div>
<script>
const token = location.pathname.split('/share/')[1];
let linkInfo = null;
let files = [];
async function init() {
try {
const r = await fetch(`/api/sharelinks/${token}/info`);
const d = await r.json();
if (!d.success) return showExpired(d.error);
linkInfo = d;
renderUploader();
} catch(e) { showExpired('Could not load upload link.'); }
}
function showExpired(msg) {
document.getElementById('card').innerHTML = `
<div class="expired">
<div class="expired-icon"></div>
<div class="expired-title">Link Unavailable</div>
<div class="expired-msg">${msg || 'This upload link is no longer valid.'}</div>
</div>`;
}
function renderUploader() {
const expiry = linkInfo.expiresAt
? `Expires ${new Date(linkInfo.expiresAt).toLocaleString()}` : 'No expiry';
document.getElementById('card').innerHTML = `
<div class="brand">
<img class="brand-icon" src="/dragon-icon.png" alt="Dragon Wind"/>
<div class="brand-title">Dragon Wind Upload</div>
<div class="brand-label">${esc(linkInfo.label)}</div>
${linkInfo.folder ? `<div class="brand-folder">📁 ${esc(linkInfo.folder)}</div>` : ''}
</div>
<div class="drop-zone" id="drop-zone" onclick="document.getElementById('file-input').click()">
<div class="drop-icon">📂</div>
<div class="drop-text">Drop files here or click to browse</div>
<div class="drop-sub">${esc(expiry)}</div>
</div>
<input type="file" id="file-input" multiple/>
<div class="file-list" id="file-list"></div>
<button class="btn-upload" id="upload-btn" disabled onclick="doUpload()">Upload Files</button>
<div class="progress-wrap" id="progress-wrap"><div class="progress-bar" id="progress-bar"></div></div>
<div class="status" id="status"></div>`;
const dz = document.getElementById('drop-zone');
const fi = document.getElementById('file-input');
dz.addEventListener('dragover', e => { e.preventDefault(); dz.classList.add('dragover'); });
dz.addEventListener('dragleave', () => dz.classList.remove('dragover'));
dz.addEventListener('drop', e => { e.preventDefault(); dz.classList.remove('dragover'); addFiles(e.dataTransfer.files); });
fi.addEventListener('change', () => addFiles(fi.files));
}
function addFiles(newFiles) {
for (const f of newFiles) files.push(f);
renderFiles();
}
function removeFile(i) { files.splice(i, 1); renderFiles(); }
function renderFiles() {
const list = document.getElementById('file-list');
const btn = document.getElementById('upload-btn');
const dz = document.getElementById('drop-zone');
list.innerHTML = files.map((f, i) => `
<div class="file-item">
<span class="file-item-name">${esc(f.name)}</span>
<span class="file-item-size">${fmtSize(f.size)}</span>
<button class="file-item-rm" onclick="removeFile(${i})"></button>
</div>`).join('');
btn.disabled = files.length === 0;
dz.classList.toggle('has-files', files.length > 0);
}
async function doUpload() {
if (!files.length) return;
const btn = document.getElementById('upload-btn');
const status = document.getElementById('status');
const progressWrap = document.getElementById('progress-wrap');
const progressBar = document.getElementById('progress-bar');
btn.disabled = true; btn.textContent = 'Uploading…';
status.className = 'status'; status.textContent = '';
progressWrap.style.display = 'block'; progressBar.style.width = '0%';
const form = new FormData();
for (const f of files) form.append('files', f);
const xhr = new XMLHttpRequest();
xhr.open('POST', `/api/sharelinks/${token}/upload`);
xhr.upload.onprogress = e => {
if (e.lengthComputable) progressBar.style.width = (e.loaded/e.total*100) + '%';
};
xhr.onload = () => {
progressBar.style.width = '100%';
try {
const d = JSON.parse(xhr.responseText);
if (d.success) {
status.className = 'status success';
status.textContent = `✅ ${d.uploaded.length} file${d.uploaded.length!==1?'s':''} uploaded successfully!`;
files = []; renderFiles();
btn.textContent = 'Upload Files';
} else {
status.className = 'status error';
status.textContent = `❌ ${d.error}`;
btn.disabled = false; btn.textContent = 'Upload Files';
}
} catch(e) {
status.className = 'status error';
status.textContent = '❌ Upload failed — unexpected response';
btn.disabled = false; btn.textContent = 'Upload Files';
}
};
xhr.onerror = () => {
status.className = 'status error';
status.textContent = '❌ Network error — please try again';
btn.disabled = false; btn.textContent = 'Upload Files';
};
xhr.send(form);
}
function esc(s) { return (s||'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); }
function fmtSize(b) {
if (b < 1024) return b + ' B';
if (b < 1048576) return (b/1024).toFixed(1) + ' KB';
if (b < 1073741824) return (b/1048576).toFixed(1) + ' MB';
return (b/1073741824).toFixed(2) + ' GB';
}
init();
</script>
</body>
</html>

190
server.js
View file

@ -48,7 +48,8 @@ function loadData() {
}
const data = {
users: [
{ username: DEFAULT_ADMIN_USER, password: hashPassword(DEFAULT_ADMIN_PASS), role: "admin", created: new Date().toISOString() }
{ username: DEFAULT_ADMIN_USER, password: hashPassword(DEFAULT_ADMIN_PASS), role: "admin", created: new Date().toISOString(),
quotaMB: 0, allowedFolders: [], uploadedBytes: 0 }
],
folderTree: [
{ name: "Media", children: [] },
@ -66,18 +67,30 @@ function loadData() {
relayConfig: {
relayUrl: process.env.RELAY_URL || "",
udpPort: parseInt(process.env.UDP_PORT || "5000"),
}
},
shareLinks: [],
};
saveData(data);
return data;
}
// Migrate existing data to include new fields if missing
function migrateData(data) {
if (!data.shareLinks) data.shareLinks = [];
for (const u of data.users) {
if (u.quotaMB === undefined) u.quotaMB = 0;
if (!u.allowedFolders) u.allowedFolders = [];
if (u.uploadedBytes === undefined) u.uploadedBytes = 0;
}
return data;
}
function saveData(data) {
ensureDataDir();
fs.writeFileSync(DATA_FILE, JSON.stringify(data, null, 2), "utf8");
}
let db = loadData();
let db = migrateData(loadData());
function getTree() { return db.folderTree; }
function setTree(t) { db.folderTree = t; saveData(db); }
@ -183,6 +196,35 @@ function findNode(pathArr) {
return current;
}
// Collect all folder key-paths from the tree (e.g. ["Media", "Media/Dailies"])
function allFolderPaths(nodes, prefix) {
const result = [];
for (const node of nodes) {
const p = prefix ? `${prefix}/${node.name}` : node.name;
result.push(p);
if (node.children?.length) result.push(...allFolderPaths(node.children, p));
}
return result;
}
// Check if a prefix (upload destination) is within the user's allowed folders.
// allowedFolders empty = all allowed. prefix empty = root = always allowed for admins only.
function isFolderAllowed(user, prefix) {
if (user.role === "admin") return true;
const allowed = user.allowedFolders || [];
if (allowed.length === 0) return true; // no restriction
if (!prefix) return false; // non-admin can't upload to root if restrictions set
return allowed.some(f => prefix === f || prefix.startsWith(f + "/") || prefix.startsWith(f + "--"));
}
// Check quota. quotaMB 0 = unlimited.
function isQuotaExceeded(user, additionalBytes) {
if (user.role === "admin") return false;
if (!user.quotaMB || user.quotaMB === 0) return false;
const limitBytes = user.quotaMB * 1024 * 1024;
return (user.uploadedBytes || 0) + additionalBytes > limitBytes;
}
// ==================== MIDDLEWARE ====================
const upload = multer({
dest: "/tmp/uploads/",
@ -296,6 +338,31 @@ app.put("/api/users/:username/role", requireAdmin, (req, res) => {
res.json({ success: true });
});
// ---- User permissions & quota ----
app.get("/api/users/:username/permissions", requireAdmin, (req, res) => {
const user = db.users.find(u => u.username === decodeURIComponent(req.params.username));
if (!user) return res.status(404).json({ success: false, error: "User not found" });
res.json({ success: true, quotaMB: user.quotaMB || 0, allowedFolders: user.allowedFolders || [], uploadedBytes: user.uploadedBytes || 0 });
});
app.put("/api/users/:username/permissions", requireAdmin, (req, res) => {
const user = db.users.find(u => u.username === decodeURIComponent(req.params.username));
if (!user) return res.status(404).json({ success: false, error: "User not found" });
const { quotaMB, allowedFolders } = req.body;
if (quotaMB !== undefined) user.quotaMB = Math.max(0, parseInt(quotaMB) || 0);
if (Array.isArray(allowedFolders)) user.allowedFolders = allowedFolders;
saveData(db);
res.json({ success: true, message: "Permissions updated" });
});
app.post("/api/users/:username/quota/reset", requireAdmin, (req, res) => {
const user = db.users.find(u => u.username === decodeURIComponent(req.params.username));
if (!user) return res.status(404).json({ success: false, error: "User not found" });
user.uploadedBytes = 0;
saveData(db);
res.json({ success: true, message: "Quota usage reset" });
});
// ==================== FOLDERS ====================
app.get("/api/folders", requireAuth, (req, res) => res.json({ success: true, tree: getTree() }));
@ -431,9 +498,22 @@ function isBlockedFile(filename) {
// ==================== FILE UPLOAD (multipart / HTTP mode) ====================
app.post("/api/upload", requireAuth, upload.array("files", 50), async (req, res) => {
if (!s3Client) return res.status(503).json({ success: false, error: "S3 not configured. Go to Admin → S3 Settings." });
console.log(`Upload: ${req.files?.length || 0} file(s), prefix="${req.body.prefix || ""}"`);
const user = db.users.find(u => u.username === req.sessionData.user);
const prefix = req.body.prefix || "";
console.log(`Upload: ${req.files?.length || 0} file(s), prefix="${prefix}", user="${req.sessionData.user}"`);
try {
const prefix = req.body.prefix || "";
// Folder permission check
if (user && !isFolderAllowed(user, prefix)) {
for (const f of req.files) { try { fs.unlinkSync(f.path); } catch (_) {} }
return res.status(403).json({ success: false, error: "You do not have permission to upload to this folder" });
}
// Quota check
const totalBytes = (req.files || []).reduce((s, f) => s + f.size, 0);
if (user && isQuotaExceeded(user, totalBytes)) {
for (const f of req.files) { try { fs.unlinkSync(f.path); } catch (_) {} }
const usedMB = ((user.uploadedBytes || 0) / 1024 / 1024).toFixed(1);
return res.status(403).json({ success: false, error: `Upload quota exceeded (${usedMB} MB of ${user.quotaMB} MB used)` });
}
const results = [];
const blocked = req.files.filter((f) => isBlockedFile(f.originalname));
if (blocked.length > 0) {
@ -456,8 +536,10 @@ app.post("/api/upload", requireAuth, upload.array("files", 50), async (req, res)
if (result?.assumed) console.log(`Assumed success (timeout): ${key}`);
else console.log(`Confirmed success: ${key}`);
try { fs.unlinkSync(file.path); } catch (_) {}
if (user) { user.uploadedBytes = (user.uploadedBytes || 0) + file.size; }
results.push({ originalName: file.originalname, key, size: file.size, timestamp: new Date().toISOString() });
}
saveData(db);
res.json({ success: true, uploaded: results });
} catch (err) {
console.error("Upload error:", err.message);
@ -646,6 +728,104 @@ app.get("/api/ampp/jobs/:jobId", requireAuth, async (req, res) => {
});
// ==================== CHROME EXTENSION DOWNLOAD ====================
// ==================== SHARE LINKS ====================
app.get("/api/sharelinks", requireAdmin, (req, res) => {
const links = (db.shareLinks || []).map(l => ({
token: l.token, label: l.label, folder: l.folder,
expiresAt: l.expiresAt, maxUses: l.maxUses, uses: l.uses,
createdBy: l.createdBy, createdAt: l.createdAt,
active: !l.expiresAt || new Date(l.expiresAt) > new Date(),
}));
res.json({ success: true, links });
});
app.post("/api/sharelinks", requireAdmin, (req, res) => {
const { label, folder, expiresInHours, maxUses } = req.body;
const token = crypto.randomBytes(20).toString("hex");
const link = {
token,
label: (label || "Share Link").trim(),
folder: folder || "",
expiresAt: expiresInHours ? new Date(Date.now() + expiresInHours * 3600000).toISOString() : null,
maxUses: parseInt(maxUses) || 0,
uses: 0,
createdBy: req.sessionData.user,
createdAt: new Date().toISOString(),
};
if (!db.shareLinks) db.shareLinks = [];
db.shareLinks.push(link);
saveData(db);
res.json({ success: true, link });
});
app.delete("/api/sharelinks/:token", requireAdmin, (req, res) => {
if (!db.shareLinks) db.shareLinks = [];
const idx = db.shareLinks.findIndex(l => l.token === req.params.token);
if (idx === -1) return res.status(404).json({ success: false, error: "Link not found" });
db.shareLinks.splice(idx, 1);
saveData(db);
res.json({ success: true });
});
// Public share link info (no auth — just enough for the upload page to render)
app.get("/api/sharelinks/:token/info", (req, res) => {
const link = (db.shareLinks || []).find(l => l.token === req.params.token);
if (!link) return res.status(404).json({ success: false, error: "Link not found or expired" });
if (link.expiresAt && new Date(link.expiresAt) < new Date())
return res.status(410).json({ success: false, error: "This upload link has expired" });
if (link.maxUses > 0 && link.uses >= link.maxUses)
return res.status(410).json({ success: false, error: "This upload link has reached its maximum uses" });
res.json({ success: true, label: link.label, folder: link.folder, expiresAt: link.expiresAt });
});
// Public upload via share link
app.post("/api/sharelinks/:token/upload", upload.array("files", 50), async (req, res) => {
if (!s3Client) return res.status(503).json({ success: false, error: "S3 not configured" });
const link = (db.shareLinks || []).find(l => l.token === req.params.token);
if (!link) return res.status(404).json({ success: false, error: "Invalid upload link" });
if (link.expiresAt && new Date(link.expiresAt) < new Date()) {
for (const f of req.files || []) { try { fs.unlinkSync(f.path); } catch (_) {} }
return res.status(410).json({ success: false, error: "This upload link has expired" });
}
if (link.maxUses > 0 && link.uses >= link.maxUses) {
for (const f of req.files || []) { try { fs.unlinkSync(f.path); } catch (_) {} }
return res.status(410).json({ success: false, error: "This upload link has reached its maximum uses" });
}
const blocked = (req.files || []).filter(f => isBlockedFile(f.originalname));
if (blocked.length > 0) {
for (const f of req.files) { try { fs.unlinkSync(f.path); } catch (_) {} }
return res.status(400).json({ success: false, error: `Blocked file types: ${blocked.map(f => f.originalname).join(", ")}` });
}
try {
const prefix = link.folder || "";
const bucket = db.s3Config?.bucket || "";
const results = [];
for (const file of req.files) {
const key = prefix ? `${prefix.replace(/[-\/]+$/, "")}--${file.originalname}` : file.originalname;
const contentType = getMimeType(file.originalname, file.mimetype);
const uploadPromise = new Upload({
client: s3Client,
params: { Bucket: bucket, Key: key, Body: fs.createReadStream(file.path), ContentType: contentType },
queueSize: 4, partSize: 10 * 1024 * 1024, leavePartsOnError: false,
}).done();
await withTimeout(uploadPromise, UPLOAD_TIMEOUT_MS, key);
try { fs.unlinkSync(file.path); } catch (_) {}
results.push({ originalName: file.originalname, key, size: file.size });
}
link.uses = (link.uses || 0) + 1;
saveData(db);
res.json({ success: true, uploaded: results });
} catch (err) {
if (req.files) for (const f of req.files) { try { fs.unlinkSync(f.path); } catch (_) {} }
res.status(500).json({ success: false, error: err.message });
}
});
// Public share upload page
app.get("/share/:token", (req, res) => {
res.sendFile(path.join(__dirname, "public", "share.html"));
});
app.get("/api/extension/download", requireAdmin, (req, res) => {
const extDir = path.join(__dirname, "chrome-extension");
if (!fs.existsSync(extDir)) {