Refs #37 — implements ONLYOFFICE integration suggested in the discussion; video progress saving will be tracked separately.
312 lines
14 KiB
PHP
312 lines
14 KiB
PHP
<?php
|
||
// src/controllers/AdminController.php
|
||
|
||
require_once __DIR__ . '/../../config/config.php';
|
||
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);
|
||
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'] ?? '');
|
||
|
||
// 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,
|
||
],
|
||
];
|
||
|
||
$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'); // 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 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' => ''
|
||
],
|
||
];
|
||
|
||
// 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;
|
||
}
|
||
$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;
|
||
}
|
||
}
|
||
?>
|