Fix folder sorting, default selection, subfolder S3 keys, and HTTP mode description

Addresses feedback from Gavin (VPM):
- Sort folders alphabetically in both upload tree and admin tree views
- Auto-select VPM as default folder on login instead of Root
- Fix S3 key construction for nested folders: convert "/" to "--" so FLX
  correctly maps subfolders (e.g. Content/TEST - AMPP Demo now produces
  Content--TEST - AMPP Demo--file.ext instead of Content/TEST - AMPP Demo--file.ext)
- Clarify HTTP mode note: "Files are processed 6 at a time" instead of
  "up to 6 concurrent files" which implied a total file limit

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Zac Gaetano 2026-04-08 21:40:44 -04:00
parent 5b0a3ef2cc
commit 84cf9cccbe
3 changed files with 28 additions and 10 deletions

View file

@ -21,7 +21,9 @@ class UploadManager {
createSession({ filename, size, mode = "http", prefix = "" }) { createSession({ filename, size, mode = "http", prefix = "" }) {
if (!["http", "udp"].includes(mode)) throw new Error("mode must be 'http' or 'udp'"); if (!["http", "udp"].includes(mode)) throw new Error("mode must be 'http' or 'udp'");
const sessionId = crypto.randomBytes(16).toString("hex"); const sessionId = crypto.randomBytes(16).toString("hex");
const key = prefix ? `${prefix.replace(/[-\/]+$/, "")}--${filename}` : filename; // Normalize prefix: UI uses "/" for nested folders, FLX expects "--" as delimiter
const normalized = prefix ? prefix.replace(/\//g, "--").replace(/[-]+$/, "") : "";
const key = normalized ? `${normalized}--${filename}` : filename;
const session = { const session = {
sessionId, filename, size, mode, key, prefix, sessionId, filename, size, mode, key, prefix,
status: "pending", status: "pending",

View file

@ -467,7 +467,7 @@ body::before{content:'';position:fixed;inset:0;background:radial-gradient(ellips
<button class="mode-btn" id="btn-udp" disabled style="opacity:.4;cursor:not-allowed;pointer-events:none">🖥️ Electron App</button> <button class="mode-btn" id="btn-udp" disabled style="opacity:.4;cursor:not-allowed;pointer-events:none">🖥️ Electron App</button>
</div> </div>
<div class="mode-desc" id="mode-desc" style="margin-bottom:0"> <div class="mode-desc" id="mode-desc" style="margin-bottom:0">
<strong id="mode-label">HTTP Mode:</strong> <span id="mode-detail">Direct-to-S3 upload via presigned URLs (up to 6 concurrent files). Browser uploads straight to storage, bypassing the server.</span> <strong id="mode-label">HTTP Mode:</strong> <span id="mode-detail">Direct-to-S3 upload via presigned URLs. Browser uploads straight to storage, bypassing the server. Files are processed 6 at a time.</span>
</div> </div>
<div style="text-align:right;margin-top:.3rem;font-size:.7rem;color:var(--text-dim);opacity:.6">Electron App — Aspera-speed desktop application (coming soon)</div> <div style="text-align:right;margin-top:.3rem;font-size:.7rem;color:var(--text-dim);opacity:.6">Electron App — Aspera-speed desktop application (coming soon)</div>
</div> </div>
@ -946,7 +946,7 @@ function setMode(mode) {
if (mode === 'http') { if (mode === 'http') {
btn.className = 'btn-upload'; btn.className = 'btn-upload';
if (label) label.textContent = 'HTTP Mode:'; if (label) label.textContent = 'HTTP Mode:';
if (detail) detail.textContent = 'Direct-to-S3 upload via presigned URLs (up to 6 concurrent files). Browser uploads straight to storage, bypassing the server.'; if (detail) detail.textContent = 'Direct-to-S3 upload via presigned URLs. Browser uploads straight to storage, bypassing the server. Files are processed 6 at a time.';
if (hint) hint.style.display = 'none'; if (hint) hint.style.display = 'none';
if (btnHttp) { btnHttp.className = 'mode-btn active-http'; } if (btnHttp) { btnHttp.className = 'mode-btn active-http'; }
if (btnUdp) { btnUdp.className = 'mode-btn'; } if (btnUdp) { btnUdp.className = 'mode-btn'; }
@ -974,6 +974,11 @@ async function loadFolders() {
try { try {
const d = await api('GET','/api/folders'); const d = await api('GET','/api/folders');
folderTree = d.tree || []; folderTree = d.tree || [];
// Auto-select VPM as default folder if nothing is selected yet
if (!selectedPrefix && folderTree.some(n => n.name === 'VPM')) {
selectedPrefix = 'VPM';
updatePrefixDisplay();
}
renderFolderTree(); renderFolderTree();
} catch(e) { console.error('loadFolders:',e); } } catch(e) { console.error('loadFolders:',e); }
} }
@ -1003,7 +1008,8 @@ function renderFolderTree() {
} }
function addRows(nodes, pathArr, container) { function addRows(nodes, pathArr, container) {
nodes.forEach(n => { const sorted = [...nodes].sort((a, b) => a.name.localeCompare(b.name, undefined, {sensitivity: 'base'}));
sorted.forEach(n => {
if (filter && !matchesFilter(n, pathArr)) return; if (filter && !matchesFilter(n, pathArr)) return;
const fullPath = [...pathArr, n.name]; const fullPath = [...pathArr, n.name];
const key = fullPath.join('/'); const key = fullPath.join('/');
@ -1702,7 +1708,8 @@ async function loadAdminFolders() {
function renderAdminFolderTree(nodes,container,pathArr) { function renderAdminFolderTree(nodes,container,pathArr) {
container.innerHTML=''; container.innerHTML='';
if(!nodes.length){container.innerHTML='<div style="color:var(--text-dim);font-size:.8rem;padding:.5rem">No folders. Add one below.</div>';return;} if(!nodes.length){container.innerHTML='<div style="color:var(--text-dim);font-size:.8rem;padding:.5rem">No folders. Add one below.</div>';return;}
nodes.forEach(n=>{ const sorted=[...nodes].sort((a,b)=>a.name.localeCompare(b.name,undefined,{sensitivity:'base'}));
sorted.forEach(n=>{
const fp=[...pathArr,n.name]; const fp=[...pathArr,n.name];
const div=document.createElement('div');div.style.paddingLeft=pathArr.length*20+'px';div.style.marginBottom='.3rem'; const div=document.createElement('div');div.style.paddingLeft=pathArr.length*20+'px';div.style.marginBottom='.3rem';
const row=document.createElement('div'); const row=document.createElement('div');

View file

@ -186,6 +186,15 @@ function cleanName(s) {
return (s || "").trim().replace(/[^a-zA-Z0-9'\-.,&()! ]/g, ""); return (s || "").trim().replace(/[^a-zA-Z0-9'\-.,&()! ]/g, "");
} }
// Build S3 object key from a folder prefix and filename.
// The prefix may use "/" to separate nested folders (from the UI tree),
// but FLX expects "--" as the folder delimiter in object keys.
function buildS3Key(prefix, filename) {
if (!prefix) return filename;
const normalized = prefix.replace(/\//g, "--").replace(/[-]+$/, "");
return `${normalized}--${filename}`;
}
function findNode(pathArr) { function findNode(pathArr) {
let current = getTree(); let current = getTree();
for (const segment of pathArr) { for (const segment of pathArr) {
@ -532,7 +541,7 @@ app.post("/api/upload", requireAuth, upload.array("files", 50), async (req, res)
} }
const bucket = db.s3Config?.bucket || ""; const bucket = db.s3Config?.bucket || "";
for (const file of req.files) { for (const file of req.files) {
const key = prefix ? `${prefix.replace(/[-\/]+$/, "")}--${file.originalname}` : file.originalname; const key = buildS3Key(prefix, file.originalname);
const contentType = getMimeType(file.originalname, file.mimetype); const contentType = getMimeType(file.originalname, file.mimetype);
console.log(`Uploading: ${key} (${file.size} bytes, ${contentType})`); console.log(`Uploading: ${key} (${file.size} bytes, ${contentType})`);
// Use PutObjectCommand (single PUT) — compatible with RustFS/MinIO/generic S3. // Use PutObjectCommand (single PUT) — compatible with RustFS/MinIO/generic S3.
@ -579,7 +588,7 @@ app.post("/api/presigned", requireAuth, async (req, res) => {
} }
const bucket = db.s3Config?.bucket || ""; const bucket = db.s3Config?.bucket || "";
const key = prefix ? `${prefix.replace(/[-\/]+$/, "")}--${filename}` : filename; const key = buildS3Key(prefix, filename);
const mime = contentType || getMimeType(filename, "application/octet-stream"); const mime = contentType || getMimeType(filename, "application/octet-stream");
try { try {
const url = await getSignedUrl(s3Client, new PutObjectCommand({ Bucket: bucket, Key: key, ContentType: mime }), { expiresIn: 3600 }); const url = await getSignedUrl(s3Client, new PutObjectCommand({ Bucket: bucket, Key: key, ContentType: mime }), { expiresIn: 3600 });
@ -614,7 +623,7 @@ app.post("/api/udp/session", requireAuth, async (req, res) => {
if (!relayUrl) return res.status(503).json({ success: false, error: "UDP relay not configured. Go to Admin → Relay Settings." }); if (!relayUrl) return res.status(503).json({ success: false, error: "UDP relay not configured. Go to Admin → Relay Settings." });
const publicRelayUrl = (db.relayConfig?.publicRelayUrl || "").trim() || relayUrl; const publicRelayUrl = (db.relayConfig?.publicRelayUrl || "").trim() || relayUrl;
const sessionId = crypto.randomBytes(16).toString("hex"); const sessionId = crypto.randomBytes(16).toString("hex");
const key = (prefix ? `${prefix.replace(/[-\/]+$/, "")}--${filename}` : filename); const key = buildS3Key(prefix, filename);
const s3Cfg = db.s3Config || {}; const s3Cfg = db.s3Config || {};
// Register session on the relay server so it can accept chunks and upload to S3 // Register session on the relay server so it can accept chunks and upload to S3
@ -873,7 +882,7 @@ app.post("/api/sharelinks/:token/upload", upload.array("files", 50), async (req,
const bucket = db.s3Config?.bucket || ""; const bucket = db.s3Config?.bucket || "";
const results = []; const results = [];
for (const file of req.files) { for (const file of req.files) {
const key = prefix ? `${prefix.replace(/[-\/]+$/, "")}--${file.originalname}` : file.originalname; const key = buildS3Key(prefix, file.originalname);
const contentType = getMimeType(file.originalname, file.mimetype); const contentType = getMimeType(file.originalname, file.mimetype);
const fileBuffer = fs.readFileSync(file.path); const fileBuffer = fs.readFileSync(file.path);
const putPromise = s3Client.send(new PutObjectCommand({ const putPromise = s3Client.send(new PutObjectCommand({
@ -964,7 +973,7 @@ app.post("/api/upload/initiate", requireAuth, async (req, res) => {
const bucket = db.s3Config?.bucket || ""; const bucket = db.s3Config?.bucket || "";
if (!bucket) return res.status(503).json({ success: false, error: "S3 bucket not configured" }); if (!bucket) return res.status(503).json({ success: false, error: "S3 bucket not configured" });
const key = prefix ? `${prefix.replace(/[-\/]+$/, "")}--${filename}` : filename; const key = buildS3Key(prefix, filename);
const mime = contentType || getMimeType(filename, "application/octet-stream"); const mime = contentType || getMimeType(filename, "application/octet-stream");
try { try {