mirror of
https://github.com/error311/FileRise.git
synced 2026-05-12 06:50:54 -05:00
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:
@@ -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.5–100 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
@@ -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
@@ -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
@@ -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">×</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
@@ -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;
|
||||
|
||||
@@ -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 = [];
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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
@@ -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 drag–and–drop, 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 Drag–and–Drop (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", "");
|
||||
}
|
||||
|
||||
// Drag–and–drop events (for folder uploads) use original processing.
|
||||
// Drag–and–drop 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');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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])) {
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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
@@ -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.'];
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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 &
|
||||
|
||||
Reference in New Issue
Block a user