release(v1.9.8): feat(pro): wire core to Pro licensing + branding hooks

This commit is contained in:
Ryan
2025-11-16 21:11:06 -05:00
committed by GitHub
parent 9880adb417
commit 060a548af4
9 changed files with 1308 additions and 106 deletions

View File

@@ -1,5 +1,75 @@
# Changelog
## Changes 11/16/2025 (v1.9.8)
release(v1.9.8): feat(pro): wire core to Pro licensing + branding hooks
- Add Pro feature flags + bootstrap wiring
- Define FR_PRO_ACTIVE/FR_PRO_TYPE/FR_PRO_EMAIL/FR_PRO_VERSION/FR_PRO_LICENSE_FILE
in config.php and optionally require src/pro/bootstrap_pro.php.
- Expose a `pro` block from AdminController::getConfig() so the UI can show
license status, type, email, and bundle version without leaking the raw key.
- Implement license save endpoint
- Add AdminController::setLicense() and /api/admin/setLicense.php to accept a
FRP1 license string via JSON, validate basic shape, and persist it to
FR_PRO_LICENSE_FILE with strict 0600 permissions.
- Return structured JSON success/error responses for the admin UI.
- Extend admin config model with branding + safer validation
- Add `branding.customLogoUrl`, `branding.headerBgLight`, and
`branding.headerBgDark` fields to AdminModel defaults and updateConfig().
- Introduce AdminModel::sanitizeLogoUrl() to allow only site-relative /uploads
paths or http(s) URLs; reject absolute filesystem paths, data: URLs, and
javascript: URLs.
- Continue to validate ONLYOFFICE docsOrigin as http(s) only, keeping core
config hardening intact.
- New Pro-aware Admin Panel UI
- Rework User Management section to group:
- Add user / Remove user
- Folder Access (per-folder ACL)
- User Permissions (account-level flags)
- Add Pro-only actions with clear gating:
- “User groups” button (Pro)
- “Client upload portal” button with “Pro · Coming soon” pill
- Add “FileRise Pro” section:
- Show current Pro status (Free vs Active) + license metadata.
- Textarea for pasting license key, file upload helper, and “Save license”
action wired to /api/admin/setLicense.php.
- Optional “Copy current license” button when a license is present.
- Add “Sponsor / Donations” section with fixed GitHub Sponsors and Ko-fi URLs
and one-click copy/open buttons.
- Header branding controls (Pro)
- Add Header Logo + Header Colors controls under Header Settings, gated by
`config.pro.active`.
- Allow uploading a logo via /api/pro/uploadBrandLogo.php and auto-filling the
normalized /uploads path.
- Add live-preview helpers to update the header logo and header background
colors in the running UI after saving.
- Apply branding on app boot
- Update main.js to read branding config on load and apply:
- Custom header logo (or fallback to /assets/logo.svg).
- Light/dark header background colors via CSS variables.
- Keeps header consistent with saved branding across reloads and before
opening the admin panel.
- Styling + UX polish
- Add styles for new admin sections: collapsible headers, dark-mode aware
modal content, and refined folder access grid.
- Introduce .btn-pro-admin and .btn-pro-pill classes to render “Pro” and
“Pro · Coming soon” pills overlayed on buttons, matching the existing
header “Core/Pro” badge treatment.
- Minor spacing/typography tweaks in admin panel and ACL UI.
Note: Core code remains MIT-licensed; Pro functionality is enabled via optional
runtime hooks and separate closed-source bundle, without changing the core
license text.
---
## Changes 11/14/2025 (v1.9.7)
release(v1.9.7): harden client path guard and refine header/folder strip CSS

View File

@@ -238,4 +238,32 @@ if (strpos(BASE_URL, 'yourwebsite') !== false) {
}
// Final: env var wins, else fallback
define('SHARE_URL', getenv('SHARE_URL') ?: $defaultShare);
define('SHARE_URL', getenv('SHARE_URL') ?: $defaultShare);
// --------------------------------
// FileRise Pro (optional add-on)
// --------------------------------
// Where the Pro license JSON lives
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') ?: '');
}
if (!defined('FR_PRO_LICENSE_FILE')) {
define('FR_PRO_LICENSE_FILE', getenv('FR_PRO_LICENSE_FILE') ?: '');
}
// Optional Pro bootstrap (shipped only with Pro bundle)
$proBootstrap = PROJECT_ROOT . '/src/pro/bootstrap_pro.php';
if (is_file($proBootstrap)) {
require_once $proBootstrap;
}
// Safe default so the rest of the app always has the constant
if (!defined('FR_PRO_ACTIVE')) {
define('FR_PRO_ACTIVE', false);
}

View File

@@ -0,0 +1,8 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/../../../config/config.php';
require_once PROJECT_ROOT . '/src/controllers/AdminController.php';
$ctrl = new AdminController();
$ctrl->setLicense();

View File

@@ -67,17 +67,129 @@ body{letter-spacing: 0.2px;
font-size: 34px !important;
color: red !important;
transform: translateY(-3px) !important;}
.header-container{display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
height: 55px;
padding: 10px 20px;
background-color: #2196F3;
transition: background-color 0.3s ease;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);}
.dark-mode .header-container{background-color: #1f1f1f;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.7);}
.header-container{
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
height: 55px;
padding: 10px 20px;
background-color: var(--header-bg-light, #2196F3);
transition: background-color 0.3s ease;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
}
.dark-mode .header-container{
background-color: var(--header-bg-dark, #1f1f1f);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.7);
}
.admin-core-badge,
.admin-pro-badge {
font-size: 12px;
padding: 2px 6px;
vertical-align: middle;
position: relative;
top: -1px;
margin-left: 6px;
}
#adminPanelModal .section-content .pro-card {
margin: 8px 10px;
border-radius: 12px;
}
#adminPanelModal .section-content {
margin: 0px 10px;
}
.pro-license-meta {
margin-top: 6px;
padding: 6px 10px;
border-radius: 6px;
font-size: 11px;
font-weight: 500;
background-color: rgba(40, 167, 69, 0.08); /* light green tint */
border: 1px solid rgba(40, 167, 69, 0.4);
color: #2e7d32;
}
.pro-license-meta div + div {
margin-top: 2px;
}
/* Dark mode tweak so it doesn't glow too bright */
.dark-mode .pro-license-meta {
background-color: rgba(40, 167, 69, 0.18);
border-color: rgba(40, 167, 69, 0.6);
color: #c8e6c9;
}
/* FileRise Pro button styling (admin) */
.btn-pro-admin {
background: linear-gradient(135deg, #ff9800, #ff5722);
border-color: #ff9800;
color: #1b0f00 !important;
font-weight: 600;
box-shadow: 0 0 10px rgba(255, 152, 0, 0.4);
}
.btn-pro-admin:hover {
filter: brightness(1.05);
}
/* User management action bar */
.admin-user-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 4px;
}
.admin-user-actions .btn {
border-radius: 999px;
font-size: 12px;
padding: 6px 12px;
display: inline-flex;
align-items: center;
gap: 4px;
}
.admin-user-actions .btn .material-icons {
font-size: 16px;
line-height: 1;
}
/* ---------- Pro buttons + pill ---------- */
.admin-user-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 6px;
}
.btn-pro-wrapper {
position: relative;
display: inline-block;
}
.btn-pro-admin:disabled {
opacity: 0.9;
}
.btn-pro-pill {
position: absolute;
top: -7px;
right: -4px;
font-size: 10px;
line-height: 1.2;
padding: 2px 6px;
border-radius: 999px;
background: #ffc107;
color: black;
box-shadow: 0 1px 3px rgba(0,0,0,0.3);
pointer-events: none;
white-space: nowrap;
font-weight: 600;
}
#userManagementContent {
margin-top: 10px !important;
}
#darkModeIcon{color: #fff;}
.header-logo{max-height: 50px;
width: auto;

View File

@@ -4,8 +4,46 @@ import { loadAdminConfigFunc } from './auth.js?v={{APP_QVER}}';
import { showToast, toggleVisibility, attachEnterKeyListener } from './domUtils.js?v={{APP_QVER}}';
import { sendRequest } from './networkUtils.js?v={{APP_QVER}}';
function normalizeLogoPath(raw) {
if (!raw) return '';
const parts = String(raw).split(':');
let pic = parts[parts.length - 1];
pic = pic.replace(/^:+/, '');
if (pic && !pic.startsWith('/')) pic = '/' + pic;
return pic;
}
const version = window.APP_VERSION || "dev";
const adminTitle = `${t("admin_panel")} <small style="font-size:12px;color:gray;">${version}</small>`;
function getAdminTitle(isPro, proVersion) {
const corePill = `
<span class="badge badge-pill badge-secondary admin-core-badge">
Core ${version}
</span>
`;
if (!isPro) {
// Free/core only
return `
${t("admin_panel")}
${corePill}
`;
}
const pv = proVersion ? `Pro v${proVersion}` : 'Pro';
const proPill = `
<span class="badge badge-pill badge-warning admin-pro-badge">
${pv}
</span>
`;
return `
${t("admin_panel")}
${corePill}
${proPill}
`;
}
function buildFullGrantsForAllFolders(folders) {
@@ -16,6 +54,49 @@ function buildFullGrantsForAllFolders(folders) {
};
return folders.reduce((acc, f) => { acc[f] = { ...allTrue }; return acc; }, {});
}
function applyHeaderColorsFromAdmin() {
try {
const lightInput = document.getElementById('brandingHeaderBgLight');
const darkInput = document.getElementById('brandingHeaderBgDark');
const root = document.documentElement;
const light = lightInput ? lightInput.value.trim() : '';
const dark = darkInput ? darkInput.value.trim() : '';
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) {
console.warn('Failed to live-update header colors from admin panel', e);
}
}
function updateHeaderLogoFromAdmin() {
try {
const input = document.getElementById('brandingCustomLogoUrl');
const logoImg = document.querySelector('.header-logo img');
if (!logoImg) return;
let url = (input && input.value.trim()) || '';
// If they used a bare "uploads/..." path, normalize to "/uploads/..."
if (url && !url.startsWith('/') && url.startsWith('uploads/')) {
url = '/' + url;
}
if (url) {
logoImg.setAttribute('src', url);
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) {
console.warn('Failed to live-update header logo from admin panel', e);
}
}
/* === BEGIN: Folder Access helpers (merged + improved) === */
function qs(scope, sel) { return (scope || document).querySelector(sel); }
@@ -175,7 +256,7 @@ async function safeJson(res) {
.dark-mode .form-control::placeholder { color:#888; }
.section-header {
background:#f5f5f5; padding:10px 15px; cursor:pointer; border-radius:4px; font-weight:bold;
background:#f5f5f5; padding:10px 15px; cursor:pointer; border-radius:12px; font-weight:bold;
display:flex; align-items:center; justify-content:space-between; margin-top:16px;
}
.section-header:first-of-type { margin-top:0; }
@@ -301,7 +382,10 @@ function captureInitialAdminConfig() {
disableOIDCLogin: !!document.getElementById("disableOIDCLogin")?.checked,
enableWebDAV: !!document.getElementById("enableWebDAV")?.checked,
sharedMaxUploadSize: (document.getElementById("sharedMaxUploadSize")?.value || "").trim(),
globalOtpauthUrl: (document.getElementById("globalOtpauthUrl")?.value || "").trim()
globalOtpauthUrl: (document.getElementById("globalOtpauthUrl")?.value || "").trim(),
brandingCustomLogoUrl: (document.getElementById("brandingCustomLogoUrl")?.value || "").trim(),
brandingHeaderBgLight: (document.getElementById("brandingHeaderBgLight")?.value || "").trim(),
brandingHeaderBgDark: (document.getElementById("brandingHeaderBgDark")?.value || "").trim(),
};
}
function hasUnsavedChanges() {
@@ -319,7 +403,10 @@ function hasUnsavedChanges() {
getChk("disableOIDCLogin") !== o.disableOIDCLogin ||
getChk("enableWebDAV") !== o.enableWebDAV ||
getVal("sharedMaxUploadSize") !== o.sharedMaxUploadSize ||
getVal("globalOtpauthUrl") !== o.globalOtpauthUrl
getVal("globalOtpauthUrl") !== o.globalOtpauthUrl ||
getVal("brandingCustomLogoUrl") !== (o.brandingCustomLogoUrl || "") ||
getVal("brandingHeaderBgLight") !== (o.brandingHeaderBgLight || "") ||
getVal("brandingHeaderBgDark") !== (o.brandingHeaderBgDark || "")
);
}
@@ -460,6 +547,16 @@ export function openAdminPanel() {
if (config.globalOtpauthUrl) window.currentOIDCConfig.globalOtpauthUrl = config.globalOtpauthUrl;
const dark = document.body.classList.contains("dark-mode");
const proInfo = config.pro || {};
const isPro = !!proInfo.active;
const proType = proInfo.type || '';
const proEmail = proInfo.email || '';
const proVersion = proInfo.version || 'not installed';
const proLicense = proInfo.license || '';
const brandingCfg = config.branding || {};
const brandingCustomLogoUrl = brandingCfg.customLogoUrl || "";
const brandingHeaderBgLight = brandingCfg.headerBgLight || "";
const brandingHeaderBgDark = brandingCfg.headerBgDark || "";
const bg = dark ? "rgba(0,0,0,0.7)" : "rgba(0,0,0,0.3)";
const inner = `
background:${dark ? "#2c2c2c" : "#fff"};
@@ -484,7 +581,7 @@ export function openAdminPanel() {
mdl.innerHTML = `
<div class="modal-content" style="${inner}">
<div class="editor-close-btn" id="closeAdminPanel">&times;</div>
<h3>${adminTitle}</h3>
<h3>${getAdminTitle(isPro, proVersion)}</h3>
<form id="adminPanelForm">
${[
{ id: "userManagement", label: t("user_management") },
@@ -495,6 +592,7 @@ export function openAdminPanel() {
{ id: "upload", label: t("shared_max_upload_size_bytes_title") },
{ id: "oidc", label: t("oidc_configuration") + " & TOTP" },
{ id: "shareLinks", label: t("manage_shared_links") },
{ id: "pro", label: "FileRise Pro" },
{ id: "sponsor", label: (typeof tf === 'function' ? tf("sponsor_donations", "Sponsor / Donations") : "Sponsor / Donations") }
].map(sec => `
<div id="${sec.id}Header" class="section-header collapsed">
@@ -515,18 +613,100 @@ export function openAdminPanel() {
document.getElementById("closeAdminPanel").addEventListener("click", closeAdminPanel);
document.getElementById("cancelAdminSettings").addEventListener("click", closeAdminPanel);
["userManagement", "headerSettings", "loginOptions", "webdav", "onlyoffice", "upload", "oidc", "shareLinks", "sponsor"]
["userManagement", "headerSettings", "loginOptions", "webdav", "onlyoffice", "upload", "oidc", "shareLinks", "pro", "sponsor"]
.forEach(id => {
document.getElementById(id + "Header")
.addEventListener("click", () => toggleSection(id));
});
document.getElementById("userManagementContent").innerHTML = `
<button type="button" id="adminOpenAddUser" class="btn btn-success me-2">${t("add_user")}</button>
<button type="button" id="adminOpenRemoveUser" class="btn btn-danger me-2">${t("remove_user")}</button>
<button type="button" id="adminOpenUserPermissions" class="btn btn-secondary">${tf("folder_access", "Folder Access")}</button>
<button type="button" id="adminOpenUserFlags" class="btn btn-secondary">${tf("user_permissions", "User Permissions")}</button>
`;
document.getElementById("userManagementContent").innerHTML = `
<div class="admin-user-actions">
<!-- Core buttons -->
<button type="button" id="adminOpenAddUser" class="btn btn-success btn-sm">
<i class="material-icons">person_add</i>
<span>${t("add_user")}</span>
</button>
<button type="button" id="adminOpenRemoveUser" class="btn btn-danger btn-sm">
<i class="material-icons">person_remove</i>
<span>${t("remove_user")}</span>
</button>
<button type="button" id="adminOpenUserPermissions" class="btn btn-secondary btn-sm">
<i class="material-icons">folder_shared</i>
<span>${tf("folder_access", "Folder Access")}</span>
</button>
<button type="button" id="adminOpenUserFlags" class="btn btn-secondary btn-sm">
<i class="material-icons">tune</i>
<span>${tf("user_permissions", "User Permissions")}</span>
</button>
<!-- Pro-only: User groups -->
${
isPro
? `
<div class="btn-pro-wrapper">
<button
type="button"
id="adminOpenUserGroups"
class="btn btn-sm btn-pro-admin">
<i class="material-icons">groups</i>
<span>User groups</span>
</button>
</div>
`
: `
<div class="btn-pro-wrapper">
<button
type="button"
id="adminOpenUserGroups"
class="btn btn-sm btn-pro-admin">
<i class="material-icons">groups</i>
<span>User groups</span>
</button>
<span class="btn-pro-pill">Pro · Coming soon</span>
</div>
`
}
<!-- Pro roadmap: Client portal -->
${
isPro
? `
<div class="btn-pro-wrapper">
<button
type="button"
id="adminOpenClientPortal"
class="btn btn-sm btn-pro-admin"
title="Client upload portals are part of FileRise Pro.">
<i class="material-icons">cloud_upload</i>
<span>Client upload portal</span>
</button>
</div>
`
: `
<div class="btn-pro-wrapper">
<button
type="button"
id="adminOpenClientPortal"
class="btn btn-sm btn-pro-admin"
disabled
title="Planned FileRise Pro feature: client upload portals">
<i class="material-icons">cloud_upload</i>
<span>Client upload portal</span>
</button>
<span class="btn-pro-pill">Pro · Coming soon</span>
</div>
`
}
</div>
<small class="text-muted d-block" style="margin-top:6px;">
Use the core tools to manage users and per-folder access.
User groups and Client upload portals are planned FileRise Pro features.
</small>
`;
document.getElementById("adminOpenAddUser")
.addEventListener("click", () => {
@@ -541,13 +721,174 @@ export function openAdminPanel() {
document.getElementById("adminOpenUserPermissions")
.addEventListener("click", openUserPermissionsModal);
document.getElementById("headerSettingsContent").innerHTML = `
<div class="form-group">
<label for="headerTitle">${t("header_title_text")}:</label>
<input type="text" id="headerTitle" class="form-control" value="${window.headerTitle || ""}" />
</div>
`;
wireHeaderTitleLive();
// Pro-only stubs for future features
const regBtn = document.getElementById("adminOpenUserRegistration");
const groupsBtn = document.getElementById("adminOpenUserGroups");
const clientBtn = document.getElementById("adminOpenClientPortal");
if (regBtn) {
regBtn.addEventListener("click", () => {
if (!isPro) {
showToast("User registration is a FileRise Pro feature. Visit filerise.net to purchase a license.");
window.open("https://filerise.net", "_blank", "noopener");
return;
}
// Placeholder for future Pro UI:
showToast("User registration management is coming soon in FileRise Pro.");
});
}
if (groupsBtn) {
groupsBtn.addEventListener("click", () => {
if (!isPro) {
showToast("User groups are a FileRise Pro feature. Visit filerise.net to purchase a license.");
window.open("https://filerise.net", "_blank", "noopener");
return;
}
// Placeholder for future Pro UI:
showToast("User groups management is coming soon in FileRise Pro.");
});
}
if (clientBtn) {
clientBtn.addEventListener("click", () => {
if (!isPro) {
showToast("Client portal uploads are a FileRise Pro feature. Visit filerise.net to purchase a license.");
window.open("https://filerise.net", "_blank", "noopener");
return;
}
// Placeholder for future Pro UI:
showToast("Client portal uploads are coming soon in FileRise Pro.");
});
}
document.getElementById("headerSettingsContent").innerHTML = `
<div class="form-group">
<label for="headerTitle">${t("header_title_text")}:</label>
<input type="text" id="headerTitle" class="form-control" value="${window.headerTitle || ""}" />
</div>
<!-- Pro: Logo -->
<div class="form-group" style="margin-top:16px;">
<label for="brandingCustomLogoUrl">
Header Logo
${!isPro ? '<span class="badge badge-pill badge-warning admin-pro-badge" style="margin-left:6px;">Pro</span>' : ''}
</label>
<small class="text-muted d-block mb-1">
${isPro
? 'Upload a logo image or paste a local path.'
: 'Requires FileRise Pro to enable custom header branding.'}
</small>
<div class="input-group mb-2">
<input
type="text"
id="brandingCustomLogoUrl"
class="form-control"
placeholder="/uploads/profile_pics/logo.png"
value="${isPro ? (brandingCustomLogoUrl.replace(/"/g, '&quot;')) : ''}"
${!isPro ? 'disabled data-disabled-reason="pro"' : ''}
/>
</div>
<div class="input-group">
<input
type="file"
id="brandingLogoFile"
class="form-control"
accept="image/*"
${!isPro ? 'disabled' : ''}
/>
<button
type="button"
class="btn btn-sm btn-secondary"
id="brandingUploadBtn"
${!isPro ? 'disabled' : ''}>
Upload logo
</button>
</div>
</div>
<!-- Pro: Header colors -->
<div class="form-group" style="margin-top:16px;">
<label>
Header Colors
${!isPro ? '<span class="badge badge-pill badge-warning admin-pro-badge" style="margin-left:6px;">Pro</span>' : ''}
</label>
<div class="d-flex align-items-center" style="gap: 12px; flex-wrap: wrap;">
<div>
<label for="brandingHeaderBgLight" class="d-block" style="font-size: 12px; margin-bottom: 4px;">Light mode</label>
<input
type="color"
id="brandingHeaderBgLight"
value="${brandingHeaderBgLight || '#2196F3'}"
${!isPro ? 'disabled' : ''}
/>
</div>
<div>
<label for="brandingHeaderBgDark" class="d-block" style="font-size: 12px; margin-bottom: 4px;">Dark mode</label>
<input
type="color"
id="brandingHeaderBgDark"
value="${brandingHeaderBgDark || '#1f1f1f'}"
${!isPro ? 'disabled' : ''}
/>
</div>
</div>
<small class="text-muted d-block mt-1">
${isPro
? 'If left empty, FileRise uses its default blue and dark header colors.'
: 'Requires FileRise Pro to enable custom color branding.'}
</small>
</div>
`;
wireHeaderTitleLive();
// Upload logo -> reuse profile picture endpoint, then fill the logo path
if (isPro) {
const fileInput = document.getElementById('brandingLogoFile');
const uploadBtn = document.getElementById('brandingUploadBtn');
const urlInput = document.getElementById('brandingCustomLogoUrl');
if (fileInput && uploadBtn && urlInput) {
uploadBtn.addEventListener('click', async () => {
const f = fileInput.files && fileInput.files[0];
if (!f) {
showToast('Please choose an image first.');
return;
}
const fd = new FormData();
fd.append('brand_logo', f); // <- must match PHP field
try {
const res = await fetch('/api/pro/uploadBrandLogo.php', {
method: 'POST',
credentials: 'include',
headers: { 'X-CSRF-Token': window.csrfToken },
body: fd
});
const text = await res.text();
let js = {};
try { js = JSON.parse(text || '{}'); } catch (e) { js = {}; }
if (!res.ok || !js.url) {
showToast(js.error || 'Error uploading logo');
return;
}
const normalized = normalizeLogoPath(js.url); // your helper
urlInput.value = normalized;
showToast('Logo uploaded. Don\'t forget to Save settings.');
} catch (e) {
console.error(e);
showToast('Error uploading logo');
}
});
}
}
document.getElementById("loginOptionsContent").innerHTML = `
<div class="form-group"><input type="checkbox" id="disableFormLogin" /> <label for="disableFormLogin">${t("disable_login_form")}</label></div>
@@ -912,6 +1253,236 @@ async function ooProbeFrame(docsOrigin, timeoutMs = 4000) {
document.getElementById("shareLinksContent").textContent = t("loading") + "…";
document.getElementById("shareLinksContent").textContent = t("loading") + "…";
// --- FileRise Pro / License section ---
const proContent = document.getElementById("proContent");
if (proContent) {
const proMetaHtml =
isPro && (proType || proEmail || proVersion)
? `
<div class="pro-license-meta">
<div>
${proType ? `License type: ${proType}` : 'License active'}
${proType && proEmail ? ' • ' : ''}
${proEmail ? `Licensed to: ${proEmail}` : ''}
</div>
<div>
Pro bundle version: v${proVersion}
</div>
</div>
`
: '';
proContent.innerHTML = `
<div class="card pro-card" style="padding:12px; border:1px solid #ddd; border-radius:12px; max-width:620px; margin:8px auto;">
<div>
<!-- Title row with pill aligned to "FileRise Pro" -->
<div class="d-flex align-items-center" style="gap:8px;">
<strong>FileRise Pro</strong>
<span class="badge badge-pill ${isPro ? 'badge-success' : 'badge-secondary'} admin-pro-badge">
${isPro ? 'Active' : 'Free'}
</span>
</div>
<!-- Subtitle + meta under the title -->
<div style="font-size:12px; color:#777; margin-top:2px;">
${isPro
? 'Pro features are currently enabled on this instance.'
: 'You are running the free edition. Enter a license key to activate FileRise Pro.'}
</div>
${proMetaHtml}
</div>
${isPro ? `
<div style="margin-top:8px;">
<a
href="https://filerise.net/pro/update.php"
target="_blank"
rel="noopener noreferrer"
class="btn btn-sm btn-pro-admin">
Download latest Pro bundle
</a>
<small class="text-muted d-block" style="margin-top:4px;">
Opens filerise.net in a new tab where you can enter your Pro license
to download the latest FileRise Pro ZIP.
</small>
</div>
` : `
<div style="margin-top:8px;">
<a
href="https://filerise.net/pro/checkout.php"
target="_blank"
rel="noopener noreferrer"
class="btn btn-sm btn-pro-admin">
Buy FileRise Pro
</a>
<small class="text-muted d-block" style="margin-top:4px;">
Opens filerise.net in a new tab so you can purchase a FileRise Pro license.
</small>
</div>
`}
<div class="form-group" style="margin-top:10px;">
<label for="proLicenseInput" style="font-size:12px;">License key</label>
<textarea
id="proLicenseInput"
class="form-control"
rows="3"
placeholder="Paste your FileRise Pro license key here..."></textarea>
<small class="text-muted">
You can purchase a license at
<a href="https://filerise.net" target="_blank" rel="noopener noreferrer">filerise.net</a>.
</small>
</div>
${isPro && proLicense ? `
<div style="margin-top:6px;">
<button type="button" class="btn btn-secondary btn-sm" id="proCopyLicenseBtn">
Copy current license
</button>
<small class="text-muted d-block" style="margin-top:4px;">
Copies the saved license so you can reuse it for upgrades or downloads on filerise.net.
</small>
</div>
` : ''}
<div class="form-group" style="margin-top:6px;">
<label style="font-size:12px;">Or upload license file</label>
<div class="input-group">
<input
type="file"
id="proLicenseFile"
class="form-control"
accept=".lic,.json,.txt,.filerise-lic"
/>
<button type="button" class="btn btn-sm btn-secondary" id="proLoadLicenseFileBtn">
Load from file
</button>
</div>
<small class="text-muted">
Supported: FileRise.lic, plain text with FRP1... or JSON containing a <code>license</code> field.
</small>
</div>
<button type="button" class="btn btn-primary btn-sm" id="proSaveLicenseBtn" style="margin-top:8px;">
Save license
</button>
</div>
`;
// Pre-fill textarea with saved license if present
const licenseTextarea = document.getElementById('proLicenseInput');
if (licenseTextarea && proLicense) {
licenseTextarea.value = proLicense;
}
// File upload → fill textarea (unchanged)
const fileInput = document.getElementById('proLicenseFile');
const fileBtn = document.getElementById('proLoadLicenseFileBtn');
if (fileInput && fileBtn && licenseTextarea) {
fileBtn.addEventListener('click', () => {
const file = fileInput.files && fileInput.files[0];
if (!file) {
showToast('Please choose a license file first.');
return;
}
const reader = new FileReader();
reader.onload = (e) => {
let raw = String(e.target.result || '').trim();
let license = raw;
try {
const js = JSON.parse(raw);
if (js && typeof js.license === 'string') {
license = js.license.trim();
}
} catch (_) {
// not JSON, treat as plain text
}
if (!license || !license.startsWith('FRP1.')) {
showToast('Could not find a valid FRP1 license in that file.');
return;
}
licenseTextarea.value = license;
showToast('License loaded from file. Click "Save license" to apply.');
};
reader.onerror = () => {
showToast('Error reading license file.');
};
reader.readAsText(file);
});
}
// Copy current license button
const proCopyBtn = document.getElementById('proCopyLicenseBtn');
if (proCopyBtn && proLicense) {
proCopyBtn.addEventListener('click', async () => {
try {
if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(proLicense);
} else {
const ta = document.createElement('textarea');
ta.value = proLicense;
ta.style.position = 'fixed';
ta.style.left = '-9999px';
document.body.appendChild(ta);
ta.select();
document.execCommand('copy');
ta.remove();
}
showToast('License copied to clipboard.');
} catch {
showToast('Could not copy license. Please copy it manually.');
}
});
}
// Save license handler (unchanged)
const proSaveBtn = document.getElementById('proSaveLicenseBtn');
if (proSaveBtn) {
proSaveBtn.addEventListener('click', async () => {
const ta = document.getElementById('proLicenseInput');
const license = (ta && ta.value.trim()) || '';
try {
const res = await fetch('/api/admin/setLicense.php', {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': (document.querySelector('meta[name="csrf-token"]')?.content || '')
},
body: JSON.stringify({ license }),
});
const text = await res.text();
let data = {};
try { data = JSON.parse(text || '{}'); } catch (e) { data = {}; }
if (!res.ok || !data.success) {
console.error('setLicense error:', res.status, text);
showToast(data.error || 'Error saving license');
return;
}
showToast('License saved. Reloading…');
window.location.reload();
} catch (e) {
console.error(e);
showToast('Error saving license');
}
});
}
}
// --- end FileRise Pro section ---
document.getElementById("saveAdminSettings")
.addEventListener("click", handleSave);
["disableFormLogin", "disableBasicAuth", "disableOIDCLogin"].forEach(id => {
@@ -1065,6 +1636,11 @@ function handleSave() {
// clientId/clientSecret: only include when replacing
},
globalOtpauthUrl: document.getElementById("globalOtpauthUrl").value.trim(),
branding: {
customLogoUrl: (document.getElementById("brandingCustomLogoUrl")?.value || "").trim(),
headerBgLight: (document.getElementById("brandingHeaderBgLight")?.value || "").trim(),
headerBgDark: (document.getElementById("brandingHeaderBgDark")?.value || "").trim(),
},
};
const idEl = document.getElementById("oidcClientId");
@@ -1119,6 +1695,8 @@ function handleSave() {
if (j.error) { showToast('Error: ' + j.error); return; }
showToast('Settings saved.');
closeAdminPanel();
applyHeaderColorsFromAdmin();
updateHeaderLogoFromAdmin();
})
.catch(() => showToast('Save failed.'));
}

View File

@@ -406,6 +406,40 @@ function bindDarkMode() {
// 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
}
// --- Login options (apply in BOTH phases so login page is correct) ---
const lo = (cfg && cfg.loginOptions) ? cfg.loginOptions : {};

View File

@@ -6,91 +6,271 @@ require_once PROJECT_ROOT . '/src/models/AdminModel.php';
class AdminController
{
public function getConfig(): void
{
header('Content-Type: application/json; charset=utf-8');
$config = AdminModel::getConfig();
if (isset($config['error'])) {
http_response_code(500);
header('Cache-Control: no-store');
echo json_encode(['error' => $config['error']], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
/** Enforce authentication (401). */
private static function requireAuth(): void
{
if (empty($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true) {
http_response_code(401);
header('Content-Type: application/json');
echo json_encode(['error' => 'Unauthorized']);
exit;
}
}
/** Enforce admin (401). */
private static function requireAdmin(): void
{
self::requireAuth();
// Prefer the session flag
$isAdmin = (!empty($_SESSION['isAdmin']) && $_SESSION['isAdmin'] === true);
// Fallback: check the users role in storage (e.g., users.txt/DB)
if (!$isAdmin) {
$u = $_SESSION['username'] ?? '';
if ($u) {
try {
// UserModel::getUserRole($u) should return '1' for admins
$isAdmin = (UserModel::getUserRole($u) === '1');
if ($isAdmin) {
// Normalize session so downstream ACL checks see admin
$_SESSION['isAdmin'] = true;
}
} catch (\Throwable $e) {
// ignore and continue to deny
}
}
}
if (!$isAdmin) {
http_response_code(403);
header('Content-Type: application/json');
echo json_encode(['error' => 'Admin privileges required.']);
exit;
}
}
/** Get headers in lowercase, robust across SAPIs. */
private static function headersLower(): array
{
$headers = function_exists('getallheaders') ? getallheaders() : [];
$out = [];
foreach ($headers as $k => $v) {
$out[strtolower($k)] = $v;
}
// Fallbacks from $_SERVER if needed
foreach ($_SERVER as $k => $v) {
if (strpos($k, 'HTTP_') === 0) {
$h = strtolower(str_replace('_', '-', substr($k, 5)));
if (!isset($out[$h])) $out[$h] = $v;
}
}
return $out;
}
/** Enforce CSRF using X-CSRF-Token header (or csrfToken param as fallback). */
private static function requireCsrf(): void
{
$h = self::headersLower();
$token = trim($h['x-csrf-token'] ?? ($_POST['csrfToken'] ?? ''));
if (empty($_SESSION['csrf_token']) || $token !== $_SESSION['csrf_token']) {
http_response_code(403);
header('Content-Type: application/json');
echo json_encode(['error' => 'Invalid CSRF token']);
exit;
}
}
/** Read JSON body (empty array if not valid). */
private static function readJson(): array
{
$raw = file_get_contents('php://input');
$data = json_decode($raw, true);
return is_array($data) ? $data : [];
}
public function getConfig(): void
{
header('Content-Type: application/json; charset=utf-8');
$config = AdminModel::getConfig();
if (isset($config['error'])) {
http_response_code(500);
header('Cache-Control: no-store');
echo json_encode(['error' => $config['error']], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
return;
}
// ---- Effective ONLYOFFICE values (constants override adminConfig) ----
$ooCfg = is_array($config['onlyoffice'] ?? null) ? $config['onlyoffice'] : [];
$effEnabled = defined('ONLYOFFICE_ENABLED')
? (bool) ONLYOFFICE_ENABLED
: (bool) ($ooCfg['enabled'] ?? false);
$effDocs = (defined('ONLYOFFICE_DOCS_ORIGIN') && ONLYOFFICE_DOCS_ORIGIN !== '')
? (string) ONLYOFFICE_DOCS_ORIGIN
: (string) ($ooCfg['docsOrigin'] ?? '');
$hasSecret = defined('ONLYOFFICE_JWT_SECRET')
? (ONLYOFFICE_JWT_SECRET !== '')
: (!empty($ooCfg['jwtSecret']));
$publicOriginCfg = (string) ($ooCfg['publicOrigin'] ?? '');
// ---- Pro / license info (all guarded for clean core installs) ----
$licenseString = null;
if (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);
if (is_array($decoded) && !empty($decoded['license'])) {
$licenseString = (string) $decoded['license'];
}
}
}
$proActive = defined('FR_PRO_ACTIVE') && FR_PRO_ACTIVE;
// FR_PRO_INFO is only defined when bootstrap_pro.php has run; guard it
$proPayload = [];
if (defined('FR_PRO_INFO') && is_array(FR_PRO_INFO)) {
$p = FR_PRO_INFO['payload'] ?? null;
if (is_array($p)) {
$proPayload = $p;
}
}
$proType = $proPayload['type'] ?? null;
$proEmail = $proPayload['email'] ?? null;
$proVersion = defined('FR_PRO_BUNDLE_VERSION') ? FR_PRO_BUNDLE_VERSION : null;
// Whitelisted public subset only (+ ONLYOFFICE enabled flag)
$public = [
'header_title' => (string)($config['header_title'] ?? 'FileRise'),
'loginOptions' => [
'disableFormLogin' => (bool)($config['loginOptions']['disableFormLogin'] ?? false),
'disableBasicAuth' => (bool)($config['loginOptions']['disableBasicAuth'] ?? false),
'disableOIDCLogin' => (bool)($config['loginOptions']['disableOIDCLogin'] ?? false),
],
'globalOtpauthUrl' => (string)($config['globalOtpauthUrl'] ?? ''),
'enableWebDAV' => (bool)($config['enableWebDAV'] ?? false),
'sharedMaxUploadSize' => (int)($config['sharedMaxUploadSize'] ?? 0),
'oidc' => [
'providerUrl' => (string)($config['oidc']['providerUrl'] ?? ''),
'redirectUri' => (string)($config['oidc']['redirectUri'] ?? ''),
// never include clientId/clientSecret
],
'onlyoffice' => [
// Public only needs to know if its on; no secrets/origins here.
'enabled' => $effEnabled,
],
'branding' => [
'customLogoUrl' => (string)($config['branding']['customLogoUrl'] ?? ''),
'headerBgLight' => (string)($config['branding']['headerBgLight'] ?? ''),
'headerBgDark' => (string)($config['branding']['headerBgDark'] ?? ''),
],
'pro' => [
'active' => $proActive,
'type' => $proType,
'email' => $proEmail,
'version' => $proVersion,
'license' => $licenseString,
],
];
$isAdmin = !empty($_SESSION['authenticated']) && !empty($_SESSION['isAdmin']);
if ($isAdmin) {
// admin-only extras: presence flags + proxy options + ONLYOFFICE effective view
$adminExtra = [
'loginOptions' => array_merge($public['loginOptions'], [
'authBypass' => (bool)($config['loginOptions']['authBypass'] ?? false),
'authHeaderName' => (string)($config['loginOptions']['authHeaderName'] ?? 'X-Remote-User'),
]),
'oidc' => array_merge($public['oidc'], [
'hasClientId' => !empty($config['oidc']['clientId']),
'hasClientSecret' => !empty($config['oidc']['clientSecret']),
]),
'onlyoffice' => [
'enabled' => $effEnabled,
'docsOrigin' => $effDocs, // effective (constants win)
'publicOrigin' => $publicOriginCfg, // optional override from adminConfig
'hasJwtSecret' => (bool)$hasSecret, // boolean only; never leak secret
'lockedByPhp' => (
defined('ONLYOFFICE_ENABLED')
|| defined('ONLYOFFICE_DOCS_ORIGIN')
|| defined('ONLYOFFICE_JWT_SECRET')
|| defined('ONLYOFFICE_PUBLIC_ORIGIN')
),
],
];
header('Cache-Control: no-store'); // dont cache admin config
echo json_encode(array_merge($public, $adminExtra), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
return;
}
// Non-admins / unauthenticated: only the public subset
header('Cache-Control: no-store');
echo json_encode($public, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
return;
}
public function setLicense(): void
{
// Always respond JSON
header('Content-Type: application/json; charset=utf-8');
try {
// Same guards as other admin endpoints
self::requireAuth();
self::requireAdmin();
self::requireCsrf();
$raw = file_get_contents('php://input');
$data = json_decode($raw ?: '{}', true);
if (!is_array($data)) {
http_response_code(400);
echo json_encode(['success' => false, 'error' => 'Invalid JSON body']);
return;
}
// ---- Effective ONLYOFFICE values (constants override adminConfig) ----
$ooCfg = is_array($config['onlyoffice'] ?? null) ? $config['onlyoffice'] : [];
$effEnabled = defined('ONLYOFFICE_ENABLED')
? (bool) ONLYOFFICE_ENABLED
: (bool) ($ooCfg['enabled'] ?? false);
$license = isset($data['license']) ? trim((string)$data['license']) : '';
$effDocs = defined('ONLYOFFICE_DOCS_ORIGIN') && ONLYOFFICE_DOCS_ORIGIN !== ''
? (string) ONLYOFFICE_DOCS_ORIGIN
: (string) ($ooCfg['docsOrigin'] ?? '');
// Store license + updatedAt in JSON file
if (!defined('PRO_LICENSE_FILE')) {
// Fallback if constant not defined for some reason
define('PRO_LICENSE_FILE', PROJECT_ROOT . '/users/proLicense.json');
}
$hasSecret = defined('ONLYOFFICE_JWT_SECRET')
? (ONLYOFFICE_JWT_SECRET !== '')
: (!empty($ooCfg['jwtSecret']));
$publicOriginCfg = (string) ($ooCfg['publicOrigin'] ?? '');
// Whitelisted public subset only (+ ONLYOFFICE enabled flag)
$public = [
'header_title' => (string)($config['header_title'] ?? 'FileRise'),
'loginOptions' => [
'disableFormLogin' => (bool)($config['loginOptions']['disableFormLogin'] ?? false),
'disableBasicAuth' => (bool)($config['loginOptions']['disableBasicAuth'] ?? false),
'disableOIDCLogin' => (bool)($config['loginOptions']['disableOIDCLogin'] ?? false),
],
'globalOtpauthUrl' => (string)($config['globalOtpauthUrl'] ?? ''),
'enableWebDAV' => (bool)($config['enableWebDAV'] ?? false),
'sharedMaxUploadSize' => (int)($config['sharedMaxUploadSize'] ?? 0),
'oidc' => [
'providerUrl' => (string)($config['oidc']['providerUrl'] ?? ''),
'redirectUri' => (string)($config['oidc']['redirectUri'] ?? ''),
// never include clientId/clientSecret
],
'onlyoffice' => [
// Public only needs to know if its on; no secrets/origins here.
'enabled' => $effEnabled,
],
$payload = [
'license' => $license,
'updatedAt' => gmdate('c'),
];
$isAdmin = !empty($_SESSION['authenticated']) && !empty($_SESSION['isAdmin']);
if ($isAdmin) {
// admin-only extras: presence flags + proxy options + ONLYOFFICE effective view
$adminExtra = [
'loginOptions' => array_merge($public['loginOptions'], [
'authBypass' => (bool)($config['loginOptions']['authBypass'] ?? false),
'authHeaderName' => (string)($config['loginOptions']['authHeaderName'] ?? 'X-Remote-User'),
]),
'oidc' => array_merge($public['oidc'], [
'hasClientId' => !empty($config['oidc']['clientId']),
'hasClientSecret' => !empty($config['oidc']['clientSecret']),
]),
'onlyoffice' => [
'enabled' => $effEnabled,
'docsOrigin' => $effDocs, // effective (constants win)
'publicOrigin' => $publicOriginCfg, // optional override from adminConfig
'hasJwtSecret' => (bool)$hasSecret, // boolean only; never leak secret
'lockedByPhp' => (
defined('ONLYOFFICE_ENABLED')
|| defined('ONLYOFFICE_DOCS_ORIGIN')
|| defined('ONLYOFFICE_JWT_SECRET')
),
],
];
header('Cache-Control: no-store'); // dont cache admin config
echo json_encode(array_merge($public, $adminExtra), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
$dir = dirname(PRO_LICENSE_FILE);
if (!is_dir($dir) && !mkdir($dir, 0755, true)) {
http_response_code(500);
echo json_encode(['success' => false, 'error' => 'Failed to create license dir']);
return;
}
// Non-admins / unauthenticated: only the public subset
header('Cache-Control: no-store');
echo json_encode($public, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
return;
$json = json_encode($payload, JSON_PRETTY_PRINT);
if ($json === false || file_put_contents(PRO_LICENSE_FILE, $json) === false) {
http_response_code(500);
echo json_encode(['success' => false, 'error' => 'Failed to write license file']);
return;
}
echo json_encode(['success' => true]);
} catch (Throwable $e) {
http_response_code(500);
echo json_encode([
'success' => false,
'error' => 'Exception: ' . $e->getMessage(),
]);
}
}
public function updateConfig(): void
{
@@ -149,6 +329,11 @@ class AdminController
'clientSecret'=> '',
'redirectUri' => ''
],
'branding' => [
'customLogoUrl' => '',
'headerBgLight' => '',
'headerBgDark' => '',
],
];
// header_title (cap length and strip control chars)
@@ -250,6 +435,7 @@ class AdminController
exit;
}
}
// —– persist merged config —–
// ---- ONLYOFFICE: merge from payload (unless locked by PHP defines) ----
@@ -286,6 +472,22 @@ class AdminController
$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];
}
}
}
$result = AdminModel::updateConfig($merged);
if (isset($result['error'])) {
http_response_code(500);

View File

@@ -649,8 +649,16 @@ class UserController
exit;
}
// Assuming /uploads maps to UPLOAD_DIR publicly
$url = '/uploads/profile_pics/' . $filename;
$fsPath = rtrim(UPLOAD_DIR, '/\\') . '/profile_pics/' . $filename;
// Remove the filesystem root (PROJECT_ROOT) so we get a web-relative path
$root = rtrim(PROJECT_ROOT, '/\\');
$url = preg_replace('#^' . preg_quote($root, '#') . '#', '', $fsPath);
// Ensure it starts with /
if ($url === '' || $url[0] !== '/') {
$url = '/' . $url;
}
$result = UserModel::setProfilePicture($_SESSION['username'], $url);
if (!($result['success'] ?? false)) {
@@ -667,6 +675,76 @@ class UserController
exit;
}
/**
* Upload branding logo (Pro-only; admin, CSRF).
* Reuses the profile_pics directory but does NOT change the user's avatar.
*/
public function uploadBrandLogo()
{
self::jsonHeaders();
// Auth, admin & CSRF
self::requireAuth();
self::requireAdmin();
self::requireCsrf();
if (empty($_FILES['brand_logo']) || $_FILES['brand_logo']['error'] !== UPLOAD_ERR_OK) {
http_response_code(400);
echo json_encode(['success' => false, 'error' => 'No file uploaded or error']);
exit;
}
$file = $_FILES['brand_logo'];
// Validate MIME & size (same rules as uploadPicture)
$allowed = ['image/jpeg' => 'jpg', 'image/png' => 'png', 'image/gif' => 'gif'];
$finfo = finfo_open(FILEINFO_MIME_TYPE);
$mime = finfo_file($finfo, $file['tmp_name']);
finfo_close($finfo);
if (!isset($allowed[$mime])) {
http_response_code(400);
echo json_encode(['success' => false, 'error' => 'Invalid file type']);
exit;
}
if ($file['size'] > 2 * 1024 * 1024) {
http_response_code(400);
echo json_encode(['success' => false, 'error' => 'File too large']);
exit;
}
// Destination: reuse profile_pics directory
$uploadDir = rtrim(UPLOAD_DIR, '/\\') . '/profile_pics';
if (!is_dir($uploadDir) && !mkdir($uploadDir, 0755, true)) {
http_response_code(500);
echo json_encode(['success' => false, 'error' => 'Cannot create upload folder']);
exit;
}
$ext = $allowed[$mime];
$user = preg_replace('/[^a-zA-Z0-9_\-]/', '', $_SESSION['username'] ?? 'logo');
$filename = 'branding_' . $user . '_' . bin2hex(random_bytes(8)) . '.' . $ext;
$dest = $uploadDir . '/' . $filename;
if (!move_uploaded_file($file['tmp_name'], $dest)) {
http_response_code(500);
echo json_encode(['success' => false, 'error' => 'Failed to save file']);
exit;
}
$fsPath = rtrim(UPLOAD_DIR, '/\\') . '/profile_pics/' . $filename;
// Remove the filesystem root (PROJECT_ROOT) so we get a web-relative path
$root = rtrim(PROJECT_ROOT, '/\\');
$url = preg_replace('#^' . preg_quote($root, '#') . '#', '', $fsPath);
// Ensure it starts with /
if ($url === '' || $url[0] !== '/') {
$url = '/' . $url;
}
echo json_encode(['success' => true, 'url' => $url]);
exit;
}
public function siteConfig(): void
{
header('Content-Type: application/json');

View File

@@ -73,6 +73,27 @@ class AdminModel
return ($scheme === 'http' || $scheme === 'https') ? $url : '';
}
/** Allow logo URLs that are either site-relative (/uploads/…) or http(s). */
private static function sanitizeLogoUrl($url): string
{
$url = trim((string)$url);
if ($url === '') return '';
// 1) Site-relative like "/uploads/profile_pics/branding_foo.png"
if ($url[0] === '/') {
// Strip CRLF just in case
$url = preg_replace('~[\r\n]+~', '', $url);
// Dont allow sneaky schemes embedded in a relative path
if (strpos($url, '://') !== false) {
return '';
}
return $url;
}
// 2) Fallback to plain http(s) validation
return self::sanitizeHttpUrl($url);
}
public static function buildPublicSubset(array $config): array
{
$public = [
@@ -89,6 +110,17 @@ class AdminModel
'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'] ?? ''
),
],
];
// NEW: include ONLYOFFICE minimal public flag
@@ -226,6 +258,30 @@ $public['onlyoffice'] = ['enabled' => $ooEnabled];
$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'] = '';
}
}
// Convert configuration to JSON.
$plainTextConfig = json_encode($configUpdate, JSON_PRETTY_PRINT);
@@ -267,6 +323,18 @@ $public['onlyoffice'] = ['enabled' => $ooEnabled];
return ["success" => "Configuration updated successfully."];
}
private static function sanitizeColorHex($value): string
{
$value = trim((string)$value);
if ($value === '') return '';
// allow #RGB or #RRGGBB
if (preg_match('/^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/', $value)) {
return strtoupper($value);
}
return '';
}
/**
* Retrieves the current configuration.
*
@@ -368,6 +436,25 @@ $public['onlyoffice'] = ['enabled' => $ooEnabled];
$config['onlyoffice']['publicOrigin'] = self::sanitizeHttpUrl($config['onlyoffice']['publicOrigin'] ?? '');
}
// Branding
if (!isset($config['branding']) || !is_array($config['branding'])) {
$config['branding'] = [
'customLogoUrl' => '',
'headerBgLight' => '',
'headerBgDark' => '',
];
} else {
$config['branding']['customLogoUrl'] = self::sanitizeLogoUrl(
$config['branding']['customLogoUrl'] ?? ''
);
$config['branding']['headerBgLight'] = self::sanitizeColorHex(
$config['branding']['headerBgLight'] ?? ''
);
$config['branding']['headerBgDark'] = self::sanitizeColorHex(
$config['branding']['headerBgDark'] ?? ''
);
}
return $config;
}
@@ -393,6 +480,11 @@ $public['onlyoffice'] = ['enabled' => $ooEnabled];
'docsOrigin' => '',
'publicOrigin' => '',
],
'branding' => [
'customLogoUrl' => '',
'headerBgLight' => '',
'headerBgDark' => '',
],
];
}
}