From c03b7ef491abc9e8c5f785bbc491459a3e29571f Mon Sep 17 00:00:00 2001 From: Zac Gaetano Date: Mon, 6 Apr 2026 19:58:29 -0400 Subject: [PATCH] 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 --- public/index.html | 238 ++++++++++++++++++++++++++++++++++++++++++++-- public/logo.png | Bin 0 -> 12048 bytes public/share.html | 198 ++++++++++++++++++++++++++++++++++++++ server.js | 190 +++++++++++++++++++++++++++++++++++- 4 files changed, 615 insertions(+), 11 deletions(-) create mode 100755 public/logo.png create mode 100644 public/share.html diff --git a/public/index.html b/public/index.html index e6549f8..2ac4a06 100644 --- a/public/index.html +++ b/public/index.html @@ -535,6 +535,7 @@ body::before{content:'';position:fixed;inset:0;background:radial-gradient(ellips
AMPP
🧩 Extension
Users
+
🔗 Share Links
Folders
@@ -660,7 +661,64 @@ body::before{content:'';position:fixed;inset:0;background:radial-gradient(ellips
-
UsernameRoleCreatedActions
+
UsernameRoleQuotaCreatedActions
+ + + + + + + @@ -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` + : 'unlimited'; const tr=document.createElement('tr'); - tr.innerHTML=`${esc(u.username)}${u.role}${u.created?new Date(u.created).toLocaleDateString():''}${u.username!==currentUser?``:'(you)'}`; + tr.innerHTML=` + ${esc(u.username)} + ${u.role} + ${quotaLabel} + ${u.created?new Date(u.created).toLocaleDateString():''} + + + ${u.username!==currentUser?``:'(you)'} + `; 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 = '
Loading…
'; + 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 = '
No folders configured yet.
'; + } else { + list.innerHTML = allFolders.map(f => ` + `).join(''); + } + } catch(e) { + document.getElementById('perm-folder-list').innerHTML = `
Error: ${e.message}
`; + } +} +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=`
❌ ${d.error}
`; return; } + if (!d.links.length) { list.innerHTML='
No share links yet.
'; 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 `
+
+
+
${esc(l.label||'(no label)')}
+ ${l.folder?`
📁 ${esc(l.folder)}
`:''} +
Expires: ${exp}  ·  Uses: ${uses}${expired?'  EXPIRED':''}
+
+
+ + +
+
+
${url}
+
`; + }).join(''); + } catch(e) { list.innerHTML=`
❌ ${e.message}
`; } +} +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!
${url} `; + 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 = '' + folders.map(f=>``).join(''); + } catch(_){} +} + // ============================================================ // FOLDERS ADMIN // ============================================================ diff --git a/public/logo.png b/public/logo.png new file mode 100755 index 0000000000000000000000000000000000000000..330d3d3761c124b82802d8442dcd8e1008d09248 GIT binary patch literal 12048 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 + + + + +Dragon Wind · Upload + + + + + + +
+
+
🌪️
+
Loading…
+
+
+ + + + diff --git a/server.js b/server.js index 713dd1d..c96e8eb 100644 --- a/server.js +++ b/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)) {