Files
FileRise/src/models/AdminModel.php

492 lines
20 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/models/AdminModel.php
require_once PROJECT_ROOT . '/config/config.php';
class AdminModel
{
/**
* Parse a shorthand size value (e.g. "5G", "500M", "123K", "50MB", "10KiB") into bytes.
* Accepts bare numbers (bytes) and common suffixes: K, KB, KiB, M, MB, MiB, G, GB, GiB, etc.
*
* @param string $val
* @return int Bytes (rounded)
*/
private static function parseSize(string $val): int
{
$val = trim($val);
if ($val === '') {
return 0;
}
// Match: number + optional unit/suffix (K, KB, KiB, M, MB, MiB, G, GB, GiB, ...)
if (preg_match('/^\s*(\d+(?:\.\d+)?)\s*([kmgtpezy]?i?b?)?\s*$/i', $val, $m)) {
$num = (float)$m[1];
$unit = strtolower($m[2] ?? '');
switch ($unit) {
case 'k': case 'kb': case 'kib':
$num *= 1024;
break;
case 'm': case 'mb': case 'mib':
$num *= 1024 ** 2;
break;
case 'g': case 'gb': case 'gib':
$num *= 1024 ** 3;
break;
case 't': case 'tb': case 'tib':
$num *= 1024 ** 4;
break;
case 'p': case 'pb': case 'pib':
$num *= 1024 ** 5;
break;
case 'e': case 'eb': case 'eib':
$num *= 1024 ** 6;
break;
case 'z': case 'zb': case 'zib':
$num *= 1024 ** 7;
break;
case 'y': case 'yb': case 'yib':
$num *= 1024 ** 8;
break;
// case 'b' or empty => bytes; do nothing
default:
// If unit is just 'b' or empty, treat as bytes.
// For unknown units fall back to bytes.
break;
}
return (int) round($num);
}
// Fallback: cast any unrecognized input to int (bytes)
return (int)$val;
}
/** Allow only http(s) URLs; return '' for invalid input. */
private static function sanitizeHttpUrl($url): string
{
$url = trim((string)$url);
if ($url === '') return '';
$valid = filter_var($url, FILTER_VALIDATE_URL);
if (!$valid) return '';
$scheme = strtolower(parse_url($url, PHP_URL_SCHEME) ?: '');
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 = [
'header_title' => $config['header_title'] ?? 'FileRise',
'loginOptions' => [
'disableFormLogin' => (bool)($config['loginOptions']['disableFormLogin'] ?? false),
'disableBasicAuth' => (bool)($config['loginOptions']['disableBasicAuth'] ?? false),
'disableOIDCLogin' => (bool)($config['loginOptions']['disableOIDCLogin'] ?? false),
],
'globalOtpauthUrl' => $config['globalOtpauthUrl'] ?? '',
'enableWebDAV' => (bool)($config['enableWebDAV'] ?? false),
'sharedMaxUploadSize' => (int)($config['sharedMaxUploadSize'] ?? 0),
'oidc' => [
'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'] ?? ''
),
],
'demoMode' => (defined('FR_DEMO_MODE') && FR_DEMO_MODE),
];
// NEW: include ONLYOFFICE minimal public flag
$ooEnabled = null;
if (isset($config['onlyoffice']['enabled'])) {
$ooEnabled = (bool)$config['onlyoffice']['enabled'];
} elseif (defined('ONLYOFFICE_ENABLED')) {
$ooEnabled = (bool)ONLYOFFICE_ENABLED;
}
if ($ooEnabled !== null) {
$public['onlyoffice'] = ['enabled' => $ooEnabled];
}
$locked = defined('ONLYOFFICE_ENABLED') || defined('ONLYOFFICE_JWT_SECRET')
|| defined('ONLYOFFICE_DOCS_ORIGIN') || defined('ONLYOFFICE_PUBLIC_ORIGIN');
if ($locked) {
$ooEnabled = defined('ONLYOFFICE_ENABLED') ? (bool)ONLYOFFICE_ENABLED : false;
} else {
$ooEnabled = isset($config['onlyoffice']['enabled']) ? (bool)$config['onlyoffice']['enabled'] : false;
}
$public['onlyoffice'] = ['enabled' => $ooEnabled];
$public['demoMode'] = defined('FR_DEMO_MODE') ? (bool)FR_DEMO_MODE : false;
return $public;
}
/** Write USERS_DIR/siteConfig.json atomically (unencrypted). */
public static function writeSiteConfig(array $publicSubset): array
{
$dest = rtrim(USERS_DIR, '/\\') . DIRECTORY_SEPARATOR . 'siteConfig.json';
$tmp = $dest . '.tmp';
$json = json_encode($publicSubset, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
if ($json === false) {
return ["error" => "Failed to encode siteConfig.json"];
}
if (file_put_contents($tmp, $json, LOCK_EX) === false) {
return ["error" => "Failed to write temp siteConfig.json"];
}
if (!@rename($tmp, $dest)) {
@unlink($tmp);
return ["error" => "Failed to move siteConfig.json into place"];
}
@chmod($dest, 0664); // readable in bind mounts
return ["success" => true];
}
/**
* Updates the admin configuration file.
*
* @param array $configUpdate The configuration to update.
* @return array Returns an array with "success" on success or "error" on failure.
*/
public static function updateConfig(array $configUpdate): array
{
// Ensure encryption key exists
if (empty($GLOBALS['encryptionKey']) || !is_string($GLOBALS['encryptionKey'])) {
return ["error" => "Server encryption key is not configured."];
}
// Only enforce OIDC fields when OIDC is enabled
$oidcDisabled = isset($configUpdate['loginOptions']['disableOIDCLogin'])
? (bool)$configUpdate['loginOptions']['disableOIDCLogin']
: true; // default to disabled when not present
if (!$oidcDisabled) {
$oidc = $configUpdate['oidc'] ?? [];
$required = ['providerUrl','clientId','clientSecret','redirectUri'];
foreach ($required as $k) {
if (empty($oidc[$k]) || !is_string($oidc[$k])) {
return ["error" => "Incomplete OIDC configuration (enable OIDC requires providerUrl, clientId, clientSecret, redirectUri)."];
}
}
}
// Ensure enableWebDAV flag is boolean (default to false if missing)
$configUpdate['enableWebDAV'] = isset($configUpdate['enableWebDAV'])
? (bool)$configUpdate['enableWebDAV']
: false;
// Validate sharedMaxUploadSize if provided
if (isset($configUpdate['sharedMaxUploadSize'])) {
$sms = filter_var(
$configUpdate['sharedMaxUploadSize'],
FILTER_VALIDATE_INT,
["options" => ["min_range" => 1]]
);
if ($sms === false) {
return ["error" => "Invalid sharedMaxUploadSize."];
}
$totalBytes = self::parseSize(TOTAL_UPLOAD_SIZE);
if ($sms > $totalBytes) {
return ["error" => "sharedMaxUploadSize must be ≤ TOTAL_UPLOAD_SIZE."];
}
$configUpdate['sharedMaxUploadSize'] = $sms;
}
// Normalize authBypass & authHeaderName
if (!isset($configUpdate['loginOptions']['authBypass'])) {
$configUpdate['loginOptions']['authBypass'] = false;
}
$configUpdate['loginOptions']['authBypass'] = (bool)$configUpdate['loginOptions']['authBypass'];
if (
!isset($configUpdate['loginOptions']['authHeaderName'])
|| !is_string($configUpdate['loginOptions']['authHeaderName'])
|| trim($configUpdate['loginOptions']['authHeaderName']) === ''
) {
$configUpdate['loginOptions']['authHeaderName'] = 'X-Remote-User';
} else {
$configUpdate['loginOptions']['authHeaderName'] = trim($configUpdate['loginOptions']['authHeaderName']);
}
// ---- ONLYOFFICE (persist, sanitize; keep secret unless explicitly replaced) ----
if (isset($configUpdate['onlyoffice']) && is_array($configUpdate['onlyoffice'])) {
$oo = $configUpdate['onlyoffice'];
$norm = [
'enabled' => (bool)($oo['enabled'] ?? false),
'docsOrigin' => self::sanitizeHttpUrl($oo['docsOrigin'] ?? ''),
'publicOrigin' => self::sanitizeHttpUrl($oo['publicOrigin'] ?? ''),
];
// Only accept a new secret if provided (non-empty). We do NOT clear on empty.
if (array_key_exists('jwtSecret', $oo)) {
$js = trim((string)$oo['jwtSecret']);
if ($js !== '') {
if (strlen($js) > 1024) $js = substr($js, 0, 1024);
$norm['jwtSecret'] = $js; // will be encrypted with encryptData()
}
}
$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);
if ($plainTextConfig === false) {
return ["error" => "Failed to encode configuration to JSON."];
}
// Encrypt configuration.
$encryptedContent = encryptData($plainTextConfig, $GLOBALS['encryptionKey']);
if ($encryptedContent === false) {
return ["error" => "Failed to encrypt configuration."];
}
// Define the configuration file path.
$configFile = USERS_DIR . 'adminConfig.json';
// Attempt to write the new configuration.
if (file_put_contents($configFile, $encryptedContent, LOCK_EX) === false) {
// Attempt a cleanup: delete the old file and try again.
if (file_exists($configFile)) {
@unlink($configFile);
}
if (file_put_contents($configFile, $encryptedContent, LOCK_EX) === false) {
error_log("AdminModel::updateConfig: Failed to write configuration even after deletion.");
return ["error" => "Failed to update configuration even after cleanup."];
}
}
// Best-effort normalize perms for host visibility (user rw, group rw)
@chmod($configFile, 0664);
$public = self::buildPublicSubset($configUpdate);
$w = self::writeSiteConfig($public);
// Dont fail the whole update if public cache write had a minor issue.
if (isset($w['error'])) {
// Log but keep success for admin write
error_log("AdminModel::writeSiteConfig warning: " . $w['error']);
}
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.
*
* @return array The configuration array, or defaults if not found.
*/
public static function getConfig(): array
{
$configFile = USERS_DIR . 'adminConfig.json';
if (file_exists($configFile)) {
$encryptedContent = file_get_contents($configFile);
$decryptedContent = decryptData($encryptedContent, $GLOBALS['encryptionKey']);
if ($decryptedContent === false) {
// Do not set HTTP status here; let the controller decide.
return ["error" => "Failed to decrypt configuration."];
}
$config = json_decode($decryptedContent, true);
if (!is_array($config)) {
$config = [];
}
// Normalize login options if missing
if (!isset($config['loginOptions'])) {
// Migrate legacy top-level flags; default OIDC to true (disabled)
$config['loginOptions'] = [
'disableFormLogin' => isset($config['disableFormLogin']) ? (bool)$config['disableFormLogin'] : false,
'disableBasicAuth' => isset($config['disableBasicAuth']) ? (bool)$config['disableBasicAuth'] : false,
'disableOIDCLogin' => isset($config['disableOIDCLogin']) ? (bool)$config['disableOIDCLogin'] : true,
];
unset($config['disableFormLogin'], $config['disableBasicAuth'], $config['disableOIDCLogin']);
} else {
// Normalize booleans; default OIDC to true (disabled) if missing
$lo = &$config['loginOptions'];
$lo['disableFormLogin'] = isset($lo['disableFormLogin']) ? (bool)$lo['disableFormLogin'] : false;
$lo['disableBasicAuth'] = isset($lo['disableBasicAuth']) ? (bool)$lo['disableBasicAuth'] : false;
$lo['disableOIDCLogin'] = isset($lo['disableOIDCLogin']) ? (bool)$lo['disableOIDCLogin'] : true;
}
// Ensure OIDC structure exists
if (!isset($config['oidc']) || !is_array($config['oidc'])) {
$config['oidc'] = [
'providerUrl' => '',
'clientId' => '',
'clientSecret' => '',
'redirectUri' => '',
];
} else {
foreach (['providerUrl','clientId','clientSecret','redirectUri'] as $k) {
if (!isset($config['oidc'][$k]) || !is_string($config['oidc'][$k])) {
$config['oidc'][$k] = '';
}
}
}
// Normalize authBypass & authHeaderName
if (!array_key_exists('authBypass', $config['loginOptions'])) {
$config['loginOptions']['authBypass'] = false;
} else {
$config['loginOptions']['authBypass'] = (bool)$config['loginOptions']['authBypass'];
}
if (
!array_key_exists('authHeaderName', $config['loginOptions'])
|| !is_string($config['loginOptions']['authHeaderName'])
|| trim($config['loginOptions']['authHeaderName']) === ''
) {
$config['loginOptions']['authHeaderName'] = 'X-Remote-User';
}
// Default values for other keys
if (!isset($config['globalOtpauthUrl'])) {
$config['globalOtpauthUrl'] = "";
}
if (!isset($config['header_title']) || $config['header_title'] === '') {
$config['header_title'] = "FileRise";
}
if (!isset($config['enableWebDAV'])) {
$config['enableWebDAV'] = false;
}
// sharedMaxUploadSize: default if missing; clamp if present
$maxBytes = self::parseSize(TOTAL_UPLOAD_SIZE);
if (!isset($config['sharedMaxUploadSize']) || !is_numeric($config['sharedMaxUploadSize']) || $config['sharedMaxUploadSize'] < 1) {
$config['sharedMaxUploadSize'] = min(50 * 1024 * 1024, $maxBytes);
} else {
$config['sharedMaxUploadSize'] = (int)min((int)$config['sharedMaxUploadSize'], $maxBytes);
}
// ---- Ensure ONLYOFFICE structure exists, sanitize values ----
if (!isset($config['onlyoffice']) || !is_array($config['onlyoffice'])) {
$config['onlyoffice'] = [
'enabled' => false,
'docsOrigin' => '',
'publicOrigin' => '',
];
} else {
$config['onlyoffice']['enabled'] = (bool)($config['onlyoffice']['enabled'] ?? false);
$config['onlyoffice']['docsOrigin'] = self::sanitizeHttpUrl($config['onlyoffice']['docsOrigin'] ?? '');
$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;
}
// No config on disk; return defaults.
return [
'header_title' => "FileRise",
'oidc' => [
'providerUrl' => 'https://your-oidc-provider.com',
'clientId' => '',
'clientSecret' => '',
'redirectUri' => 'https://yourdomain.com/api/auth/auth.php?oidc=callback'
],
'loginOptions' => [
'disableFormLogin' => false,
'disableBasicAuth' => true,
'disableOIDCLogin' => true
],
'globalOtpauthUrl' => "",
'enableWebDAV' => false,
'sharedMaxUploadSize' => min(50 * 1024 * 1024, self::parseSize(TOTAL_UPLOAD_SIZE)),
'onlyoffice' => [
'enabled' => false,
'docsOrigin' => '',
'publicOrigin' => '',
],
'branding' => [
'customLogoUrl' => '',
'headerBgLight' => '',
'headerBgDark' => '',
],
];
}
}