release(v3.1.1): OIDC env overrides + configurable resumable chunk size + clearer startup logs (closes #86, closes #87, closes #90)

- config: allow env overrides for OIDC knobs (auto-create, group claim, admin group, Pro group prefix)
- uploads: add configurable Resumable.js chunk size (Admin + siteConfig) and honor it in upload.js
- uploads: improve relative-path folder uploads and remote staging/cleanup for non-local sources
- admin: add settings search + smoother section open/close animations
- admin: restrict Pro license actions to the registered/primary admin user
- remote storage: add FR_REMOTE_DIR_MARKER to preserve empty dirs; skip Trash on Google Drive sources
- UX: clearer “FileRise startup complete” log line + better long-running delete/restore/loading feedback

Closes #86
Closes #87
Closes #90
This commit is contained in:
Ryan
2026-01-17 23:13:02 -05:00
committed by GitHub
parent 4dd517ae1e
commit 67054bb61a
19 changed files with 1457 additions and 221 deletions
+62
View File
@@ -1,5 +1,67 @@
# Changelog
## 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)`
**Commit message**
```text
release(v3.1.1): OIDC env overrides + configurable resumable chunk size + clearer startup logs (closes #86, closes #87, closes #90)
- config: allow env overrides for OIDC knobs (auto-create, group claim, admin group, Pro group prefix)
- uploads: add configurable Resumable.js chunk size (Admin + siteConfig) and honor it in upload.js
- uploads: improve relative-path folder uploads and remote staging/cleanup for non-local sources
- admin: add settings search + smoother section open/close animations
- admin: restrict Pro license actions to the registered/primary admin user
- remote storage: add FR_REMOTE_DIR_MARKER to preserve empty dirs; skip Trash on Google Drive sources
- UX: clearer “FileRise startup complete” log line + better long-running delete/restore/loading feedback
Closes #86
Closes #87
Closes #90
```
**Added**
- **OIDC env overrides** (in addition to config defaults):
`FR_OIDC_AUTO_CREATE`, `FR_OIDC_GROUP_CLAIM`, `FR_OIDC_ADMIN_GROUP`, `FR_OIDC_PRO_GROUP_PREFIX`.
- **Upload tuning (Admin):** “Resumable chunk size (MB)” (0.5100 MB).
Exported via siteConfig so the frontend can size chunks dynamically.
- **Remote folder marker:** `FR_REMOTE_DIR_MARKER` (default: `.filerise_keep`) to preserve empty remote folders (S3-style prefix backends).
- **Admin settings search:** quick filter for sections/settings in the Admin panel UI.
**Changed**
- **Resumable uploads honor configured chunk size** (used by file picker + drag/drop when Resumable is available).
- **Upload handling for folder paths**:
- validates and sanitizes `resumableRelativePath` / `relativePath`
- supports subfolder uploads more consistently
- remote sources stage chunks in meta root (`uploadtmp/`) and push via adapter, then cleanup temp folders
- **Admin Pro license visibility/actions** are restricted to the **primary/registered admin** (first admin in `users.txt` order).
- **Remote deletes / Trash behavior**:
- Google Drive sources skip Trash (deletes are permanent)
- remote folder “empty checks” ignore the marker file
- **Docker startup log clarity**:
- `start.sh` prints a “startup complete” line and clarifies that further output is Apache logs.
**Fixed**
- **#86:** OIDC behavior is now controllable via environment variables (no code/config edits required).
- **#87:** Resumable chunk size is now configurable to fit proxy limits (e.g., tunnels/CDNs).
- **#90:** Clearer startup output + better guidance for collecting logs.
- **UI responsiveness / long operations**
- “Deleting…” busy states for file/folder delete confirmations and Trash restore/delete actions
- “Still loading…” toast for slow remote listings, with a fallback if a folder no longer exists
**Notes**
- `FR_REMOTE_DIR_MARKER` is best-effort and primarily intended for remote backends that treat directories as prefixes (e.g., S3).
- Google Drive sources do not support Trash semantics in the adapter; the UI notes this and deletes are permanent.
- Some Admin Panel strings still fall back to English; translations will continue to improve over time.
---
## Changes 01/16/2026 (v3.1.0)
`release(v3.1.0): default language + portal i18n + ffmpeg path config (closes #88, closes #89)`
+22 -4
View File
@@ -45,6 +45,8 @@ if (!defined('DEFAULT_CAN_ZIP')) define('DEFAULT_CAN_ZIP', true);
if (!defined('DEFAULT_VIEW_OWN_ONLY')) define('DEFAULT_VIEW_OWN_ONLY', false);
define('FOLDER_OWNERS_FILE', META_DIR . 'folder_owners.json');
define('ACL_INHERIT_ON_CREATE', true);
// Hidden file used to preserve empty remote folders (S3 prefixes, etc.).
if (!defined('FR_REMOTE_DIR_MARKER')) define('FR_REMOTE_DIR_MARKER', '.filerise_keep');
// ONLYOFFICE integration overrides (uncomment and set as needed)
/*
define('ONLYOFFICE_ENABLED', false);
@@ -63,23 +65,39 @@ if (!defined('OIDC_TOKEN_ENDPOINT_AUTH_METHOD')) {
// Auto-create users from OIDC when no users.txt match.
if (!defined('FR_OIDC_AUTO_CREATE')) {
define('FR_OIDC_AUTO_CREATE', true);
$envVal = getenv('FR_OIDC_AUTO_CREATE');
if ($envVal !== false && $envVal !== '') {
$val = strtolower(trim((string)$envVal));
define('FR_OIDC_AUTO_CREATE', in_array($val, ['1', 'true', 'yes', 'on'], true));
} else {
define('FR_OIDC_AUTO_CREATE', true);
}
}
// Claim that contains IdP groups/roles (typical: "groups" or "roles").
if (!defined('FR_OIDC_GROUP_CLAIM')) {
define('FR_OIDC_GROUP_CLAIM', 'groups');
$envVal = getenv('FR_OIDC_GROUP_CLAIM');
define(
'FR_OIDC_GROUP_CLAIM',
($envVal !== false && trim((string)$envVal) !== '') ? trim((string)$envVal) : 'groups'
);
}
// Name of an IdP group that should be treated as "FileRise admin".
if (!defined('FR_OIDC_ADMIN_GROUP')) {
define('FR_OIDC_ADMIN_GROUP', 'filerise-admins');
$envVal = getenv('FR_OIDC_ADMIN_GROUP');
if ($envVal !== false) {
define('FR_OIDC_ADMIN_GROUP', trim((string)$envVal));
} else {
define('FR_OIDC_ADMIN_GROUP', 'filerise-admins');
}
}
// Prefix for IdP groups that should map into FileRise Pro groups.
// Example: IdP group "frp_clients_acme" → Pro group "clients_acme".
if (!defined('FR_OIDC_PRO_GROUP_PREFIX')) {
define('FR_OIDC_PRO_GROUP_PREFIX', '');
$envVal = getenv('FR_OIDC_PRO_GROUP_PREFIX');
define('FR_OIDC_PRO_GROUP_PREFIX', ($envVal !== false) ? trim((string)$envVal) : '');
}
// Optional env/constant override: if set, it wins; if not set, UI setting is used.
if (!defined('FR_OIDC_ALLOW_DEMOTE')) {
+221 -2
View File
@@ -441,6 +441,16 @@ body.dark-mode #restoreFilesModal .restore-empty{color: var(--fr-muted-dark,#aaa
font-weight:600;
box-shadow:0 1px 2px rgba(0,0,0,.08);
transition: background-color var(--filr-transition-fast,150ms ease-out), transform var(--filr-transition-fast,150ms ease-out), box-shadow var(--filr-transition-fast,150ms ease-out);}
#restoreFilesModal .restore-btn[aria-busy="true"]{cursor: wait;}
#restoreFilesModal .restore-btn[aria-busy="true"]::after{content:"";
width:14px; height:14px;
border-radius:50%;
border:2px solid currentColor;
border-right-color: transparent;
display:inline-block;
margin-left:4px;
animation: filr-spin .8s linear infinite;}
#restoreFilesModal .restore-btn[aria-busy="true"] .material-icons{opacity:0.6;}
#restoreFilesModal .restore-btn .material-icons{font-size:20px;}
#restoreFilesModal .restore-btn:hover{transform: translateY(-1px); background: #ffffff; box-shadow:0 6px 14px rgba(0,0,0,.12);}
body.dark-mode #restoreFilesModal .restore-btn,body.dark-mode #deleteTrashSelectedBtn{background: var(--fr-surface-dark,#212121); color:#f1f1f1; border-color: var(--fr-border-dark,#303030);}
@@ -1331,7 +1341,7 @@ label{font-size: 0.9rem;}
}
#viewOptionsPopover input[type=range]{
flex:1;
accent-color: var(--filr-accent-500,#0d6efd);
}
#viewOptionsPopover .vo-value{
min-width:42px;
@@ -2943,6 +2953,171 @@ 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;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.08em;
font-size: 0.85rem;
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 {
background: rgba(15, 23, 42, 0.04);
border-color: rgba(15, 23, 42, 0.16);
transform: translateY(-1px);
}
#adminPanelModal .section-header:not(.collapsed) {
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 {
content: "";
position: absolute;
left: 8px;
top: 8px;
bottom: 8px;
width: 3px;
border-radius: 999px;
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 {
background: rgba(255, 255, 255, 0.05);
border-color: rgba(255, 255, 255, 0.08);
color: #f1f5f9;
}
body.dark-mode #adminPanelModal .section-header:hover {
background: rgba(255, 255, 255, 0.08);
}
body.dark-mode #adminPanelModal .section-header:not(.collapsed) {
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 {
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; }
#adminPanelModal .section-content {
display: none;
margin: 8px 0 12px;
padding: 12px 14px;
border-radius: 12px;
border: 1px solid rgba(15, 23, 42, 0.06);
background: #ffffff;
box-shadow: 0 4px 10px rgba(15, 23, 42, 0.04);
opacity: 0;
transform: translateY(-4px);
transition: opacity 160ms ease, transform 160ms ease;
}
#adminPanelModal .section-content.is-open {
opacity: 1;
transform: translateY(0);
}
#adminPanelModal .section-content.is-closing {
opacity: 0;
transform: translateY(-4px);
}
body.dark-mode #adminPanelModal .section-content {
background: #232323;
border-color: rgba(255, 255, 255, 0.08);
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.45);
}
#adminPanelModal .admin-panel-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding-right: 42px;
margin-bottom: 6px;
}
#adminPanelModal .admin-panel-header h3 {
margin: 0;
font-weight: 600;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 6px;
}
#adminPanelModal .admin-panel-actions {
display: flex;
align-items: center;
gap: 8px;
}
#adminPanelModal .admin-search-toggle {
display: inline-flex;
align-items: center;
gap: 6px;
border-radius: 999px;
padding: 4px 10px;
border: 1px solid rgba(15, 23, 42, 0.12);
background: #fff;
color: #111;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.06);
transition: background 120ms ease, border-color 120ms ease, box-shadow 120ms ease;
}
#adminPanelModal .admin-search-toggle .material-icons { font-size: 18px; }
#adminPanelModal .admin-search-toggle:hover {
background: #f8fafc;
}
#adminPanelModal .admin-search-toggle.is-active {
border-color: rgba(37, 99, 235, 0.45);
box-shadow: 0 0 0 2px rgba(37, 99, 235, 0.12);
}
#adminPanelModal .admin-search-toggle-label {
font-size: 12px;
font-weight: 600;
letter-spacing: 0.02em;
}
#adminPanelModal .admin-search-wrap {
margin: 6px 0 12px;
padding: 8px 12px;
border-radius: 12px;
border: 1px solid rgba(15, 23, 42, 0.08);
background: rgba(15, 23, 42, 0.03);
overflow: hidden;
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-wrap.is-collapsed {
max-height: 0;
opacity: 0;
transform: translateY(-6px);
margin: 0;
padding-top: 0;
padding-bottom: 0;
border-color: transparent;
}
body.dark-mode #adminPanelModal .admin-search-toggle {
background: #2a2a2a;
color: #e5e7eb;
border-color: rgba(255, 255, 255, 0.12);
box-shadow: none;
}
body.dark-mode #adminPanelModal .admin-search-toggle:hover {
background: #333;
}
body.dark-mode #adminPanelModal .admin-search-toggle.is-active {
border-color: rgba(147, 197, 253, 0.45);
box-shadow: 0 0 0 2px rgba(147, 197, 253, 0.12);
}
body.dark-mode #adminPanelModal .admin-search-wrap {
background: rgba(255, 255, 255, 0.04);
border-color: rgba(255, 255, 255, 0.08);
}
@media (max-width: 720px) {
#adminPanelModal .admin-search-toggle-label { display: none; }
#adminPanelModal .admin-panel-header { gap: 8px; }
}
#adminPanelModal .editor-close-btn,
#closeRestoreModal {
position:absolute; top:10px; right:10px; display:flex; align-items:center; justify-content:center;
@@ -2957,6 +3132,50 @@ body.dark-mode #decreaseFont:not(:disabled):hover,body.dark-mode #increaseFont:n
.action-row { display:flex; justify-content:space-between; margin-top:15px; }
#adminPanelModal .action-row {
position: sticky;
bottom: 0;
z-index: 2;
margin-top: 18px;
padding: 10px 0 6px;
gap: 6px;
justify-content: flex-end;
background: linear-gradient(180deg, rgba(255, 255, 255, 0), rgba(255, 255, 255, 0.9) 35%, rgba(255, 255, 255, 1));
border-top: 1px solid rgba(15, 23, 42, 0.08);
}
#adminPanelModal .action-row .btn {
border-radius: 999px;
min-width: 110px;
box-shadow: none;
transform: none;
transition: background-color 120ms ease, border-color 120ms ease, color 120ms ease;
}
#adminPanelModal .action-row .btn:hover,
#adminPanelModal .action-row .btn:focus-visible,
#adminPanelModal .action-row .btn:active {
box-shadow: none;
transform: none;
opacity: 1;
filter: brightness(1.02);
}
#adminPanelModal .action-row .btn.btn-primary {
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.16);
}
#adminPanelModal .action-row .btn.btn-primary:hover,
#adminPanelModal .action-row .btn.btn-primary:focus-visible {
transform: translateY(-1px);
box-shadow: 0 10px 22px rgba(0, 140, 180, 0.28);
filter: brightness(1.04);
}
#adminPanelModal .action-row .btn.btn-primary:active {
transform: translateY(0);
box-shadow: 0 4px 12px rgba(0, 140, 180, 0.22);
}
body.dark-mode #adminPanelModal .action-row {
background: linear-gradient(180deg, rgba(35, 35, 35, 0), rgba(35, 35, 35, 0.92) 35%, rgba(35, 35, 35, 1));
border-top-color: rgba(255, 255, 255, 0.08);
}
/* ---------- Folder access editor ---------- */
.folder-access-toolbar {
display:flex; gap:8px; align-items:center; flex-wrap:wrap; margin:8px 0 6px;
@@ -3979,7 +4198,7 @@ body.dark-mode #adminPanelModal .sources-hint-btn::after{background:var(--fr-sur
/* Give every admin section a little breathing room at the top */
#adminPanelModal .section-content {
padding-top: 10px;
padding-top: 12px;
}
#adminPanelModal .admin-subsection-title {
font-weight: 600;
+315 -55
View File
@@ -39,6 +39,16 @@ const PRO_API_MIN_VERSION_LABELS = {
sources: '1.5.0'
};
const CORE_REQUIRED_PRO_API_LEVEL = Math.max(...Object.values(PRO_API_LEVELS));
const DEFAULT_HEADER_TITLE = 'FileRise';
const PRO_DEFAULT_HEADER_TITLE = 'FileRise Pro';
function resolveHeaderTitle(rawTitle, isPro) {
const cleaned = String(rawTitle || '').trim();
if (!cleaned || cleaned === DEFAULT_HEADER_TITLE) {
return isPro ? PRO_DEFAULT_HEADER_TITLE : DEFAULT_HEADER_TITLE;
}
return cleaned;
}
function compareSemver(a, b) {
const pa = String(a || '').split('.').map(n => parseInt(n, 10) || 0);
@@ -435,7 +445,7 @@ function wireHeaderTitleLive() {
input.__live = true;
const apply = (val) => {
const title = (val || '').trim() || 'FileRise';
const title = resolveHeaderTitle(val, window.__FR_IS_PRO === true);
const h1 = document.querySelector('.header-title h1');
if (h1) h1.textContent = title;
document.title = title;
@@ -1414,6 +1424,9 @@ function initSourcesSection({ modalEl, sourcesEnabled, sourcesCfg, isPro, proSou
<input type="text" id="sourceGDriveDriveId" class="form-control" placeholder="" />
</div>
</div>
<div class="text-muted small" style="margin-top:6px;">
${tf('source_gdrive_trash_note', 'Trash is not supported on Google Drive sources; deletes are permanent.')}
</div>
</div>
<div class="sources-form-actions">
@@ -2317,6 +2330,7 @@ function captureInitialAdminConfig() {
enableWebDAV: !!document.getElementById("enableWebDAV")?.checked,
sharedMaxUploadSize: (document.getElementById("sharedMaxUploadSize")?.value || "").trim(),
resumableChunkMb: (document.getElementById("resumableChunkMb")?.value || "").trim(),
globalOtpauthUrl: (document.getElementById("globalOtpauthUrl")?.value || "").trim(),
brandingCustomLogoUrl: (document.getElementById("brandingCustomLogoUrl")?.value || "").trim(),
brandingHeaderBgLight: (document.getElementById("brandingHeaderBgLight")?.value || "").trim(),
@@ -2361,6 +2375,7 @@ function hasUnsavedChanges() {
getChk("enableWebDAV") !== o.enableWebDAV ||
getVal("sharedMaxUploadSize") !== o.sharedMaxUploadSize ||
getVal("resumableChunkMb") !== o.resumableChunkMb ||
getVal("globalOtpauthUrl") !== o.globalOtpauthUrl ||
getVal("brandingCustomLogoUrl") !== (o.brandingCustomLogoUrl || "") ||
getVal("brandingHeaderBgLight") !== (o.brandingHeaderBgLight || "") ||
@@ -2520,14 +2535,178 @@ function showTypedConfirmModal({ title, message, confirmText, placeholder }) {
});
}
const SECTION_ANIM_MS = 160;
function clearSectionTimer(cnt) {
if (cnt && cnt.__closeTimer) {
clearTimeout(cnt.__closeTimer);
cnt.__closeTimer = null;
}
}
function openSectionContent(cnt) {
if (!cnt) return;
clearSectionTimer(cnt);
cnt.style.display = "block";
cnt.classList.remove("is-closing");
requestAnimationFrame(() => {
cnt.classList.add("is-open");
});
}
function closeSectionContent(cnt) {
if (!cnt) return;
clearSectionTimer(cnt);
cnt.classList.remove("is-open");
cnt.classList.add("is-closing");
cnt.__closeTimer = setTimeout(() => {
if (cnt.classList.contains("is-closing")) {
cnt.style.display = "none";
cnt.classList.remove("is-closing");
}
cnt.__closeTimer = null;
}, SECTION_ANIM_MS);
}
function setSectionContentImmediate(cnt, open) {
if (!cnt) return;
clearSectionTimer(cnt);
if (open) {
cnt.style.display = "block";
cnt.classList.add("is-open");
cnt.classList.remove("is-closing");
} else {
cnt.style.display = "none";
cnt.classList.remove("is-open", "is-closing");
}
}
function toggleSection(id) {
const hdr = document.getElementById(id + "Header");
const cnt = document.getElementById(id + "Content");
if (!hdr || !cnt) return;
const isCollapsedNow = hdr.classList.toggle("collapsed");
cnt.style.display = isCollapsedNow ? "none" : "block";
if (isCollapsedNow) {
closeSectionContent(cnt);
} else {
openSectionContent(cnt);
}
if (!isCollapsedNow && id === "shareLinks") {
loadShareLinksSection();
cnt.dataset.loaded = "1";
}
}
function normalizeAdminSearchText(value) {
return String(value || "")
.toLowerCase()
.replace(/\s+/g, " ")
.trim();
}
function wireAdminPanelSearch(sectionIds) {
const input = document.getElementById("adminSettingsSearch");
const emptyState = document.getElementById("adminSettingsSearchEmpty");
const wrap = document.getElementById("adminSettingsSearchWrap");
const toggleBtn = document.getElementById("adminSearchToggle");
if (!input || !Array.isArray(sectionIds)) return;
const ids = sectionIds.filter(Boolean);
const setOpen = (open, opts = {}) => {
if (!wrap || !toggleBtn) return;
wrap.classList.toggle("is-collapsed", !open);
toggleBtn.setAttribute("aria-expanded", open ? "true" : "false");
if (open && opts.focus) {
input.focus();
}
};
const updateToggleState = () => {
if (!toggleBtn) return;
const hasQuery = !!normalizeAdminSearchText(input.value);
toggleBtn.classList.toggle("is-active", hasQuery);
};
const restoreState = () => {
ids.forEach(id => {
const hdr = document.getElementById(id + "Header");
const cnt = document.getElementById(id + "Content");
if (!hdr || !cnt) return;
const saved = hdr.dataset.prevCollapsed;
hdr.style.display = "";
cnt.style.display = "";
if (saved !== undefined) {
const wasCollapsed = saved === "1";
hdr.classList.toggle("collapsed", wasCollapsed);
setSectionContentImmediate(cnt, !wasCollapsed);
delete hdr.dataset.prevCollapsed;
}
});
if (emptyState) emptyState.style.display = "none";
};
const applyFilter = () => {
const query = normalizeAdminSearchText(input.value);
if (!query) {
restoreState();
updateToggleState();
return;
}
let matches = 0;
ids.forEach(id => {
const hdr = document.getElementById(id + "Header");
const cnt = document.getElementById(id + "Content");
if (!hdr || !cnt) return;
if (hdr.dataset.prevCollapsed === undefined) {
hdr.dataset.prevCollapsed = hdr.classList.contains("collapsed") ? "1" : "0";
}
const haystack = normalizeAdminSearchText(hdr.textContent + " " + cnt.textContent);
const match = haystack.includes(query);
hdr.style.display = match ? "" : "none";
setSectionContentImmediate(cnt, match);
if (match) {
hdr.classList.remove("collapsed");
matches += 1;
if (id === "shareLinks" && !cnt.dataset.loaded) {
loadShareLinksSection();
cnt.dataset.loaded = "1";
}
}
});
if (emptyState) emptyState.style.display = matches ? "none" : "block";
updateToggleState();
};
if (toggleBtn && wrap && !toggleBtn.__wired) {
toggleBtn.__wired = true;
toggleBtn.addEventListener("click", () => {
const isCollapsed = wrap.classList.contains("is-collapsed");
if (isCollapsed) {
setOpen(true, { focus: true });
return;
}
const hasQuery = !!normalizeAdminSearchText(input.value);
if (hasQuery) {
input.focus();
return;
}
setOpen(false);
});
}
input.addEventListener("input", applyFilter);
const initialQuery = normalizeAdminSearchText(input.value);
if (initialQuery) {
setOpen(true);
applyFilter();
} else {
setOpen(false);
updateToggleState();
}
}
@@ -3542,11 +3721,8 @@ export function openAdminPanel() {
fetch("/api/admin/getConfig.php", { credentials: "include" })
.then(r => r.json())
.then(config => {
if (config.header_title) {
const h = document.querySelector(".header-title h1");
if (h) h.textContent = config.header_title;
window.headerTitle = config.header_title;
}
const rawHeaderTitle = (typeof config.header_title === 'string') ? config.header_title : '';
window.headerTitle = rawHeaderTitle;
window.currentOIDCConfig = window.currentOIDCConfig || {};
if (config.oidc && typeof config.oidc === 'object') {
@@ -3561,6 +3737,10 @@ export function openAdminPanel() {
const proInfo = config.pro || {};
const isPro = !!proInfo.active;
window.__FR_IS_PRO = isPro;
const headerDisplayTitle = resolveHeaderTitle(rawHeaderTitle, isPro);
const h = document.querySelector(".header-title h1");
if (h) h.textContent = headerDisplayTitle;
document.title = headerDisplayTitle;
const proType = proInfo.type || '';
const proEmail = proInfo.email || '';
const proVersion = proInfo.version || 'not installed';
@@ -3575,6 +3755,9 @@ export function openAdminPanel() {
const proPlan = proInfo.plan || ''; // e.g. "early_supporter_1x", "personal_yearly"
const proUpdatesUntil = proInfo.updatesUntil || proInfo.expiresAt || ''; // ISO timestamp string or ""
const proInstanceId = proInfo.instanceId || '';
const proPrimaryAdmin = Object.prototype.hasOwnProperty.call(proInfo, 'primaryAdmin')
? !!proInfo.primaryAdmin
: true;
const proMaxMajor = (
typeof proInfo.maxMajor === 'number'
? proInfo.maxMajor
@@ -3685,36 +3868,64 @@ export function openAdminPanel() {
display:flex; justify-content:center; align-items:center;
z-index:3000;
`;
const sections = [
{ id: "userManagement", label: tf("users_access", "Users & Access") },
{ id: "headerSettings", label: tf("appearance_ui", "Appearance & UI") },
{ 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") },
{ id: "network", label: tf("network_proxy", "Network & Proxy") },
{ id: "encryption", label: tf("encryption_at_rest", "Encryption at rest") },
{ id: "onlyoffice", label: "ONLYOFFICE" },
{ id: "storage", label: tf("storage_usage", "Storage / Disk Usage") }
];
if (showSourcesSection) {
const sourcesLabel = !isPro
? `<span style="display:inline-flex; align-items:center; gap:6px;">${tf("sources", "Sources")}<span class="btn-pro-pill" style="position:static; display:inline-flex; align-items:center; margin:0;">Pro</span></span>`
: tf("sources", "Sources");
sections.push({ id: "sources", label: sourcesLabel });
}
sections.push(
{ id: "proFeatures", label: "Pro Features" },
{ id: "pro", label: "FileRise Pro" },
{ id: "sponsor", label: (typeof tf === 'function' ? tf("sponsor_donations", "Thanks / Sponsor / Donations") : "Thanks / Sponsor / Donations") }
);
const sectionIds = sections.map(sec => sec.id);
mdl.innerHTML = `
<div class="modal-content" style="${inner}">
<div class="editor-close-btn" id="closeAdminPanel">&times;</div>
<h3>${getAdminTitle(isPro, proVersion, updatesExpired)}</h3>
<div class="admin-panel-header">
<h3>${getAdminTitle(isPro, proVersion, updatesExpired)}</h3>
<div class="admin-panel-actions">
<button
type="button"
id="adminSearchToggle"
class="btn btn-sm btn-light admin-search-toggle"
title="${tf("settings_search_toggle", "Search settings")}"
aria-expanded="false"
>
<i class="material-icons">search</i>
<span class="admin-search-toggle-label">${tf("search", "Search")}</span>
</button>
</div>
</div>
<form id="adminPanelForm">
${(() => {
const sections = [
{ id: "userManagement", label: t("user_management") },
{ id: "headerSettings", label: tf("header_footer_settings", "Header, FileList, Language & Footer Settings") },
{ id: "loginOptions", label: t("login_webdav") + " (OIDC/TOTP)" },
{ id: "network", label: tf("firewall_proxy_settings", "Firewall and Proxy Settings") },
{ id: "encryption", label: tf("encryption_at_rest", "Encryption at rest") },
{ id: "onlyoffice", label: "ONLYOFFICE" },
{ id: "upload", label: tf("antivirus_settings", "Antivirus") },
{ id: "shareLinks", label: t("manage_shared_links_size") },
{ id: "storage", label: "Storage / Disk Usage" }
];
if (showSourcesSection) {
const sourcesLabel = !isPro
? `<span style="display:inline-flex; align-items:center; gap:6px;">${tf("sources", "Sources")}<span class="btn-pro-pill" style="position:static; display:inline-flex; align-items:center; margin:0;">Pro</span></span>`
: tf("sources", "Sources");
sections.push({ id: "sources", label: sourcesLabel });
}
sections.push(
{ id: "proFeatures", label: "Pro Features" },
{ id: "pro", label: "FileRise Pro" },
{ id: "sponsor", label: (typeof tf === 'function' ? tf("sponsor_donations", "Thanks / Sponsor / Donations") : "Thanks / Sponsor / Donations") }
);
return sections;
})().map(sec => `
<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...")}"
/>
<small class="text-muted d-block mt-1">
${tf("settings_search_help", "Type to filter sections and settings.")}
</small>
<div id="adminSettingsSearchEmpty" class="small text-muted" style="display:none; margin-top:6px;">
${tf("settings_search_empty", "No matching settings found.")}
</div>
</div>
${sections.map(sec => `
<div id="${sec.id}Header" class="section-header collapsed">
${sec.label} <i class="material-icons">expand_more</i>
</div>
@@ -3732,22 +3943,9 @@ export function openAdminPanel() {
document.getElementById("closeAdminPanel").addEventListener("click", closeAdminPanel);
document.getElementById("cancelAdminSettings").addEventListener("click", closeAdminPanel);
wireAdminPanelSearch(sectionIds);
[
"userManagement",
"headerSettings",
"loginOptions",
"network",
"encryption",
"onlyoffice",
"upload",
"shareLinks",
"storage",
showSourcesSection ? "sources" : null,
"proFeatures",
"pro",
"sponsor"
].filter(Boolean).forEach(id => {
sectionIds.filter(Boolean).forEach(id => {
const headerEl = document.getElementById(id + "Header");
if (!headerEl || headerEl.__wired) return;
headerEl.__wired = true;
@@ -4264,11 +4462,37 @@ export function openAdminPanel() {
renderAdminEncryptionSection({ config, dark });
document.getElementById("uploadContent").innerHTML = `
<div class="admin-subsection-title" style="margin-top:2px;">
Antivirus upload scanning
<div class="admin-subsection-title" style="margin-top:2px;">
${tf("upload_settings", "Upload settings")}
</div>
<div class="form-group" style="margin-top:10px;">
<div class="form-group" style="margin-top:10px;">
<label for="resumableChunkMb">
${tf("resumable_chunk_size_label", "Resumable chunk size (MB)")}
</label>
<input
type="number"
id="resumableChunkMb"
class="form-control"
min="0.5"
max="100"
step="0.1"
/>
<small class="text-muted d-block mt-1">
${tf(
"resumable_chunk_size_help",
"Applies to file picker uploads (Resumable.js). Use smaller chunks if your proxy limits request size (e.g., Cloudflare Tunnels 100 MB)."
)}
</small>
</div>
<hr class="admin-divider">
<div class="admin-subsection-title" style="margin-top:2px;">
${tf("antivirus_upload_scanning", "Antivirus upload scanning")}
</div>
<div class="form-group" style="margin-top:10px;">
<div class="form-check fr-toggle">
<input
type="checkbox"
@@ -4635,6 +4859,16 @@ ${t("shared_max_upload_size_bytes")}
// --- FileRise Pro / License section ---
const proContent = document.getElementById("proContent");
if (proContent) {
if (!proPrimaryAdmin) {
proContent.innerHTML = `
<div class="card" style="padding:12px; border:1px solid #ddd; border-radius:12px; max-width:720px; margin:8px auto;">
<strong>FileRise Pro</strong>
<div class="text-muted" style="margin-top:6px;">
This is only viewable on the registered administrator.
</div>
</div>
`;
} else {
// Normalize versions so "v1.0.1" and "1.0.1" compare cleanly
const norm = (v) => (String(v || '').trim().replace(/^v/i, ''));
@@ -5100,6 +5334,7 @@ ${t("shared_max_upload_size_bytes")}
});
}
}
}
// --- end FileRise Pro section ---
@@ -5109,9 +5344,9 @@ ${t("shared_max_upload_size_bytes")}
if (proFeaturesHeaderEl) {
const iconHtml = '<i class="material-icons">expand_more</i>';
const pill = (!isPro || !proSearchApiOk || !proAuditAvailable)
? '<span class="btn-pro-pill" style="position:static; display:inline-flex; align-items:center; margin-left:6px;">Pro</span>'
? '<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}`;
proFeaturesHeaderEl.innerHTML = `<span style="display:inline-flex;align-items:center;gap:6px;">Pro Features${pill}</span> ${iconHtml}`;
}
if (proFeaturesContainer) {
const proSearchBlockedReason = !isPro ? 'pro' : (!proSearchApiOk ? 'api' : null);
@@ -5541,6 +5776,14 @@ ${t("shared_max_upload_size_bytes")}
document.getElementById("enableWebDAV").checked = config.enableWebDAV === true;
document.getElementById("sharedMaxUploadSize").value = config.sharedMaxUploadSize || "";
const uploadCfg = (config.uploads && typeof config.uploads === "object") ? config.uploads : {};
const chunkEl = document.getElementById("resumableChunkMb");
if (chunkEl) {
const raw = uploadCfg.resumableChunkMb;
const num = parseFloat(raw);
const val = Number.isFinite(num) ? Math.min(100, Math.max(0.5, num)) : 1.5;
chunkEl.value = val;
}
// Published URL (optional)
const deploy = (config && config.deployment && typeof config.deployment === 'object') ? config.deployment : {};
@@ -5620,6 +5863,14 @@ ${t("shared_max_upload_size_bytes")}
document.getElementById("enableWebDAV").checked = config.enableWebDAV === true;
document.getElementById("sharedMaxUploadSize").value = config.sharedMaxUploadSize || "";
const uploadCfg2 = (config.uploads && typeof config.uploads === "object") ? config.uploads : {};
const chunkEl2 = document.getElementById("resumableChunkMb");
if (chunkEl2) {
const raw = uploadCfg2.resumableChunkMb;
const num = parseFloat(raw);
const val = Number.isFinite(num) ? Math.min(100, Math.max(0.5, num)) : 1.5;
chunkEl2.value = val;
}
// Published URL (optional)
const deploy2 = (config && config.deployment && typeof config.deployment === 'object') ? config.deployment : {};
@@ -5715,9 +5966,9 @@ ${t("shared_max_upload_size_bytes")}
if (pfHeader) {
const iconHtml = '<i class="material-icons">expand_more</i>';
const pill = (!isPro || !proSearchApiOk || !proAuditAvailable)
? '<span class="btn-pro-pill" style="position:static; display:inline-flex; align-items:center; margin-left:6px;">Pro</span>'
? '<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}`;
pfHeader.innerHTML = `<span style="display:inline-flex;align-items:center;gap:6px;">Pro Features${pill}</span> ${iconHtml}`;
}
const psToggle = document.getElementById("proSearchEnabled");
const psLimit = document.getElementById("proSearchLimit");
@@ -5847,6 +6098,15 @@ function handleSave() {
Number.isFinite(parseInt(document.getElementById("fileListSummaryDepth")?.value || "2", 10))
? parseInt(document.getElementById("fileListSummaryDepth")?.value || "2", 10)
: 2
)
),
},
uploads: {
resumableChunkMb: Math.max(
0.5,
Math.min(
100,
parseFloat(document.getElementById("resumableChunkMb")?.value || "1.5") || 1.5
)
),
},
+19 -3
View File
@@ -34,6 +34,16 @@ window.currentOIDCConfig = currentOIDCConfig;
// Shared permissions cache across modules (populated once per tab)
const PERMISSIONS_URL = '/api/getUserPermissions.php';
const DEFAULT_HEADER_TITLE = 'FileRise';
const PRO_DEFAULT_HEADER_TITLE = 'FileRise Pro';
function resolveHeaderTitle(rawTitle, isPro) {
const cleaned = String(rawTitle || '').trim();
if (!cleaned || cleaned === DEFAULT_HEADER_TITLE) {
return isPro ? PRO_DEFAULT_HEADER_TITLE : DEFAULT_HEADER_TITLE;
}
return cleaned;
}
async function fetchUserPermissionsOnce() {
if (window.__FR_PERMISSIONS_CACHE) return window.__FR_PERMISSIONS_CACHE;
@@ -261,7 +271,8 @@ export function loadAdminConfigFunc() {
let config = {};
try { config = await response.json(); } catch (e) { config = {}; }
const headerTitle = config.header_title || "FileRise";
const isPro = !!(config.pro && config.pro.active);
const headerTitle = resolveHeaderTitle(config.header_title, isPro);
localStorage.setItem("headerTitle", headerTitle);
document.title = headerTitle;
@@ -281,7 +292,11 @@ export function loadAdminConfigFunc() {
})
.catch(() => {
// Fallback defaults if request truly fails
localStorage.setItem("headerTitle", "FileRise");
const fallbackTitle = resolveHeaderTitle(
DEFAULT_HEADER_TITLE,
window.__FR_IS_PRO === true
);
localStorage.setItem("headerTitle", fallbackTitle);
localStorage.setItem("disableFormLogin", "false");
localStorage.setItem("disableBasicAuth", "false");
localStorage.setItem("disableOIDCLogin", "false");
@@ -289,7 +304,8 @@ export function loadAdminConfigFunc() {
updateLoginOptionsUIFromStorage();
const headerTitleElem = document.querySelector(".header-title h1");
if (headerTitleElem) headerTitleElem.textContent = "FileRise";
if (headerTitleElem) headerTitleElem.textContent = fallbackTitle;
document.title = fallbackTitle;
})
.finally(() => { window.__FR_SITE_CFG_PROMISE = null; });
return window.__FR_SITE_CFG_PROMISE;
+38
View File
@@ -361,7 +361,42 @@ document.addEventListener("DOMContentLoaded", function () {
const confirmDelete = document.getElementById("confirmDeleteFiles");
if (confirmDelete) {
confirmDelete.setAttribute("data-default", "");
const deleteMsg = document.getElementById("deleteFilesMessage");
const setDeletingState = (busy) => {
if (busy) {
confirmDelete.dataset.originalLabel = confirmDelete.innerHTML;
confirmDelete.innerHTML =
'<span class="material-icons spinning" style="font-size:16px; vertical-align:middle; margin-right:6px;">autorenew</span>Deleting...';
confirmDelete.disabled = true;
if (cancelDelete) cancelDelete.disabled = true;
if (deleteMsg) {
deleteMsg.dataset.originalText = deleteMsg.textContent || "";
deleteMsg.textContent = "Deleting...";
}
return;
}
confirmDelete.innerHTML = confirmDelete.dataset.originalLabel || confirmDelete.innerHTML;
confirmDelete.disabled = false;
if (cancelDelete) cancelDelete.disabled = false;
if (deleteMsg && deleteMsg.dataset.originalText) {
deleteMsg.textContent = deleteMsg.dataset.originalText;
delete deleteMsg.dataset.originalText;
}
delete confirmDelete.dataset.originalLabel;
};
confirmDelete.addEventListener("click", function () {
if (confirmDelete.dataset.busy === "1") return;
confirmDelete.dataset.busy = "1";
setDeletingState(true);
const fileCount = Array.isArray(window.filesToDelete) ? window.filesToDelete.length : 0;
const slowTimer = setTimeout(() => {
showToast(
fileCount > 0
? `Deleting ${fileCount} file${fileCount === 1 ? '' : 's'}...`
: 'Deleting files...',
'info'
);
}, 2500);
fetch("/api/file/deleteFiles.php", {
method: "POST",
credentials: "include",
@@ -386,6 +421,9 @@ document.addEventListener("DOMContentLoaded", function () {
})
.catch(error => console.error("Error deleting files:", error))
.finally(() => {
clearTimeout(slowTimer);
setDeletingState(false);
delete confirmDelete.dataset.busy;
document.getElementById("deleteFilesModal").style.display = "none";
window.filesToDelete = [];
});
+24 -1
View File
@@ -5670,8 +5670,14 @@ export async function loadFileList(folderParam, options = {}) {
refreshCurrentFolderCaps(folder, paneSourceId, pane);
// 1) show loader (only this request is allowed to render)
fileListContainer.style.visibility = "hidden";
fileListContainer.style.visibility = "visible";
fileListContainer.setAttribute('aria-busy', 'true');
fileListContainer.innerHTML = "<div class='loader'>Loading files...</div>";
const slowToastTimer = setTimeout(() => {
if (reqId === __fileListReqSeq[pane]) {
showToast(t('loading_slow') || "Still loading... remote sources can take a while.", 'info');
}
}, 8000);
try {
// Kick off both in parallel, but render as soon as FILES are ready
@@ -5983,6 +5989,21 @@ export async function loadFileList(folderParam, options = {}) {
return data.files;
} catch (err) {
if (reqId !== __fileListReqSeq[pane]) return [];
const errMsg = String(err?.message || '').toLowerCase();
const missingFolder = /directory not found|folder does not exist|does not exist|not found/.test(errMsg);
if (missingFolder && !skipFallback) {
const fallback = getParentFolder(folder || 'root') || 'root';
if (fallback && fallback !== folder) {
setLastOpenedFolder(fallback, paneSourceId);
if (isActivePane) {
window.currentFolder = fallback;
updateBreadcrumbTitle(fallback);
try { syncFolderTreeSelection(fallback); } catch (e) { /* ignore */ }
}
return await loadFileList(fallback, { pane, skipFallback: true });
}
}
console.error("Error loading file list:", err);
if (err.status === 403) {
showToast(t("no_access_to_resource") || "You don't have access to this folder.", "error");
@@ -5995,7 +6016,9 @@ export async function loadFileList(folderParam, options = {}) {
return [];
} finally {
if (reqId === __fileListReqSeq[pane]) {
clearTimeout(slowToastTimer);
fileListContainer.style.visibility = "visible";
fileListContainer.removeAttribute('aria-busy');
}
}
}
+59
View File
@@ -1028,6 +1028,24 @@ export function refreshFolderIcon(folder) {
invalidateFolderCaches(folder);
ensureFolderIcon(folder);
}
export async function refreshFolderChildren(folder) {
const target = folder || 'root';
if (target === 'recycle_bin') return false;
invalidateFolderCaches(target);
clearPeekCache([target]);
const ul = getULForFolder(target);
if (!ul) {
refreshFolderIcon(target);
return false;
}
ul._renderedOnce = false;
ul.innerHTML = '';
try { await ensureChildrenLoaded(target, ul); primeChildToggles(ul); } catch (e) {}
if (target === 'root') placeRecycleBinNode();
refreshFolderIcon(target);
return true;
}
function ensureFolderIcon(folder) {
if (folder === 'recycle_bin') return; // keep custom recycle icon intact
const opt = document.querySelector(`.folder-option[data-folder="${CSS.escape(folder)}"]`);
@@ -3696,6 +3714,36 @@ attachEnterKeyListener("deleteFolderModal", "confirmDeleteFolder");
const confirmDelete = document.getElementById("confirmDeleteFolder");
if (confirmDelete) confirmDelete.addEventListener("click", async function () {
const selectedFolder = window.currentFolder || "root";
if (confirmDelete.dataset.busy === "1") return;
confirmDelete.dataset.busy = "1";
const cancelBtn = document.getElementById("cancelDeleteFolder");
const msgEl = document.getElementById("deleteFolderMessage");
const setDeletingState = (busy) => {
if (busy) {
confirmDelete.dataset.originalLabel = confirmDelete.innerHTML;
confirmDelete.innerHTML =
'<span class="material-icons spinning" style="font-size:16px; vertical-align:middle; margin-right:6px;">autorenew</span>Deleting...';
confirmDelete.disabled = true;
if (cancelBtn) cancelBtn.disabled = true;
if (msgEl) {
msgEl.dataset.originalText = msgEl.textContent || "";
msgEl.textContent = "Deleting...";
}
return;
}
confirmDelete.innerHTML = confirmDelete.dataset.originalLabel || confirmDelete.innerHTML;
confirmDelete.disabled = false;
if (cancelBtn) cancelBtn.disabled = false;
if (msgEl && msgEl.dataset.originalText) {
msgEl.textContent = msgEl.dataset.originalText;
delete msgEl.dataset.originalText;
}
delete confirmDelete.dataset.originalLabel;
};
setDeletingState(true);
const slowTimer = setTimeout(() => {
showToast(`Deleting folder ${selectedFolder}...`, 'info');
}, 2500);
fetchWithCsrf("/api/folder/deleteFolder.php", {
method: "POST", headers: { "Content-Type": "application/json" }, credentials: "include",
body: JSON.stringify({ folder: selectedFolder })
@@ -3705,6 +3753,14 @@ if (confirmDelete) confirmDelete.addEventListener("click", async function () {
const parent = getParentFolder(selectedFolder);
window.currentFolder = parent;
setLastOpenedFolder(parent);
const sourceId = getActiveSourceId();
const sourceType = String(getSourceTypeById(sourceId || '') || '').toLowerCase();
const isRemote = !!sourceId && sourceType !== 'local';
if (isRemote) {
resetFolderTreeCaches();
await loadFolderTree(parent);
return;
}
invalidateFolderCaches(parent);
clearPeekCache([parent, selectedFolder]);
const ul = getULForFolder(parent);
@@ -3716,6 +3772,9 @@ if (confirmDelete) confirmDelete.addEventListener("click", async function () {
showToast(t('delete_folder_error', { error: errMsg }), 'error');
}
}).catch(err => console.error("Error deleting folder:", err)).finally(() => {
clearTimeout(slowTimer);
setDeletingState(false);
delete confirmDelete.dataset.busy;
const modal = document.getElementById("deleteFolderModal");
if (modal) modal.style.display = "none";
});
+12 -1
View File
@@ -782,6 +782,16 @@ function bindDarkMode() {
m.content = val;
};
const DEFAULT_HEADER_TITLE = 'FileRise';
const PRO_DEFAULT_HEADER_TITLE = 'FileRise Pro';
const resolveHeaderTitle = (rawTitle, isPro) => {
const cleaned = String(rawTitle || '').trim();
if (!cleaned || cleaned === DEFAULT_HEADER_TITLE) {
return isPro ? PRO_DEFAULT_HEADER_TITLE : DEFAULT_HEADER_TITLE;
}
return cleaned;
};
// ---------- site config / auth ----------
function applySiteConfig(cfg, { phase = 'final' } = {}) {
try {
@@ -843,7 +853,8 @@ function bindDarkMode() {
window.__FR_FLAGS.clamavScanUploads = !!cfg.clamav.scanUploads;
}
const title = (cfg && cfg.header_title) ? String(cfg.header_title) : 'FileRise';
const rawTitle = (cfg && typeof cfg.header_title === 'string') ? cfg.header_title : '';
const title = resolveHeaderTitle(rawTitle, window.__FR_IS_PRO === true);
// Always keep <title> correct early (no visual flicker)
document.title = title;
+38 -2
View File
@@ -234,6 +234,30 @@ function getSelectedFiles() {
return Array.from(list.querySelectorAll("input[type='checkbox']:checked")).map(chk => chk.value);
}
function setTrashActionBusy(isBusy, activeBtn) {
const {
restoreSelectedBtn,
restoreAllBtn,
deleteSelectedBtn,
deleteAllBtn
} = getModalElements();
[restoreSelectedBtn, restoreAllBtn, deleteSelectedBtn, deleteAllBtn].forEach((btn) => {
if (!btn) return;
if (isBusy) {
btn.disabled = true;
if (btn === activeBtn) {
btn.setAttribute("aria-busy", "true");
} else {
btn.removeAttribute("aria-busy");
}
} else {
btn.disabled = false;
btn.removeAttribute("aria-busy");
}
});
}
function afterTrashMutation(message, closeModal = false) {
if (message) showToast(message, 'success');
loadTrashItems();
@@ -252,13 +276,16 @@ async function deleteAllTrashItems() {
}
}
export function confirmEmptyRecycleBin() {
export function confirmEmptyRecycleBin(activeBtn = null) {
showConfirm(tr("confirm_delete_all_trash", "Permanently delete all trash items? This cannot be undone."), async () => {
setTrashActionBusy(true, activeBtn);
try {
await deleteAllTrashItems();
} catch (err) {
console.error("Error deleting all trash files:", err);
showToast(tr("error_deleting_trash", "Error deleting trash files."), 'error');
} finally {
setTrashActionBusy(false);
}
});
}
@@ -310,6 +337,7 @@ export function setupTrashRestoreDelete() {
showToast(tr("no_trash_selected", "No trash items selected."), 'warning');
return;
}
setTrashActionBusy(true, restoreSelectedBtn);
try {
await fetchJson(ENDPOINTS.restore, { files });
const restoredLabel = tr("restored", "Restored");
@@ -320,6 +348,8 @@ export function setupTrashRestoreDelete() {
} catch (err) {
console.error("Error restoring files:", err);
showToast(tr("error_restoring_files", "Error restoring files."), 'error');
} finally {
setTrashActionBusy(false);
}
});
}
@@ -332,6 +362,7 @@ export function setupTrashRestoreDelete() {
showToast(tr("trash_empty", "Trash is empty."), 'info');
return;
}
setTrashActionBusy(true, restoreAllBtn);
try {
await fetchJson(ENDPOINTS.restore, { files });
const restoredLabel = tr("restored", "Restored");
@@ -342,6 +373,8 @@ export function setupTrashRestoreDelete() {
} catch (err) {
console.error("Error restoring files:", err);
showToast(tr("error_restoring_files", "Error restoring files."), 'error');
} finally {
setTrashActionBusy(false);
}
});
}
@@ -354,6 +387,7 @@ export function setupTrashRestoreDelete() {
return;
}
showConfirm(tr("confirm_delete_selected", "Permanently delete the selected items?"), async () => {
setTrashActionBusy(true, deleteSelectedBtn);
try {
const data = await fetchJson(ENDPOINTS.delete, { files });
if (data.success) {
@@ -364,6 +398,8 @@ export function setupTrashRestoreDelete() {
} catch (err) {
console.error("Error deleting trash files:", err);
showToast(tr("error_deleting_trash", "Error deleting trash files."), 'error');
} finally {
setTrashActionBusy(false);
}
});
});
@@ -371,7 +407,7 @@ export function setupTrashRestoreDelete() {
if (deleteAllBtn) {
deleteAllBtn.addEventListener("click", () => {
confirmEmptyRecycleBin();
confirmEmptyRecycleBin(deleteAllBtn);
});
}
+150 -34
View File
@@ -1,9 +1,8 @@
import { initFileActions } from './fileActions.js?v={{APP_QVER}}';
import { displayFilePreview } from './filePreview.js?v={{APP_QVER}}';
import { showToast, escapeHTML } from './domUtils.js?v={{APP_QVER}}';
import { loadFolderTree } from './folderManager.js?v={{APP_QVER}}';
import { refreshFolderIcon, refreshFolderChildren } from './folderManager.js?v={{APP_QVER}}';
import { loadFileList } from './fileListView.js?v={{APP_QVER}}';
import { refreshFolderIcon } from './folderManager.js?v={{APP_QVER}}';
import { t } from './i18n.js?v={{APP_QVER}}';
import { withBase } from './basePath.js?v={{APP_QVER}}';
@@ -23,6 +22,15 @@ function getActiveUploadSourceId() {
return '';
}
function getResumableChunkSizeBytes() {
const cfg = window.__FR_SITE_CFG__ || window.siteConfig || {};
const uploads = (cfg && typeof cfg === 'object') ? cfg.uploads : null;
const raw = uploads ? uploads.resumableChunkMb : null;
const num = parseFloat(raw);
const mb = Number.isFinite(num) ? Math.min(100, Math.max(0.5, num)) : 1.5;
return mb * 1024 * 1024;
}
// --- ClamAV scanning UI helpers ----------------------------------------
function isVirusScanLikelyEnabled() {
@@ -197,20 +205,7 @@ function wireFileInputChange(fileInput) {
if (!files.length) return;
if (useResumable) {
// New resumable batch: reset selectedFiles so the count is correct
window.selectedFiles = [];
_currentResumableIds.clear(); // <--- add this
// Ensure the lib/instance exists
if (!_resumableReady) await initResumableUpload();
if (resumableInstance) {
for (const f of files) {
resumableInstance.addFile(f);
}
} else {
// If Resumable failed to load, fall back to XHR
processFiles(files);
}
await queueResumableFiles(files);
} else {
// Non-resumable: normal XHR path, drag-and-drop etc.
processFiles(files);
@@ -226,6 +221,65 @@ function setUploadButtonVisible(visible) {
btn.disabled = !visible;
}
let _uploadRefreshTimer = null;
let _uploadRefreshFolder = '';
function scheduleUploadRefresh(folder, immediate = false) {
const target = folder || window.currentFolder || 'root';
_uploadRefreshFolder = target;
if (_uploadRefreshTimer) {
clearTimeout(_uploadRefreshTimer);
}
const delay = immediate ? 0 : 400;
_uploadRefreshTimer = setTimeout(() => {
_uploadRefreshTimer = null;
const active = _uploadRefreshFolder || target;
try { refreshFolderIcon(active); } catch (e) {}
loadFileList(active);
}, delay);
}
let _treeRefreshTimer = null;
let _treeRefreshFolder = '';
function getRelativePathForFile(file) {
if (!file || typeof file !== 'object') return '';
return (
file.relativePath ||
file.webkitRelativePath ||
file.customRelativePath ||
(file.file && (file.file.webkitRelativePath || file.file.customRelativePath)) ||
''
);
}
function hasFolderPaths(files) {
if (!Array.isArray(files)) return false;
return files.some((file) => {
const relRaw = getRelativePathForFile(file);
if (!relRaw) return false;
const rel = String(relRaw).replace(/\\/g, '/').replace(/^\/+/, '');
return rel.includes('/');
});
}
function scheduleFolderTreeRefresh(folder, immediate = false) {
const target = folder || window.currentFolder || 'root';
_treeRefreshFolder = target;
if (_treeRefreshTimer) {
clearTimeout(_treeRefreshTimer);
}
const delay = immediate ? 0 : 500;
_treeRefreshTimer = setTimeout(() => {
_treeRefreshTimer = null;
const active = _treeRefreshFolder || target;
const p = refreshFolderChildren(active);
if (p && typeof p.catch === 'function') {
p.catch(() => {});
}
}, delay);
}
function getUserDraftContext() {
const all = loadResumableDraftsAll();
const userKey = getCurrentUserKey();
@@ -377,6 +431,11 @@ function traverseFileTreePromise(item, path = "") {
writable: true,
configurable: true
});
Object.defineProperty(file, 'relativePath', {
value: path + file.name,
writable: true,
configurable: true
});
resolve([file]);
});
} else if (item.isDirectory) {
@@ -521,15 +580,54 @@ function updateFileInfoCount() {
}
}
function applyResumableRelativePath(file) {
if (!file || typeof file !== 'object') return;
const rel = file.webkitRelativePath || file.customRelativePath || '';
if (rel && (!('relativePath' in file) || !file.relativePath)) {
Object.defineProperty(file, 'relativePath', {
value: rel,
writable: true,
configurable: true
});
}
}
async function queueResumableFiles(files) {
if (!useResumable) {
processFiles(files);
return;
}
// New resumable batch: reset selectedFiles so the count is correct
window.selectedFiles = [];
_currentResumableIds.clear();
if (!_resumableReady) await initResumableUpload();
if (!resumableInstance) {
// If Resumable failed to load, fall back to XHR
processFiles(files);
return;
}
files.forEach(file => {
applyResumableRelativePath(file);
resumableInstance.addFile(file);
});
}
// Helper function to repeatedly call removeChunks.php
function removeChunkFolderRepeatedly(identifier, csrfToken, maxAttempts = 3, interval = 1000) {
function removeChunkFolderRepeatedly(identifier, csrfToken, targetFolder = null, maxAttempts = 3, interval = 1000) {
let attempt = 0;
const folder = (typeof targetFolder === "string" && targetFolder.trim() !== "")
? targetFolder
: (window.currentFolder || "root");
const removalInterval = setInterval(() => {
attempt++;
const params = new URLSearchParams();
// Prefix with "resumable_" to match your PHP regex.
params.append('folder', 'resumable_' + identifier);
params.append('csrf_token', csrfToken);
params.append('targetFolder', folder);
const sourceId = getActiveUploadSourceId();
if (sourceId) {
params.append('sourceId', sourceId);
@@ -588,7 +686,7 @@ function createFileEntry(file) {
// Call our helper repeatedly to remove the chunk folder.
if (file.uniqueIdentifier) {
removeChunkFolderRepeatedly(file.uniqueIdentifier, window.csrfToken, 3, 1000);
removeChunkFolderRepeatedly(file.uniqueIdentifier, window.csrfToken, file.targetFolder, 3, 1000);
}
li.remove();
@@ -688,9 +786,8 @@ function createFileEntry(file) {
/* -----------------------------------------------------
Processing Files
- For draganddrop, use original processing (supports folders).
- For file picker, if using Resumable, those files use resumable.
----------------------------------------------------- */
- Used for XHR fallback + grouping in the upload UI.
------------------------------------------------------ */
function processFiles(filesInput) {
const fileInfoContainer = document.getElementById("fileInfoContainer");
const files = Array.from(filesInput);
@@ -832,7 +929,7 @@ async function initResumableUpload() {
if (!resumableInstance) {
resumableInstance = new ResumableCtor({
target: RESUMABLE_TARGET,
chunkSize: 1.5 * 1024 * 1024,
chunkSize: getResumableChunkSizeBytes(),
simultaneousUploads: 3,
forceChunkSize: true,
testChunks: false,
@@ -882,6 +979,7 @@ async function initResumableUpload() {
// Initialize custom paused flag
file.paused = false;
file.uploadIndex = file.uniqueIdentifier;
file.targetFolder = window.currentFolder || "root";
if (!window.selectedFiles) {
window.selectedFiles = [];
}
@@ -1021,8 +1119,9 @@ async function initResumableUpload() {
if (removeBtn) removeBtn.style.display = "none";
setTimeout(() => li.remove(), 5000);
}
refreshFolderIcon(window.currentFolder);
loadFileList(window.currentFolder);
if (!hasFolderPaths([file])) {
scheduleUploadRefresh(window.currentFolder);
}
// This file finished successfully, remove its draft record
clearResumableDraft(file.uniqueIdentifier);
showResumableDraftBanner();
@@ -1058,7 +1157,11 @@ async function initResumableUpload() {
resumableInstance.on("complete", function () {
// If any file is marked with an error, leave the list intact.
const hasError = Array.isArray(window.selectedFiles) && window.selectedFiles.some(f => f.isError);
const files = Array.isArray(window.selectedFiles) ? window.selectedFiles : [];
const hadFolderPaths = hasFolderPaths(files);
const failed = files.filter(f => f && f.isError).length;
const succeeded = Math.max(0, files.length - failed);
const hasError = failed > 0;
if (!hasError) {
// All files succeeded—clear the file input and progress container after 5 seconds.
setTimeout(() => {
@@ -1087,8 +1190,15 @@ async function initResumableUpload() {
showResumableDraftBanner();
setUploadButtonVisible(false);
}, 5000);
if (succeeded > 0) {
scheduleUploadRefresh(window.currentFolder, true);
showToast(t('upload_summary_success', { succeeded }), 'success');
}
} else {
showToast(t('upload_failed_some'), 'warning');
showToast(t('upload_summary_failed', { failed, succeeded }), 'warning');
}
if (succeeded > 0 && hadFolderPaths) {
scheduleFolderTreeRefresh(window.currentFolder, true);
}
// In all cases, once Resumable has finished its batch, hide the ClamAV notice.
hideVirusScanNotice();
@@ -1104,8 +1214,8 @@ async function initResumableUpload() {
}
/* -----------------------------------------------------
XHR-based submitFiles for DragandDrop (Folder) Uploads
----------------------------------------------------- */
XHR-based submitFiles (fallback when Resumable is unavailable)
------------------------------------------------------ */
function submitFiles(allFiles) {
const folderToUse = (() => {
const f = window.currentFolder || "root";
@@ -1316,6 +1426,10 @@ function submitFiles(allFiles) {
});
function refreshFileList(allFiles, uploadResults, progressElements) {
const hadFolderPaths = hasFolderPaths(allFiles);
const transferSucceeded = Array.isArray(uploadResults)
? uploadResults.filter(Boolean).length
: 0;
loadFileList(folderToUse)
.then(serverFiles => {
initFileActions();
@@ -1383,8 +1497,10 @@ function submitFiles(allFiles) {
showToast(t('upload_may_have_failed'), 'warning');
})
.finally(() => {
// Folder list refresh + hide any ClamAV scan notice
loadFolderTree(window.currentFolder);
try { refreshFolderIcon(folderToUse); } catch (e) {}
if (hadFolderPaths && transferSucceeded > 0) {
scheduleFolderTreeRefresh(folderToUse, true);
}
hideVirusScanNotice();
});
}
@@ -1414,7 +1530,7 @@ function initUpload() {
fileInput.setAttribute("multiple", "");
}
// Draganddrop events (for folder uploads) use original processing.
// Draganddrop events use Resumable when available, XHR as fallback.
if (dropArea && !dropArea.__uploadBound) {
dropArea.__uploadBound = true;
dropArea.classList.add("upload-drop-area");
@@ -1437,11 +1553,11 @@ function initUpload() {
if (dt && dt.items && dt.items.length > 0) {
getFilesFromDataTransferItems(dt.items).then(files => {
if (files.length > 0) {
processFiles(files);
queueResumableFiles(files);
}
});
} else if (dt && dt.files && dt.files.length > 0) {
processFiles(dt.files);
queueResumableFiles(Array.from(dt.files));
}
});
@@ -1549,7 +1665,7 @@ document.addEventListener('paste', function handlePasteUpload(e) {
}
if (files.length > 0) {
processFiles(files);
queueResumableFiles(files);
showToast(t('upload_pasted_added'), 'success');
}
});
+56 -6
View File
@@ -5,6 +5,7 @@ declare(strict_types=1);
require_once __DIR__ . '/../../config/config.php';
require_once PROJECT_ROOT . '/src/models/AdminModel.php';
require_once PROJECT_ROOT . '/src/models/UserModel.php';
require_once PROJECT_ROOT . '/src/lib/CryptoAtRest.php';
require_once PROJECT_ROOT . '/src/models/FolderCrypto.php';
require_once PROJECT_ROOT . '/src/lib/SourceContext.php';
@@ -55,6 +56,36 @@ class AdminController
exit;
}
}
/** True if the current admin is the first admin in users.txt. */
private static function isPrimaryAdminUser(): bool
{
$user = $_SESSION['username'] ?? '';
if ($user === '') {
return false;
}
try {
$primary = UserModel::getPrimaryAdminUsername();
} catch (\Throwable $e) {
return false;
}
if ($primary === '') {
return false;
}
return strcasecmp($user, $primary) === 0;
}
/** Enforce primary admin (registered administrator) for Pro license actions. */
private static function requirePrimaryAdmin(): void
{
self::requireAdmin();
if (!self::isPrimaryAdminUser()) {
http_response_code(403);
header('Content-Type: application/json');
echo json_encode(['error' => 'Restricted to the registered administrator.']);
exit;
}
}
/** Get headers in lowercase, robust across SAPIs. */
private static function headersLower(): array
{
@@ -152,9 +183,12 @@ class AdminController
$publicOriginCfg = (string) ($ooCfg['publicOrigin'] ?? '');
$isAdmin = !empty($_SESSION['authenticated']) && !empty($_SESSION['isAdmin']);
$isPrimaryAdmin = $isAdmin && self::isPrimaryAdminUser();
// ---- Pro / license info (all guarded for clean core installs) ----
$licenseString = null;
if (defined('PRO_LICENSE_FILE') && PRO_LICENSE_FILE && @is_file(PRO_LICENSE_FILE)) {
if ($isPrimaryAdmin && defined('PRO_LICENSE_FILE') && PRO_LICENSE_FILE && @is_file(PRO_LICENSE_FILE)) {
$json = @file_get_contents(PRO_LICENSE_FILE);
if ($json !== false) {
$decoded = json_decode($json, true);
@@ -211,6 +245,7 @@ class AdminController
'apiLevel' => $proApiLevel,
'buildEpoch' => $proBuildEpoch,
'license' => $licenseString ?: '',
'primaryAdmin' => (bool)$isPrimaryAdmin,
'plan' => $proPlan ?: '',
'expiresAt' => $proExpiresAt ?: '',
'updatesUntil'=> $proUpdatesUntil ?: '',
@@ -227,8 +262,6 @@ class AdminController
'apiLevel' => $proApiLevel,
];
$isAdmin = !empty($_SESSION['authenticated']) && !empty($_SESSION['isAdmin']);
if ($isAdmin) {
// ---- Encryption at rest (master key status) ----
$encSupported = CryptoAtRest::isAvailable();
@@ -763,7 +796,7 @@ class AdminController
try {
// Same guards as other admin endpoints
self::requireAuth();
self::requireAdmin();
self::requirePrimaryAdmin();
self::requireCsrf();
$raw = file_get_contents('php://input');
@@ -1395,7 +1428,7 @@ class AdminController
}
self::requireAuth();
self::requireAdmin();
self::requirePrimaryAdmin();
self::requireCsrf();
// Ensure ZipArchive is available
@@ -1491,7 +1524,7 @@ class AdminController
}
self::requireAuth();
self::requireAdmin();
self::requirePrimaryAdmin();
self::requireCsrf();
$licenseRaw = '';
@@ -1708,6 +1741,9 @@ class AdminController
'globalOtpauthUrl' => '',
'enableWebDAV' => false,
'sharedMaxUploadSize' => 0,
'uploads' => [
'resumableChunkMb' => 1.5,
],
'oidc' => [
'providerUrl' => '',
'clientId' => '',
@@ -1830,6 +1866,20 @@ class AdminController
$merged['sharedMaxUploadSize'] = $sms;
}
// uploads: Resumable.js chunk size (MB)
if (isset($data['uploads']) && is_array($data['uploads'])) {
if (!isset($merged['uploads']) || !is_array($merged['uploads'])) {
$merged['uploads'] = [
'resumableChunkMb' => 1.5,
];
}
if (array_key_exists('resumableChunkMb', $data['uploads'])) {
$raw = $data['uploads']['resumableChunkMb'];
$num = is_numeric($raw) ? (float)$raw : 1.5;
$merged['uploads']['resumableChunkMb'] = max(0.5, min(100, $num));
}
}
// oidc: only overwrite non-empty inputs; validate when enabling OIDC
foreach (['providerUrl', 'clientId', 'clientSecret', 'redirectUri'] as $f) {
if (!empty($data['oidc'][$f])) {
+1 -1
View File
@@ -13,7 +13,7 @@ class UploadController
header('Content-Type: application/json');
$method = $_SERVER['REQUEST_METHOD'] ?? 'GET';
$requestParams = ($method === 'GET') ? $_GET : $_POST;
$requestParams = ($method === 'GET') ? $_GET : array_merge($_GET, $_POST);
// Detect Resumable.js chunk "test" requests (testChunks=true, default GET)
$isResumableTest =
+34
View File
@@ -152,6 +152,15 @@ class AdminModel
],
'demoMode' => (defined('FR_DEMO_MODE') && FR_DEMO_MODE),
];
$uploadsCfg = (isset($config['uploads']) && is_array($config['uploads']))
? $config['uploads']
: [];
$resumableChunkMb = (isset($uploadsCfg['resumableChunkMb']) && is_numeric($uploadsCfg['resumableChunkMb']))
? (float)$uploadsCfg['resumableChunkMb']
: 1.5;
$public['uploads'] = [
'resumableChunkMb' => max(0.5, min(100, $resumableChunkMb)),
];
$displayCfg = (isset($config['display']) && is_array($config['display']))
? $config['display']
: [];
@@ -383,6 +392,17 @@ class AdminModel
}
}
// ---- Upload tuning (Resumable.js) ----
if (!isset($configUpdate['uploads']) || !is_array($configUpdate['uploads'])) {
$configUpdate['uploads'] = [
'resumableChunkMb' => 1.5,
];
} else {
$raw = $configUpdate['uploads']['resumableChunkMb'] ?? 1.5;
$num = is_numeric($raw) ? (float)$raw : 1.5;
$configUpdate['uploads']['resumableChunkMb'] = max(0.5, min(100, $num));
}
// ---- ClamAV (simple boolean flag) ----
if (!isset($configUpdate['clamav']) || !is_array($configUpdate['clamav'])) {
$configUpdate['clamav'] = [
@@ -666,6 +686,17 @@ class AdminModel
$config['sharedMaxUploadSize'] = (int)min((int)$config['sharedMaxUploadSize'], $maxBytes);
}
// Upload tuning (Resumable.js chunk size in MB)
if (!isset($config['uploads']) || !is_array($config['uploads'])) {
$config['uploads'] = [
'resumableChunkMb' => 1.5,
];
} else {
$raw = $config['uploads']['resumableChunkMb'] ?? 1.5;
$num = is_numeric($raw) ? (float)$raw : 1.5;
$config['uploads']['resumableChunkMb'] = max(0.5, min(100, $num));
}
// ---- Ensure ONLYOFFICE structure exists, sanitize values ----
if (!isset($config['onlyoffice']) || !is_array($config['onlyoffice'])) {
$config['onlyoffice'] = [
@@ -816,6 +847,9 @@ class AdminModel
'globalOtpauthUrl' => "",
'enableWebDAV' => false,
'sharedMaxUploadSize' => min(50 * 1024 * 1024, self::parseSize(TOTAL_UPLOAD_SIZE)),
'uploads' => [
'resumableChunkMb' => 1.5,
],
'onlyoffice' => [
'enabled' => false,
'docsOrigin' => '',
+141 -53
View File
@@ -68,6 +68,49 @@ class FileModel
return rtrim((string)TRASH_DIR, '/\\') . DIRECTORY_SEPARATOR;
}
private static function shouldUseRemoteMarker(StorageAdapterInterface $storage): bool
{
if ($storage->isLocal()) {
return false;
}
if (class_exists('SourceContext')) {
$src = SourceContext::getActiveSource();
$type = strtolower((string)($src['type'] ?? ''));
return $type === 's3';
}
return true;
}
private static function remoteDirMarker(): string
{
return defined('FR_REMOTE_DIR_MARKER') ? (string)FR_REMOTE_DIR_MARKER : '.filerise_keep';
}
private static function ensureRemoteFolderMarker(StorageAdapterInterface $storage, string $dir): void
{
if (!self::shouldUseRemoteMarker($storage)) {
return;
}
$markerName = self::remoteDirMarker();
if ($markerName === '') {
return;
}
$dir = rtrim($dir, "/\\");
if ($dir === '' || $dir === '.') {
return;
}
$markerPath = $dir . DIRECTORY_SEPARATOR . $markerName;
try {
if ($storage->stat($markerPath) !== null) {
return;
}
$storage->mkdir($dir, 0775, true);
$storage->write($markerPath, '');
} catch (\Throwable $e) {
// Best-effort only; remote backends may not support markers.
}
}
/**
* Resolve a logical folder key (e.g. "root", "invoices/2025") to a
* real path under UPLOAD_DIR, enforce REGEX_FOLDER_NAME, and ensure
@@ -749,24 +792,38 @@ class FileModel
$errors = [];
$storage = self::storage();
$isLocal = $storage->isLocal();
$skipTrash = false;
if (!$isLocal && class_exists('SourceContext')) {
$src = SourceContext::getActiveSource();
if (is_array($src)) {
$type = strtolower((string)($src['type'] ?? ''));
if ($type === 'gdrive') {
$skipTrash = true;
}
}
}
list($uploadDir, $err) = self::resolveFolderPath($folder, false);
if ($err) return ["error" => $err];
$uploadDir .= DIRECTORY_SEPARATOR;
// Setup the Trash folder and metadata.
$trashDir = rtrim(self::trashRoot(), '/\\') . DIRECTORY_SEPARATOR;
if ($storage->stat($trashDir) === null) {
$storage->mkdir($trashDir, 0755, true);
}
$trashMetadataFile = $trashDir . "trash.json";
$trashDir = '';
$trashMetadataFile = '';
$trashData = [];
$trashJson = $storage->read($trashMetadataFile);
if ($trashJson !== false) {
$trashData = json_decode($trashJson, true);
}
if (!is_array($trashData)) {
$trashData = [];
if (!$skipTrash) {
$trashDir = rtrim(self::trashRoot(), '/\\') . DIRECTORY_SEPARATOR;
if ($storage->stat($trashDir) === null) {
$storage->mkdir($trashDir, 0755, true);
}
$trashMetadataFile = $trashDir . "trash.json";
$trashJson = $storage->read($trashMetadataFile);
if ($trashJson !== false) {
$trashData = json_decode($trashJson, true);
}
if (!is_array($trashData)) {
$trashData = [];
}
}
// Load folder metadata if available.
@@ -793,53 +850,74 @@ class FileModel
$filePath = $uploadDir . $basename;
// Check if file exists.
if ($storage->stat($filePath) !== null) {
// Unique trash name (timestamp + random)
$trashFileName = $basename . '_' . time() . '_' . bin2hex(random_bytes(4));
$trashTarget = $trashDir . $trashFileName;
$moved = $storage->move($filePath, $trashTarget);
if (!$moved) {
// Fallback for backends that don't support MOVE across collections.
$copied = $storage->copy($filePath, $trashTarget);
if ($copied) {
if ($storage->delete($filePath)) {
$moved = true;
} else {
// Best-effort cleanup to avoid leaving a duplicate in trash.
$storage->delete($trashTarget);
}
}
}
if ($moved) {
$movedToTrash[] = $basename;
// Record trash metadata for possible restoration.
$trashData[] = [
'type' => 'file',
'originalFolder' => $uploadDir,
'originalName' => $basename,
'trashName' => $trashFileName,
'trashedAt' => time(),
'uploaded' => $folderMetadata[$basename]['uploaded'] ?? "Unknown",
'uploader' => $folderMetadata[$basename]['uploader'] ?? "Unknown",
'deletedBy' => $_SESSION['username'] ?? "Unknown"
];
} else {
if (!$isLocal && $storage->delete($filePath)) {
if ($skipTrash) {
if (!$storage->delete($filePath)) {
if (!$isLocal && $storage->stat($filePath) === null) {
$deletedPermanent[] = $basename;
} else {
$errors[] = "Failed to move $basename to Trash.";
continue;
}
$errors[] = "Failed to delete $basename.";
continue;
}
} else {
// If file does not exist, consider it already removed.
$deletedPermanent[] = $basename;
continue;
}
// Local: check existence; Remote: avoid per-file stat unless needed.
if ($isLocal) {
if ($storage->stat($filePath) === null) {
$deletedPermanent[] = $basename;
continue;
}
}
// Unique trash name (timestamp + random)
$trashFileName = $basename . '_' . time() . '_' . bin2hex(random_bytes(4));
$trashTarget = $trashDir . $trashFileName;
$moved = $storage->move($filePath, $trashTarget);
if (!$moved) {
// Fallback for backends that don't support MOVE across collections.
$copied = $storage->copy($filePath, $trashTarget);
if ($copied) {
if ($storage->delete($filePath)) {
$moved = true;
} else {
// Best-effort cleanup to avoid leaving a duplicate in trash.
$storage->delete($trashTarget);
}
}
}
if ($moved) {
$movedToTrash[] = $basename;
// Record trash metadata for possible restoration.
$trashData[] = [
'type' => 'file',
'originalFolder' => $uploadDir,
'originalName' => $basename,
'trashName' => $trashFileName,
'trashedAt' => time(),
'uploaded' => $folderMetadata[$basename]['uploaded'] ?? "Unknown",
'uploader' => $folderMetadata[$basename]['uploader'] ?? "Unknown",
'deletedBy' => $_SESSION['username'] ?? "Unknown"
];
} else {
if (!$isLocal && $storage->delete($filePath)) {
$deletedPermanent[] = $basename;
} else {
if (!$isLocal && $storage->stat($filePath) === null) {
$deletedPermanent[] = $basename;
continue;
}
$errors[] = "Failed to move $basename to Trash.";
}
continue;
}
}
// Save updated trash metadata.
$storage->write($trashMetadataFile, json_encode($trashData, JSON_PRETTY_PRINT), LOCK_EX);
if (!$skipTrash) {
$storage->write($trashMetadataFile, json_encode($trashData, JSON_PRETTY_PRINT), LOCK_EX);
}
// Remove deleted file entries from folder metadata.
if (file_exists($metadataFile)) {
@@ -855,6 +933,12 @@ class FileModel
}
}
if (!$isLocal && strtolower((string)$folder) !== 'root') {
if (!empty($movedToTrash) || !empty($deletedPermanent)) {
self::ensureRemoteFolderMarker($storage, rtrim($uploadDir, '/\\'));
}
}
if (empty($errors)) {
$parts = [];
if (!empty($movedToTrash)) {
@@ -1014,6 +1098,10 @@ class FileModel
$errors[] = "Failed to update destination metadata.";
}
if (!$isLocal && !empty($movedFiles) && strtolower((string)$sourceFolder) !== 'root') {
self::ensureRemoteFolderMarker($storage, rtrim($sourceDir, '/\\'));
}
if (empty($errors)) {
return ["success" => "Files moved successfully"];
} else {
@@ -3188,13 +3276,13 @@ class FileModel
$localUploader = $id;
}
}
$skipContentForFtp = false;
$skipContentForRemote = false;
if (!$storage->isLocal() && class_exists('SourceContext')) {
$src = SourceContext::getActiveSource();
if (is_array($src)) {
$type = strtolower((string)($src['type'] ?? ''));
if ($type === 'ftp') {
$skipContentForFtp = true;
if (in_array($type, ['ftp', 'sftp', 'webdav', 'gdrive'], true)) {
$skipContentForRemote = true;
}
}
}
@@ -3286,7 +3374,7 @@ class FileModel
$fileEntry['contentTruncated'] = false;
if ($isText && $fileSizeBytes > 0) {
if ($skipContentForFtp) {
if ($skipContentForRemote) {
$fileEntry['contentTruncated'] = true;
} elseif ($fileSizeBytes <= INDEX_TEXT_BYTES_MAX) {
$snippet = $storage->read($filePath, LISTING_CONTENT_BYTES_MAX, 0);
+18 -1
View File
@@ -1571,15 +1571,32 @@ class FolderModel
if ($storage->isLocal()) {
$items = array_diff(@scandir($real) ?: [], array('.', '..'));
} else {
$markerName = defined('FR_REMOTE_DIR_MARKER') ? (string)FR_REMOTE_DIR_MARKER : '.filerise_keep';
$items = array_values(array_filter(
$storage->list($real),
static fn($n) => $n !== '.' && $n !== '..' && $n !== ''
static function ($n) use ($markerName) {
if ($n === '.' || $n === '..' || $n === '') {
return false;
}
if ($markerName !== '' && $n === $markerName) {
return false;
}
return true;
}
));
}
if (count($items) > 0) {
return ["error" => "Folder is not empty."];
}
if (!$storage->isLocal()) {
$markerName = defined('FR_REMOTE_DIR_MARKER') ? (string)FR_REMOTE_DIR_MARKER : '.filerise_keep';
if ($markerName !== '') {
$markerPath = rtrim($real, '/\\') . DIRECTORY_SEPARATOR . $markerName;
$storage->delete($markerPath);
}
}
if (!$storage->delete($real)) {
return ["error" => "Failed to delete folder."];
}
+211 -57
View File
@@ -34,6 +34,78 @@ class UploadModel
return rtrim((string)META_DIR, '/\\') . DIRECTORY_SEPARATOR;
}
private static function isLocalSourceType(): bool
{
if (!class_exists('SourceContext')) {
return true;
}
$src = SourceContext::getActiveSource();
$type = strtolower((string)($src['type'] ?? 'local'));
return $type === '' || $type === 'local';
}
private static function stagingRoot(bool $isLocal): string
{
if ($isLocal) {
return self::uploadRoot();
}
$base = rtrim(self::metaRoot(), '/\\') . DIRECTORY_SEPARATOR . 'uploadtmp' . DIRECTORY_SEPARATOR;
if (!is_dir($base)) {
@mkdir($base, 0775, true);
}
return $base;
}
private static function buildStorageDir(string $folderSan, string $relativeSubDir): string
{
$base = rtrim(self::uploadRoot(), '/\\');
$path = $base;
if ($folderSan !== '') {
$path .= DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, $folderSan);
}
if ($relativeSubDir !== '') {
$path .= DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, $relativeSubDir);
}
return $path;
}
private static function buildStoragePath(string $folderSan, string $relativeSubDir, string $fileName): string
{
$path = self::buildStorageDir($folderSan, $relativeSubDir);
return $path . DIRECTORY_SEPARATOR . $fileName;
}
private static function ensureRemoteUploadDir(StorageAdapterInterface $storage, string $folderSan, string $relativeSubDir): void
{
if ($storage->isLocal()) {
return;
}
$dir = rtrim(self::buildStorageDir($folderSan, $relativeSubDir), '/\\');
if ($dir === '' || $dir === '.') {
return;
}
try {
$stat = $storage->stat($dir);
if ($stat && ($stat['type'] ?? '') === 'dir') {
return;
}
$storage->mkdir($dir, 0775, true);
} catch (\Throwable $e) {
// Best-effort: some backends may auto-create paths on write.
}
}
private static function buildFolderForLog(string $folderSan, string $relativeSubDir = ''): string
{
$folderForLog = ($folderSan === '') ? 'root' : $folderSan;
if ($relativeSubDir !== '') {
$folderForLog = ($folderForLog === 'root')
? $relativeSubDir
: ($folderForLog . '/' . $relativeSubDir);
}
return $folderForLog;
}
private static function sanitizeFolder(string $folder): string
{
// decode "%20", normalise slashes & trim via ACL helper
@@ -60,6 +132,43 @@ class UploadModel
return $f; // safe, normalised, with spaces allowed
}
/**
* Parse a resumable relative path into [subDir, fileName].
* Returns [null, null] on invalid input.
*/
private static function parseRelativePath(string $raw): array
{
$raw = rawurldecode($raw);
$raw = str_replace('\\', '/', trim($raw));
$raw = preg_replace('/[\x00-\x1F\x7F]/', '', $raw);
$raw = ltrim($raw, '/');
if ($raw === '' || $raw === '.') {
return ['', ''];
}
if (preg_match('~(^|/)\.\.(?:/|$)~', $raw)) {
return [null, null];
}
if (preg_match('~(^|/)\.(?:/|$)~', $raw)) {
return [null, null];
}
$file = basename($raw);
if ($file === '' || !preg_match(REGEX_FILE_NAME, $file)) {
return [null, null];
}
$dir = dirname($raw);
if ($dir === '.' || $dir === '') {
return ['', $file];
}
if (!preg_match(REGEX_FOLDER_NAME, $dir)) {
return [null, null];
}
return [$dir, $file];
}
private static function portalMetaFromRequest(): ?array
{
$src = $_POST['source'] ?? $_GET['source'] ?? '';
@@ -157,7 +266,10 @@ class UploadModel
public static function handleUpload(array $post, array $files): array
{
$storage = StorageRegistry::getAdapter();
$isLocal = $storage->isLocal();
$isLocal = self::isLocalSourceType();
if (!$isLocal && $storage->isLocal()) {
return ['error' => 'Remote storage adapter unavailable.'];
}
// --- GET resumable test (make folder handling consistent) ---
if (
@@ -168,7 +280,7 @@ class UploadModel
$resumableIdentifier = $post['resumableIdentifier'] ?? '';
$folderSan = self::sanitizeFolder((string)($post['folder'] ?? 'root'));
$baseUploadDir = self::uploadRoot();
$baseUploadDir = self::stagingRoot($isLocal);
if ($folderSan !== '') {
$baseUploadDir = rtrim($baseUploadDir, '/\\') . DIRECTORY_SEPARATOR
. str_replace('/', DIRECTORY_SEPARATOR, $folderSan) . DIRECTORY_SEPARATOR;
@@ -186,6 +298,18 @@ class UploadModel
$totalChunks = (int)$post['resumableTotalChunks'];
$resumableIdentifier = $post['resumableIdentifier'] ?? '';
$resumableFilename = urldecode(basename($post['resumableFilename'] ?? ''));
$relativeSubDir = '';
if (!empty($post['resumableRelativePath'])) {
[$subDir, $relFile] = self::parseRelativePath((string)$post['resumableRelativePath']);
if ($subDir === null) {
return ['error' => 'Invalid relative path'];
}
if ($relFile !== '') {
$resumableFilename = $relFile;
}
$relativeSubDir = $subDir;
}
if (!preg_match(REGEX_FILE_NAME, $resumableFilename)) {
return ['error' => "Invalid file name: $resumableFilename"];
@@ -197,17 +321,19 @@ class UploadModel
return ['error' => 'No files received'];
}
$baseUploadDir = self::uploadRoot();
$createdDirs = [];
$stagingRoot = self::stagingRoot($isLocal);
$baseUploadDir = $stagingRoot;
if ($folderSan !== '') {
$baseUploadDir = rtrim($baseUploadDir, '/\\') . DIRECTORY_SEPARATOR
. str_replace('/', DIRECTORY_SEPARATOR, $folderSan) . DIRECTORY_SEPARATOR;
}
if (!is_dir($baseUploadDir) && !mkdir($baseUploadDir, 0775, true)) {
if (!self::ensureDir($baseUploadDir, $createdDirs)) {
return ['error' => 'Failed to create upload directory'];
}
$tempDir = $baseUploadDir . 'resumable_' . $resumableIdentifier . DIRECTORY_SEPARATOR;
if (!is_dir($tempDir) && !mkdir($tempDir, 0775, true)) {
if (!self::ensureDir($tempDir, $createdDirs)) {
return ['error' => 'Failed to create temporary chunk directory'];
}
@@ -229,19 +355,38 @@ class UploadModel
}
}
$cleanupChunk = function () use ($tempDir, $createdDirs, $stagingRoot, $isLocal): void {
self::rrmdir($tempDir);
if (!$isLocal) {
self::cleanupCreatedDirs($createdDirs, $stagingRoot);
}
};
// Merge
$targetPath = $baseUploadDir . $resumableFilename;
$targetDir = $baseUploadDir;
if ($relativeSubDir !== '') {
$targetDir = rtrim($baseUploadDir, '/\\') . DIRECTORY_SEPARATOR
. str_replace('/', DIRECTORY_SEPARATOR, $relativeSubDir) . DIRECTORY_SEPARATOR;
if (!self::ensureDir($targetDir, $createdDirs)) {
$cleanupChunk();
return ['error' => 'Failed to create upload subfolder'];
}
}
$targetPath = $targetDir . $resumableFilename;
if (!$out = fopen($targetPath, 'wb')) {
$cleanupChunk();
return ['error' => 'Failed to open target file for writing'];
}
for ($i = 1; $i <= $totalChunks; $i++) {
$chunkPath = $tempDir . $i;
if (!file_exists($chunkPath)) {
fclose($out);
$cleanupChunk();
return ['error' => "Chunk $i missing during merge"];
}
if (!$in = fopen($chunkPath, 'rb')) {
fclose($out);
$cleanupChunk();
return ['error' => "Failed to open chunk $i"];
}
while ($buff = fread($in, 4096)) {
@@ -252,7 +397,7 @@ class UploadModel
fclose($out);
// Optional: virus scan the merged file
$folderForLog = ($folderSan === '' ? 'root' : $folderSan);
$folderForLog = self::buildFolderForLog($folderSan, $relativeSubDir);
$scanResult = self::scanFileIfEnabled($targetPath, [
'folder' => $folderForLog,
'file' => $resumableFilename,
@@ -260,8 +405,7 @@ class UploadModel
]);
if (is_array($scanResult) && isset($scanResult['error'])) {
// Clean up temporary chunk directory
self::rrmdir($tempDir);
$cleanupChunk();
return $scanResult; // e.g. "Upload blocked: virus detected in file."
}
@@ -269,7 +413,7 @@ class UploadModel
try {
if (FolderCrypto::isEncryptedOrAncestor($folderForLog)) {
@unlink($targetPath);
self::rrmdir($tempDir);
$cleanupChunk();
return ['error' => 'Encrypted folders are not supported for remote storage.'];
}
} catch (\Throwable $e) { /* ignore */ }
@@ -290,7 +434,7 @@ class UploadModel
} catch (\Throwable $e) {
error_log('Upload encryption failed: ' . $e->getMessage());
@unlink($targetPath);
self::rrmdir($tempDir);
$cleanupChunk();
$msg = $e->getMessage();
if (!is_string($msg) || trim($msg) === '') {
$msg = 'Upload failed: could not encrypt file at rest.';
@@ -300,27 +444,29 @@ class UploadModel
}
if (!$isLocal) {
self::ensureRemoteUploadDir($storage, $folderSan, $relativeSubDir);
$remoteTargetPath = self::buildStoragePath($folderSan, $relativeSubDir, $resumableFilename);
$mimeType = function_exists('mime_content_type') ? mime_content_type($targetPath) : null;
$size = @filesize($targetPath);
$stream = @fopen($targetPath, 'rb');
if ($stream === false) {
@unlink($targetPath);
self::rrmdir($tempDir);
$cleanupChunk();
return ['error' => 'Failed to open file for remote upload.'];
}
$ok = $storage->writeStream($targetPath, $stream, ($size === false ? null : (int)$size), $mimeType ?: null);
$ok = $storage->writeStream($remoteTargetPath, $stream, ($size === false ? null : (int)$size), $mimeType ?: null);
@fclose($stream);
if (!$ok) {
$detail = self::adapterErrorDetail($storage);
@unlink($targetPath);
self::rrmdir($tempDir);
$cleanupChunk();
return ['error' => $detail !== '' ? ('Failed to upload to remote storage: ' . $detail) : 'Failed to upload to remote storage.'];
}
@unlink($targetPath);
}
// Metadata
$metadataKey = ($folderSan === '') ? 'root' : $folderSan;
$metadataKey = ($folderForLog === '' ? 'root' : $folderForLog);
$metadataFileName = str_replace(['/', '\\', ' '], '-', $metadataKey) . '_metadata.json';
$metadataFile = self::metaRoot() . $metadataFileName;
$uploadedDate = date(DATE_TIME_FORMAT);
@@ -346,8 +492,7 @@ class UploadModel
'meta' => self::portalMetaFromRequest(),
]);
// Cleanup temp
self::rrmdir($tempDir);
$cleanupChunk();
return ['success' => 'File uploaded successfully'];
}
@@ -362,7 +507,7 @@ class UploadModel
$baseUploadDir = rtrim($baseUploadDir, '/\\') . DIRECTORY_SEPARATOR
. str_replace('/', DIRECTORY_SEPARATOR, $folderSan) . DIRECTORY_SEPARATOR;
}
if (!self::ensureDir($baseUploadDir, $createdDirs)) {
if ($isLocal && !self::ensureDir($baseUploadDir, $createdDirs)) {
return ['error' => 'Failed to create upload directory'];
}
@@ -385,9 +530,6 @@ class UploadModel
}
$safeFileName = trim(urldecode(basename($fileName)));
if (!preg_match($safeFileNamePattern, $safeFileName)) {
return ['error' => 'Invalid file name: ' . $fileName];
}
$relativePath = '';
if (isset($post['relativePath'])) {
@@ -397,44 +539,49 @@ class UploadModel
}
$uploadDir = rtrim($baseUploadDir, '/\\') . DIRECTORY_SEPARATOR;
$relativeSubDir = '';
if (!empty($relativePath)) {
$subDir = dirname($relativePath);
if ($subDir !== '.' && $subDir !== '') {
[$subDir, $relFile] = self::parseRelativePath((string)$relativePath);
if ($subDir === null) {
return ['error' => 'Invalid relative path'];
}
if ($relFile !== '') {
$safeFileName = $relFile;
}
$relativeSubDir = $subDir;
if ($relativeSubDir !== '') {
$uploadDir = rtrim($baseUploadDir, '/\\') . DIRECTORY_SEPARATOR
. str_replace('/', DIRECTORY_SEPARATOR, $subDir) . DIRECTORY_SEPARATOR;
. str_replace('/', DIRECTORY_SEPARATOR, $relativeSubDir) . DIRECTORY_SEPARATOR;
}
$safeFileName = basename($relativePath);
}
if (!preg_match($safeFileNamePattern, $safeFileName)) {
return ['error' => 'Invalid file name: ' . $fileName];
}
if (!self::ensureDir($uploadDir, $createdDirs)) {
return ['error' => 'Failed to create subfolder: ' . $uploadDir];
}
$folderForLog = self::buildFolderForLog($folderSan, $relativeSubDir);
$targetPath = $uploadDir . $safeFileName;
if (!move_uploaded_file($files['file']['tmp_name'][$index], $targetPath)) {
return ['error' => 'Error uploading file'];
}
// Compute logical folder for logging: relative to UPLOAD_DIR
$folderForLog = 'root';
$rootDir = rtrim(self::uploadRoot(), '/\\') . DIRECTORY_SEPARATOR;
if (strpos($targetPath, $rootDir) === 0) {
$rel = substr($targetPath, strlen($rootDir));
$rel = str_replace(DIRECTORY_SEPARATOR, '/', $rel);
$slashPos = strrpos($rel, '/');
if ($slashPos !== false) {
$folderRel = substr($rel, 0, $slashPos);
if ($folderRel !== '') {
$folderForLog = $folderRel;
}
if ($isLocal) {
if (!self::ensureDir($uploadDir, $createdDirs)) {
return ['error' => 'Failed to create subfolder: ' . $uploadDir];
}
} elseif ($folderSan !== '') {
// Fallback: if above fails, use sanitized folder
$folderForLog = $folderSan;
$targetPath = $uploadDir . $safeFileName;
if (!move_uploaded_file($files['file']['tmp_name'][$index], $targetPath)) {
return ['error' => 'Error uploading file'];
}
$scanPath = $targetPath;
} else {
$tmpPath = $files['file']['tmp_name'][$index] ?? '';
if ($tmpPath === '' || !is_uploaded_file($tmpPath)) {
return ['error' => 'Error uploading file'];
}
self::ensureRemoteUploadDir($storage, $folderSan, $relativeSubDir);
$targetPath = self::buildStoragePath($folderSan, $relativeSubDir, $safeFileName);
$scanPath = $tmpPath;
}
// Optional: virus scan this file
$scanResult = self::scanFileIfEnabled($targetPath, [
$scanResult = self::scanFileIfEnabled($scanPath, [
'folder' => $folderForLog,
'file' => $safeFileName,
'source' => 'normal', // core non-resumable upload
@@ -448,7 +595,7 @@ class UploadModel
if (!$isLocal) {
try {
if (FolderCrypto::isEncryptedOrAncestor($folderForLog)) {
@unlink($targetPath);
@unlink($scanPath);
return ['error' => 'Encrypted folders are not supported for remote storage.'];
}
} catch (\Throwable $e) { /* ignore */ }
@@ -478,21 +625,21 @@ class UploadModel
}
if (!$isLocal) {
$mimeType = function_exists('mime_content_type') ? mime_content_type($targetPath) : null;
$size = @filesize($targetPath);
$stream = @fopen($targetPath, 'rb');
$mimeType = function_exists('mime_content_type') ? mime_content_type($scanPath) : null;
$size = @filesize($scanPath);
$stream = @fopen($scanPath, 'rb');
if ($stream === false) {
@unlink($targetPath);
@unlink($scanPath);
return ['error' => 'Failed to open file for remote upload.'];
}
$ok = $storage->writeStream($targetPath, $stream, ($size === false ? null : (int)$size), $mimeType ?: null);
@fclose($stream);
if (!$ok) {
$detail = self::adapterErrorDetail($storage);
@unlink($targetPath);
@unlink($scanPath);
return ['error' => $detail !== '' ? ('Failed to upload to remote storage: ' . $detail) : 'Failed to upload to remote storage.'];
}
@unlink($targetPath);
@unlink($scanPath);
}
$uploader = $_SESSION['username'] ?? 'Unknown';
@@ -704,7 +851,14 @@ class UploadModel
return ['error' => 'Invalid folder name'];
}
$tempDir = rtrim(self::uploadRoot(), '/\\') . DIRECTORY_SEPARATOR . $folder;
$isLocal = self::isLocalSourceType();
$targetFolder = self::sanitizeFolder((string)($_POST['targetFolder'] ?? 'root'));
$baseDir = self::stagingRoot($isLocal);
if ($targetFolder !== '') {
$baseDir = rtrim($baseDir, '/\\') . DIRECTORY_SEPARATOR
. str_replace('/', DIRECTORY_SEPARATOR, $targetFolder) . DIRECTORY_SEPARATOR;
}
$tempDir = $baseDir . $folder;
if (!is_dir($tempDir)) {
return ['success' => true, 'message' => 'Temporary folder already removed.'];
}
+34
View File
@@ -193,6 +193,40 @@ class userModel
return $users;
}
/**
* Return the first admin username from users.txt order.
*/
public static function getPrimaryAdminUsername(): string
{
self::ensureUserCaseMigration();
$usersFile = USERS_DIR . USERS_FILE;
if (!file_exists($usersFile)) {
return '';
}
$lines = file($usersFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: [];
foreach ($lines as $line) {
$line = trim($line);
if ($line === '') {
continue;
}
$parts = explode(':', $line);
if (count($parts) < 3) {
continue;
}
$username = $parts[0];
$role = trim((string)$parts[2]);
if (!preg_match(REGEX_USER, $username)) {
continue;
}
if ($role === '1') {
return $username;
}
}
return '';
}
/**
* Add a user.
*
+2 -1
View File
@@ -223,7 +223,8 @@ case "${LOG_STREAM,,}" in
error|*)STREAM_ERR=true; STREAM_ACC=false ;;
esac
echo "🔥 Starting Apache..."
echo "🔥 Starting Apache (foreground)..."
echo "[startup] FileRise startup complete. Any further output will be Apache logs (errors by default)."
# Stream only the chosen logs; -n0 = don't dump history, -F = follow across rotations/creation
[ "${STREAM_ERR}" = "true" ] && tail -n0 -F /var/www/metadata/log/error.log 2>/dev/null &
[ "${STREAM_ACC}" = "true" ] && tail -n0 -F /var/www/metadata/log/access.log 2>/dev/null &