feat/perf: large-file handling, faster file list, richer CodeMirror modes (fixes #48)

This commit is contained in:
Ryan
2025-10-06 00:10:31 -04:00
committed by GitHub
parent 0cb47b4054
commit 3dd5a8664a
5 changed files with 554 additions and 246 deletions

View File

@@ -1,5 +1,16 @@
# Changelog
## Changes 10/6/2025 v1.3.15
feat/perf: large-file handling, faster file list, richer CodeMirror modes (fixes #48)
- fileEditor.js: block ≥10 MB; plain-text fallback >5 MB; lighter CM settings for big files.
- fileListView.js: latest-call-wins; compute editable via ext + sizeBytes (no blink).
- FileModel.php: add sizeBytes; cap inline content to ≤5 MB (INDEX_TEXT_BYTES_MAX).
- HTML: load extra CM modes: htmlmixed, php, clike, python, yaml, markdown, shell, sql, vb, ruby, perl, properties, nginx.
---
## Changes 10/5/2025 v1.3.14
fix(admin): OIDC optional by default; validate only when enabled (fixes #44)
@@ -9,6 +20,8 @@ fix(admin): OIDC optional by default; validate only when enabled (fixes #44)
- AdminController default loginOptions sets disableOIDCLogin=true; CSRF via header or body
- Normalize file perms to 0664 after write
---
## Changes 10/4/2025 v1.3.13
fix(scanner): resolve dirs via CLI/env/constants; write per-item JSON; skip trash
@@ -27,6 +40,8 @@ chore(scanner): skip profile_pics subtree during scans
- Avoids indexing internal avatar images (folder already hidden in UI)
- Reduces scan noise and metadata churn; keeps firmware/other content indexed
---
## Changes 10/4/2025 v1.3.12
Fix: robust PUID/PGID handling; optional ownership normalization (closes #43)
@@ -35,6 +50,8 @@ Fix: robust PUID/PGID handling; optional ownership normalization (closes #43)
- Added CHOWN_ON_START env to control recursive chown (default true; turn off after first run)
- SCAN_ON_START unchanged, with non-root fallback
---
## Changes 10/4/2025 v1.3.11
Chore: keep BASE_URL fallback, prefer env SHARE_URL; fix HTTPS auto-detect
@@ -42,6 +59,8 @@ Chore: keep BASE_URL fallback, prefer env SHARE_URL; fix HTTPS auto-detect
- Remove no-op sed of SHARE_URL from start.sh (env already used)
- Build default share link with correct scheme (http/https, proxy-aware)
---
## Changes 10/4/2025 v1.3.10
Fix: index externally added files on startup; harden start.sh (#46)
@@ -54,6 +73,8 @@ Fix: index externally added files on startup; harden start.sh (#46)
No behavior change unless SCAN_ON_START=true.
---
## Changes 5/27/2025 v1.3.9
- Support for mounting CIFS (SMB) network shares via Docker volumes

View File

@@ -3,7 +3,7 @@ import { loadAdminConfigFunc } from './auth.js';
import { showToast, toggleVisibility, attachEnterKeyListener } from './domUtils.js';
import { sendRequest } from './networkUtils.js';
const version = "v1.3.13";
const version = "v1.3.15";
const adminTitle = `${t("admin_panel")} <small style="font-size:12px;color:gray;">${version}</small>`;
// ————— Inject updated styles —————

View File

@@ -3,20 +3,143 @@ import { escapeHTML, showToast } from './domUtils.js';
import { loadFileList } from './fileListView.js';
import { t } from './i18n.js';
// thresholds for editor behavior
const EDITOR_PLAIN_THRESHOLD = 5 * 1024 * 1024; // >5 MiB => force plain text, lighter settings
const EDITOR_BLOCK_THRESHOLD = 10 * 1024 * 1024; // >10 MiB => block editing
// Lazy-load CodeMirror modes on demand
const CM_CDN = "https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.5/";
const MODE_URL = {
// core you've likely already loaded:
"xml": "mode/xml/xml.min.js",
"css": "mode/css/css.min.js",
"javascript": "mode/javascript/javascript.min.js",
// extras you may want on-demand:
"htmlmixed": "mode/htmlmixed/htmlmixed.min.js",
"application/x-httpd-php": "mode/php/php.min.js",
"php": "mode/php/php.min.js",
"markdown": "mode/markdown/markdown.min.js",
"python": "mode/python/python.min.js",
"sql": "mode/sql/sql.min.js",
"shell": "mode/shell/shell.min.js",
"yaml": "mode/yaml/yaml.min.js",
"properties": "mode/properties/properties.min.js",
"text/x-csrc": "mode/clike/clike.min.js",
"text/x-c++src": "mode/clike/clike.min.js",
"text/x-java": "mode/clike/clike.min.js",
"text/x-csharp": "mode/clike/clike.min.js",
"text/x-kotlin": "mode/clike/clike.min.js"
};
function loadScriptOnce(url) {
return new Promise((resolve, reject) => {
const key = `cm:${url}`;
let s = document.querySelector(`script[data-key="${key}"]`);
if (s) {
if (s.dataset.loaded === "1") return resolve();
s.addEventListener("load", () => resolve());
s.addEventListener("error", reject);
return;
}
s = document.createElement("script");
s.src = url;
s.defer = true;
s.dataset.key = key;
s.addEventListener("load", () => { s.dataset.loaded = "1"; resolve(); });
s.addEventListener("error", reject);
document.head.appendChild(s);
});
}
async function ensureModeLoaded(modeOption) {
if (!window.CodeMirror) return; // CM core must be present
const name = typeof modeOption === "string" ? modeOption : (modeOption && modeOption.name);
if (!name) return;
// Already registered?
if ((CodeMirror.modes && CodeMirror.modes[name]) || (CodeMirror.mimeModes && CodeMirror.mimeModes[name])) {
return;
}
const url = MODE_URL[name];
if (!url) return; // unknown -> fallback to text/plain
// Dependencies (htmlmixed needs xml/css/js; php highlighting with HTML also benefits from htmlmixed)
if (name === "htmlmixed") {
await Promise.all([
ensureModeLoaded("xml"),
ensureModeLoaded("css"),
ensureModeLoaded("javascript")
]);
}
if (name === "application/x-httpd-php") {
await ensureModeLoaded("htmlmixed");
}
await loadScriptOnce(CM_CDN + url);
}
function getModeForFile(fileName) {
const ext = fileName.slice(fileName.lastIndexOf('.') + 1).toLowerCase();
const dot = fileName.lastIndexOf(".");
const ext = dot >= 0 ? fileName.slice(dot + 1).toLowerCase() : "";
switch (ext) {
case "css":
return "css";
case "json":
return { name: "javascript", json: true };
case "js":
return "javascript";
// markup
case "html":
case "htm":
return "text/html";
return "text/html"; // ensureModeLoaded will map to htmlmixed
case "xml":
return "xml";
case "md":
case "markdown":
return "markdown";
case "yml":
case "yaml":
return "yaml";
// styles & scripts
case "css":
return "css";
case "js":
return "javascript";
case "json":
return { name: "javascript", json: true };
// server / langs
case "php":
return "application/x-httpd-php";
case "py":
return "python";
case "sql":
return "sql";
case "sh":
case "bash":
case "zsh":
case "bat":
return "shell";
// config-y files
case "ini":
case "conf":
case "config":
case "properties":
return "properties";
// C-family / JVM
case "c":
case "h":
return "text/x-csrc";
case "cpp":
case "cxx":
case "hpp":
case "hh":
case "hxx":
return "text/x-c++src";
case "java":
return "text/x-java";
case "cs":
return "text/x-csharp";
case "kt":
case "kts":
return "text/x-kotlin";
default:
return "text/plain";
}
@@ -47,6 +170,7 @@ export function editFile(fileName, folder) {
if (existingEditor) {
existingEditor.remove();
}
const folderUsed = folder || window.currentFolder || "root";
const folderPath = folderUsed === "root"
? "uploads/"
@@ -55,26 +179,40 @@ export function editFile(fileName, folder) {
fetch(fileUrl, { method: "HEAD" })
.then(response => {
const contentLength = response.headers.get("Content-Length");
if (contentLength !== null && parseInt(contentLength) > 10485760) {
const lenHeader =
response.headers.get("content-length") ??
response.headers.get("Content-Length");
const sizeBytes = lenHeader ? parseInt(lenHeader, 10) : null;
if (sizeBytes !== null && sizeBytes > EDITOR_BLOCK_THRESHOLD) {
showToast("This file is larger than 10 MB and cannot be edited in the browser.");
throw new Error("File too large.");
}
return fetch(fileUrl);
return response;
})
.then(() => fetch(fileUrl))
.then(response => {
if (!response.ok) {
throw new Error("HTTP error! Status: " + response.status);
}
return response.text();
const lenHeader =
response.headers.get("content-length") ??
response.headers.get("Content-Length");
const sizeBytes = lenHeader ? parseInt(lenHeader, 10) : null;
return Promise.all([response.text(), sizeBytes]);
})
.then(content => {
.then(([content, sizeBytes]) => {
const forcePlainText =
sizeBytes !== null && sizeBytes > EDITOR_PLAIN_THRESHOLD;
const modal = document.createElement("div");
modal.id = "editorContainer";
modal.classList.add("modal", "editor-modal");
modal.innerHTML = `
<div class="editor-header">
<h3 class="editor-title">${t("editing")}: ${escapeHTML(fileName)}</h3>
<h3 class="editor-title">${t("editing")}: ${escapeHTML(fileName)}${
forcePlainText ? " <span style='font-size:.8em;opacity:.7'>(plain text mode)</span>" : ""
}</h3>
<div class="editor-controls">
<button id="decreaseFont" class="btn btn-sm btn-secondary">${t("decrease_font")}</button>
<button id="increaseFont" class="btn btn-sm btn-secondary">${t("increase_font")}</button>
@@ -90,61 +228,74 @@ export function editFile(fileName, folder) {
document.body.appendChild(modal);
modal.style.display = "block";
const mode = getModeForFile(fileName);
const isDarkMode = document.body.classList.contains("dark-mode");
const theme = isDarkMode ? "material-darker" : "default";
const editor = CodeMirror.fromTextArea(document.getElementById("fileEditor"), {
lineNumbers: true,
// choose mode + lighter settings for large files
const mode = forcePlainText ? "text/plain" : getModeForFile(fileName);
const cmOptions = {
lineNumbers: !forcePlainText,
mode: mode,
theme: theme,
viewportMargin: Infinity
});
viewportMargin: forcePlainText ? 20 : Infinity,
lineWrapping: false,
};
window.currentEditor = editor;
// ✅ LOAD MODE FIRST, THEN INSTANTIATE CODEMIRROR
ensureModeLoaded(mode).finally(() => {
const editor = CodeMirror.fromTextArea(
document.getElementById("fileEditor"),
cmOptions
);
setTimeout(() => {
adjustEditorSize();
}, 50);
window.currentEditor = editor;
observeModalResize(modal);
setTimeout(() => {
adjustEditorSize();
}, 50);
let currentFontSize = 14;
editor.getWrapperElement().style.fontSize = currentFontSize + "px";
editor.refresh();
observeModalResize(modal);
document.getElementById("closeEditorX").addEventListener("click", function () {
modal.remove();
});
document.getElementById("decreaseFont").addEventListener("click", function () {
currentFontSize = Math.max(8, currentFontSize - 2);
let currentFontSize = 14;
editor.getWrapperElement().style.fontSize = currentFontSize + "px";
editor.refresh();
document.getElementById("closeEditorX").addEventListener("click", function () {
modal.remove();
});
document.getElementById("decreaseFont").addEventListener("click", function () {
currentFontSize = Math.max(8, currentFontSize - 2);
editor.getWrapperElement().style.fontSize = currentFontSize + "px";
editor.refresh();
});
document.getElementById("increaseFont").addEventListener("click", function () {
currentFontSize = Math.min(32, currentFontSize + 2);
editor.getWrapperElement().style.fontSize = currentFontSize + "px";
editor.refresh();
});
document.getElementById("saveBtn").addEventListener("click", function () {
saveFile(fileName, folderUsed);
});
document.getElementById("closeBtn").addEventListener("click", function () {
modal.remove();
});
function updateEditorTheme() {
const isDark = document.body.classList.contains("dark-mode");
editor.setOption("theme", isDark ? "material-darker" : "default");
}
const toggle = document.getElementById("darkModeToggle");
if (toggle) toggle.addEventListener("click", updateEditorTheme);
});
document.getElementById("increaseFont").addEventListener("click", function () {
currentFontSize = Math.min(32, currentFontSize + 2);
editor.getWrapperElement().style.fontSize = currentFontSize + "px";
editor.refresh();
});
document.getElementById("saveBtn").addEventListener("click", function () {
saveFile(fileName, folderUsed);
});
document.getElementById("closeBtn").addEventListener("click", function () {
modal.remove();
});
function updateEditorTheme() {
const isDarkMode = document.body.classList.contains("dark-mode");
editor.setOption("theme", isDarkMode ? "material-darker" : "default");
}
document.getElementById("darkModeToggle").addEventListener("click", updateEditorTheme);
})
.catch(error => console.error("Error loading file:", error));
.catch(error => {
if (error && error.name === "AbortError") return;
console.error("Error loading file:", error);
});
}

View File

@@ -35,6 +35,12 @@ import {
export let fileData = [];
export let sortOrder = { column: "uploaded", ascending: true };
// Hide "Edit" for files >10 MiB
const MAX_EDIT_BYTES = 10 * 1024 * 1024;
// Latest-response-wins guard (prevents double render/flicker if loadFileList gets called twice)
let __fileListReqSeq = 0;
window.itemsPerPage = parseInt(
localStorage.getItem('itemsPerPage') || window.itemsPerPage || '10',
10
@@ -202,51 +208,39 @@ window.toggleRowSelection = toggleRowSelection;
window.updateRowHighlight = updateRowHighlight;
export async function loadFileList(folderParam) {
const reqId = ++__fileListReqSeq; // latest call wins
const folder = folderParam || "root";
const fileListContainer = document.getElementById("fileList");
const actionsContainer = document.getElementById("fileListActions");
// 1) show loader
// 1) show loader (only this request is allowed to render)
fileListContainer.style.visibility = "hidden";
fileListContainer.innerHTML = "<div class='loader'>Loading files...</div>";
try {
// 2) fetch files + folders in parallel
const [filesRes, foldersRes] = await Promise.all([
fetch(`/api/file/getFileList.php?folder=${encodeURIComponent(folder)}&recursive=1&t=${Date.now()}`),
fetch(`/api/folder/getFolderList.php?folder=${encodeURIComponent(folder)}`)
]);
// Kick off both in parallel, but we'll render as soon as FILES are ready
const filesPromise = fetch(`/api/file/getFileList.php?folder=${encodeURIComponent(folder)}&recursive=1&t=${Date.now()}`);
const foldersPromise = fetch(`/api/folder/getFolderList.php?folder=${encodeURIComponent(folder)}`);
// ----- FILES FIRST -----
const filesRes = await filesPromise;
if (filesRes.status === 401) {
window.location.href = "/api/auth/logout.php";
throw new Error("Unauthorized");
}
const data = await filesRes.json();
const folderRaw = await foldersRes.json();
// --- build ONLY the *direct* children of current folder ---
let subfolders = [];
const hidden = new Set(["profile_pics", "trash"]);
if (Array.isArray(folderRaw)) {
const allPaths = folderRaw.map(item => item.folder ?? item);
const depth = folder === "root" ? 1 : folder.split("/").length + 1;
subfolders = allPaths
.filter(p => {
if (folder === "root") {
return p.indexOf("/") === -1;
}
if (!p.startsWith(folder + "/")) return false;
return p.split("/").length === depth;
})
.map(p => ({ name: p.split("/").pop(), full: p }));
}
subfolders = subfolders.filter(sf => !hidden.has(sf.name));
// If another loadFileList ran after this one, bail before touching the DOM
if (reqId !== __fileListReqSeq) return [];
// 3) clear loader
// 3) clear loader (still only if this request is the latest)
fileListContainer.innerHTML = "";
// 4) handle “no files” case
if (!data.files || Object.keys(data.files).length === 0) {
if (reqId !== __fileListReqSeq) return [];
fileListContainer.textContent = t("no_files_found");
// hide summary + slider
@@ -255,36 +249,12 @@ export async function loadFileList(folderParam) {
const sliderContainer = document.getElementById("viewSliderContainer");
if (sliderContainer) sliderContainer.style.display = "none";
// show/hide folder strip *even when there are no files*
let strip = document.getElementById("folderStripContainer");
if (!strip) {
strip = document.createElement("div");
strip.id = "folderStripContainer";
strip.className = "folder-strip-container";
actionsContainer.parentNode.insertBefore(strip, fileListContainer);
}
if (window.showFoldersInList && subfolders.length) {
strip.innerHTML = subfolders.map(sf => `
<div class="folder-item" data-folder="${sf.full}">
<i class="material-icons">folder</i>
<div class="folder-name">${escapeHTML(sf.name)}</div>
</div>
`).join("");
strip.style.display = "flex";
strip.querySelectorAll(".folder-item").forEach(el => {
el.addEventListener("click", () => {
const dest = el.dataset.folder;
window.currentFolder = dest;
localStorage.setItem("lastOpenedFolder", dest);
updateBreadcrumbTitle(dest);
loadFileList(dest);
});
});
} else {
strip.style.display = "none";
}
// hide folder strip for now; well re-show it after folders load (below)
const strip = document.getElementById("folderStripContainer");
if (strip) strip.style.display = "none";
updateFileActionButtons();
fileListContainer.style.visibility = "visible";
return [];
}
@@ -295,14 +265,49 @@ export async function loadFileList(folderParam) {
return meta;
});
}
data.files = data.files.map(f => {
f.fullName = (f.path || f.name).trim().toLowerCase();
f.editable = canEditFile(f.name);
// Prefer numeric size if your API provides it; otherwise parse the "1.2 MB" string
let bytes = Number.isFinite(f.sizeBytes)
? f.sizeBytes
: parseSizeToBytes(String(f.size || ""));
if (!Number.isFinite(bytes)) bytes = Infinity;
// extension policy + size policy
f.editable = canEditFile(f.name) && (bytes <= MAX_EDIT_BYTES);
f.folder = folder;
return f;
});
fileData = data.files;
// Decide editability BEFORE render to avoid any post-render “blink”
data.files = data.files.map(f => {
f.fullName = (f.path || f.name).trim().toLowerCase();
// extension policy
const extOk = canEditFile(f.name);
// prefer numeric byte size if API provides it; otherwise parse "12.3 MB" strings
let bytes = Infinity;
if (Number.isFinite(f.sizeBytes)) {
bytes = f.sizeBytes;
} else if (f.size != null && String(f.size).trim() !== "") {
bytes = parseSizeToBytes(String(f.size));
}
f.editable = extOk && (bytes <= MAX_EDIT_BYTES);
f.folder = folder;
return f;
});
fileData = data.files;
// If stale, stop before any DOM updates
if (reqId !== __fileListReqSeq) return [];
// 6) inject summary + slider
if (actionsContainer) {
// a) summary
@@ -342,19 +347,19 @@ export async function loadFileList(folderParam) {
);
sliderContainer.innerHTML = `
<label for="galleryColumnsSlider" style="margin-right:8px;line-height:1;">
${t("columns")}:
</label>
<input
type="range"
id="galleryColumnsSlider"
min="1"
max="${maxCols}"
value="${currentCols}"
style="vertical-align:middle;"
>
<span id="galleryColumnsValue" style="margin-left:6px;line-height:1;">${currentCols}</span>
`;
<label for="galleryColumnsSlider" style="margin-right:8px;line-height:1;">
${t("columns")}:
</label>
<input
type="range"
id="galleryColumnsSlider"
min="1"
max="${maxCols}"
value="${currentCols}"
style="vertical-align:middle;"
>
<span id="galleryColumnsValue" style="margin-left:6px;line-height:1;">${currentCols}</span>
`;
const gallerySlider = document.getElementById("galleryColumnsSlider");
const galleryValue = document.getElementById("galleryColumnsValue");
gallerySlider.oninput = e => {
@@ -367,12 +372,12 @@ export async function loadFileList(folderParam) {
} else {
const currentHeight = parseInt(localStorage.getItem("rowHeight") || "48", 10);
sliderContainer.innerHTML = `
<label for="rowHeightSlider" style="margin-right:8px;line-height:1;">
${t("row_height")}:
</label>
<input type="range" id="rowHeightSlider" min="30" max="60" value="${currentHeight}" style="vertical-align:middle;">
<span id="rowHeightValue" style="margin-left:6px;line-height:1;">${currentHeight}px</span>
`;
<label for="rowHeightSlider" style="margin-right:8px;line-height:1;">
${t("row_height")}:
</label>
<input type="range" id="rowHeightSlider" min="30" max="60" value="${currentHeight}" style="vertical-align:middle;">
<span id="rowHeightValue" style="margin-left:6px;line-height:1;">${currentHeight}px</span>
`;
const rowSlider = document.getElementById("rowHeightSlider");
const rowValue = document.getElementById("rowHeightValue");
rowSlider.oninput = e => {
@@ -384,93 +389,121 @@ export async function loadFileList(folderParam) {
}
}
// 7) inject folder strip below actions, above file list
let strip = document.getElementById("folderStripContainer");
if (!strip) {
strip = document.createElement("div");
strip.id = "folderStripContainer";
strip.className = "folder-strip-container";
actionsContainer.parentNode.insertBefore(strip, actionsContainer);
}
// 7) render files (only if still latest)
if (reqId !== __fileListReqSeq) return [];
if (window.showFoldersInList && subfolders.length) {
strip.innerHTML = subfolders.map(sf => `
<div class="folder-item" data-folder="${sf.full}" draggable="true">
<i class="material-icons">folder</i>
<div class="folder-name">${escapeHTML(sf.name)}</div>
</div>
`).join("");
strip.style.display = "flex";
// wire up each foldertile
strip.querySelectorAll(".folder-item").forEach(el => {
// 1) click to navigate
el.addEventListener("click", () => {
const dest = el.dataset.folder;
window.currentFolder = dest;
localStorage.setItem("lastOpenedFolder", dest);
updateBreadcrumbTitle(dest);
document.querySelectorAll(".folder-option.selected").forEach(o => o.classList.remove("selected"));
document.querySelector(`.folder-option[data-folder="${dest}"]`)?.classList.add("selected");
loadFileList(dest);
});
// 2) drag & drop
el.addEventListener("dragover", folderDragOverHandler);
el.addEventListener("dragleave", folderDragLeaveHandler);
el.addEventListener("drop", folderDropHandler);
// 3) right-click context menu
el.addEventListener("contextmenu", e => {
e.preventDefault();
e.stopPropagation();
const dest = el.dataset.folder;
window.currentFolder = dest;
localStorage.setItem("lastOpenedFolder", dest);
// highlight the strip tile
strip.querySelectorAll(".folder-item.selected").forEach(i => i.classList.remove("selected"));
el.classList.add("selected");
// reuse folderManager menu
const menuItems = [
{
label: t("create_folder"),
action: () => document.getElementById("createFolderModal").style.display = "block"
},
{
label: t("rename_folder"),
action: () => openRenameFolderModal()
},
{
label: t("folder_share"),
action: () => openFolderShareModal(dest)
},
{
label: t("delete_folder"),
action: () => openDeleteFolderModal()
}
];
showFolderManagerContextMenu(e.pageX, e.pageY, menuItems);
});
});
// one global click to hide any open context menu
document.addEventListener("click", hideFolderManagerContextMenu);
} else {
strip.style.display = "none";
}
// 8) render files
if (window.viewMode === "gallery") {
renderGalleryView(folder);
} else {
renderFileTable(folder);
}
updateFileActionButtons();
fileListContainer.style.visibility = "visible";
// ----- FOLDERS NEXT (populate strip when ready; doesn't block rows) -----
try {
const foldersRes = await foldersPromise;
const folderRaw = await foldersRes.json();
if (reqId !== __fileListReqSeq) return data.files;
// --- build ONLY the *direct* children of current folder ---
let subfolders = [];
const hidden = new Set(["profile_pics", "trash"]);
if (Array.isArray(folderRaw)) {
const allPaths = folderRaw.map(item => item.folder ?? item);
const depth = folder === "root" ? 1 : folder.split("/").length + 1;
subfolders = allPaths
.filter(p => {
if (folder === "root") return p.indexOf("/") === -1;
if (!p.startsWith(folder + "/")) return false;
return p.split("/").length === depth;
})
.map(p => ({ name: p.split("/").pop(), full: p }));
}
subfolders = subfolders.filter(sf => !hidden.has(sf.name));
// inject folder strip below actions, above file list
let strip = document.getElementById("folderStripContainer");
if (!strip) {
strip = document.createElement("div");
strip.id = "folderStripContainer";
strip.className = "folder-strip-container";
actionsContainer.parentNode.insertBefore(strip, actionsContainer);
}
if (window.showFoldersInList && subfolders.length) {
strip.innerHTML = subfolders.map(sf => `
<div class="folder-item" data-folder="${sf.full}" draggable="true">
<i class="material-icons">folder</i>
<div class="folder-name">${escapeHTML(sf.name)}</div>
</div>
`).join("");
strip.style.display = "flex";
// wire up each foldertile
strip.querySelectorAll(".folder-item").forEach(el => {
// 1) click to navigate
el.addEventListener("click", () => {
const dest = el.dataset.folder;
window.currentFolder = dest;
localStorage.setItem("lastOpenedFolder", dest);
updateBreadcrumbTitle(dest);
document.querySelectorAll(".folder-option.selected").forEach(o => o.classList.remove("selected"));
document.querySelector(`.folder-option[data-folder="${dest}"]`)?.classList.add("selected");
loadFileList(dest);
});
// 2) drag & drop
el.addEventListener("dragover", folderDragOverHandler);
el.addEventListener("dragleave", folderDragLeaveHandler);
el.addEventListener("drop", folderDropHandler);
// 3) right-click context menu
el.addEventListener("contextmenu", e => {
e.preventDefault();
e.stopPropagation();
const dest = el.dataset.folder;
window.currentFolder = dest;
localStorage.setItem("lastOpenedFolder", dest);
// highlight the strip tile
strip.querySelectorAll(".folder-item.selected").forEach(i => i.classList.remove("selected"));
el.classList.add("selected");
// reuse folderManager menu
const menuItems = [
{
label: t("create_folder"),
action: () => document.getElementById("createFolderModal").style.display = "block"
},
{
label: t("rename_folder"),
action: () => openRenameFolderModal()
},
{
label: t("folder_share"),
action: () => openFolderShareModal(dest)
},
{
label: t("delete_folder"),
action: () => openDeleteFolderModal()
}
];
showFolderManagerContextMenu(e.pageX, e.pageY, menuItems);
});
});
// one global click to hide any open context menu
document.addEventListener("click", hideFolderManagerContextMenu);
} else {
strip.style.display = "none";
}
} catch {
// ignore folder errors; rows already rendered
}
return data.files;
} catch (err) {
@@ -480,7 +513,10 @@ export async function loadFileList(folderParam) {
}
return [];
} finally {
fileListContainer.style.visibility = "visible";
// Only the latest call should restore visibility
if (reqId === __fileListReqSeq) {
fileListContainer.style.visibility = "visible";
}
}
}
@@ -1137,12 +1173,64 @@ function parseCustomDate(dateStr) {
}
export function canEditFile(fileName) {
if (!fileName || typeof fileName !== "string") return false;
const dot = fileName.lastIndexOf(".");
if (dot < 0) return false;
const ext = fileName.slice(dot + 1).toLowerCase();
// Text/code-only. Intentionally exclude php/phtml/phar/etc.
const allowedExtensions = [
"txt", "html", "htm", "css", "js", "json", "xml",
"md", "py", "ini", "csv", "log", "conf", "config", "bat",
"rtf", "doc", "docx"
// Plain text & docs (text)
"txt", "text", "md", "markdown", "rst",
// Web
"html", "htm", "xhtml", "shtml",
"css", "scss", "sass", "less",
// JS/TS
"js", "mjs", "cjs", "jsx",
"ts", "tsx",
// Data & config formats
"json", "jsonc", "ndjson",
"yml", "yaml", "toml", "xml", "plist",
"ini", "conf", "config", "cfg", "cnf", "properties", "props", "rc",
"env", "dotenv",
"csv", "tsv", "tab",
"log",
// Shell / scripts
"sh", "bash", "zsh", "ksh", "fish",
"bat", "cmd",
"ps1", "psm1", "psd1",
// Languages
"py", "pyw", // Python
"rb", // Ruby
"pl", "pm", // Perl
"go", // Go
"rs", // Rust
"java", // Java
"kt", "kts", // Kotlin
"scala", "sc", // Scala
"groovy", "gradle", // Groovy/Gradle
"c", "h", "cpp", "cxx", "cc", "hpp", "hh", "hxx", // C/C++
"m", "mm", // Obj-C / Obj-C++
"swift", // Swift
"cs", "fs", "fsx", // C#, F#
"dart",
"lua",
"r", "rmd",
// SQL
"sql",
// Front-end SFC/templates
"vue", "svelte",
"twig", "mustache", "hbs", "handlebars", "ejs", "pug", "jade"
];
const ext = fileName.slice(fileName.lastIndexOf('.') + 1).toLowerCase();
return allowedExtensions.includes(ext);
}

View File

@@ -1167,19 +1167,24 @@ public static function saveFile(string $folder, string $fileName, $content, ?str
* @return array Returns an associative array with keys "files" and "globalTags".
*/
public static function getFileList(string $folder): array {
// --- caps for safe inlining ---
if (!defined('LISTING_CONTENT_BYTES_MAX')) define('LISTING_CONTENT_BYTES_MAX', 8192); // 8 KB snippet
if (!defined('INDEX_TEXT_BYTES_MAX')) define('INDEX_TEXT_BYTES_MAX', 5 * 1024 * 1024); // only sample files ≤ 5 MB
$folder = trim($folder) ?: 'root';
// Determine the target directory.
if (strtolower($folder) !== 'root') {
$directory = rtrim(UPLOAD_DIR, '/\\') . DIRECTORY_SEPARATOR . $folder;
} else {
$directory = UPLOAD_DIR;
}
// Validate folder.
if (strtolower($folder) !== 'root' && !preg_match(REGEX_FOLDER_NAME, $folder)) {
return ["error" => "Invalid folder name."];
}
// Helper: Build the metadata file path.
$getMetadataFilePath = function(string $folder): string {
if (strtolower($folder) === 'root' || trim($folder) === '') {
@@ -1188,23 +1193,26 @@ public static function saveFile(string $folder, string $fileName, $content, ?str
return META_DIR . str_replace(['/', '\\', ' '], '-', trim($folder)) . '_metadata.json';
};
$metadataFile = $getMetadataFilePath($folder);
$metadata = file_exists($metadataFile) ? json_decode(file_get_contents($metadataFile), true) : [];
$metadata = file_exists($metadataFile) ? (json_decode(file_get_contents($metadataFile), true) ?: []) : [];
if (!is_dir($directory)) {
return ["error" => "Directory not found."];
}
$allFiles = array_values(array_diff(scandir($directory), array('.', '..')));
$fileList = [];
// Define a safe file name pattern.
$safeFileNamePattern = REGEX_FILE_NAME;
// Prepare finfo (if available) for MIME sniffing.
$finfo = function_exists('finfo_open') ? @finfo_open(FILEINFO_MIME_TYPE) : false;
foreach ($allFiles as $file) {
if (substr($file, 0, 1) === '.') {
continue; // Skip hidden files.
if ($file === '' || $file[0] === '.') {
continue; // Skip hidden/invalid entries.
}
$filePath = $directory . DIRECTORY_SEPARATOR . $file;
if (!is_file($filePath)) {
continue; // Only process files.
@@ -1212,13 +1220,17 @@ public static function saveFile(string $folder, string $fileName, $content, ?str
if (!preg_match($safeFileNamePattern, $file)) {
continue;
}
$fileDateModified = filemtime($filePath) ? date(DATE_TIME_FORMAT, filemtime($filePath)) : "Unknown";
// Meta
$mtime = @filemtime($filePath);
$fileDateModified = $mtime ? date(DATE_TIME_FORMAT, $mtime) : "Unknown";
$metaKey = $file;
$fileUploadedDate = isset($metadata[$metaKey]["uploaded"]) ? $metadata[$metaKey]["uploaded"] : "Unknown";
$fileUploader = isset($metadata[$metaKey]["uploader"]) ? $metadata[$metaKey]["uploader"] : "Unknown";
$fileSizeBytes = filesize($filePath);
// Size
$fileSizeBytes = @filesize($filePath);
if (!is_int($fileSizeBytes)) $fileSizeBytes = 0;
if ($fileSizeBytes >= 1073741824) {
$fileSizeFormatted = sprintf("%.1f GB", $fileSizeBytes / 1073741824);
} elseif ($fileSizeBytes >= 1048576) {
@@ -1228,29 +1240,65 @@ public static function saveFile(string $folder, string $fileName, $content, ?str
} else {
$fileSizeFormatted = sprintf("%s bytes", number_format($fileSizeBytes));
}
$fileEntry = [
'name' => $file,
'modified' => $fileDateModified,
'uploaded' => $fileUploadedDate,
'size' => $fileSizeFormatted,
'uploader' => $fileUploader,
'tags' => isset($metadata[$metaKey]['tags']) ? $metadata[$metaKey]['tags'] : []
];
// Optionally include file content for text-based files.
if (preg_match('/\.(txt|html|htm|md|js|css|json|xml|php|py|ini|conf|log)$/i', $file)) {
$content = file_get_contents($filePath);
$fileEntry['content'] = $content;
// MIME + text detection (fallback to extension)
$mime = 'application/octet-stream';
if ($finfo) {
$det = @finfo_file($finfo, $filePath);
if (is_string($det) && $det !== '') $mime = $det;
}
$isTextByMime = (strpos((string)$mime, 'text/') === 0) || $mime === 'application/json' || $mime === 'application/xml';
$isTextByExt = (bool)preg_match('/\.(txt|md|csv|json|xml|html?|css|js|log|ini|conf|config|yml|yaml|php|py|rb|sh|bat|ps1|ts|tsx|c|cpp|h|hpp|java|go|rs)$/i', $file);
$isText = $isTextByMime || $isTextByExt;
// Build entry
$fileEntry = [
'name' => $file,
'modified' => $fileDateModified,
'uploaded' => $fileUploadedDate,
'size' => $fileSizeFormatted,
'sizeBytes' => $fileSizeBytes, // ← numeric size for frontend logic
'uploader' => $fileUploader,
'tags' => isset($metadata[$metaKey]['tags']) ? $metadata[$metaKey]['tags'] : [],
'mime' => $mime,
];
// Small, safe snippet for text files only (never full content)
$fileEntry['content'] = '';
$fileEntry['contentTruncated'] = false;
if ($isText && $fileSizeBytes > 0) {
if ($fileSizeBytes <= INDEX_TEXT_BYTES_MAX) {
$fh = @fopen($filePath, 'rb');
if ($fh) {
$snippet = @fread($fh, LISTING_CONTENT_BYTES_MAX);
@fclose($fh);
if ($snippet !== false) {
// ensure UTF-8 for JSON
if (function_exists('mb_check_encoding') && !mb_check_encoding($snippet, 'UTF-8')) {
if (function_exists('mb_convert_encoding')) {
$snippet = @mb_convert_encoding($snippet, 'UTF-8', 'UTF-8, ISO-8859-1, Windows-1252');
}
}
$fileEntry['content'] = $snippet;
$fileEntry['contentTruncated'] = ($fileSizeBytes > LISTING_CONTENT_BYTES_MAX);
}
}
} else {
// too large to sample: mark truncated so UI/search knows
$fileEntry['contentTruncated'] = true;
}
}
$fileList[] = $fileEntry;
}
if ($finfo) { @finfo_close($finfo); }
// Load global tags.
$globalTagsFile = META_DIR . "createdTags.json";
$globalTags = file_exists($globalTagsFile) ? json_decode(file_get_contents($globalTagsFile), true) : [];
$globalTags = file_exists($globalTagsFile) ? (json_decode(file_get_contents($globalTagsFile), true) ?: []) : [];
return ["files" => $fileList, "globalTags" => $globalTags];
}