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
- | Username | Role | Created | Actions |
|---|
+ | Username | Role | Quota | Created | Actions |
|---|
+
+
+
+
+
Permissions
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Share Links
+
Generate a shareable upload link for external users. No account needed — just send the link and they can upload directly to a specified folder.
+
+
Create New Link
+
+
+
+
+
+
+
+
+
+
Active Links
+
@@ -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
+
+
+
+
+
+
+
+
+
+
+
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)) {