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:
parent
12441b7bf4
commit
c03b7ef491
4 changed files with 615 additions and 11 deletions
|
|
@ -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} · Uses: ${uses}${expired?' <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
BIN
public/logo.png
Executable file
Binary file not shown.
|
After Width: | Height: | Size: 12 KiB |
198
public/share.html
Normal file
198
public/share.html
Normal 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> & <strong>Wild Dragon LLC</strong> ·
|
||||
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,'&').replace(/</g,'<').replace(/>/g,'>'); }
|
||||
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
190
server.js
|
|
@ -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)) {
|
||||
|
|
|
|||
Loading…
Reference in a new issue