diff --git a/CHANGELOG.md b/CHANGELOG.md index c11c392..d9e57cd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,61 @@ # Changelog +## Changes 12/5/2025 (v2.3.3) + +release(v2.3.3): footer branding, Pro bundle UX + file list polish + +**Branding & footer** + +- Added **Pro-only footer branding** (`branding.footerHtml`) stored in `adminConfig.json` and exposed via the Admin API. +- Footer is now rendered from config; if no Pro footer is set, FileRise shows: + `© YEAR FileRise` with a link to **filerise.net**. +- New **“Header & Footer settings”** section in the Admin Panel, with a textarea for footer HTML (simple HTML + links allowed for Pro users). + +**FileRise Pro & license UX** + +- Bumped UI hint to `PRO_LATEST_BUNDLE_VERSION = v1.2.1`. +- Pro bundle install now: + - Parses the version from the uploaded ZIP basename (works with `C:\fakepath\FileRisePro-v1.2.1.zip`). + - Invalidates OPcache for updated Pro files so new code is active immediately. + - Re-fetches admin config after a successful install and displays the actual active Pro bundle version in the status line. +- Admin config now exposes richer Pro metadata (plan, expiresAt, maxMajor), and the Admin Panel shows: + - License type + email, + - Friendly **plan** description (early supporter vs personal/business), + - **Lifetime** vs **Valid until …** wording instead of a scary raw timestamp. + +**Upload UX** + +- Upload button is now only visible/enabled when there are files queued (regular or resumable): + - Hidden when the list is empty or after clearing uploads. + - Shown again when user picks or drags in files. +- Adjusted Upload / Choose Files button sizing and spacing for a cleaner upload card, especially on smaller screens. + +**File list & hover preview polish** + +- Inline folders now respect the current sort mode: + - **Name** sort: A–Z / Z–A. + - **Size** sort: uses folder stats (bytes) and sorts accordingly. +- Size and meta columns: + - Right-aligned **size**, **uploaded/created**, **modified**, and **owner/uploader** columns. + - Use tabular numerals for nicer numeric alignment. +- Hover preview: + - Skips “fake” rows (e.g. “No files found”) and rows that don’t resolve to a real file. + - Uses `sizeBytes` + `formatSize()` for a consistent, human-readable size. +- `formatSize()` now uses 1 decimal place (KB/MB/GB) and short `B` label for bytes. +- File metadata normalization: + - Every file gets a `sizeBytes`, normalized display `size`, and a `cacheKey` derived from modified/uploaded/size, used for stable cache-busting. +- Gallery / preview URLs now use `apiFileUrl()` with a stable `t` parameter instead of `Date.now()`, improving browser caching behavior. + +**Layout & animation tweaks** + +- Slightly reduced default upload card padding and button sizes to make the homepage cards feel less “tall”. +- New **site footer** styling (subtle border, centered text) added below the main layout. +- Drag-and-drop card (upload/folder cards to header dock) animations: + - Crisper ghost cards with better text opacity and anti-jank tweaks. + - Longer, smoother easing and more readable motion (both collapse-to-header and expand-from-header). + +--- + ## Changes 12/3/2025 (v2.3.2) release(v2.3.2): fix media preview URLs and tighten hover card layout diff --git a/public/css/styles.css b/public/css/styles.css index c60f59d..f83dff1 100644 --- a/public/css/styles.css +++ b/public/css/styles.css @@ -543,21 +543,22 @@ body{letter-spacing: 0.2px; flex-direction: column; align-items: flex-end; gap: 5px;} -#uploadBtn{font-size: 20px; - padding: 10px 22px; - align-items: center;} +#uploadBtn{font-size: 18px; + padding: 10px 18px; + align-items: center; + margin-top:20px;} .card-body.d-flex.flex-column{padding: 0.75rem !important;} #customChooseBtn{background-color: #9E9E9E; color: #fff; border: none; border-radius: 4px; - padding: 8px 18px; - font-size: 16px; + padding: 8px 14px; + font-size: 14px; cursor: pointer; white-space: nowrap;} @media (max-width: 768px) { - #customChooseBtn{font-size: 14px; - padding: 6px 14px;} + #customChooseBtn{font-size: 12px; + padding: 6px 10px;} } .pause-resume-btn{background: none; border: none; @@ -772,7 +773,7 @@ body:not(.dark-mode) .material-icons.pauseResumeBtn:hover{background-color: rgba text-align: left !important; line-height: 1.2 !important; vertical-align: middle !important; - padding: 8px 10px !important; + padding: 2px 4px !important; max-width: 250px !important; min-width: 120px !important;} @media (min-width: 500px) { @@ -1442,8 +1443,6 @@ label{font-size: 0.9rem;} #folderManagementCard{transition: transform 0.3s ease, opacity 0.3s ease; width: 100%; margin-bottom: 20px; - min-height: 320px; - border-radius: var(--menu-radius); overflow: hidden; border: 1px solid var(--card-border, #e5e7eb); @@ -2924,4 +2923,21 @@ th[data-column="actions"]::after { overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; +} + +.site-footer { + margin-top: 12px; + padding: 8px 16px; + font-size: 0.8rem; + color: var(--filr-muted-text, #777); + border-top: 1px solid rgba(0,0,0,0.06); + display: flex; + justify-content: center; + align-items: center; +} + +.site-footer span { + text-align: center; + max-width: 100%; + white-space: normal; } \ No newline at end of file diff --git a/public/index.html b/public/index.html index 457ed67..0b8e34e 100644 --- a/public/index.html +++ b/public/index.html @@ -188,7 +188,7 @@
Upload Files/Folders
-
+
Drop files/folders here or click 'Choose @@ -199,7 +199,7 @@
-
@@ -216,7 +216,7 @@
-
+
@@ -538,5 +538,14 @@
+ + \ No newline at end of file diff --git a/public/js/adminPanel.js b/public/js/adminPanel.js index 79e78bc..fdf9cc7 100644 --- a/public/js/adminPanel.js +++ b/public/js/adminPanel.js @@ -20,7 +20,7 @@ function normalizeLogoPath(raw) { const version = window.APP_VERSION || "dev"; // Hard-coded *FOR NOW* latest FileRise Pro bundle version for UI hints only. // Update this when I cut a new Pro ZIP. -const PRO_LATEST_BUNDLE_VERSION = 'v1.2.0'; +const PRO_LATEST_BUNDLE_VERSION = 'v1.2.1'; function getAdminTitle(isPro, proVersion) { const corePill = ` @@ -110,6 +110,25 @@ function applyHeaderColorsFromAdmin() { console.warn('Failed to live-update header colors from admin panel', e); } } +function applyFooterFromAdmin() { + try { + const footerEl = document.getElementById('siteFooter'); + if (!footerEl) return; + + const val = (document.getElementById('brandingFooterHtml')?.value || '').trim(); + if (val) { + // Allow HTML here – rely on backend sanitizing what gets stored. + footerEl.innerHTML = val; + } else { + const year = new Date().getFullYear(); + footerEl.innerHTML = + `© ${year} FileRise`; + } + } catch (e) { + console.warn('Failed to live-update footer from admin panel', e); + } +} + function updateHeaderLogoFromAdmin() { try { const input = document.getElementById('brandingCustomLogoUrl'); @@ -295,6 +314,7 @@ function captureInitialAdminConfig() { brandingCustomLogoUrl: (document.getElementById("brandingCustomLogoUrl")?.value || "").trim(), brandingHeaderBgLight: (document.getElementById("brandingHeaderBgLight")?.value || "").trim(), brandingHeaderBgDark: (document.getElementById("brandingHeaderBgDark")?.value || "").trim(), + brandingFooterHtml: (document.getElementById("brandingFooterHtml")?.value || "").trim(), }; } function hasUnsavedChanges() { @@ -315,7 +335,8 @@ function hasUnsavedChanges() { getVal("globalOtpauthUrl") !== o.globalOtpauthUrl || getVal("brandingCustomLogoUrl") !== (o.brandingCustomLogoUrl || "") || getVal("brandingHeaderBgLight") !== (o.brandingHeaderBgLight || "") || - getVal("brandingHeaderBgDark") !== (o.brandingHeaderBgDark || "") + getVal("brandingHeaderBgDark") !== (o.brandingHeaderBgDark || "") || + getVal("brandingFooterHtml") !== (o.brandingFooterHtml || "") ); } @@ -409,13 +430,42 @@ export function initProBundleInstaller() { return; } - const versionText = data.proVersion ? ` (version ${data.proVersion})` : ''; + // --- NEW: ask the server what version is now active via getConfig.php --- + let finalVersion = ''; + try { + const cfgRes = await fetch('/api/admin/getConfig.php?ts=' + Date.now(), { + credentials: 'include', + cache: 'no-store', + headers: { 'Cache-Control': 'no-store' } + }); + const cfg = await safeJson(cfgRes).catch(() => null); + const cfgVersion = cfg && cfg.pro && cfg.pro.version; + if (cfgVersion) { + finalVersion = String(cfgVersion); + } + } catch (e) { + // If this fails, just fall back to whatever installProBundle gave us. + console.warn('Failed to refresh config after Pro bundle install', e); + } + + if (!finalVersion && data.proVersion) { + finalVersion = String(data.proVersion); + } + + const versionText = finalVersion ? ` (version ${finalVersion})` : ''; statusEl.textContent = 'Pro bundle installed' + versionText + '. Reload the page to apply changes.'; statusEl.className = 'small text-success'; + // Clear file input so repeat installs feel "fresh" + try { fileInput.value = ''; } catch (_) {} + + // Keep existing behavior: refresh any admin config in the header, etc. if (typeof loadAdminConfigFunc === 'function') { loadAdminConfigFunc(); } + setTimeout(() => { + window.location.reload(); + }, 800); } catch (e) { statusEl.textContent = 'Install failed: ' + (e && e.message ? e.message : String(e)); statusEl.className = 'small text-danger'; @@ -537,10 +587,19 @@ export function openAdminPanel() { const proEmail = proInfo.email || ''; const proVersion = proInfo.version || 'not installed'; const proLicense = proInfo.license || ''; + // New: richer license metadata from FR_PRO_INFO / backend + const proPlan = proInfo.plan || ''; // e.g. "early_supporter_1x", "personal_yearly" + const proExpiresAt = proInfo.expiresAt || ''; // ISO timestamp string or "" + const proMaxMajor = ( + typeof proInfo.maxMajor === 'number' + ? proInfo.maxMajor + : (proInfo.maxMajor ? Number(proInfo.maxMajor) : null) + ); const brandingCfg = config.branding || {}; const brandingCustomLogoUrl = brandingCfg.customLogoUrl || ""; const brandingHeaderBgLight = brandingCfg.headerBgLight || ""; const brandingHeaderBgDark = brandingCfg.headerBgDark || ""; + const brandingFooterHtml = brandingCfg.footerHtml || ""; const bg = dark ? "rgba(0,0,0,0.7)" : "rgba(0,0,0,0.3)"; const inner = ` background:${dark ? "#2c2c2c" : "#fff"}; @@ -569,7 +628,7 @@ export function openAdminPanel() {
${[ { id: "userManagement", label: t("user_management") }, - { id: "headerSettings", label: t("header_settings") }, + { id: "headerSettings", label: tf("header_footer_settings", "Header & Footer settings") }, { id: "loginOptions", label: t("login_options") }, { id: "webdav", label: "WebDAV Access" }, { id: "onlyoffice", label: "ONLYOFFICE" }, @@ -758,8 +817,8 @@ export function openAdminPanel() { ${isPro - ? 'Upload a logo image or paste a local path.' - : 'Requires FileRise Pro to enable custom header branding.'} + ? 'Upload a logo image or paste a local path.' + : 'Requires FileRise Pro to enable custom header branding.'}
@@ -818,12 +877,30 @@ export function openAdminPanel() {
- ${isPro - ? 'If left empty, FileRise uses its default blue and dark header colors.' - : 'Requires FileRise Pro to enable custom color branding.'} - + ${isPro + ? 'If left empty, FileRise uses its default blue and dark header colors.' + : 'Requires FileRise Pro to enable custom color branding.'} + + +
+ + + ${isPro + ? 'Shown at the bottom of every page. You can include simple HTML like links.' + : 'Requires FileRise Pro to customize footer text.'} + + +
`; wireHeaderTitleLive(); @@ -946,26 +1023,57 @@ export function openAdminPanel() { const hasLatest = !!norm(latestVersionRaw); const hasUpdate = hasCurrent && hasLatest && norm(currentVersionRaw) !== norm(latestVersionRaw); - const proMetaHtml = - isPro && (proType || proEmail || proVersion) - ? ` -
-
- ✅ ${proType ? `License type: ${proType}` : 'License active'} - ${proType && proEmail ? ' • ' : ''} - ${proEmail ? `Licensed to: ${proEmail}` : ''} -
- ${hasCurrent ? ` -
- Installed Pro bundle: v${norm(currentVersionRaw)} -
` : ''} - ${hasLatest ? ` -
- Latest Pro bundle (UI hint): ${latestVersionRaw} -
` : ''} -
- ` - : ''; + // Friendly description of plan + lifetime/expiry + let planLabel = ''; + if (proPlan === 'early_supporter_1x' || (!proPlan && isPro)) { + const mj = proMaxMajor || 1; + planLabel = `Early supporter – lifetime for FileRise Pro ${mj}.x`; + } else if (proPlan) { + if (proPlan.startsWith('personal_') || proPlan === 'personal_yearly') { + planLabel = 'Personal license'; + } else if (proPlan.startsWith('business_') || proPlan === 'business_yearly') { + planLabel = 'Business license'; + } else { + planLabel = proPlan; + } + } + + let expiryLabel = ''; + if (proPlan === 'early_supporter_1x' || (!proPlan && isPro)) { + // Early supporters: we treat as lifetime for that major – do NOT show an expiry date + expiryLabel = 'Lifetime license (no expiry)'; + } else if (proExpiresAt) { + expiryLabel = `Valid until ${proExpiresAt}`; + } + + const proMetaHtml = + isPro && (proType || proEmail || proVersion || planLabel || expiryLabel) + ? ` +
+
+ ✅ ${proType ? `License type: ${proType}` : 'License active'} + ${proType && proEmail ? ' • ' : ''} + ${proEmail ? `Licensed to: ${proEmail}` : ''} +
+ ${planLabel ? ` +
+ Plan: ${planLabel} +
` : ''} + ${expiryLabel ? ` +
+ ${expiryLabel} +
` : ''} + ${hasCurrent ? ` +
+ Installed Pro bundle: v${norm(currentVersionRaw)} +
` : ''} + ${hasLatest ? ` +
+ Latest Pro bundle (UI hint): ${latestVersionRaw} +
` : ''} +
+ ` + : ''; proContent.innerHTML = `
@@ -1309,6 +1417,7 @@ function handleSave() { customLogoUrl: (document.getElementById("brandingCustomLogoUrl")?.value || "").trim(), headerBgLight: (document.getElementById("brandingHeaderBgLight")?.value || "").trim(), headerBgDark: (document.getElementById("brandingHeaderBgDark")?.value || "").trim(), + footerHtml: (document.getElementById("brandingFooterHtml")?.value || "").trim(), }, }; @@ -1348,6 +1457,7 @@ function handleSave() { closeAdminPanel(); applyHeaderColorsFromAdmin(); updateHeaderLogoFromAdmin(); + applyFooterFromAdmin(); }) .catch(() => showToast('Save failed.')); } diff --git a/public/js/dragAndDrop.js b/public/js/dragAndDrop.js index 6ae58bb..40c0d08 100644 --- a/public/js/dragAndDrop.js +++ b/public/js/dragAndDrop.js @@ -80,7 +80,6 @@ function createCardGhost(card, rect, opts) { const ghost = card.cloneNode(true); const cs = window.getComputedStyle(card); - // Give the ghost the same “card” chrome even though it’s attached to Object.assign(ghost.style, { position: 'fixed', left: rect.left + 'px', @@ -94,7 +93,6 @@ function createCardGhost(card, rect, opts) { transform: 'scale(' + scale + ')', opacity: String(opacity), - // pull key visuals from the real card backgroundColor: cs.backgroundColor || 'rgba(24,24,24,.96)', borderRadius: cs.borderRadius || '', boxShadow: cs.boxShadow || '', @@ -102,8 +100,17 @@ function createCardGhost(card, rect, opts) { borderWidth: cs.borderWidth || '', borderStyle: cs.borderStyle || '', backdropFilter: cs.backdropFilter || '', + + // ✨ make the ghost crisper + overflow: 'hidden', + willChange: 'transform, opacity', + backfaceVisibility: 'hidden' }); + // Subtle: de-emphasize inner text so it doesn’t look “smeared” + const ghBody = ghost.querySelector('.card-body'); + if (ghBody) ghBody.style.opacity = '0.6'; + return ghost; } @@ -396,7 +403,7 @@ function animateCardsIntoHeaderAndThen(done) { return { card, rect }; }); - // Show dock so icons exist / have positions + // Make sure header dock is visible so icons are laid out showHeaderDockPersistent(); // Move real cards into header (hidden container + icons) @@ -410,16 +417,16 @@ function animateCardsIntoHeaderAndThen(done) { // remember the size for the expand animation later card.dataset.lastWidth = String(rect.width); card.dataset.lastHeight = String(rect.height); - + const iconBtn = card.headerIconButton; if (!iconBtn) return; - + const iconRect = iconBtn.getBoundingClientRect(); - - const ghost = createCardGhost(card, rect, { scale: 1, opacity: 1 }); + + const ghost = createCardGhost(card, rect, { scale: 1, opacity: 0.95 }); ghost.id = card.id + '-ghost-collapse'; ghost.classList.add('card-collapse-ghost'); - ghost.style.transition = 'transform 0.22s ease-out, opacity 0.22s ease-out'; + ghost.style.transition = 'transform 0.4s cubic-bezier(.22,.61,.36,1), opacity 0.4s linear'; document.body.appendChild(ghost); ghosts.push({ ghost, from: rect, to: iconRect }); @@ -430,6 +437,7 @@ function animateCardsIntoHeaderAndThen(done) { return; } + // Kick off motion on next frame requestAnimationFrame(() => { ghosts.forEach(({ ghost, from, to }) => { const fromCx = from.left + from.width / 2; @@ -441,17 +449,18 @@ function animateCardsIntoHeaderAndThen(done) { const dy = toCy - fromCy; const rawScale = to.width / from.width; - const scale = Math.max(0.25, Math.min(0.5, rawScale * 0.9)); + const scale = Math.max(0.35, Math.min(0.6, rawScale * 0.9)); + // ✨ more readable: clear slide + shrink, but don’t fully vanish mid-flight ghost.style.transform = `translate(${dx}px, ${dy}px) scale(${scale})`; - ghost.style.opacity = '0'; + ghost.style.opacity = '0.35'; }); }); setTimeout(() => { ghosts.forEach(({ ghost }) => { try { ghost.remove(); } catch {} }); done(); - }, 260); + }, 430); // a bit over the 0.4s transition } function resolveTargetZoneForExpand(cardId) { @@ -508,9 +517,9 @@ function animateCardsOutOfHeaderThen(done) { if (sb) sb.style.display = ''; if (top) top.style.display = ''; - const SAFE_TOP = 16; // minimum distance from top of viewport - const START_OFFSET_Y = 40; // how far BELOW the icon we start the ghost - const DEST_EXTRA_Y = 120; // how far down into the zone center we aim + const SAFE_TOP = 16; + const START_OFFSET_Y = 32; // a touch closer to header + const DEST_EXTRA_Y = 120; const ghosts = []; @@ -528,24 +537,20 @@ function animateCardsOutOfHeaderThen(done) { const zoneRect = host.getBoundingClientRect(); if (!zoneRect.width) return; - // Where the ghost "comes from" (near the icon) const fromCx = iconRect.left + iconRect.width / 2; - const fromCy = iconRect.bottom + START_OFFSET_Y; // lower starting point + const fromCy = iconRect.bottom + START_OFFSET_Y; - // Where we want it to "land" (roughly center of the zone, a bit down) let toCx = zoneRect.left + zoneRect.width / 2; let toCy = zoneRect.top + Math.min(zoneRect.height / 2 || DEST_EXTRA_Y, DEST_EXTRA_Y); - // 🔹 If both cards are going to the sidebar, offset them so they don't stack if (zoneId === ZONES.SIDEBAR) { if (card.id === 'uploadCard') { - toCy -= 48; // a bit higher + toCy -= 48; } else if (card.id === 'folderManagementCard') { - toCy += 48; // a bit lower + toCy += 48; } } - // Try to match the real card size we captured during collapse const savedW = parseFloat(card.dataset.lastWidth || ''); const savedH = parseFloat(card.dataset.lastHeight || ''); const targetWidth = !Number.isNaN(savedW) @@ -553,10 +558,8 @@ function animateCardsOutOfHeaderThen(done) { : Math.min(280, Math.max(220, zoneRect.width * 0.85)); const targetHeight = !Number.isNaN(savedH) ? savedH : 190; - // Make sure the top of the ghost never goes above SAFE_TOP const startTop = Math.max(SAFE_TOP, fromCy - targetHeight / 2); - // Build a rect for our ghost and use createCardGhost so we KEEP bg/border/shadow. const ghostRect = { left: fromCx - targetWidth / 2, top: startTop, @@ -564,13 +567,12 @@ function animateCardsOutOfHeaderThen(done) { height: targetHeight }; - const ghost = createCardGhost(card, ghostRect, { scale: 0.7, opacity: 0 }); + const ghost = createCardGhost(card, ghostRect, { scale: 0.75, opacity: 0.25 }); ghost.id = card.id + '-ghost-expand'; ghost.classList.add('card-expand-ghost'); - // Override transform/transition for our flight animation - ghost.style.transform = 'translate(0,0) scale(0.7)'; - ghost.style.transition = 'transform 0.25s ease-out, opacity 0.25s ease-out'; + ghost.style.transform = 'translate(0,0) scale(0.75)'; + ghost.style.transition = 'transform 0.4s cubic-bezier(.22,.61,.36,1), opacity 0.4s linear'; document.body.appendChild(ghost); ghosts.push({ @@ -586,7 +588,6 @@ function animateCardsOutOfHeaderThen(done) { return; } - // Kick off the flight on the next frame requestAnimationFrame(() => { ghosts.forEach(({ ghost, from, to }) => { const dx = to.cx - from.cx; @@ -596,13 +597,12 @@ function animateCardsOutOfHeaderThen(done) { }); }); - // Clean up ghosts and then do real layout restore setTimeout(() => { ghosts.forEach(({ ghost }) => { try { ghost.remove(); } catch {} }); done(); - }, 280); // just over the 0.25s transition + }, 430); } // -------------------- zones toggle (collapse to header) -------------------- diff --git a/public/js/fileListView.js b/public/js/fileListView.js index cd07af4..57cb428 100644 --- a/public/js/fileListView.js +++ b/public/js/fileListView.js @@ -721,17 +721,31 @@ async function fetchFolderPeek(folder) { /* =========================================================== SECURITY: build file URLs only via the API (no /uploads) =========================================================== */ -function apiFileUrl(folder, name, inline = false) { - const f = folder && folder !== "root" ? folder : "root"; - const q = new URLSearchParams({ - folder: f, - file: name, - inline: inline ? "1" : "0", - t: String(Date.now()) // cache-bust - }); - return `/api/file/download.php?${q.toString()}`; -} - + function apiFileUrl(folder, name, inline = false) { + const fParam = folder && folder !== "root" ? folder : "root"; + const q = new URLSearchParams({ + folder: fParam, + file: name, + inline: inline ? "1" : "0" + }); + + // Try to find this file in fileData to get a stable cache key + try { + if (Array.isArray(fileData)) { + const meta = fileData.find( + f => f.name === name && (f.folder || "root") === fParam + ); + if (meta) { + const v = meta.cacheKey || meta.modified || meta.uploaded || meta.sizeBytes; + if (v != null && v !== "") { + q.set("t", String(v)); // stable per-file token + } + } + } + } catch { /* best-effort only */ } + + return `/api/file/download.php?${q.toString()}`; + } // Wire "select all" header checkbox for the current table render function wireSelectAll(fileListContent) { // Be flexible about how the header checkbox is identified @@ -915,20 +929,31 @@ fetchFolderPeek(folderPath).then(result => { // ====================== // FILE HOVER PREVIEW // ====================== - const name = row.getAttribute("data-file-name") || ""; - const file = fileData.find(f => f.name === name) || null; + const name = row.getAttribute("data-file-name"); + + // If this row isn't a real file row (e.g. "No files found"), don't show hover preview. + if (!name) { + hoverPreviewContext = null; + hideHoverPreview(); + return; + } + + const file = Array.isArray(fileData) + ? fileData.find(f => f.name === name) + : null; + + // If we can't resolve a real file from fileData, also skip the preview + if (!file) { + hoverPreviewContext = null; + hideHoverPreview(); + return; + } hoverPreviewContext = { type: "file", file }; - if (!file) { - titleEl.textContent = name || "(unknown)"; - metaEl.textContent = ""; - return; - } - titleEl.textContent = file.name; // IMPORTANT: no duplicate "size • modified • owner" under the title @@ -977,8 +1002,17 @@ fetchFolderPeek(folderPath).then(result => { if (ext) { props.push(`
${t("extension") || "Ext"}: .${escapeHTML(ext)}
`); } - if (file.size) { - props.push(`
${t("size") || "Size"}: ${escapeHTML(file.size)}
`); + if (Number.isFinite(file.sizeBytes) && file.sizeBytes >= 0) { + const prettySize = formatSize(file.sizeBytes); + props.push(` +
+ ${t("size") || "Size"}: + + ${escapeHTML(prettySize)} + +
+ `); } if (file.modified) { props.push(`
${t("modified") || "Modified"}: ${escapeHTML(file.modified)}
`); @@ -1325,14 +1359,16 @@ function parseSizeToBytes(sizeStr) { * Format the total bytes as a human-readable string. */ function formatSize(totalBytes) { + if (!Number.isFinite(totalBytes) || totalBytes < 0) return ""; + if (totalBytes < 1024) { - return totalBytes + " Bytes"; + return totalBytes + " B"; } else if (totalBytes < 1024 * 1024) { - return (totalBytes / 1024).toFixed(2) + " KB"; + return (totalBytes / 1024).toFixed(1) + " KB"; } else if (totalBytes < 1024 * 1024 * 1024) { - return (totalBytes / (1024 * 1024)).toFixed(2) + " MB"; + return (totalBytes / (1024 * 1024)).toFixed(1) + " MB"; } else { - return (totalBytes / (1024 * 1024 * 1024)).toFixed(2) + " GB"; + return (totalBytes / (1024 * 1024 * 1024)).toFixed(1) + " GB"; } } @@ -1591,18 +1627,30 @@ export async function loadFileList(folderParam) { ? f.sizeBytes : parseSizeToBytes(String(f.size || "")); - // If we can't parse a sane size, treat as "unknown" instead of Infinity if (!Number.isFinite(bytes) || bytes < 0) { bytes = null; } f.sizeBytes = bytes; + // New: normalize display size and create a stable cache key + if (bytes != null) { + f.size = formatSize(bytes); + } + + const cacheKey = + (f.modified && String(f.modified)) || + (f.uploaded && String(f.uploaded)) || + (bytes != null ? String(bytes) : "") || + f.name; + + f.cacheKey = cacheKey; + f.folder = folder; + // For editing: if size is unknown, assume it's OK and let the editor enforce limits. const safeForEdit = (bytes == null) || (bytes <= MAX_EDIT_BYTES); f.editable = canEditFile(f.name) && safeForEdit; - f.folder = folder; return f; }); fileData = data.files; @@ -1676,7 +1724,7 @@ export async function loadFileList(folderParam) { - + ${currentHeight}px `; const rowSlider = document.getElementById("rowHeightSlider"); @@ -1907,6 +1955,9 @@ if (headerClass) { } else if (i === sizeIdx) { td.classList.add("folder-size-cell"); td.textContent = "…"; // placeholder until we load stats + // NEW: match file-row numeric alignment + td.style.textAlign = "right"; + td.style.fontVariantNumeric = "tabular-nums"; // 4) uploader / owner column } else if (i === uploaderIdx) { @@ -2159,24 +2210,19 @@ function syncFolderIconSizeToRowHeight() { const raw = cs.getPropertyValue('--file-row-height') || '48px'; const rowH = parseInt(raw, 10) || 60; - const FUDGE = 5; + const FUDGE = 1; const MAX_GROWTH_ROW = 44; // after this, stop growing the icon const BASE_ROW_FOR_OFFSET = 40; // where icon looks centered const OFFSET_FACTOR = 0.25; - - // cap growth for size, like you already do const effectiveRow = Math.min(rowH, MAX_GROWTH_ROW); - const boxSize = Math.max(25, Math.min(35, effectiveRow - 20 + FUDGE)); + const boxSize = Math.max(20, Math.min(35, effectiveRow - 20 + FUDGE)); const scale = 1.20; - // use your existing offset curve + // use existing offset curve const clampedForOffset = Math.max(30, Math.min(60, rowH)); let offsetY = (clampedForOffset - BASE_ROW_FOR_OFFSET) * OFFSET_FACTOR; - - // 30–44: untouched (you said this range is perfect) - // 45–60: same curve, but shifted up slightly if (rowH > 53) { offsetY -= 3; } @@ -2196,6 +2242,77 @@ function syncFolderIconSizeToRowHeight() { }); } +async function sortSubfoldersForCurrentOrder(subfolders) { + const base = Array.isArray(subfolders) ? [...subfolders] : []; + if (!base.length) return base; + + const col = sortOrder?.column || "uploaded"; + const ascending = sortOrder?.ascending !== false; + const dir = ascending ? 1 : -1; + + // Name sort (A–Z / Z–A) + if (col === "name") { + base.sort((a, b) => { + const n1 = (a.name || "").toLowerCase(); + const n2 = (b.name || "").toLowerCase(); + if (n1 < n2) return -1 * dir; + if (n1 > n2) return 1 * dir; + return 0; + }); + return base; + } + + // Size sort – use folder stats (bytes); keep folders as a block above files + if (col === "size" || col === "filesize") { + const statsList = await Promise.all( + base.map(sf => fetchFolderStats(sf.full).catch(() => null)) + ); + + const decorated = base.map((sf, idx) => { + const stats = statsList[idx]; + let bytes = 0; + + if (stats) { + const candidates = [ + stats.bytes, + stats.sizeBytes, + stats.size, + stats.totalBytes + ]; + for (const v of candidates) { + const n = Number(v); + if (Number.isFinite(n) && n >= 0) { + bytes = n; + break; + } + } + } + + return { sf, bytes }; + }); + + decorated.sort((a, b) => { + if (a.bytes < b.bytes) return -1 * dir; + if (a.bytes > b.bytes) return 1 * dir; + + // tie-break by name + const n1 = (a.sf.name || "").toLowerCase(); + const n2 = (b.sf.name || "").toLowerCase(); + if (n1 < n2) return -1 * dir; + if (n1 > n2) return 1 * dir; + return 0; + }); + + return decorated.map(d => d.sf); + } + + // Default: keep folders A–Z by name regardless of other sorts + base.sort((a, b) => + (a.name || "").localeCompare(b.name || "", undefined, { sensitivity: "base" }) + ); + return base; +} + async function openDefaultFileFromHover(file) { if (!file) return; const folder = file.folder || window.currentFolder || "root"; @@ -2219,7 +2336,7 @@ async function openDefaultFileFromHover(file) { */ -export function renderFileTable(folder, container, subfolders) { +export async function renderFileTable(folder, container, subfolders) { const fileListContent = container || document.getElementById("fileList"); const searchTerm = (window.currentSearchTerm || "").toLowerCase(); const itemsPerPageSetting = parseInt(localStorage.getItem("itemsPerPage") || "50", 10); @@ -2230,11 +2347,11 @@ export function renderFileTable(folder, container, subfolders) { // Inline folders: sort once (Explorer-style A→Z) const allSubfolders = Array.isArray(window.currentSubfolders) - ? window.currentSubfolders - : []; - const subfoldersSorted = [...allSubfolders].sort((a, b) => - (a.name || "").localeCompare(b.name || "", undefined, { sensitivity: "base" }) - ); + ? window.currentSubfolders + : []; + +// NEW: sort folders according to current sort order (name / size) +const subfoldersSorted = await sortSubfoldersForCurrentOrder(allSubfolders); const totalFiles = filteredFiles.length; const totalFolders = subfoldersSorted.length; @@ -2333,6 +2450,28 @@ export function renderFileTable(folder, container, subfolders) { fileListContent.innerHTML = combinedTopHTML + headerHTML + rowsHTML + bottomControlsHTML; + (function rightAlignSizeColumn() { + const table = fileListContent.querySelector("table.filr-table"); + if (!table || !table.tHead || !table.tBodies.length) return; + + const headerCells = Array.from(table.tHead.querySelectorAll("th")); + const sizeIdx = headerCells.findIndex(th => + (th.dataset && (th.dataset.column === "size" || th.dataset.column === "filesize")) || + /\bsize\b/i.test((th.textContent || "").trim()) + ); + if (sizeIdx < 0) return; + + // Header + headerCells[sizeIdx].style.textAlign = "right"; + + // Body cells + Array.from(table.tBodies[0].rows).forEach(row => { + if (sizeIdx >= row.cells.length) return; + row.cells[sizeIdx].style.textAlign = "right"; + row.cells[sizeIdx].style.fontVariantNumeric = "tabular-nums"; + }); + })(); + // ---- MOBILE FIX: show "Size" column for files (Name | Size | Actions) ---- (function fixMobileFileSizeColumn() { const isMobile = window.innerWidth <= 640; @@ -2387,6 +2526,52 @@ if (window.showInlineFolders !== false && pageFolders.length) { injectInlineFolderRows(fileListContent, folder, pageFolders); } + // Right-align meta columns: created / modified / owner + (function rightAlignMetaColumns() { + const table = fileListContent.querySelector("table.filr-table"); + if (!table || !table.tHead || !table.tBodies.length) return; + + const headerCells = Array.from(table.tHead.querySelectorAll("th")); + const bodyRows = Array.from(table.tBodies[0].rows); + + function alignCol(matchFn, numeric = true) { + const idx = headerCells.findIndex(matchFn); + if (idx < 0) return; + + const th = headerCells[idx]; + th.style.textAlign = "right"; + + bodyRows.forEach(row => { + if (idx >= row.cells.length) return; + const td = row.cells[idx]; + if (!td) return; + td.style.textAlign = "right"; + if (numeric) { + td.style.fontVariantNumeric = "tabular-nums"; + } + }); + } + + // Uploaded / Created + alignCol(th => + (th.dataset && (th.dataset.column === "uploaded" || th.dataset.column === "created")) || + /\b(uploaded|created)\b/i.test((th.textContent || "").trim()) + ); + + // Modified + alignCol(th => + (th.dataset && th.dataset.column === "modified") || + /\bmodified\b/i.test((th.textContent || "").trim()) + ); + + // Owner / Uploader + alignCol(th => + (th.dataset && th.dataset.column === "uploader") || + /\b(owner|uploader)\b/i.test((th.textContent || "").trim()), + /* numeric = */ false // names aren't numbers, but right-align anyway + ); + })(); + // Now wire 3-dot ellipsis so it also picks up folder rows wireEllipsisContextMenu(fileListContent); @@ -2694,8 +2879,7 @@ export function renderGalleryView(folder, container) { pageFiles.forEach((file, idx) => { const idSafe = encodeURIComponent(file.name) + "-" + (startIdx + idx); - // build preview URL from API (cache-busted) - const previewURL = `${apiBase}${encodeURIComponent(file.name)}&t=${Date.now()}`; + const previewURL = apiFileUrl(folder, file.name, true); // thumbnail let thumbnail; @@ -2989,10 +3173,16 @@ export function sortFiles(column, folder) { sortOrder.column = column; sortOrder.ascending = true; } + fileData.sort((a, b) => { let valA = a[column] || ""; let valB = b[column] || ""; - if (column === "modified" || column === "uploaded") { + + if (column === "size" || column === "filesize") { + // numeric size + valA = Number.isFinite(a.sizeBytes) ? a.sizeBytes : 0; + valB = Number.isFinite(b.sizeBytes) ? b.sizeBytes : 0; + } else if (column === "modified" || column === "uploaded") { const parsedA = parseCustomDate(valA); const parsedB = parseCustomDate(valB); valA = parsedA; @@ -3001,10 +3191,12 @@ export function sortFiles(column, folder) { valA = valA.toLowerCase(); valB = valB.toLowerCase(); } + if (valA < valB) return sortOrder.ascending ? -1 : 1; - if (valA > valB) return sortOrder.ascending ? 1 : -1; + if (valA > valB) return sortOrder.ascending ? 1 : -1; return 0; }); + if (window.viewMode === "gallery") { renderGalleryView(folder); } else { diff --git a/public/js/i18n.js b/public/js/i18n.js index ecf49ab..f0782aa 100644 --- a/public/js/i18n.js +++ b/public/js/i18n.js @@ -187,6 +187,7 @@ const translations = { // Admin Panel "header_settings": "Header Settings", + "header_footer_settings": "Header & Footer Settings", "shared_max_upload_size_bytes_title": "Shared Max Upload Size", "shared_max_upload_size_bytes": "Shared Max Upload Size (bytes)", "max_bytes_shared_uploads_note": "Enter maximum bytes allowed for shared-folder uploads", diff --git a/public/js/main.js b/public/js/main.js index a0e00fe..6da279e 100644 --- a/public/js/main.js +++ b/public/js/main.js @@ -445,107 +445,127 @@ function bindDarkMode() { m.content = val; }; - // ---------- site config / auth ---------- - function applySiteConfig(cfg, { phase = 'final' } = {}) { - try { - const title = (cfg && cfg.header_title) ? String(cfg.header_title) : 'FileRise'; - - // Always keep correct early (no visual flicker) - document.title = title; - // --- Header logo (branding) in BOTH phases --- + // ---------- site config / auth ---------- + function applySiteConfig(cfg, { phase = 'final' } = {}) { try { - const branding = (cfg && cfg.branding) ? cfg.branding : {}; - const customLogoUrl = branding.customLogoUrl || ""; - const logoImg = document.querySelector('.header-logo img'); - if (logoImg) { - if (customLogoUrl) { - logoImg.setAttribute('src', customLogoUrl); - logoImg.setAttribute('alt', 'Site logo'); + const title = (cfg && cfg.header_title) ? String(cfg.header_title) : 'FileRise'; + + // Always keep <title> correct early (no visual flicker) + document.title = title; + + // --- Header logo (branding) in BOTH phases --- + try { + const branding = (cfg && cfg.branding) ? cfg.branding : {}; + const customLogoUrl = branding.customLogoUrl || ""; + const logoImg = document.querySelector('.header-logo img'); + if (logoImg) { + if (customLogoUrl) { + logoImg.setAttribute('src', customLogoUrl); + logoImg.setAttribute('alt', 'Site logo'); + } else { + // fall back to default FileRise logo + logoImg.setAttribute('src', '/assets/logo.svg?v={{APP_QVER}}'); + logoImg.setAttribute('alt', 'FileRise'); + } + } + } catch (e) { + // non-fatal; ignore branding issues + } + + // --- Header colors (branding) in BOTH phases --- + try { + const branding = (cfg && cfg.branding) ? cfg.branding : {}; + const root = document.documentElement; + + const light = branding.headerBgLight || ''; + const dark = branding.headerBgDark || ''; + + if (light) root.style.setProperty('--header-bg-light', light); + else root.style.removeProperty('--header-bg-light'); + + if (dark) root.style.setProperty('--header-bg-dark', dark); + else root.style.removeProperty('--header-bg-dark'); + } catch (e) { + // non-fatal + } + + // --- Footer HTML (branding) in BOTH phases --- + try { + const branding = (cfg && cfg.branding) ? cfg.branding : {}; + const footerEl = document.getElementById('siteFooter'); + if (footerEl) { + const html = (branding.footerHtml || '').trim(); + if (html) { + // allow simple HTML from config + footerEl.innerHTML = html; + } else { + const year = new Date().getFullYear(); + footerEl.innerHTML = + `© ${year} <a href="https://filerise.net" target="_blank" rel="noopener noreferrer">FileRise</a>`; + } + } + } catch (e) { + // non-fatal + } + + // --- Login options (apply in BOTH phases so login page is correct) --- + const lo = (cfg && cfg.loginOptions) ? cfg.loginOptions : {}; + + // be tolerant to key variants just in case + const disableForm = !!(lo.disableFormLogin ?? lo.disable_form_login ?? lo.disableForm); + const disableOIDC = !!(lo.disableOIDCLogin ?? lo.disable_oidc_login ?? lo.disableOIDC); + const disableBasic = !!(lo.disableBasicAuth ?? lo.disable_basic_auth ?? lo.disableBasic); + + const showForm = !disableForm; + const showOIDC = !disableOIDC; + const showBasic = !disableBasic; + + const loginWrap = $('#loginForm'); // outer wrapper that contains buttons + form + const authForm = $('#authForm'); // inner username/password form + const oidcBtn = $('#oidcLoginBtn'); // OIDC button + const basicLink = document.querySelector('a[href="/api/auth/login_basic.php"]'); + + // 1) Show the wrapper if ANY method is enabled (form OR OIDC OR basic) + if (loginWrap) { + const anyMethod = showForm || showOIDC || showBasic; + if (anyMethod) { + loginWrap.removeAttribute('hidden'); // remove [hidden], which beats display: + loginWrap.style.display = ''; // let CSS decide } else { - // fall back to default FileRise logo - logoImg.setAttribute('src', '/assets/logo.svg?v={{APP_QVER}}'); - logoImg.setAttribute('alt', 'FileRise'); + loginWrap.setAttribute('hidden', ''); + loginWrap.style.display = ''; } } - } catch (e) { - // non-fatal; ignore branding issues - } - // --- Header colors (branding) in BOTH phases --- - try { - const branding = (cfg && cfg.branding) ? cfg.branding : {}; - const root = document.documentElement; - - const light = branding.headerBgLight || ''; - const dark = branding.headerBgDark || ''; - - if (light) root.style.setProperty('--header-bg-light', light); - else root.style.removeProperty('--header-bg-light'); - - if (dark) root.style.setProperty('--header-bg-dark', dark); - else root.style.removeProperty('--header-bg-dark'); - } catch (e) { - // non-fatal - } - - // --- Login options (apply in BOTH phases so login page is correct) --- - const lo = (cfg && cfg.loginOptions) ? cfg.loginOptions : {}; - - - // be tolerant to key variants just in case - const disableForm = !!(lo.disableFormLogin ?? lo.disable_form_login ?? lo.disableForm); - const disableOIDC = !!(lo.disableOIDCLogin ?? lo.disable_oidc_login ?? lo.disableOIDC); - const disableBasic = !!(lo.disableBasicAuth ?? lo.disable_basic_auth ?? lo.disableBasic); - - const showForm = !disableForm; - const showOIDC = !disableOIDC; - const showBasic = !disableBasic; - - const loginWrap = $('#loginForm'); // outer wrapper that contains buttons + form - const authForm = $('#authForm'); // inner username/password form - const oidcBtn = $('#oidcLoginBtn'); // OIDC button - const basicLink = document.querySelector('a[href="/api/auth/login_basic.php"]'); - - // 1) Show the wrapper if ANY method is enabled (form OR OIDC OR basic) - if (loginWrap) { - const anyMethod = showForm || showOIDC || showBasic; - if (anyMethod) { - loginWrap.removeAttribute('hidden'); // remove [hidden], which beats display: - loginWrap.style.display = ''; // let CSS decide - } else { - loginWrap.setAttribute('hidden', ''); - loginWrap.style.display = ''; - } - } - - // 2) Toggle the pieces inside the wrapper - if (authForm) authForm.style.display = showForm ? '' : 'none'; - if (oidcBtn) oidcBtn.style.display = showOIDC ? '' : 'none'; - if (basicLink) basicLink.style.display = showBasic ? '' : 'none'; - const oidc = $('#oidcLoginBtn'); if (oidc) oidc.style.display = disableOIDC ? 'none' : ''; - const basic = document.querySelector('a[href="/api/auth/login_basic.php"]'); - if (basic) basic.style.display = disableBasic ? 'none' : ''; - - // --- Header <h1> only in the FINAL phase (prevents visible flips) --- - if (phase === 'final') { - const h1 = document.querySelector('.header-title h1'); - if (h1) { - // prevent i18n or legacy from overwriting it - if (h1.hasAttribute('data-i18n-key')) h1.removeAttribute('data-i18n-key'); - - if (h1.textContent !== title) h1.textContent = title; - - // lock it so late code can't stomp it - if (!h1.__titleLock) { - const mo = new MutationObserver(() => { - if (h1.textContent !== title) h1.textContent = title; - }); - mo.observe(h1, { childList: true, characterData: true, subtree: true }); - h1.__titleLock = mo; + + // 2) Toggle the pieces inside the wrapper + if (authForm) authForm.style.display = showForm ? '' : 'none'; + if (oidcBtn) oidcBtn.style.display = showOIDC ? '' : 'none'; + if (basicLink) basicLink.style.display = showBasic ? '' : 'none'; + const oidc = $('#oidcLoginBtn'); if (oidc) oidc.style.display = disableOIDC ? 'none' : ''; + const basic = document.querySelector('a[href="/api/auth/login_basic.php"]'); + if (basic) basic.style.display = disableBasic ? 'none' : ''; + + // --- Header <h1> only in the FINAL phase (prevents visible flips) --- + if (phase === 'final') { + const h1 = document.querySelector('.header-title h1'); + if (h1) { + // prevent i18n or legacy from overwriting it + if (h1.hasAttribute('data-i18n-key')) h1.removeAttribute('data-i18n-key'); + + if (h1.textContent !== title) h1.textContent = title; + + // lock it so late code can't stomp it + if (!h1.__titleLock) { + const mo = new MutationObserver(() => { + if (h1.textContent !== title) h1.textContent = title; + }); + mo.observe(h1, { childList: true, characterData: true, subtree: true }); + h1.__titleLock = mo; + } } } - } - } catch { } - } + } catch { } + } async function readyToReveal() { // Wait for CSS + fonts so the first revealed frame is fully styled diff --git a/public/js/upload.js b/public/js/upload.js index 0a56509..95113c4 100644 --- a/public/js/upload.js +++ b/public/js/upload.js @@ -103,6 +103,14 @@ function wireFileInputChange(fileInput) { }); } +function setUploadButtonVisible(visible) { + const btn = document.getElementById('uploadBtn'); + if (!btn) return; + + btn.style.display = visible ? 'block' : 'none'; + btn.disabled = !visible; +} + function getUserDraftContext() { const all = loadResumableDraftsAll(); const userKey = getCurrentUserKey(); @@ -346,6 +354,8 @@ function setDropAreaDefault() { const fileInput = dropArea.querySelector('#file'); wireFileInputChange(fileInput); wireChooseButton(); + + setUploadButtonVisible(false); } function adjustFolderHelpExpansion() { @@ -464,6 +474,8 @@ function createFileEntry(file) { li.remove(); updateFileInfoCount(); + const anyItems = !!document.querySelector('li.upload-progress-item'); + setUploadButtonVisible(anyItems); }); li.removeBtn = removeBtn; li.appendChild(removeBtn); @@ -674,6 +686,7 @@ function processFiles(filesInput) { window.selectedFiles = files; updateFileInfoCount(); + setUploadButtonVisible(files.length > 0); } /* ----------------------------------------------------- @@ -770,6 +783,7 @@ async function initResumableUpload() { list.appendChild(li); updateFileInfoCount(); updateResumableQuery(); + setUploadButtonVisible(true); }); resumableInstance.on("fileProgress", function (file) { @@ -931,6 +945,7 @@ async function initResumableUpload() { } clearResumableDraftsForFolder(window.currentFolder || 'root'); showResumableDraftBanner(); + setUploadButtonVisible(false); }, 5000); } else { showToast("Some files failed to upload. Please check the list."); @@ -1183,6 +1198,8 @@ function submitFiles(allFiles) { } else { showToast(`${succeeded} file(s) succeeded. Please check the list.`); } + const anyItems = !!document.querySelector('li.upload-progress-item'); + setUploadButtonVisible(anyItems); }) .catch(error => { console.error("Error fetching file list:", error); @@ -1275,6 +1292,8 @@ function initUpload() { return; } + setUploadButtonVisible(false); + const hasResumableFiles = useResumable && resumableInstance && diff --git a/src/controllers/AdminController.php b/src/controllers/AdminController.php index 89e655e..9c7ac2a 100644 --- a/src/controllers/AdminController.php +++ b/src/controllers/AdminController.php @@ -144,6 +144,9 @@ class AdminController $proType = $proPayload['type'] ?? null; $proEmail = $proPayload['email'] ?? null; $proVersion = defined('FR_PRO_BUNDLE_VERSION') ? FR_PRO_BUNDLE_VERSION : null; + $proPlan = $proPayload['plan'] ?? null; + $proExpiresAt = $proPayload['expiresAt'] ?? null; + $proMaxMajor = $proPayload['maxMajor'] ?? null; // Whitelisted public subset only (+ ONLYOFFICE enabled flag) $public = [ @@ -169,6 +172,7 @@ class AdminController 'customLogoUrl' => (string)($config['branding']['customLogoUrl'] ?? ''), 'headerBgLight' => (string)($config['branding']['headerBgLight'] ?? ''), 'headerBgDark' => (string)($config['branding']['headerBgDark'] ?? ''), + 'footerHtml' => (string)($config['branding']['footerHtml'] ?? ''), ], 'pro' => [ 'active' => $proActive, @@ -176,6 +180,9 @@ class AdminController 'email' => $proEmail, 'version' => $proVersion, 'license' => $licenseString, + 'plan' => $proPlan, + 'expiresAt' => $proExpiresAt, + 'maxMajor' => $proMaxMajor, ], 'demoMode' => defined('FR_DEMO_MODE') ? (bool)FR_DEMO_MODE : false, ]; @@ -581,6 +588,28 @@ public function installProBundle(): void return; } + // NEW: normalize to basename so C:\fakepath\FileRisePro-v1.2.1.zip works. + $basename = $origName; + if ($basename !== '') { + // Normalize slashes and then take basename + $basename = str_replace('\\', '/', $basename); + $basename = basename($basename); + } + + // Try to parse the bundle version from the *basename* + // Supports: FileRisePro-v1.2.3.zip or FileRisePro_1.2.3.zip (case-insensitive) + $declaredVersion = null; + if ( + $basename !== '' && + preg_match( + '/^FileRisePro[_-]v?([0-9]+\.[0-9]+\.[0-9]+)\.zip$/i', + $basename, + $m + ) + ) { + $declaredVersion = 'v' . $m[1]; + } + // Prepare temp working dir $tempRoot = rtrim(sys_get_temp_dir(), DIRECTORY_SEPARATOR); $workDir = $tempRoot . DIRECTORY_SEPARATOR . 'filerise_pro_' . bin2hex(random_bytes(8)); @@ -723,20 +752,36 @@ public function installProBundle(): void // Best-effort cleanup; ignore failures @unlink($zipPath); @rmdir($workDir); - + + // NEW: ensure OPcache picks up new Pro bundle code immediately + if (function_exists('opcache_invalidate')) { + foreach ($installed['src'] as $pathInfo) { + // strip " (overwritten)" suffix if present + $path = preg_replace('/\s+\(overwritten\)$/', '', $pathInfo); + if (is_string($path) && $path !== '' && is_file($path)) { + @opcache_invalidate($path, true); + } + } + } + // Reflect current Pro status in response if bootstrap was loaded - $proActive = defined('FR_PRO_ACTIVE') && FR_PRO_ACTIVE; + $proActive = defined('FR_PRO_ACTIVE') && FR_PRO_ACTIVE; + + $reportedVersion = $declaredVersion; + if ($reportedVersion === null && defined('FR_PRO_BUNDLE_VERSION')) { + $reportedVersion = FR_PRO_BUNDLE_VERSION; + } + $proPayload = defined('FR_PRO_INFO') && is_array(FR_PRO_INFO) ? (FR_PRO_INFO['payload'] ?? null) : null; - $proVersion = defined('FR_PRO_BUNDLE_VERSION') ? FR_PRO_BUNDLE_VERSION : null; - + echo json_encode([ 'success' => true, 'message' => 'Pro bundle installed.', 'installed' => $installed, 'proActive' => (bool)$proActive, - 'proVersion' => $proVersion, + 'proVersion' => $reportedVersion, 'proPayload' => $proPayload, ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); } catch (\Throwable $e) { @@ -809,6 +854,7 @@ public function installProBundle(): void 'customLogoUrl' => '', 'headerBgLight' => '', 'headerBgDark' => '', + 'footerHtml' => '', ], ]; @@ -948,21 +994,22 @@ public function installProBundle(): void $merged['onlyoffice'] = $oo; } - // Branding: pass through raw strings; AdminModel enforces Pro + sanitization. - if (isset($data['branding']) && is_array($data['branding'])) { - if (!isset($merged['branding']) || !is_array($merged['branding'])) { - $merged['branding'] = [ - 'customLogoUrl' => '', - 'headerBgLight' => '', - 'headerBgDark' => '', - ]; - } - foreach (['customLogoUrl', 'headerBgLight', 'headerBgDark'] as $key) { - if (array_key_exists($key, $data['branding'])) { - $merged['branding'][$key] = (string)$data['branding'][$key]; - } - } + // Branding: pass through raw strings; AdminModel enforces Pro + sanitization. +if (isset($data['branding']) && is_array($data['branding'])) { + if (!isset($merged['branding']) || !is_array($merged['branding'])) { + $merged['branding'] = [ + 'customLogoUrl' => '', + 'headerBgLight' => '', + 'headerBgDark' => '', + 'footerHtml' => '', + ]; + } + foreach (['customLogoUrl', 'headerBgLight', 'headerBgDark', 'footerHtml'] as $key) { + if (array_key_exists($key, $data['branding'])) { + $merged['branding'][$key] = (string)$data['branding'][$key]; } + } +} $result = AdminModel::updateConfig($merged); if (isset($result['error'])) { diff --git a/src/models/AdminModel.php b/src/models/AdminModel.php index 22d0d4f..447616a 100644 --- a/src/models/AdminModel.php +++ b/src/models/AdminModel.php @@ -110,17 +110,18 @@ private static function sanitizeLogoUrl($url): string 'providerUrl' => (string)($config['oidc']['providerUrl'] ?? ''), 'redirectUri' => (string)($config['oidc']['redirectUri'] ?? ''), ], - 'branding' => [ - 'customLogoUrl' => self::sanitizeLogoUrl( - $config['branding']['customLogoUrl'] ?? '' - ), - 'headerBgLight' => self::sanitizeColorHex( - $config['branding']['headerBgLight'] ?? '' - ), - 'headerBgDark' => self::sanitizeColorHex( - $config['branding']['headerBgDark'] ?? '' - ), - ], + 'branding' => [ + 'customLogoUrl' => self::sanitizeLogoUrl( + $config['branding']['customLogoUrl'] ?? '' + ), + 'headerBgLight' => self::sanitizeColorHex( + $config['branding']['headerBgLight'] ?? '' + ), + 'headerBgDark' => self::sanitizeColorHex( + $config['branding']['headerBgDark'] ?? '' + ), + 'footerHtml' => (string)($config['branding']['footerHtml'] ?? ''), +], 'demoMode' => (defined('FR_DEMO_MODE') && FR_DEMO_MODE), ]; @@ -261,29 +262,31 @@ private static function sanitizeLogoUrl($url): string $configUpdate['onlyoffice'] = $norm; } - // Branding (Pro-only). Normalize and only persist when Pro is active. - if (!isset($configUpdate['branding']) || !is_array($configUpdate['branding'])) { - $configUpdate['branding'] = [ - 'customLogoUrl' => '', - 'headerBgLight' => '', - 'headerBgDark' => '', - ]; - } else { - $logo = self::sanitizeLogoUrl($configUpdate['branding']['customLogoUrl'] ?? ''); - $light = self::sanitizeColorHex($configUpdate['branding']['headerBgLight'] ?? ''); - $dark = self::sanitizeColorHex($configUpdate['branding']['headerBgDark'] ?? ''); - - if (defined('FR_PRO_ACTIVE') && FR_PRO_ACTIVE) { - $configUpdate['branding']['customLogoUrl'] = $logo; - $configUpdate['branding']['headerBgLight'] = $light; - $configUpdate['branding']['headerBgDark'] = $dark; - } else { - // Free mode: always clear branding customizations - $configUpdate['branding']['customLogoUrl'] = ''; - $configUpdate['branding']['headerBgLight'] = ''; - $configUpdate['branding']['headerBgDark'] = ''; - } - } + if (!isset($configUpdate['branding']) || !is_array($configUpdate['branding'])) { + $configUpdate['branding'] = [ + 'customLogoUrl' => '', + 'headerBgLight' => '', + 'headerBgDark' => '', + 'footerHtml' => '', + ]; + } else { + $logo = self::sanitizeLogoUrl($configUpdate['branding']['customLogoUrl'] ?? ''); + $light = self::sanitizeColorHex($configUpdate['branding']['headerBgLight'] ?? ''); + $dark = self::sanitizeColorHex($configUpdate['branding']['headerBgDark'] ?? ''); + $footer = trim((string)($configUpdate['branding']['footerHtml'] ?? '')); + + if (defined('FR_PRO_ACTIVE') && FR_PRO_ACTIVE) { + $configUpdate['branding']['customLogoUrl'] = $logo; + $configUpdate['branding']['headerBgLight'] = $light; + $configUpdate['branding']['headerBgDark'] = $dark; + $configUpdate['branding']['footerHtml'] = $footer; + } else { + $configUpdate['branding']['customLogoUrl'] = ''; + $configUpdate['branding']['headerBgLight'] = ''; + $configUpdate['branding']['headerBgDark'] = ''; + $configUpdate['branding']['footerHtml'] = ''; + } + } // Convert configuration to JSON. $plainTextConfig = json_encode($configUpdate, JSON_PRETTY_PRINT); @@ -444,6 +447,7 @@ private static function sanitizeLogoUrl($url): string 'customLogoUrl' => '', 'headerBgLight' => '', 'headerBgDark' => '', + 'footerHtml' => '', ]; } else { $config['branding']['customLogoUrl'] = self::sanitizeLogoUrl( @@ -486,6 +490,7 @@ private static function sanitizeLogoUrl($url): string 'customLogoUrl' => '', 'headerBgLight' => '', 'headerBgDark' => '', + 'footerHtml' => '', ], ]; }