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 0000000..330d3d3 Binary files /dev/null and b/public/logo.png differ diff --git a/public/share.html b/public/share.html new file mode 100644 index 0000000..c479170 --- /dev/null +++ b/public/share.html @@ -0,0 +1,198 @@ + + + + + +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)) {