release(v3.1.2): configurable ignore rules for indexing/tree + admin UX polish (fixes #91, refs #92)

- add ignoreRegex setting (admin config) with env override FR_IGNORE_REGEX to hide folders from tree/counts/indexing
- add snapshot preset helper for common NAS snapshot paths (fixes #91)
- unify ignore logic via FS::shouldIgnoreEntry across folder counts, tree listing, and disk usage scans
- admin: improve settings search UX (clear button) + smoother section header styling
- UI: polish header dock collapse/expand icon animations (landing/lift + reduced-motion support)

Fixes #91
Refs #92

Co-authored-by: nikp123 <nikp123@e.email>
This commit is contained in:
Ryan
2026-01-20 02:15:03 -05:00
committed by GitHub
parent daa88157d8
commit 96e15b5242
10 changed files with 660 additions and 65 deletions
+56
View File
@@ -1,5 +1,61 @@
# Changelog
## Changes 01/20/2026 (v3.1.2)
`release(v3.1.2): configurable ignore rules for indexing/tree + admin UX polish (fixes #91, refs #92)`
**Commit message**
```text
release(v3.1.2): configurable ignore rules for indexing/tree + admin UX polish (fixes #91, refs #92)
- add ignoreRegex setting (admin config) with env override FR_IGNORE_REGEX to hide folders from tree/counts/indexing
- add snapshot preset helper for common NAS snapshot paths (fixes #91)
- unify ignore logic via FS::shouldIgnoreEntry across folder counts, tree listing, and disk usage scans
- admin: improve settings search UX (clear button) + smoother section header styling
- UI: polish header dock collapse/expand icon animations (landing/lift + reduced-motion support)
Fixes #91
Refs #92
Co-authored-by: nikp123 <nikp123@e.email>
```
**Added**
- **Indexing ignore rules (regex)**:
- Admin setting: **Ignore paths (regex)** (`ignoreRegex`) — one pattern per line.
- Env override: `FR_IGNORE_REGEX` (locks the field when set).
- Built-in “quick add” button for a common snapshot preset: `(^|/)(@?snapshots?)(/|$)` (helps with NAS snapshot dirs).
- **Centralized ignore helper**:
- `FS::shouldIgnoreEntry($name, $parentRel)` applies built-in ignores plus optional regex patterns.
**Changed**
- **Folder tree / listing / counts now share ignore logic**:
- Replaced scattered ignore arrays with `FS::shouldIgnoreEntry(...)` in folder enumeration paths.
- **Disk usage scan now filters earlier**:
- Uses a `RecursiveCallbackFilterIterator` so ignored entries are skipped before deeper traversal.
- **Admin Panel UX**:
- Settings search now includes a dedicated clear (X) button that appears only when a query exists.
- Section headers now render via a `.section-header-inner` wrapper for cleaner layout/hover/active styles.
- Audit table area now caps height and scrolls to avoid huge modal growth.
- **Header dock polish**:
- Adds “lift” and “land” animations for header dock icon buttons during card collapse/expand.
- Respects `prefers-reduced-motion`.
**Fixed**
- **#91:** Snapshot folders (e.g., `snapshot`, `@snapshots`) can now be excluded cleanly from the tree, counts, indexing, and disk usage views via ignore rules.
- Prevents “stuck landing” icon states by cleaning up animation classes/inline vars on `animationend`.
**Notes**
- Ignore rules are applied frequently during tree/list/count operations. Keep patterns simple to avoid expensive regexes.
- Invalid regex lines are ignored safely (and wont crash listing/indexing).
---
## Changes 01/17/2026 (v3.1.1)
`release(v3.1.1): OIDC env overrides + configurable resumable chunk size + clearer startup logs (closes #86, closes #87, closes #90)`
+25
View File
@@ -185,6 +185,31 @@ if ($envKey === false || $envKey === '') {
$encryptionKey = $envKey;
}
// Optional: ignore regex for indexing/listing (env wins; admin config as fallback)
if (!defined('FR_IGNORE_REGEX')) {
$envIgnore = getenv('FR_IGNORE_REGEX');
if ($envIgnore !== false && trim((string)$envIgnore) !== '') {
define('FR_IGNORE_REGEX', (string)$envIgnore);
} else {
$cfgPath = USERS_DIR . 'adminConfig.json';
if (is_file($cfgPath)) {
$enc = @file_get_contents($cfgPath);
if ($enc !== false && $enc !== '') {
$dec = decryptData($enc, $encryptionKey);
if ($dec !== false) {
$cfg = json_decode($dec, true);
if (is_array($cfg) && isset($cfg['ignoreRegex']) && is_string($cfg['ignoreRegex'])) {
$val = trim($cfg['ignoreRegex']);
if ($val !== '') {
define('FR_IGNORE_REGEX', $val);
}
}
}
}
}
}
}
// Helper to load JSON permissions (with optional decryption)
function loadUserPermissions($username)
{
+144 -12
View File
@@ -2909,10 +2909,78 @@ body.dark-mode #decreaseFont:not(:disabled):hover,body.dark-mode #increaseFont:n
display: inline-flex;
align-items: center;
justify-content: center;
--header-icon-delay: 0ms;
--header-icon-land-ms: 120ms;
--header-icon-lift-ms: 90ms;
}
.header-card-icon .material-icons {
font-size: 22px;
transform-origin: center center;
filter: none;
}
.header-card-icon.is-landing .material-icons {
animation: headerIconLand var(--header-icon-land-ms) cubic-bezier(.22,.61,.36,1) var(--header-icon-delay) backwards;
will-change: transform, opacity;
}
.header-card-icon.is-launching {
pointer-events: none;
}
.header-card-icon.is-launching .material-icons {
animation: headerIconLift var(--header-icon-lift-ms) ease-in forwards;
will-change: transform, opacity;
}
@keyframes headerIconLand {
0% {
opacity: 0;
transform: translateY(4px) rotateZ(-2deg);
filter: none;
}
35% {
opacity: 1;
transform: translateY(-1px) rotateZ(1.5deg);
filter: drop-shadow(0 6px 10px rgba(0,0,0,0.28));
}
65% {
transform: translateY(0) rotateZ(-1deg);
filter: drop-shadow(0 3px 6px rgba(0,0,0,0.18));
}
100% {
opacity: 1;
transform: translateY(0) rotateZ(0);
filter: none;
}
}
@keyframes headerIconLift {
0% {
opacity: 1;
transform: translateY(0) rotateZ(0);
filter: none;
}
35% {
transform: translateY(-2px) rotateZ(1.5deg);
filter: drop-shadow(0 6px 10px rgba(0,0,0,0.26));
}
100% {
opacity: 0;
transform: translateY(-4px) rotateZ(-1deg);
filter: none;
}
}
@media (prefers-reduced-motion: reduce) {
.header-card-icon.is-landing .material-icons,
.header-card-icon.is-launching .material-icons {
animation: none !important;
opacity: 1 !important;
transform: none !important;
filter: none !important;
}
}
.header-drop-zone.drag-active {
@@ -2954,9 +3022,11 @@ body.dark-mode #decreaseFont:not(:disabled):hover,body.dark-mode #increaseFont:n
.section-content { display:none; margin-left:20px; margin-top:8px; margin-bottom:8px; }
#adminPanelModal .section-header {
background: linear-gradient(180deg, rgba(15, 23, 42, 0.05), rgba(15, 23, 42, 0.02));
border: 1px solid rgba(15, 23, 42, 0.08);
padding: 8px 12px 8px 22px;
background: none;
border: 0;
padding: 0;
border-radius: 0;
display: block;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.08em;
@@ -2964,19 +3034,31 @@ body.dark-mode #decreaseFont:not(:disabled):hover,body.dark-mode #increaseFont:n
position: relative;
margin-top: 12px;
color: #111827;
transition: background 140ms ease, border-color 140ms ease, box-shadow 140ms ease, transform 140ms ease;
}
#adminPanelModal .section-header:hover {
#adminPanelModal .section-header-inner {
background: linear-gradient(180deg, rgba(15, 23, 42, 0.05), rgba(15, 23, 42, 0.02));
border: 1px solid rgba(15, 23, 42, 0.08);
padding: 8px 12px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: space-between;
position: relative;
transition: background 140ms ease, border-color 140ms ease, box-shadow 140ms ease, transform 140ms ease, padding-left 140ms ease;
}
#adminPanelModal .section-header:hover .section-header-inner {
background: rgba(15, 23, 42, 0.04);
border-color: rgba(15, 23, 42, 0.16);
box-shadow: 0 6px 14px rgba(15, 23, 42, 0.08);
transform: translateY(-1px);
}
#adminPanelModal .section-header:not(.collapsed) {
#adminPanelModal .section-header:not(.collapsed) .section-header-inner {
padding-left: 22px;
background: rgba(59, 130, 246, 0.06);
border-color: rgba(59, 130, 246, 0.35);
box-shadow: 0 6px 14px rgba(15, 23, 42, 0.08);
}
#adminPanelModal .section-header:not(.collapsed)::before {
#adminPanelModal .section-header:not(.collapsed) .section-header-inner::before {
content: "";
position: absolute;
left: 8px;
@@ -2987,20 +3069,22 @@ body.dark-mode #decreaseFont:not(:disabled):hover,body.dark-mode #increaseFont:n
background: linear-gradient(180deg, rgba(37, 99, 235, 0.9), rgba(96, 165, 250, 0.9));
}
#adminPanelModal .section-header .material-icons { color: #475569; }
body.dark-mode #adminPanelModal .section-header {
body.dark-mode #adminPanelModal .section-header { color: #f1f5f9; }
body.dark-mode #adminPanelModal .section-header .section-header-inner {
background: rgba(255, 255, 255, 0.05);
border-color: rgba(255, 255, 255, 0.08);
color: #f1f5f9;
}
body.dark-mode #adminPanelModal .section-header:hover {
body.dark-mode #adminPanelModal .section-header:hover .section-header-inner {
background: rgba(255, 255, 255, 0.08);
box-shadow: 0 6px 14px rgba(0, 0, 0, 0.45);
transform: translateY(-1px);
}
body.dark-mode #adminPanelModal .section-header:not(.collapsed) {
body.dark-mode #adminPanelModal .section-header:not(.collapsed) .section-header-inner {
background: rgba(96, 165, 250, 0.18);
border-color: rgba(96, 165, 250, 0.35);
box-shadow: 0 8px 18px rgba(0, 0, 0, 0.45);
}
body.dark-mode #adminPanelModal .section-header:not(.collapsed)::before {
body.dark-mode #adminPanelModal .section-header:not(.collapsed) .section-header-inner::before {
background: linear-gradient(180deg, rgba(148, 197, 255, 0.95), rgba(96, 165, 250, 0.95));
}
body.dark-mode #adminPanelModal .section-header .material-icons { color: #cbd5f5; }
@@ -3031,6 +3115,11 @@ body.dark-mode #adminPanelModal .section-content {
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.45);
}
#adminPanelModal .audit-table-wrap {
max-height: 260px;
overflow: auto;
}
#adminPanelModal .admin-panel-header {
display: flex;
align-items: center;
@@ -3087,6 +3176,39 @@ body.dark-mode #adminPanelModal .section-content {
max-height: 180px;
transition: max-height 200ms ease, opacity 200ms ease, transform 200ms ease, margin 200ms ease, padding 200ms ease, border-color 200ms ease;
}
#adminPanelModal .admin-search-input {
position: relative;
}
#adminPanelModal .admin-search-input .form-control {
padding-right: 38px;
}
#adminPanelModal .admin-search-clear {
position: absolute;
top: 50%;
right: 8px;
transform: translateY(-50%);
border: none;
background: transparent;
color: #6b7280;
width: 28px;
height: 28px;
padding: 0;
border-radius: 999px;
display: inline-flex;
align-items: center;
justify-content: center;
}
#adminPanelModal .admin-search-clear:hover {
background: rgba(15, 23, 42, 0.08);
color: #111;
}
#adminPanelModal .admin-search-clear:focus {
outline: none;
box-shadow: 0 0 0 2px rgba(37, 99, 235, 0.2);
}
#adminPanelModal .admin-search-clear .material-icons {
font-size: 18px;
}
#adminPanelModal .admin-search-wrap.is-collapsed {
max-height: 0;
opacity: 0;
@@ -3113,6 +3235,16 @@ body.dark-mode #adminPanelModal .admin-search-wrap {
background: rgba(255, 255, 255, 0.04);
border-color: rgba(255, 255, 255, 0.08);
}
body.dark-mode #adminPanelModal .admin-search-clear {
color: #9ca3af;
}
body.dark-mode #adminPanelModal .admin-search-clear:hover {
background: rgba(255, 255, 255, 0.08);
color: #e5e7eb;
}
body.dark-mode #adminPanelModal .admin-search-clear:focus {
box-shadow: 0 0 0 2px rgba(147, 197, 253, 0.25);
}
@media (max-width: 720px) {
#adminPanelModal .admin-search-toggle-label { display: none; }
#adminPanelModal .admin-panel-header { gap: 8px; }
+224 -18
View File
@@ -628,6 +628,40 @@ function wireClamavTestButton(scope = document) {
});
}
function wireIgnoreRegexPresetButton(scope = document) {
const btn = scope.querySelector('#ignoreRegexSnapshotsPreset');
const input = scope.querySelector('#ignoreRegex');
if (!btn || !input || btn.__wired) return;
btn.__wired = true;
const preset = '(^|/)(@?snapshots?)(/|$)';
btn.addEventListener('click', (e) => {
e.preventDefault();
if (input.disabled) return;
const current = String(input.value || '');
const hasPreset = current
.replace(/\r\n/g, '\n')
.split('\n')
.some(line => line.trim() === preset);
if (hasPreset) {
input.focus();
return;
}
if (current.trim() === '') {
input.value = preset;
} else {
const suffix = current.endsWith('\n') ? '' : '\n';
input.value = current + suffix + preset;
}
input.focus();
});
}
function renderAdminEncryptionSection({ config, dark }) {
const host = document.getElementById("encryptionContent");
if (!host) return;
@@ -2341,6 +2375,7 @@ function captureInitialAdminConfig() {
hoverPreviewMaxVideoMb: (document.getElementById("hoverPreviewMaxVideoMb")?.value || "").trim(),
ffmpegPath: (document.getElementById("ffmpegPath")?.value || "").trim(),
fileListSummaryDepth: (document.getElementById("fileListSummaryDepth")?.value || "").trim(),
ignoreRegex: (document.getElementById("ignoreRegex")?.value || "").trim(),
clamavScanUploads: !!document.getElementById("clamavScanUploads")?.checked,
proSearchEnabled: !!document.getElementById("proSearchEnabled")?.checked,
@@ -2386,6 +2421,7 @@ function hasUnsavedChanges() {
getVal("hoverPreviewMaxVideoMb") !== (o.hoverPreviewMaxVideoMb || "") ||
getVal("ffmpegPath") !== (o.ffmpegPath || "") ||
getVal("fileListSummaryDepth") !== (o.fileListSummaryDepth || "") ||
getVal("ignoreRegex") !== (o.ignoreRegex || "") ||
getChk("clamavScanUploads") !== o.clamavScanUploads ||
getChk("proSearchEnabled") !== o.proSearchEnabled ||
getVal("proSearchLimit") !== o.proSearchLimit ||
@@ -2542,6 +2578,10 @@ function clearSectionTimer(cnt) {
clearTimeout(cnt.__closeTimer);
cnt.__closeTimer = null;
}
if (cnt && cnt.__focusTimer) {
clearTimeout(cnt.__focusTimer);
cnt.__focusTimer = null;
}
}
function openSectionContent(cnt) {
@@ -2581,6 +2621,60 @@ function setSectionContentImmediate(cnt, open) {
}
}
function focusSectionIntoView(hdr, cnt) {
if (!hdr || !cnt) return;
const scroller = hdr.closest(".modal-content") || document.scrollingElement || document.documentElement;
if (!scroller) {
try {
hdr.scrollIntoView({ behavior: "smooth", block: "start", inline: "nearest" });
} catch (e) {
hdr.scrollIntoView();
}
return;
}
const padding = 12;
const scrollerRect = scroller.getBoundingClientRect();
const headerRect = hdr.getBoundingClientRect();
const targetTop = scroller.scrollTop + (headerRect.top - scrollerRect.top) - padding;
const nextTop = Math.max(0, targetTop);
if (Math.abs(nextTop - scroller.scrollTop) < 1) return;
try {
if (typeof scroller.scrollTo === "function") {
scroller.scrollTo({ top: nextTop, behavior: "smooth" });
setTimeout(() => {
if (Math.abs(scroller.scrollTop - nextTop) > 1) {
scroller.scrollTop = nextTop;
}
}, 40);
return;
}
} catch (e) {
// Fallback for browsers that don't accept scroll options.
}
scroller.scrollTop = nextTop;
}
function scheduleSectionFocus(hdr, cnt) {
if (!hdr || !cnt) return;
clearSectionTimer(cnt);
const focusNow = () => {
if (cnt.style.display === "none") return;
focusSectionIntoView(hdr, cnt);
};
requestAnimationFrame(() => {
requestAnimationFrame(() => {
focusNow();
cnt.__focusTimer = setTimeout(() => {
if (cnt.classList.contains("is-open") && cnt.style.display !== "none") {
focusSectionIntoView(hdr, cnt);
}
cnt.__focusTimer = null;
}, SECTION_ANIM_MS + 40);
});
});
}
function toggleSection(id) {
const hdr = document.getElementById(id + "Header");
const cnt = document.getElementById(id + "Content");
@@ -2590,6 +2684,7 @@ function toggleSection(id) {
closeSectionContent(cnt);
} else {
openSectionContent(cnt);
scheduleSectionFocus(hdr, cnt);
}
if (!isCollapsedNow && id === "shareLinks") {
loadShareLinksSection();
@@ -2609,6 +2704,7 @@ function wireAdminPanelSearch(sectionIds) {
const emptyState = document.getElementById("adminSettingsSearchEmpty");
const wrap = document.getElementById("adminSettingsSearchWrap");
const toggleBtn = document.getElementById("adminSearchToggle");
const clearBtn = document.getElementById("adminSearchClear");
if (!input || !Array.isArray(sectionIds)) return;
const ids = sectionIds.filter(Boolean);
@@ -2623,9 +2719,14 @@ function wireAdminPanelSearch(sectionIds) {
};
const updateToggleState = () => {
if (!toggleBtn) return;
const hasQuery = !!normalizeAdminSearchText(input.value);
toggleBtn.classList.toggle("is-active", hasQuery);
if (toggleBtn) {
toggleBtn.classList.toggle("is-active", hasQuery);
}
if (clearBtn) {
clearBtn.hidden = !hasQuery;
clearBtn.disabled = !hasQuery;
}
};
const restoreState = () => {
@@ -2681,6 +2782,25 @@ function wireAdminPanelSearch(sectionIds) {
updateToggleState();
};
const clearSearch = (opts = {}) => {
input.value = "";
applyFilter();
if (opts.collapse) {
setOpen(false);
}
if (opts.focus) {
input.focus();
}
};
if (clearBtn && !clearBtn.__wired) {
clearBtn.__wired = true;
clearBtn.addEventListener("click", (e) => {
e.preventDefault();
clearSearch({ focus: true });
});
}
if (toggleBtn && wrap && !toggleBtn.__wired) {
toggleBtn.__wired = true;
toggleBtn.addEventListener("click", () => {
@@ -2691,7 +2811,7 @@ function wireAdminPanelSearch(sectionIds) {
}
const hasQuery = !!normalizeAdminSearchText(input.value);
if (hasQuery) {
input.focus();
clearSearch({ collapse: true });
return;
}
setOpen(false);
@@ -3816,6 +3936,10 @@ export function openAdminPanel() {
const ffmpegPathValue = ffmpegPathLockedByEnv ? ffmpegPathEffective : ffmpegPathCfg;
const ffmpegHelpDefault = 'Used for video thumbnail generation. Leave blank to use ffmpeg from PATH.';
const ffmpegHelpLocked = 'Controlled by container env FR_FFMPEG_PATH. Change it in your Docker/host env.';
const ignoreRegexCfg = (typeof config.ignoreRegex === 'string') ? config.ignoreRegex : '';
const ignoreRegexEffective = (typeof config.ignoreRegexEffective === 'string') ? config.ignoreRegexEffective : '';
const ignoreRegexLockedByEnv = !!config.ignoreRegexLockedByEnv;
const ignoreRegexValue = ignoreRegexLockedByEnv ? ignoreRegexEffective : ignoreRegexCfg;
const supportedLanguages = [
{ code: 'en', labelKey: 'english', fallback: 'English' },
{ code: 'es', labelKey: 'spanish', fallback: 'Español' },
@@ -3870,7 +3994,7 @@ export function openAdminPanel() {
`;
const sections = [
{ id: "userManagement", label: tf("users_access", "Users & Access") },
{ id: "headerSettings", label: tf("appearance_ui", "Appearance & UI") },
{ id: "headerSettings", label: tf("appearance_ui", "Appearance, UI & Indexing") },
{ id: "loginOptions", label: tf("auth_webdav", "Auth & WebDAV (OIDC/TOTP)") },
{ id: "upload", label: tf("uploads_antivirus", "Uploads & Antivirus") },
{ id: "shareLinks", label: tf("sharing_links", "Sharing & Links") },
@@ -3911,13 +4035,25 @@ export function openAdminPanel() {
</div>
<form id="adminPanelForm">
<div id="adminSettingsSearchWrap" class="form-group admin-search-wrap is-collapsed">
<input
type="text"
id="adminSettingsSearch"
class="form-control"
autocomplete="off"
placeholder="${tf("settings_search_placeholder", "Search settings...")}"
/>
<div class="admin-search-input">
<input
type="text"
id="adminSettingsSearch"
class="form-control"
autocomplete="off"
placeholder="${tf("settings_search_placeholder", "Search settings...")}"
/>
<button
type="button"
id="adminSearchClear"
class="admin-search-clear"
aria-label="${tf("clear_search", "Clear search")}"
title="${tf("clear_search", "Clear search")}"
hidden
>
<i class="material-icons" aria-hidden="true">close</i>
</button>
</div>
<small class="text-muted d-block mt-1">
${tf("settings_search_help", "Type to filter sections and settings.")}
</small>
@@ -3927,7 +4063,9 @@ export function openAdminPanel() {
</div>
${sections.map(sec => `
<div id="${sec.id}Header" class="section-header collapsed">
${sec.label} <i class="material-icons">expand_more</i>
<div class="section-header-inner">
${sec.label} <i class="material-icons">expand_more</i>
</div>
</div>
<div id="${sec.id}Content" class="section-content"></div>
`).join("")}
@@ -4239,6 +4377,44 @@ export function openAdminPanel() {
/>
</div>
<div class="admin-subsection-title" style="margin-top:2px;">
${tf("indexing_ignore_rules", "Indexing ignore rules")}
</div>
<div class="form-group" style="margin-top:8px;">
<label for="ignoreRegex">${tf("ignore_regex_label", "Ignore paths (regex)")}</label>
<textarea
id="ignoreRegex"
class="form-control"
rows="3"
placeholder="(^|/)(@?snapshots?)(/|$)"
${ignoreRegexLockedByEnv ? "disabled data-locked='1'" : ""}>${escapeHTML(ignoreRegexValue || "")}</textarea>
<small class="text-muted d-block mt-1">
${tf("ignore_regex_help", "One pattern per line. Matches entry name or relative path from root (no leading slash; e.g. \"projects/snapshot/2024\").")}
</small>
<small class="text-muted d-block mt-1">
${tf("ignore_regex_scope_note", "Affects folder tree, counts, and indexing. Dot-prefixed entries (like .snapshots) are already hidden; use this for non-dot snapshot folders (snapshot, @snapshots).")}
</small>
<small class="text-muted d-block mt-1">
Built-in ignores: dot-prefixed entries (including .snapshots), <code>@eaDir</code>, <code>#recycle</code>,
<code>.DS_Store</code>, <code>Thumbs.db</code>, <code>trash</code>, <code>profile_pics</code>.
</small>
<div class="d-flex flex-wrap align-items-center" style="gap:8px; margin-top:6px;">
<span class="text-muted small">Quick add:</span>
<button
type="button"
id="ignoreRegexSnapshotsPreset"
class="btn btn-sm btn-outline-secondary"
${ignoreRegexLockedByEnv ? "disabled" : ""}>
Add snapshot preset
</button>
</div>
<small class="text-muted d-block mt-1">
${ignoreRegexLockedByEnv
? `Env <code>FR_IGNORE_REGEX</code> overrides and locks this field.`
: `Env <code>FR_IGNORE_REGEX</code> overrides this field when set.`}
</small>
</div>
<hr class="admin-divider">
<!-- Pro: Footer text -->
@@ -4645,9 +4821,13 @@ export function openAdminPanel() {
</div>
`
}
`;
wireClamavTestButton(document.getElementById("uploadContent"));
const uploadScope = document.getElementById("uploadContent");
const headerSettingsScope = document.getElementById("headerSettingsContent");
wireIgnoreRegexPresetButton(headerSettingsScope);
wireClamavTestButton(uploadScope);
initVirusLogUI({ isPro });
// ONLYOFFICE section (moved into adminOnlyOffice.js)
initOnlyOfficeUI({ config });
@@ -4993,7 +5173,7 @@ ${t("shared_max_upload_size_bytes")}
${!isPro ? `
<div style="margin-top:8px;">
<a
href="https://filerise.net/pro/checkout.php"
href="https://filerise.net/pro/"
target="_blank"
rel="noopener noreferrer"
class="btn btn-sm btn-pro-admin"
@@ -5346,7 +5526,13 @@ ${t("shared_max_upload_size_bytes")}
const pill = (!isPro || !proSearchApiOk || !proAuditAvailable)
? '<span class="btn-pro-pill" style="position:static; display:inline-flex; align-items:center; margin:0;">Pro</span>'
: '';
proFeaturesHeaderEl.innerHTML = `<span style="display:inline-flex;align-items:center;gap:6px;">Pro Features${pill}</span> ${iconHtml}`;
const labelHtml = `<span style="display:inline-flex;align-items:center;gap:6px;">Pro Features${pill}</span> ${iconHtml}`;
const inner = proFeaturesHeaderEl.querySelector('.section-header-inner');
if (inner) {
inner.innerHTML = labelHtml;
} else {
proFeaturesHeaderEl.innerHTML = `<div class="section-header-inner">${labelHtml}</div>`;
}
}
if (proFeaturesContainer) {
const proSearchBlockedReason = !isPro ? 'pro' : (!proSearchApiOk ? 'api' : null);
@@ -5476,7 +5662,7 @@ ${t("shared_max_upload_size_bytes")}
<div id="auditStatus" class="text-muted" style="font-size:12px; margin-bottom:8px;"></div>
<div class="table-responsive">
<div class="table-responsive audit-table-wrap">
<table class="table table-sm" style="margin-bottom:0;">
<thead>
<tr>
@@ -5901,6 +6087,16 @@ ${t("shared_max_upload_size_bytes")}
help.textContent = locked ? ffmpegHelpLocked : ffmpegHelpDefault;
}
}
const ignoreEl = document.getElementById("ignoreRegex");
if (ignoreEl) {
const locked = !!config.ignoreRegexLockedByEnv;
const val = locked
? (config.ignoreRegexEffective || "")
: (config.ignoreRegex || "");
ignoreEl.value = (val || "").toString();
ignoreEl.disabled = locked;
ignoreEl.dataset.locked = locked ? "1" : "0";
}
// --- ClamAV toggle wiring (refresh) ---
const cfgClam = config.clamav || {};
const clamChk = document.getElementById("clamavScanUploads");
@@ -5925,7 +6121,10 @@ ${t("shared_max_upload_size_bytes")}
}
}
}
wireClamavTestButton(document.getElementById("uploadContent"));
const uploadScope = document.getElementById("uploadContent");
const headerSettingsScope = document.getElementById("headerSettingsContent");
wireIgnoreRegexPresetButton(headerSettingsScope);
wireClamavTestButton(uploadScope);
initVirusLogUI({ isPro });
renderAdminEncryptionSection({ config, dark });
document.getElementById("oidcProviderUrl").value = window.currentOIDCConfig?.providerUrl || "";
@@ -5968,7 +6167,13 @@ ${t("shared_max_upload_size_bytes")}
const pill = (!isPro || !proSearchApiOk || !proAuditAvailable)
? '<span class="btn-pro-pill" style="position:static; display:inline-flex; align-items:center; margin:0;">Pro</span>'
: '';
pfHeader.innerHTML = `<span style="display:inline-flex;align-items:center;gap:6px;">Pro Features${pill}</span> ${iconHtml}`;
const labelHtml = `<span style="display:inline-flex;align-items:center;gap:6px;">Pro Features${pill}</span> ${iconHtml}`;
const inner = pfHeader.querySelector('.section-header-inner');
if (inner) {
inner.innerHTML = labelHtml;
} else {
pfHeader.innerHTML = `<div class="section-header-inner">${labelHtml}</div>`;
}
}
const psToggle = document.getElementById("proSearchEnabled");
const psLimit = document.getElementById("proSearchLimit");
@@ -6043,6 +6248,7 @@ function handleSave() {
if (el.dataset.locked === "1") return el.value || "";
return (el.value || "").trim();
})(),
ignoreRegex: (document.getElementById("ignoreRegex")?.value || "").trim(),
loginOptions: {
// Backend still expects “disable*” flags:
disableFormLogin: !enableFormLogin,
+31
View File
@@ -317,6 +317,14 @@ function insertCardInHeader(card) {
}
});
iconButton.addEventListener('animationend', (event) => {
if (event.animationName === 'headerIconLand') {
iconButton.classList.remove('is-landing');
iconButton.style.removeProperty('--header-icon-delay');
iconButton.style.removeProperty('--header-icon-land-ms');
}
});
host.appendChild(iconButton);
// make sure the dock is visible when icons exist
showHeaderDockPersistent();
@@ -503,6 +511,13 @@ const COLLAPSE_OPACITY_END = 0.06;
const COLLAPSE_RISE_MS = 120;
const COLLAPSE_RISE_PX = 14;
const COLLAPSE_RISE_SCALE = 1.02;
const HEADER_ICON_LAND_MS = 120;
const HEADER_ICON_LIFT_MS = 90;
const HEADER_ICON_LAND_EXTRA_DELAY_MS = 40;
const HEADER_ICON_LAND_DELAY_MS = Math.max(
0,
COLLAPSE_ANIMATION_MS - HEADER_ICON_LAND_MS + HEADER_ICON_LAND_EXTRA_DELAY_MS
);
function animateCardsIntoHeaderAndThen(done) {
const sb = getSidebar();
@@ -545,6 +560,17 @@ function animateCardsIntoHeaderAndThen(done) {
const iconBtn = card.headerIconButton;
if (!iconBtn) return;
iconBtn.classList.remove('is-launching');
iconBtn.classList.add('is-landing');
iconBtn.style.setProperty('--header-icon-delay', `${HEADER_ICON_LAND_DELAY_MS}ms`);
iconBtn.style.setProperty('--header-icon-land-ms', `${HEADER_ICON_LAND_MS}ms`);
setTimeout(() => {
if (!iconBtn.isConnected) return;
iconBtn.classList.remove('is-landing');
iconBtn.style.removeProperty('--header-icon-delay');
iconBtn.style.removeProperty('--header-icon-land-ms');
}, HEADER_ICON_LAND_DELAY_MS + HEADER_ICON_LAND_MS + 40);
const iconRect = iconBtn.getBoundingClientRect();
const iconName = card.id === 'uploadCard'
@@ -739,6 +765,11 @@ function animateCardsOutOfHeaderThen(done) {
const fromCx = iconRect.left + iconRect.width / 2;
const fromCy = iconRect.bottom + START_OFFSET_Y;
iconBtn.classList.remove('is-landing');
iconBtn.classList.add('is-launching');
iconBtn.style.setProperty('--header-icon-lift-ms', `${HEADER_ICON_LIFT_MS}ms`);
iconBtn.style.setProperty('--header-icon-delay', '0ms');
const savedW = parseFloat(card.dataset.lastWidth || '');
const savedH = parseFloat(card.dataset.lastHeight || '');
const targetWidth = (!Number.isNaN(savedW) && savedW > 0) ? savedW : Math.min(280, Math.max(220, zoneRect.width * 0.85));
+21
View File
@@ -303,6 +303,11 @@ class AdminController
$ffmpegCfg = (string)($config['ffmpegPath'] ?? '');
$ffmpegEffective = $ffmpegLockedByEnv ? trim((string)$envFfmpeg) : $ffmpegCfg;
$envIgnore = getenv('FR_IGNORE_REGEX');
$ignoreLockedByEnv = ($envIgnore !== false && trim((string)$envIgnore) !== '');
$ignoreCfg = (string)($config['ignoreRegex'] ?? '');
$ignoreEffective = $ignoreLockedByEnv ? trim((string)$envIgnore) : $ignoreCfg;
$adminExtra = [
'loginOptions' => array_merge($public['loginOptions'], [
'authBypass' => (bool)($config['loginOptions']['authBypass'] ?? false),
@@ -338,6 +343,9 @@ class AdminController
'ffmpegPath' => $ffmpegCfg,
'ffmpegPathEffective' => $ffmpegEffective,
'ffmpegPathLockedByEnv' => $ffmpegLockedByEnv,
'ignoreRegex' => $ignoreCfg,
'ignoreRegexEffective' => $ignoreEffective,
'ignoreRegexLockedByEnv' => $ignoreLockedByEnv,
'encryption' => [
'supported' => (bool)$encSupported,
'hasMasterKey'=> (bool)$encHasMasterKey,
@@ -1741,6 +1749,7 @@ class AdminController
'globalOtpauthUrl' => '',
'enableWebDAV' => false,
'sharedMaxUploadSize' => 0,
'ignoreRegex' => '',
'uploads' => [
'resumableChunkMb' => 1.5,
],
@@ -1817,6 +1826,18 @@ class AdminController
$merged['ffmpegPath'] = $path;
}
// ignoreRegex: optional ignore patterns (newline-delimited). Env FR_IGNORE_REGEX locks this field.
$envIgnore = getenv('FR_IGNORE_REGEX');
$ignoreLockedByEnv = ($envIgnore !== false && trim((string)$envIgnore) !== '');
if (!$ignoreLockedByEnv && array_key_exists('ignoreRegex', $data)) {
$raw = str_replace(["\r\n", "\r"], "\n", trim((string)$data['ignoreRegex']));
$raw = preg_replace('/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/', '', $raw);
if (strlen($raw) > 2000) {
$raw = substr($raw, 0, 2000);
}
$merged['ignoreRegex'] = $raw;
}
// loginOptions: inherit existing then override if provided
foreach (['disableFormLogin', 'disableBasicAuth', 'disableOIDCLogin', 'authBypass'] as $flag) {
if (isset($data['loginOptions'][$flag])) {
+80 -3
View File
@@ -17,6 +17,84 @@ final class FS
return ['trash','profile_pics'];
}
/** Optional regex patterns for additional ignores (env FR_IGNORE_REGEX). */
private static function ignoreRegexes(): array {
static $cache = null;
if ($cache !== null) {
return $cache;
}
$raw = '';
if (defined('FR_IGNORE_REGEX')) {
$raw = (string)FR_IGNORE_REGEX;
} else {
$env = getenv('FR_IGNORE_REGEX');
$raw = ($env !== false) ? (string)$env : '';
}
$raw = trim($raw);
if ($raw === '') {
$cache = [];
return $cache;
}
$patterns = [];
$lines = preg_split('/\r?\n/', $raw) ?: [];
foreach ($lines as $line) {
$line = trim((string)$line);
if ($line === '') continue;
$pattern = self::normalizeIgnoreRegex($line);
if ($pattern === null) continue;
if (@preg_match($pattern, '') === false) {
error_log('FR_IGNORE_REGEX ignored invalid pattern.');
continue;
}
$patterns[] = $pattern;
}
$cache = $patterns;
return $cache;
}
private static function normalizeIgnoreRegex(string $raw): ?string {
$raw = trim($raw);
if ($raw === '') return null;
$delim = $raw[0] ?? '';
if ($delim !== '' && !ctype_alnum($delim) && $delim !== '\\') {
$quoted = preg_quote($delim, '/');
if (preg_match('/^' . $quoted . '.+' . $quoted . '[imsxuADU]*$/', $raw)) {
return $raw;
}
}
$wrap = '~';
$safe = str_replace($wrap, '\\' . $wrap, $raw);
return $wrap . $safe . $wrap;
}
public static function shouldIgnoreEntry(string $name, string $parentRel = ''): bool {
if ($name === '') return false;
if (in_array($name, self::IGNORE(), true)) return true;
$regexes = self::ignoreRegexes();
if (!$regexes) return false;
$prefix = str_replace('\\', '/', trim((string)$parentRel));
if ($prefix === '' || strtolower($prefix) === 'root') {
$path = $name;
} else {
$prefix = trim($prefix, '/');
$path = $prefix === '' ? $name : ($prefix . '/' . $name);
}
foreach ($regexes as $rx) {
if (preg_match($rx, $name) === 1) return true;
if ($path !== $name && preg_match($rx, $path) === 1) return true;
}
return false;
}
public static function isSafeSegment(string $name): bool {
if ($name === '.' || $name === '..') return false;
if (strpos($name, '/') !== false || strpos($name, '\\') !== false) return false;
@@ -50,14 +128,13 @@ final class FS
): bool {
if ($maxDepth <= 0 || !is_dir($absPath)) return false;
$IGNORE = self::IGNORE();
$SKIP = self::SKIP();
$items = @scandir($absPath) ?: [];
foreach ($items as $child) {
if ($child === '.' || $child === '..') continue;
if ($child[0] === '.') continue;
if (in_array($child, $IGNORE, true)) continue;
if (self::shouldIgnoreEntry($child, $relPath)) continue;
if (!self::isSafeSegment($child)) continue;
$lower = strtolower($child);
@@ -84,4 +161,4 @@ final class FS
}
return false;
}
}
}
+23
View File
@@ -536,6 +536,11 @@ class AdminModel
}
}
// Ignore regex (optional)
$configUpdate['ignoreRegex'] = self::sanitizeIgnoreRegex(
$configUpdate['ignoreRegex'] ?? ''
);
// Convert configuration to JSON.
$plainTextConfig = json_encode($configUpdate, JSON_PRETTY_PRINT);
if ($plainTextConfig === false) {
@@ -588,6 +593,17 @@ class AdminModel
return '';
}
private static function sanitizeIgnoreRegex($value): string
{
$value = str_replace(["\r\n", "\r"], "\n", trim((string)$value));
if ($value === '') return '';
$value = preg_replace('/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/', '', $value);
if (strlen($value) > 2000) {
$value = substr($value, 0, 2000);
}
return $value;
}
/**
* Retrieves the current configuration.
*
@@ -824,6 +840,12 @@ class AdminModel
$config['ffmpegPath'] = $path;
}
if (!isset($config['ignoreRegex']) || !is_string($config['ignoreRegex'])) {
$config['ignoreRegex'] = '';
} else {
$config['ignoreRegex'] = self::sanitizeIgnoreRegex($config['ignoreRegex']);
}
return $config;
}
@@ -882,6 +904,7 @@ class AdminModel
],
'publishedUrl' => '',
'ffmpegPath' => '',
'ignoreRegex' => '',
];
}
}
+39 -10
View File
@@ -215,7 +215,6 @@ class DiskUsageModel
}
$root = rtrim($root, DIRECTORY_SEPARATOR);
$IGNORE = FS::IGNORE();
$SKIP = FS::SKIP();
// Folder map: key => [
@@ -252,12 +251,46 @@ class DiskUsageModel
$rootLen = strlen($root);
$dirIter = new RecursiveDirectoryIterator(
$root,
FilesystemIterator::SKIP_DOTS
| FilesystemIterator::FOLLOW_SYMLINKS
);
$filter = new RecursiveCallbackFilterIterator(
$dirIter,
function (SplFileInfo $current) use ($rootLen, $SKIP): bool {
$name = $current->getFilename();
if ($name === '.' || $name === '..') return false;
if ($name !== '' && $name[0] === '.') return false;
if (!FS::isSafeSegment($name)) return false;
$path = $current->getPathname();
$rel = substr($path, $rootLen);
$rel = str_replace('\\', '/', $rel);
$rel = ltrim($rel, '/');
$parentRel = dirname($rel);
if ($parentRel === '.' || $parentRel === DIRECTORY_SEPARATOR) {
$parentRel = '';
}
if (FS::shouldIgnoreEntry($name, $parentRel)) return false;
$lowerRel = strtolower($rel);
if ($lowerRel === 'trash' || strpos($lowerRel, 'trash/') === 0) {
return false;
}
if ($lowerRel === 'profile_pics' || strpos($lowerRel, 'profile_pics/') === 0) {
return false;
}
$baseLower = strtolower(basename($rel));
if (in_array($baseLower, $SKIP, true)) return false;
return true;
}
);
$it = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator(
$root,
FilesystemIterator::SKIP_DOTS
| FilesystemIterator::FOLLOW_SYMLINKS
),
$filter,
RecursiveIteratorIterator::SELF_FIRST
);
@@ -274,10 +307,6 @@ class DiskUsageModel
}
// Skip system/ignored entries
if (in_array($name, $IGNORE, true)) {
continue;
}
// Relative path under UPLOAD_DIR, normalized with '/'
$rel = substr($path, $rootLen);
$rel = str_replace('\\', '/', $rel);
+17 -22
View File
@@ -86,8 +86,7 @@ class FolderModel
$relPrefix = implode('/', $parts);
}
$IGNORE = ['@eaDir', '#recycle', '.DS_Store', 'Thumbs.db'];
$SKIP = ['trash', 'profile_pics'];
$SKIP = FS::SKIP();
$entries = @scandir($dir);
if ($entries === false) {
@@ -112,7 +111,7 @@ class FolderModel
if ($name === '.' || $name === '..') continue;
if ($name[0] === '.') continue;
if (in_array($name, $IGNORE, true)) continue;
if (FS::shouldIgnoreEntry($name, $relPrefix)) continue;
if (in_array(strtolower($name), $SKIP, true)) continue;
if (!self::isSafeSegment($name)) continue;
@@ -222,8 +221,7 @@ class FolderModel
$relPrefix = implode('/', $parts);
}
$IGNORE = ['@eaDir', '#recycle', '.DS_Store', 'Thumbs.db'];
$SKIP = ['trash', 'profile_pics'];
$SKIP = FS::SKIP();
$folderCount = 0;
$fileCount = 0;
@@ -251,7 +249,7 @@ class FolderModel
if ($name === '.' || $name === '..') continue;
if ($name[0] === '.') continue;
if (in_array($name, $IGNORE, true)) continue;
if (FS::shouldIgnoreEntry($name, $curRel)) continue;
if (in_array(strtolower($name), $SKIP, true)) continue;
if (!self::isSafeSegment($name)) continue;
@@ -347,7 +345,6 @@ class FolderModel
return ['folders' => 0, 'files' => 0, 'bytes' => 0];
}
$IGNORE = FS::IGNORE();
$SKIP = FS::SKIP();
$folderCount = 0;
@@ -359,7 +356,7 @@ class FolderModel
foreach ($entries as $name) {
if ($name === '.' || $name === '..' || $name === '') continue;
if ($name[0] === '.') continue;
if (in_array($name, $IGNORE, true)) continue;
if (FS::shouldIgnoreEntry($name, $folder)) continue;
if (!FS::isSafeSegment($name)) continue;
if (in_array(strtolower($name), $SKIP, true)) continue;
@@ -443,7 +440,6 @@ class FolderModel
: $base . DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, $folder);
$startRel = ($folder === 'root') ? '' : $folder;
$IGNORE = FS::IGNORE();
$SKIP = FS::SKIP();
$folderCount = 0;
@@ -469,7 +465,7 @@ class FolderModel
if ($name === '.' || $name === '..' || $name === '') continue;
if ($name[0] === '.') continue;
if (in_array($name, $IGNORE, true)) continue;
if (FS::shouldIgnoreEntry($name, $curRel)) continue;
if (!FS::isSafeSegment($name)) continue;
if (in_array(strtolower($name), $SKIP, true)) continue;
@@ -581,7 +577,6 @@ class FolderModel
if ($dirReal === null || !is_dir($dirReal)) return ['items'=>[], 'nextCursor'=>null];
}
$IGNORE = FS::IGNORE();
$SKIP = FS::SKIP(); // lowercased names to skip (e.g. 'trash', 'profile_pics')
$entries = @scandir($dirReal);
@@ -591,7 +586,7 @@ class FolderModel
foreach ($entries as $item) {
if ($item === '.' || $item === '..') continue;
if ($item[0] === '.') continue;
if (in_array($item, $IGNORE, true)) continue;
if (FS::shouldIgnoreEntry($item, $relPrefix)) continue;
if (!FS::isSafeSegment($item)) continue;
$lower = strtolower($item);
@@ -624,6 +619,7 @@ class FolderModel
$name = $child->getFilename();
if (!$name) continue;
if ($name[0] === '.') continue;
if (FS::shouldIgnoreEntry($name, $rel)) continue;
if (!FS::isSafeSegment($name)) continue;
if (in_array(strtolower($name), $SKIP, true)) continue;
@@ -710,7 +706,6 @@ class FolderModel
? $base
: $base . DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, $folder);
$IGNORE = FS::IGNORE();
$SKIP = FS::SKIP();
$entries = $storage->list($dirPath);
@@ -720,7 +715,7 @@ class FolderModel
foreach ($entries as $item) {
if ($item === '.' || $item === '..') continue;
if ($item === '' || $item[0] === '.') continue;
if (in_array($item, $IGNORE, true)) continue;
if (FS::shouldIgnoreEntry($item, $folder)) continue;
if (!FS::isSafeSegment($item)) continue;
$lower = strtolower($item);
@@ -794,13 +789,12 @@ class FolderModel
$entries = $storage->list($dirPath);
if (!$entries) return false;
$IGNORE = FS::IGNORE();
$SKIP = FS::SKIP();
foreach ($entries as $item) {
if ($item === '.' || $item === '..') continue;
if ($item === '' || $item[0] === '.') continue;
if (in_array($item, $IGNORE, true)) continue;
if (FS::shouldIgnoreEntry($item, $folder)) continue;
if (!FS::isSafeSegment($item)) continue;
if (in_array(strtolower($item), $SKIP, true)) continue;
@@ -1054,7 +1048,6 @@ class FolderModel
$fileCount = 0;
$totalBytes = 0;
$IGNORE = FS::IGNORE();
$SKIP = FS::SKIP();
while ($queue) {
@@ -1080,7 +1073,7 @@ class FolderModel
foreach ($entries as $name) {
if ($name === '.' || $name === '..') continue;
if ($name === '' || $name[0] === '.') continue;
if (in_array($name, $IGNORE, true)) continue;
if (FS::shouldIgnoreEntry($name, $rel)) continue;
if (!FS::isSafeSegment($name)) continue;
$lower = strtolower($name);
@@ -1707,9 +1700,13 @@ class FolderModel
{
$folders = [];
$items = @scandir($dir) ?: [];
$SKIP = FS::SKIP();
foreach ($items as $item) {
if ($item === '.' || $item === '..') continue;
if ($item === '' || $item[0] === '.') continue;
if (FS::shouldIgnoreEntry($item, $relative)) continue;
if (!preg_match(REGEX_FOLDER_NAME, $item)) continue;
if (in_array(strtolower($item), $SKIP, true)) continue;
$path = $dir . DIRECTORY_SEPARATOR . $item;
if (is_dir($path)) {
@@ -1810,7 +1807,6 @@ class FolderModel
$scanned = 0;
$queue = [['root', $base]];
$IGNORE = FS::IGNORE();
$SKIP = FS::SKIP();
while ($queue && $scanned < $maxFolders) {
@@ -1821,7 +1817,7 @@ class FolderModel
foreach ($entries as $name) {
if ($name === '.' || $name === '..') continue;
if ($name === '' || $name[0] === '.') continue;
if (in_array($name, $IGNORE, true)) continue;
if (FS::shouldIgnoreEntry($name, $rel)) continue;
if (!FS::isSafeSegment($name)) continue;
if (in_array(strtolower($name), $SKIP, true)) continue;
@@ -1888,13 +1884,12 @@ class FolderModel
return $folderInfoList;
}
$IGNORE = FS::IGNORE();
$SKIP = FS::SKIP();
foreach ($entries as $name) {
if ($name === '.' || $name === '..') continue;
if ($name === '' || $name[0] === '.') continue;
if (in_array($name, $IGNORE, true)) continue;
if (FS::shouldIgnoreEntry($name, $parentRel)) continue;
if (!FS::isSafeSegment($name)) continue;
if (in_array(strtolower($name), $SKIP, true)) continue;