diff --git a/CHANGELOG.md b/CHANGELOG.md index 4ee0fbd..a617d0b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/public/js/adminPanel.js b/public/js/adminPanel.js index 6f534df..3d06105 100644 --- a/public/js/adminPanel.js +++ b/public/js/adminPanel.js @@ -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")} ${version}`; // ————— Inject updated styles ————— diff --git a/public/js/fileEditor.js b/public/js/fileEditor.js index ca74af4..741dbd5 100644 --- a/public/js/fileEditor.js +++ b/public/js/fileEditor.js @@ -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 = `
-

${t("editing")}: ${escapeHTML(fileName)}

+

${t("editing")}: ${escapeHTML(fileName)}${ + forcePlainText ? " (plain text mode)" : "" + }

@@ -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); + }); } diff --git a/public/js/fileListView.js b/public/js/fileListView.js index 43e8136..378358a 100644 --- a/public/js/fileListView.js +++ b/public/js/fileListView.js @@ -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 = "
Loading files...
"; 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 => ` -
- folder -
${escapeHTML(sf.name)}
-
- `).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; we’ll 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 = ` - - - ${currentCols} - `; + + + ${currentCols} + `; 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 = ` - - - ${currentHeight}px - `; + + + ${currentHeight}px + `; 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 => ` -
- folder -
${escapeHTML(sf.name)}
-
- `).join(""); - strip.style.display = "flex"; - - // wire up each folder‐tile - 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 => ` +
+ folder +
${escapeHTML(sf.name)}
+
+ `).join(""); + strip.style.display = "flex"; + + // wire up each folder‐tile + 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); } diff --git a/src/models/FileModel.php b/src/models/FileModel.php index 279bcad..49ee7ac 100644 --- a/src/models/FileModel.php +++ b/src/models/FileModel.php @@ -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]; }