'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 user’s 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 it’s 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'); // don’t 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; } } ?>