diff --git a/CHANGELOG.md b/CHANGELOG.md
index d5dc109..2182066 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,17 @@
# Changelog
+## changes 11/18/2025 (v1.9.10)
+
+release(v1.9.10): add Pro bundle installer and admin panel polish
+
+- Add FileRise Pro section in admin panel with license management and bundle upload
+- Persist Pro bundle under users/pro and sync public/api/pro endpoints on container startup
+- Improve admin config API: Pro metadata, license file handling, hardened auth/CSRF helpers
+- Update Pro badge/version UI with “update available” hint and link to filerise.net
+- Change Pro bundle installer to always overwrite existing bundle files for clean upgrades
+
+---
+
## Changes 11/16/2025 (v1.9.9)
release(v1.9.9): fix(branding): sanitize custom logo URL preview
diff --git a/config/config.php b/config/config.php
index 98a0377..afabdf9 100644
--- a/config/config.php
+++ b/config/config.php
@@ -240,30 +240,57 @@ if (strpos(BASE_URL, 'yourwebsite') !== false) {
// Final: env var wins, else fallback
define('SHARE_URL', getenv('SHARE_URL') ?: $defaultShare);
-// --------------------------------
-// FileRise Pro (optional add-on)
-// --------------------------------
+// ------------------------------------------------------------
+// FileRise Pro bootstrap wiring
+// ------------------------------------------------------------
-// Where the Pro license JSON lives
+// Inline license (optional; usually set via Admin UI and PRO_LICENSE_FILE)
+if (!defined('FR_PRO_LICENSE')) {
+ $envLicense = getenv('FR_PRO_LICENSE');
+ define('FR_PRO_LICENSE', $envLicense !== false ? trim((string)$envLicense) : '');
+}
+
+// JSON license file used by AdminController::setLicense()
if (!defined('PRO_LICENSE_FILE')) {
define('PRO_LICENSE_FILE', PROJECT_ROOT . '/users/proLicense.json');
}
-// Inline/env license strings (optional)
-if (!defined('FR_PRO_LICENSE')) {
- define('FR_PRO_LICENSE', getenv('FR_PRO_LICENSE') ?: '');
-}
+// Optional plain-text license file (used as fallback in bootstrap)
if (!defined('FR_PRO_LICENSE_FILE')) {
- define('FR_PRO_LICENSE_FILE', getenv('FR_PRO_LICENSE_FILE') ?: '');
+ $lf = getenv('FR_PRO_LICENSE_FILE');
+ if ($lf === false || $lf === '') {
+ $lf = PROJECT_ROOT . '/users/proLicense.txt';
+ }
+ define('FR_PRO_LICENSE_FILE', $lf);
}
-// Optional Pro bootstrap (shipped only with Pro bundle)
-$proBootstrap = PROJECT_ROOT . '/src/pro/bootstrap_pro.php';
-if (is_file($proBootstrap)) {
+// Where Pro code lives by default → inside users volume
+$proDir = getenv('FR_PRO_BUNDLE_DIR');
+if ($proDir === false || $proDir === '') {
+ $proDir = PROJECT_ROOT . '/users/pro';
+}
+$proDir = rtrim($proDir, "/\\");
+if (!defined('FR_PRO_BUNDLE_DIR')) {
+ define('FR_PRO_BUNDLE_DIR', $proDir);
+}
+
+// Try to load Pro bootstrap if enabled + present
+$proBootstrap = FR_PRO_BUNDLE_DIR . '/bootstrap_pro.php';
+if (@is_file($proBootstrap)) {
require_once $proBootstrap;
}
-// Safe default so the rest of the app always has the constant
+// If bootstrap didn’t define these, give safe defaults
if (!defined('FR_PRO_ACTIVE')) {
define('FR_PRO_ACTIVE', false);
}
+if (!defined('FR_PRO_INFO')) {
+ define('FR_PRO_INFO', [
+ 'valid' => false,
+ 'error' => null,
+ 'payload' => null,
+ ]);
+}
+if (!defined('FR_PRO_BUNDLE_VERSION')) {
+ define('FR_PRO_BUNDLE_VERSION', null);
+}
\ No newline at end of file
diff --git a/public/api/admin/installProBundle.php b/public/api/admin/installProBundle.php
new file mode 100644
index 0000000..1d4fd09
--- /dev/null
+++ b/public/api/admin/installProBundle.php
@@ -0,0 +1,8 @@
+installProBundle();
\ No newline at end of file
diff --git a/public/js/adminPanel.js b/public/js/adminPanel.js
index 8085953..7175757 100644
--- a/public/js/adminPanel.js
+++ b/public/js/adminPanel.js
@@ -14,6 +14,9 @@ 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.0.0';
function getAdminTitle(isPro, proVersion) {
const corePill = `
@@ -22,6 +25,23 @@ function getAdminTitle(isPro, proVersion) {
`;
+ // Normalize versions so "v1.0.1" and "1.0.1" compare cleanly
+ const norm = (v) => String(v || '').trim().replace(/^v/i, '');
+
+ const latestRaw = (typeof PRO_LATEST_BUNDLE_VERSION !== 'undefined'
+ ? PRO_LATEST_BUNDLE_VERSION
+ : ''
+ );
+
+ const currentRaw = (proVersion && proVersion !== 'not installed')
+ ? String(proVersion)
+ : '';
+
+ const hasCurrent = !!norm(currentRaw);
+ const hasLatest = !!norm(latestRaw);
+ const hasUpdate = isPro && hasCurrent && hasLatest &&
+ norm(currentRaw) !== norm(latestRaw);
+
if (!isPro) {
// Free/core only
return `
@@ -30,18 +50,32 @@ function getAdminTitle(isPro, proVersion) {
`;
}
- const pv = proVersion ? `Pro v${proVersion}` : 'Pro';
+ const pvLabel = hasCurrent ? `Pro v${norm(currentRaw)}` : 'Pro';
const proPill = `
- ${pv}
+ ${pvLabel}
`;
+ const updateHint = hasUpdate
+ ? `
+
+ Pro update available
+
+ `
+ : '';
+
return `
${t("admin_panel")}
${corePill}
${proPill}
+ ${updateHint}
`;
}
@@ -450,6 +484,81 @@ function toggleSection(id) {
}
}
+export function initProBundleInstaller() {
+ try {
+ const fileInput = document.getElementById('proBundleFile');
+ const btn = document.getElementById('btnInstallProBundle');
+ const statusEl = document.getElementById('proBundleStatus');
+
+ if (!fileInput || !btn || !statusEl) return;
+
+ // Allow names like: FileRisePro_v1.0.0.zip or FileRisePro-1.0.0.zip
+ const PRO_ZIP_NAME_RE = /^FileRisePro[_-]v?[0-9]+\.[0-9]+\.[0-9]+\.zip$/i;
+
+ btn.addEventListener('click', async () => {
+ const file = fileInput.files && fileInput.files[0];
+
+ if (!file) {
+ statusEl.textContent = 'Choose a FileRise Pro .zip bundle first.';
+ statusEl.className = 'small text-danger';
+ return;
+ }
+
+ const name = file.name || '';
+ if (!PRO_ZIP_NAME_RE.test(name)) {
+ statusEl.textContent = 'Bundle must be named like "FileRisePro_v1.0.0.zip".';
+ statusEl.className = 'small text-danger';
+ return;
+ }
+
+ const formData = new FormData();
+ formData.append('bundle', file);
+
+ statusEl.textContent = 'Uploading and installing Pro bundle...';
+ statusEl.className = 'small text-muted';
+
+ try {
+ const resp = await fetch('/api/admin/installProBundle.php', {
+ method: 'POST',
+ headers: {
+ 'X-CSRF-Token': window.csrfToken || ''
+ },
+ body: formData
+ });
+
+ let data = null;
+ try {
+ data = await resp.json();
+ } catch (_) {
+ // ignore JSON parse errors; handled below
+ }
+
+ if (!resp.ok || !data || !data.success) {
+ const msg = data && data.error
+ ? data.error
+ : `HTTP ${resp.status}`;
+ statusEl.textContent = 'Install failed: ' + msg;
+ statusEl.className = 'small text-danger';
+ return;
+ }
+
+ const versionText = data.proVersion ? ` (version ${data.proVersion})` : '';
+ statusEl.textContent = 'Pro bundle installed' + versionText + '. Reload the page to apply changes.';
+ statusEl.className = 'small text-success';
+
+ if (typeof loadAdminConfigFunc === 'function') {
+ loadAdminConfigFunc();
+ }
+ } catch (e) {
+ statusEl.textContent = 'Install failed: ' + (e && e.message ? e.message : String(e));
+ statusEl.className = 'small text-danger';
+ }
+ });
+ } catch (e) {
+ console.warn('Failed to init Pro bundle installer', e);
+ }
+}
+
function loadShareLinksSection() {
const container = document.getElementById("shareLinksContent");
if (!container) return;
@@ -1266,18 +1375,32 @@ async function ooProbeFrame(docsOrigin, timeoutMs = 4000) {
// --- FileRise Pro / License section ---
const proContent = document.getElementById("proContent");
if (proContent) {
+ // Normalize versions so "v1.0.1" and "1.0.1" compare cleanly
+ const norm = (v) => (String(v || '').trim().replace(/^v/i, ''));
+
+ const currentVersionRaw = (proVersion && proVersion !== 'not installed') ? String(proVersion) : '';
+ const latestVersionRaw = PRO_LATEST_BUNDLE_VERSION || '';
+ const hasCurrent = !!norm(currentVersionRaw);
+ const hasLatest = !!norm(latestVersionRaw);
+ const hasUpdate = hasCurrent && hasLatest && norm(currentVersionRaw) !== norm(latestVersionRaw);
+
const proMetaHtml =
isPro && (proType || proEmail || proVersion)
? `
-
`
: '';
@@ -1308,8 +1431,13 @@ if (proContent) {
href="https://filerise.net/pro/update.php"
target="_blank"
rel="noopener noreferrer"
- class="btn btn-sm btn-pro-admin">
- Download latest Pro bundle
+ class="btn btn-sm btn-pro-admin d-inline-flex align-items-center"
+ >
+ Download latest Pro bundle
+ ${hasUpdate ? `
+
+ Update available
+ ` : ''}
Opens filerise.net in a new tab where you can enter your Pro license
@@ -1322,7 +1450,8 @@ if (proContent) {
href="https://filerise.net/pro/checkout.php"
target="_blank"
rel="noopener noreferrer"
- class="btn btn-sm btn-pro-admin">
+ class="btn btn-sm btn-pro-admin"
+ >
Buy FileRise Pro
@@ -1332,7 +1461,16 @@ if (proContent) {
`}
- ${isPro && proLicense ? `
-
-
-
- Copies the saved license so you can reuse it for upgrades or downloads on filerise.net.
-
-
- ` : ''}
-
`;
+ // Wire up local Pro bundle installer (upload .zip into core)
+ initProBundleInstaller();
+
// Pre-fill textarea with saved license if present
const licenseTextarea = document.getElementById('proLicenseInput');
if (licenseTextarea && proLicense) {
licenseTextarea.value = proLicense;
}
- // File upload → fill textarea (unchanged)
+ // Auto-load license when a file is selected
const fileInput = document.getElementById('proLicenseFile');
- const fileBtn = document.getElementById('proLoadLicenseFileBtn');
-
- if (fileInput && fileBtn && licenseTextarea) {
- fileBtn.addEventListener('click', () => {
+ if (fileInput && licenseTextarea) {
+ fileInput.addEventListener('change', () => {
const file = fileInput.files && fileInput.files[0];
- if (!file) {
- showToast('Please choose a license file first.');
- return;
- }
+ if (!file) return;
const reader = new FileReader();
reader.onload = (e) => {
@@ -1428,7 +1568,7 @@ if (proContent) {
});
}
- // Copy current license button
+ // Copy current license button (now inline next to the label)
const proCopyBtn = document.getElementById('proCopyLicenseBtn');
if (proCopyBtn && proLicense) {
proCopyBtn.addEventListener('click', async () => {
diff --git a/src/controllers/AdminController.php b/src/controllers/AdminController.php
index 8142ec7..3dfd7ce 100644
--- a/src/controllers/AdminController.php
+++ b/src/controllers/AdminController.php
@@ -272,6 +272,265 @@ public function setLicense(): void
}
}
+public function installProBundle(): void
+{
+ header('Content-Type: application/json; charset=utf-8');
+
+ try {
+ // Guard rails: method + auth + CSRF
+ if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'POST') {
+ http_response_code(405);
+ echo json_encode(['success' => false, 'error' => 'Method not allowed']);
+ return;
+ }
+
+ self::requireAuth();
+ self::requireAdmin();
+ self::requireCsrf();
+
+ // Ensure ZipArchive is available
+ if (!class_exists('\\ZipArchive')) {
+ http_response_code(500);
+ echo json_encode(['success' => false, 'error' => 'ZipArchive extension is required on the server.']);
+ return;
+ }
+
+ // Basic upload validation
+ if (empty($_FILES['bundle']) || !is_array($_FILES['bundle'])) {
+ http_response_code(400);
+ echo json_encode(['success' => false, 'error' => 'Missing uploaded bundle (field "bundle").']);
+ return;
+ }
+
+ $f = $_FILES['bundle'];
+
+ if (!empty($f['error']) && $f['error'] !== UPLOAD_ERR_OK) {
+ $msg = 'Upload error.';
+ switch ($f['error']) {
+ case UPLOAD_ERR_INI_SIZE:
+ case UPLOAD_ERR_FORM_SIZE:
+ $msg = 'Uploaded file exceeds size limit.';
+ break;
+ case UPLOAD_ERR_PARTIAL:
+ $msg = 'Uploaded file was only partially received.';
+ break;
+ case UPLOAD_ERR_NO_FILE:
+ $msg = 'No file was uploaded.';
+ break;
+ default:
+ $msg = 'Upload failed with error code ' . (int)$f['error'];
+ break;
+ }
+ http_response_code(400);
+ echo json_encode(['success' => false, 'error' => $msg]);
+ return;
+ }
+
+ $tmpName = $f['tmp_name'] ?? '';
+ if ($tmpName === '' || !is_uploaded_file($tmpName)) {
+ http_response_code(400);
+ echo json_encode(['success' => false, 'error' => 'Invalid uploaded file.']);
+ return;
+ }
+
+ // Guard against unexpectedly large bundles (e.g., >100MB)
+ $size = isset($f['size']) ? (int)$f['size'] : 0;
+ if ($size <= 0 || $size > 100 * 1024 * 1024) {
+ http_response_code(413);
+ echo json_encode(['success' => false, 'error' => 'Bundle size is invalid or too large (max 100MB).']);
+ return;
+ }
+
+ // Optional: require .zip extension by name (best-effort)
+ $origName = (string)($f['name'] ?? '');
+ if ($origName !== '' && !preg_match('/\.zip$/i', $origName)) {
+ http_response_code(400);
+ echo json_encode(['success' => false, 'error' => 'Bundle must be a .zip file.']);
+ return;
+ }
+
+ // Prepare temp working dir
+ $tempRoot = rtrim(sys_get_temp_dir(), DIRECTORY_SEPARATOR);
+ $workDir = $tempRoot . DIRECTORY_SEPARATOR . 'filerise_pro_' . bin2hex(random_bytes(8));
+ if (!@mkdir($workDir, 0700, true)) {
+ http_response_code(500);
+ echo json_encode(['success' => false, 'error' => 'Failed to prepare temp dir.']);
+ return;
+ }
+
+ $zipPath = $workDir . DIRECTORY_SEPARATOR . 'bundle.zip';
+ if (!@move_uploaded_file($tmpName, $zipPath)) {
+ http_response_code(500);
+ echo json_encode(['success' => false, 'error' => 'Failed to move uploaded bundle.']);
+ return;
+ }
+
+ $zip = new \ZipArchive();
+ if ($zip->open($zipPath) !== true) {
+ http_response_code(400);
+ echo json_encode(['success' => false, 'error' => 'Failed to open ZIP bundle.']);
+ return;
+ }
+
+ $installed = [
+ 'src' => [],
+ 'public' => [],
+ 'docs' => [],
+ ];
+
+ $projectRoot = rtrim(PROJECT_ROOT, DIRECTORY_SEPARATOR);
+
+ // Where Pro bundle code lives (defaults to PROJECT_ROOT . '/users/pro')
+ $bundleRoot = defined('FR_PRO_BUNDLE_DIR')
+ ? rtrim(FR_PRO_BUNDLE_DIR, DIRECTORY_SEPARATOR)
+ : ($projectRoot . DIRECTORY_SEPARATOR . 'users' . DIRECTORY_SEPARATOR . 'pro');
+
+ // Put README-Pro.txt / LICENSE-Pro.txt inside the bundle dir as well
+ $proDocsDir = $bundleRoot;
+ if (!is_dir($proDocsDir)) {
+ @mkdir($proDocsDir, 0755, true);
+ }
+
+ $allowedTopLevel = ['LICENSE-Pro.txt', 'README-Pro.txt'];
+
+ // Iterate entries and selectively extract/copy expected files only
+ for ($i = 0; $i < $zip->numFiles; $i++) {
+ $name = $zip->getNameIndex($i);
+ if ($name === false) {
+ continue;
+ }
+
+ // Normalise and guard
+ $name = ltrim($name, "/\\");
+ if ($name === '' || substr($name, -1) === '/') {
+ continue; // skip directories
+ }
+ if (strpos($name, '../') !== false || strpos($name, '..\\') !== false) {
+ continue; // path traversal guard
+ }
+
+ // Ignore macOS Finder junk: __MACOSX and "._" resource forks
+ $base = basename($name);
+ if (
+ str_starts_with($name, '__MACOSX/') ||
+ str_contains($name, '/__MACOSX/') ||
+ str_starts_with($base, '._')
+ ) {
+ continue;
+ }
+
+ $targetPath = null;
+ $category = null;
+
+ if (in_array($name, $allowedTopLevel, true)) {
+ // Docs → bundle dir (under /users/pro)
+ $targetPath = $proDocsDir . DIRECTORY_SEPARATOR . $name;
+ $category = 'docs';
+
+ } elseif (strpos($name, 'src/pro/') === 0) {
+ // e.g. src/pro/bootstrap_pro.php -> FR_PRO_BUNDLE_DIR/bootstrap_pro.php
+ $relative = substr($name, strlen('src/pro/'));
+ if ($relative === '' || substr($relative, -1) === '/') {
+ continue;
+ }
+ $targetPath = $bundleRoot . DIRECTORY_SEPARATOR . $relative;
+ $category = 'src';
+
+ } elseif (strpos($name, 'public/api/pro/') === 0) {
+ // e.g. public/api/pro/uploadBrandLogo.php
+ $relative = substr($name, strlen('public/api/pro/'));
+ if ($relative === '' || substr($relative, -1) === '/') {
+ continue;
+ }
+
+ // Persist under bundle dir so it survives image rebuilds:
+ // users/pro/public/api/pro/...
+ $targetPath = $bundleRoot
+ . DIRECTORY_SEPARATOR . 'public'
+ . DIRECTORY_SEPARATOR . 'api'
+ . DIRECTORY_SEPARATOR . 'pro'
+ . DIRECTORY_SEPARATOR . $relative;
+ $category = 'public';
+ } else {
+ // Skip anything outside these prefixes
+ continue;
+ }
+
+ if (!$targetPath || !$category) {
+ continue;
+ }
+
+ // Track whether we're overwriting an existing file (for reporting only)
+ $wasExisting = is_file($targetPath);
+
+ // Read from ZIP entry
+ $stream = $zip->getStream($name);
+ if (!$stream) {
+ continue;
+ }
+
+ $dir = dirname($targetPath);
+ if (!is_dir($dir) && !@mkdir($dir, 0755, true)) {
+ fclose($stream);
+ http_response_code(500);
+ echo json_encode(['success' => false, 'error' => 'Failed to create destination directory for ' . $name]);
+ return;
+ }
+
+ $data = stream_get_contents($stream);
+ fclose($stream);
+ if ($data === false) {
+ http_response_code(500);
+ echo json_encode(['success' => false, 'error' => 'Failed to read data for ' . $name]);
+ return;
+ }
+
+ // Always overwrite target file on install/upgrade
+ if (@file_put_contents($targetPath, $data) === false) {
+ http_response_code(500);
+ echo json_encode(['success' => false, 'error' => 'Failed to write ' . $name]);
+ return;
+ }
+
+ @chmod($targetPath, 0644);
+
+ // Track what we installed (and whether it was overwritten)
+ if (!isset($installed[$category])) {
+ $installed[$category] = [];
+ }
+ $installed[$category][] = $targetPath . ($wasExisting ? ' (overwritten)' : '');
+ }
+
+ $zip->close();
+
+ // Best-effort cleanup; ignore failures
+ @unlink($zipPath);
+ @rmdir($workDir);
+
+ // Reflect current Pro status in response if bootstrap was loaded
+ $proActive = defined('FR_PRO_ACTIVE') && FR_PRO_ACTIVE;
+ $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,
+ 'proPayload' => $proPayload,
+ ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
+ } catch (\Throwable $e) {
+ http_response_code(500);
+ echo json_encode([
+ 'success' => false,
+ 'error' => 'Exception during bundle install: ' . $e->getMessage(),
+ ]);
+ }
+}
+
public function updateConfig(): void
{
header('Content-Type: application/json');
diff --git a/start.sh b/start.sh
index 7a35fde..b4a16d3 100644
--- a/start.sh
+++ b/start.sh
@@ -72,6 +72,23 @@ for d in uploads users metadata; do
chmod 775 "${tgt}"
done
+# 2.4) Sync FileRise Pro public endpoints from persistent bundle
+BUNDLE_PRO_PUBLIC="/var/www/users/pro/public/api/pro"
+LIVE_PRO_PUBLIC="/var/www/public/api/pro"
+
+if [ -d "${BUNDLE_PRO_PUBLIC}" ]; then
+ echo "[startup] Syncing FileRise Pro public endpoints..."
+ mkdir -p "${LIVE_PRO_PUBLIC}"
+
+ # Copy files from bundle to live api/pro (overwrite for upgrades)
+ cp -R "${BUNDLE_PRO_PUBLIC}/." "${LIVE_PRO_PUBLIC}/" || echo "[startup] Pro sync copy failed (continuing)"
+
+ # Normalize ownership/permissions
+ chown -R www-data:www-data "${LIVE_PRO_PUBLIC}" || echo "[startup] chown api/pro failed (continuing)"
+ find "${LIVE_PRO_PUBLIC}" -type d -exec chmod 755 {} \; 2>/dev/null || true
+ find "${LIVE_PRO_PUBLIC}" -type f -exec chmod 644 {} \; 2>/dev/null || true
+fi
+
# 3) Ensure PHP conf dir & set upload limits
mkdir -p /etc/php/8.3/apache2/conf.d
if [ -n "${TOTAL_UPLOAD_SIZE:-}" ]; then