Files
FileRise/src/controllers/AdminController.php

943 lines
37 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
// src/controllers/AdminController.php
require_once __DIR__ . '/../../config/config.php';
require_once PROJECT_ROOT . '/src/models/AdminModel.php';
class AdminController
{
/** Enforce authentication (401). */
public 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). */
public 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). */
public 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;
}
$license = isset($data['license']) ? trim((string)$data['license']) : '';
// 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');
}
$payload = [
'license' => $license,
'updatedAt' => gmdate('c'),
];
$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;
}
$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 getProPortals(): array
{
if (!defined('FR_PRO_ACTIVE') || !FR_PRO_ACTIVE || !defined('FR_PRO_BUNDLE_DIR') || !FR_PRO_BUNDLE_DIR) {
throw new RuntimeException('FileRise Pro is not active.');
}
$proPortalsPath = rtrim((string)FR_PRO_BUNDLE_DIR, "/\\") . '/ProPortals.php';
if (!is_file($proPortalsPath)) {
throw new RuntimeException('ProPortals.php not found in Pro bundle.');
}
require_once $proPortalsPath;
// ProPortals is implemented in the Pro bundle and handles JSON storage.
$store = new ProPortals(FR_PRO_BUNDLE_DIR);
$portals = $store->listPortals();
return $portals;
}
/**
* @param array $portalsPayload Raw "portals" array from JSON body
*/
public function saveProPortals(array $portalsPayload): void
{
if (!defined('FR_PRO_ACTIVE') || !FR_PRO_ACTIVE || !defined('FR_PRO_BUNDLE_DIR') || !FR_PRO_BUNDLE_DIR) {
throw new RuntimeException('FileRise Pro is not active.');
}
$proPortalsPath = rtrim((string)FR_PRO_BUNDLE_DIR, "/\\") . '/ProPortals.php';
if (!is_file($proPortalsPath)) {
throw new RuntimeException('ProPortals.php not found in Pro bundle.');
}
require_once $proPortalsPath;
if (!is_array($portalsPayload)) {
throw new InvalidArgumentException('Invalid portals format.');
}
// Minimal normalization; deeper validation can live inside ProPortals
$data = ['portals' => []];
foreach ($portalsPayload as $slug => $info) {
$slug = trim((string)$slug);
if ($slug === '') {
continue;
}
if (!is_array($info)) {
$info = [];
}
$label = trim((string)($info['label'] ?? $slug));
$folder = trim((string)($info['folder'] ?? ''));
$clientEmail = trim((string)($info['clientEmail'] ?? ''));
$uploadOnly = !empty($info['uploadOnly']);
$allowDownload = array_key_exists('allowDownload', $info)
? !empty($info['allowDownload'])
: true;
$expiresAt = trim((string)($info['expiresAt'] ?? ''));
// Optional branding + form behavior
$title = trim((string)($info['title'] ?? ''));
$introText = trim((string)($info['introText'] ?? ''));
$requireForm = !empty($info['requireForm']);
$brandColor = trim((string)($info['brandColor'] ?? ''));
$footerText = trim((string)($info['footerText'] ?? ''));
$formDefaults = isset($info['formDefaults']) && is_array($info['formDefaults'])
? $info['formDefaults']
: [];
// Normalize defaults for known keys
$formDefaults = [
'name' => trim((string)($formDefaults['name'] ?? '')),
'email' => trim((string)($formDefaults['email'] ?? '')),
'reference' => trim((string)($formDefaults['reference'] ?? '')),
'notes' => trim((string)($formDefaults['notes'] ?? '')),
];
$formRequired = isset($info['formRequired']) && is_array($info['formRequired'])
? $info['formRequired']
: [];
$formRequired = [
'name' => !empty($formRequired['name']),
'email' => !empty($formRequired['email']),
'reference' => !empty($formRequired['reference']),
'notes' => !empty($formRequired['notes']),
];
if ($folder === '') {
continue;
}
$data['portals'][$slug] = [
'label' => $label,
'folder' => $folder,
'clientEmail' => $clientEmail,
'uploadOnly' => $uploadOnly,
'allowDownload' => $allowDownload,
'expiresAt' => $expiresAt,
// NEW
'title' => $title,
'introText' => $introText,
'requireForm' => $requireForm,
'brandColor' => $brandColor,
'footerText' => $footerText,
'formDefaults' => $formDefaults,
'formRequired' => $formRequired,
];
}
$store = new ProPortals(FR_PRO_BUNDLE_DIR);
$ok = $store->savePortals($data);
if (!$ok) {
throw new RuntimeException('Could not write portals.json');
}
}
public function getProGroups(): array
{
if (!defined('FR_PRO_ACTIVE') || !FR_PRO_ACTIVE || !defined('FR_PRO_BUNDLE_DIR') || !FR_PRO_BUNDLE_DIR) {
throw new RuntimeException('FileRise Pro is not active.');
}
$proGroupsPath = rtrim((string)FR_PRO_BUNDLE_DIR, "/\\") . '/ProGroups.php';
if (!is_file($proGroupsPath)) {
throw new RuntimeException('ProGroups.php not found in Pro bundle.');
}
require_once $proGroupsPath;
$store = new ProGroups(FR_PRO_BUNDLE_DIR);
$groups = $store->listGroups();
return $groups;
}
/**
* @param array $groupsPayload Raw "groups" array from JSON body
*/
public function saveProGroups(array $groupsPayload): void
{
if (!defined('FR_PRO_ACTIVE') || !FR_PRO_ACTIVE || !defined('FR_PRO_BUNDLE_DIR') || !FR_PRO_BUNDLE_DIR) {
throw new RuntimeException('FileRise Pro is not active.');
}
$proGroupsPath = rtrim((string)FR_PRO_BUNDLE_DIR, "/\\") . '/ProGroups.php';
if (!is_file($proGroupsPath)) {
throw new RuntimeException('ProGroups.php not found in Pro bundle.');
}
require_once $proGroupsPath;
// Normalize / validate the payload into the canonical structure
if (!is_array($groupsPayload)) {
throw new InvalidArgumentException('Invalid groups format.');
}
$data = ['groups' => []];
foreach ($groupsPayload as $name => $info) {
$name = trim((string)$name);
if ($name === '') {
continue;
}
$label = isset($info['label']) ? trim((string)$info['label']) : $name;
$members = isset($info['members']) && is_array($info['members']) ? $info['members'] : [];
$grants = isset($info['grants']) && is_array($info['grants']) ? $info['grants'] : [];
$data['groups'][$name] = [
'name' => $name,
'label' => $label,
'members' => array_values(array_unique(array_map('strval', $members))),
'grants' => $grants,
];
}
$store = new ProGroups(FR_PRO_BUNDLE_DIR);
if (!$store->save($data)) {
throw new RuntimeException('Could not write groups.json');
}
}
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' => [],
'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';
} 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');
// —– auth & CSRF checks —–
if (
!isset($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true ||
!isset($_SESSION['isAdmin']) || !$_SESSION['isAdmin']
) {
http_response_code(403);
echo json_encode(['error' => 'Unauthorized access.']);
exit;
}
$headersArr = array_change_key_case(getallheaders(), CASE_LOWER);
$receivedToken = trim($headersArr['x-csrf-token'] ?? ($_POST['csrfToken'] ?? ''));
if (!isset($_SESSION['csrf_token']) || $receivedToken !== $_SESSION['csrf_token']) {
http_response_code(403);
echo json_encode(['error' => 'Invalid CSRF token.']);
exit;
}
// —– fetch payload —–
$data = json_decode(file_get_contents('php://input'), true);
if (!is_array($data)) {
http_response_code(400);
echo json_encode(['error' => 'Invalid input.']);
exit;
}
// —– load existing on-disk config —–
$existing = AdminModel::getConfig();
if (isset($existing['error'])) {
http_response_code(500);
echo json_encode(['error' => $existing['error']]);
exit;
}
// —– start merge with existing as base —–
// Ensure minimal structure if the file was partially missing.
$merged = $existing + [
'header_title' => '',
'loginOptions' => [
'disableFormLogin' => false,
'disableBasicAuth' => true,
'disableOIDCLogin' => true,
'authBypass' => false,
'authHeaderName' => 'X-Remote-User'
],
'globalOtpauthUrl' => '',
'enableWebDAV' => false,
'sharedMaxUploadSize' => 0,
'oidc' => [
'providerUrl' => '',
'clientId' => '',
'clientSecret'=> '',
'redirectUri' => ''
],
'branding' => [
'customLogoUrl' => '',
'headerBgLight' => '',
'headerBgDark' => '',
],
];
// header_title (cap length and strip control chars)
if (array_key_exists('header_title', $data)) {
$title = trim((string)$data['header_title']);
$title = preg_replace('/[\x00-\x1F\x7F]/', '', $title);
if (mb_strlen($title) > 100) { // hard cap
$title = mb_substr($title, 0, 100);
}
$merged['header_title'] = $title;
}
// loginOptions: inherit existing then override if provided
foreach (['disableFormLogin','disableBasicAuth','disableOIDCLogin','authBypass'] as $flag) {
if (isset($data['loginOptions'][$flag])) {
$merged['loginOptions'][$flag] = filter_var(
$data['loginOptions'][$flag],
FILTER_VALIDATE_BOOLEAN
);
}
}
if (isset($data['loginOptions']['authHeaderName'])) {
$hdr = trim((string)$data['loginOptions']['authHeaderName']);
// very restrictive header-name pattern: letters, numbers, dashes
if ($hdr !== '' && preg_match('/^[A-Za-z0-9\-]+$/', $hdr)) {
$merged['loginOptions']['authHeaderName'] = $hdr;
} else {
http_response_code(400);
echo json_encode(['error' => 'Invalid authHeaderName.']);
exit;
}
}
// globalOtpauthUrl
if (array_key_exists('globalOtpauthUrl', $data)) {
$merged['globalOtpauthUrl'] = trim((string)$data['globalOtpauthUrl']);
}
// enableWebDAV
if (array_key_exists('enableWebDAV', $data)) {
$merged['enableWebDAV'] = filter_var($data['enableWebDAV'], FILTER_VALIDATE_BOOLEAN);
}
// sharedMaxUploadSize
if (array_key_exists('sharedMaxUploadSize', $data)) {
$sms = filter_var($data['sharedMaxUploadSize'], FILTER_VALIDATE_INT);
if ($sms === false || $sms < 0) {
http_response_code(400);
echo json_encode(['error' => 'sharedMaxUploadSize must be a non-negative integer (bytes).']);
exit;
}
// Clamp to PHP limits to avoid confusing UX
$maxPost = self::iniToBytes(ini_get('post_max_size'));
$maxFile = self::iniToBytes(ini_get('upload_max_filesize'));
$phpCap = min($maxPost ?: PHP_INT_MAX, $maxFile ?: PHP_INT_MAX);
if ($phpCap !== PHP_INT_MAX && $sms > $phpCap) {
$sms = $phpCap;
}
$merged['sharedMaxUploadSize'] = $sms;
}
// oidc: only overwrite non-empty inputs; validate when enabling OIDC
foreach (['providerUrl','clientId','clientSecret','redirectUri'] as $f) {
if (!empty($data['oidc'][$f])) {
$val = trim((string)$data['oidc'][$f]);
if ($f === 'providerUrl' || $f === 'redirectUri') {
$val = filter_var($val, FILTER_SANITIZE_URL);
}
$merged['oidc'][$f] = $val;
}
}
// If OIDC login is enabled, ensure required fields are present and sane
$oidcEnabled = !empty($merged['loginOptions']['disableOIDCLogin']) ? false : true;
if ($oidcEnabled) {
$prov = $merged['oidc']['providerUrl'] ?? '';
$rid = $merged['oidc']['redirectUri'] ?? '';
$cid = $merged['oidc']['clientId'] ?? '';
// clientSecret may be empty for some PKCE-only flows, but commonly needed for code flow.
if ($prov === '' || $rid === '' || $cid === '') {
http_response_code(400);
echo json_encode(['error' => 'OIDC is enabled but providerUrl, redirectUri, and clientId are required.']);
exit;
}
// Require https except for localhost development
$httpsOk = function(string $url): bool {
if ($url === '') return false;
$parts = parse_url($url);
if (!$parts || empty($parts['scheme'])) return false;
if ($parts['scheme'] === 'https') return true;
if ($parts['scheme'] === 'http' && (isset($parts['host']) && ($parts['host'] === 'localhost' || $parts['host'] === '127.0.0.1'))) {
return true;
}
return false;
};
if (!$httpsOk($prov) || !$httpsOk($rid)) {
http_response_code(400);
echo json_encode(['error' => 'providerUrl and redirectUri must be https (or http on localhost)']);
exit;
}
}
// —– persist merged config —–
// ---- ONLYOFFICE: merge from payload (unless locked by PHP defines) ----
$ooLockedByPhp = (
defined('ONLYOFFICE_ENABLED') ||
defined('ONLYOFFICE_DOCS_ORIGIN') ||
defined('ONLYOFFICE_JWT_SECRET') ||
defined('ONLYOFFICE_PUBLIC_ORIGIN')
);
if (!$ooLockedByPhp && isset($data['onlyoffice']) && is_array($data['onlyoffice'])) {
$ooExisting = (isset($existing['onlyoffice']) && is_array($existing['onlyoffice']))
? $existing['onlyoffice'] : [];
$oo = $ooExisting;
if (array_key_exists('enabled', $data['onlyoffice'])) {
$oo['enabled'] = filter_var($data['onlyoffice']['enabled'], FILTER_VALIDATE_BOOLEAN);
}
if (isset($data['onlyoffice']['docsOrigin'])) {
$oo['docsOrigin'] = (string)$data['onlyoffice']['docsOrigin'];
}
if (isset($data['onlyoffice']['publicOrigin'])) {
$oo['publicOrigin'] = (string)$data['onlyoffice']['publicOrigin'];
}
// Allow setting/changing the secret when NOT locked by PHP
if (isset($data['onlyoffice']['jwtSecret'])) {
$js = trim((string)$data['onlyoffice']['jwtSecret']);
if ($js !== '') {
$oo['jwtSecret'] = $js; // stored encrypted by AdminModel
}
// If blank, we leave existing secret unchanged (no implicit wipe).
}
$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);
}
echo json_encode($result);
exit;
}
/** Convert php.ini shorthand like "128M" to bytes */
private static function iniToBytes($val)
{
if ($val === false || $val === null || $val === '') return 0;
$val = trim((string)$val);
$last = strtolower($val[strlen($val)-1]);
$num = (int)$val;
switch ($last) {
case 'g': $num *= 1024;
case 'm': $num *= 1024;
case 'k': $num *= 1024;
}
return $num;
}
}
?>