diff --git a/CHANGELOG.md b/CHANGELOG.md index d268d23..b8fc8f0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,16 @@ # Changelog +## 2026-02-27 +- Reworked ACP System navigation into `Health` and `Updates`. +- Moved update/version actions into the new `Updates` area and grouped update checks under `Live Update`, `CLI`, and `CI/CD`. +- Added CLI PHP interpreter `Check` action (no persistence) plus save-time validation endpoint. +- Updated CLI PHP save UX to keep persistent inline errors and avoid duplicate danger toasts. +- Added iconized, accent-styled `Check` and `Save` actions in ACP CLI settings. +- Fixed system-status PHP detection to avoid false positives when a configured CLI binary is invalid. +- Switched `Health` PHP requirement checks to the web runtime interpreter (`PHP_BINARY`/`PHP_VERSION`) instead of configured CLI binary. +- Limited `Health` checks to runtime-relevant items (removed `tar`/`rsync` from Health view). +- Fixed `public/storage` symlink health check to correctly resolve absolute and relative symlink targets. + ## 2026-02-24 - Added login modal actions: `Cancel` button and accent-styled, right-aligned `Sign in` button. - Added functional `Forgot password?` flow with dedicated SPA route/page at `/reset-password`. diff --git a/app/Http/Controllers/SettingController.php b/app/Http/Controllers/SettingController.php index 90427e0..6391f3c 100644 --- a/app/Http/Controllers/SettingController.php +++ b/app/Http/Controllers/SettingController.php @@ -5,6 +5,7 @@ namespace App\Http\Controllers; use App\Models\Setting; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; +use Symfony\Component\Process\Process; class SettingController extends Controller { @@ -38,6 +39,12 @@ class SettingController extends Controller ]); $value = $data['value'] ?? ''; + if ($data['key'] === 'system.php_binary') { + $validationError = $this->validatePhpBinarySetting($value); + if ($validationError !== null) { + return response()->json(['message' => $validationError], 422); + } + } $setting = Setting::updateOrCreate( ['key' => $data['key']], @@ -67,6 +74,12 @@ class SettingController extends Controller $updated = []; foreach ($data['settings'] as $entry) { + if (($entry['key'] ?? '') === 'system.php_binary') { + $validationError = $this->validatePhpBinarySetting($entry['value'] ?? ''); + if ($validationError !== null) { + return response()->json(['message' => $validationError], 422); + } + } $setting = Setting::updateOrCreate( ['key' => $entry['key']], ['value' => $entry['value'] ?? ''] @@ -80,4 +93,66 @@ class SettingController extends Controller return response()->json($updated); } + + public function validateSystemPhpBinary(Request $request): JsonResponse + { + $user = $request->user(); + if (!$user || !$user->roles()->where('name', 'ROLE_ADMIN')->exists()) { + return response()->json(['message' => 'Forbidden'], 403); + } + + $data = $request->validate([ + 'value' => ['required', 'string'], + ]); + + $validationError = $this->validatePhpBinarySetting($data['value']); + if ($validationError !== null) { + return response()->json(['message' => $validationError], 422); + } + + return response()->json([ + 'message' => 'PHP interpreter is valid.', + ]); + } + + private function validatePhpBinarySetting(string $value): ?string + { + $binary = trim($value); + if ($binary === '' || $binary === 'php') { + return null; + } + + if ($binary === 'keyhelp-php-domain') { + return '`keyhelp-php-domain` is disabled. Use a concrete binary (e.g. keyhelp-php84).'; + } + + $resolved = null; + if (str_contains($binary, '/')) { + if (!is_executable($binary)) { + return "Configured PHP binary '{$binary}' is not executable."; + } + $resolved = $binary; + } else { + $escapedBinary = escapeshellarg($binary); + $process = new Process(['sh', '-lc', "command -v {$escapedBinary}"]); + $process->setTimeout(5); + $process->run(); + if (!$process->isSuccessful()) { + return "Configured PHP binary '{$binary}' was not found in PATH."; + } + $resolved = trim($process->getOutput()); + if ($resolved === '') { + return "Configured PHP binary '{$binary}' was not found in PATH."; + } + } + + $phpCheck = new Process([$resolved, '-r', 'echo PHP_VERSION;']); + $phpCheck->setTimeout(5); + $phpCheck->run(); + if (!$phpCheck->isSuccessful() || trim($phpCheck->getOutput()) === '') { + return "Configured binary '{$binary}' is not a working PHP CLI executable."; + } + + return null; + } } diff --git a/app/Http/Controllers/SystemStatusController.php b/app/Http/Controllers/SystemStatusController.php index 6e9d677..6672674 100644 --- a/app/Http/Controllers/SystemStatusController.php +++ b/app/Http/Controllers/SystemStatusController.php @@ -19,11 +19,22 @@ class SystemStatusController extends Controller $phpDefaultPath = $this->resolveBinary('php'); $phpDefaultVersion = $phpDefaultPath ? $this->resolvePhpVersion($phpDefaultPath) : null; $phpConfiguredPath = trim((string) Setting::where('key', 'system.php_binary')->value('value')); - $phpSelectedPath = $phpConfiguredPath ?: (PHP_BINARY ?: $phpDefaultPath); - $phpSelectedOk = (bool) $phpSelectedPath; - $phpSelectedVersion = $phpSelectedPath - ? ($this->resolvePhpVersion($phpSelectedPath) ?? PHP_VERSION) - : PHP_VERSION; + $phpSelectedPath = null; + $phpSelectedVersion = null; + $phpSelectedOk = false; + + if ($phpConfiguredPath !== '') { + $resolvedConfiguredPhpPath = $this->resolveConfiguredPhpBinaryPath($phpConfiguredPath); + $phpSelectedPath = $resolvedConfiguredPhpPath ?: $phpConfiguredPath; + $phpSelectedVersion = $resolvedConfiguredPhpPath ? $this->resolvePhpVersion($resolvedConfiguredPhpPath) : null; + $phpSelectedOk = $resolvedConfiguredPhpPath !== null && $phpSelectedVersion !== null; + } else { + $phpSelectedPath = PHP_BINARY ?: $phpDefaultPath; + $phpSelectedVersion = $phpSelectedPath + ? ($this->resolvePhpVersion($phpSelectedPath) ?? $phpDefaultVersion ?? PHP_VERSION) + : null; + $phpSelectedOk = $phpSelectedPath !== null && $phpSelectedVersion !== null; + } $minVersions = $this->resolveMinVersions(); $composerPath = $this->resolveBinary('composer'); $nodePath = $this->resolveBinary('node'); @@ -44,6 +55,8 @@ class SystemStatusController extends Controller return response()->json([ 'php' => PHP_VERSION, + 'php_web_path' => PHP_BINARY ?: null, + 'php_web_version' => PHP_VERSION ?: null, 'php_default' => $phpDefaultPath, 'php_default_version' => $phpDefaultVersion, 'php_configured' => $phpConfiguredPath ?: null, @@ -82,7 +95,12 @@ class SystemStatusController extends Controller return false; } - $resolvedTarget = realpath(dirname($publicStorage) . DIRECTORY_SEPARATOR . $target); + $targetPath = $target; + if (!str_starts_with($target, DIRECTORY_SEPARATOR) && !preg_match('/^[A-Za-z]:[\\\\\\/]/', $target)) { + $targetPath = dirname($publicStorage) . DIRECTORY_SEPARATOR . $target; + } + + $resolvedTarget = realpath($targetPath); $expectedTarget = realpath($storagePublic); return $resolvedTarget !== false && $expectedTarget !== false && $resolvedTarget === $expectedTarget; @@ -116,6 +134,20 @@ class SystemStatusController extends Controller return $output !== '' ? $output : null; } + private function resolveConfiguredPhpBinaryPath(string $binary): ?string + { + $value = trim($binary); + if ($value === '') { + return null; + } + + if (str_contains($value, '/')) { + return is_executable($value) ? $value : null; + } + + return $this->resolveBinary($value); + } + private function resolveBinaryVersion(?string $path, array $args): ?string { if (!$path) { diff --git a/composer.json b/composer.json index 4ddd248..1e2f228 100644 --- a/composer.json +++ b/composer.json @@ -98,5 +98,5 @@ "minimum-stability": "stable", "prefer-stable": true, "version": "26.0.3", - "build": "104" + "build": "105" } diff --git a/resources/js/api/client.js b/resources/js/api/client.js index b984c71..b41272c 100644 --- a/resources/js/api/client.js +++ b/resources/js/api/client.js @@ -198,6 +198,13 @@ export async function saveSettings(settings) { }) } +export async function validateSystemPhpBinary(value) { + return apiFetch('/settings/system/php-binary/validate', { + method: 'POST', + body: JSON.stringify({ value }), + }) +} + export async function uploadLogo(file) { const body = new FormData() body.append('file', file) diff --git a/resources/js/pages/Acp.jsx b/resources/js/pages/Acp.jsx index ba7fc9e..8ad3dea 100644 --- a/resources/js/pages/Acp.jsx +++ b/resources/js/pages/Acp.jsx @@ -1,5 +1,5 @@ import { useEffect, useMemo, useRef, useState, useId } from 'react' -import { Accordion, Button, ButtonGroup, Col, Container, Form, Modal, Row, Tab, Tabs, OverlayTrigger, Tooltip } from 'react-bootstrap' +import { Accordion, Button, ButtonGroup, Col, Container, Form, Modal, Row, Tab, Tabs, OverlayTrigger, Toast, ToastContainer, Tooltip } from 'react-bootstrap' import DataTable, { createTheme } from 'react-data-table-component' import { useTranslation } from 'react-i18next' import { useDropzone } from 'react-dropzone' @@ -20,6 +20,7 @@ import { reorderForums, saveSetting, saveSettings, + validateSystemPhpBinary, createRank, deleteRank, updateUserRank, @@ -96,7 +97,8 @@ function Acp({ isAdmin }) { const [systemStatus, setSystemStatus] = useState(null) const [systemLoading, setSystemLoading] = useState(false) const [systemError, setSystemError] = useState('') - const [systemSection, setSystemSection] = useState('info') + const [systemSection, setSystemSection] = useState('overview') + const [systemUpdateSection, setSystemUpdateSection] = useState('insite') const [usersPage, setUsersPage] = useState(1) const [usersPerPage, setUsersPerPage] = useState(10) const [userSort, setUserSort] = useState({ columnId: 'name', direction: 'asc' }) @@ -206,7 +208,9 @@ function Acp({ isAdmin }) { php_custom: '', }) const [systemCliSaving, setSystemCliSaving] = useState(false) + const [systemCliChecking, setSystemCliChecking] = useState(false) const [systemCliError, setSystemCliError] = useState('') + const [systemCliToast, setSystemCliToast] = useState({ show: false, variant: 'success', message: '' }) const settingsDetailMap = { forum_name: 'forumName', default_theme: 'defaultTheme', @@ -385,28 +389,108 @@ function Acp({ isAdmin }) { } } + const persistSystemPhpBinary = async (rawValue, mode) => { + const value = typeof rawValue === 'string' ? rawValue.trim() : String(rawValue ?? '') + if (!value) { + throw new Error('Please provide a PHP binary.') + } + if (value === 'keyhelp-php-domain') { + throw new Error('`keyhelp-php-domain` is disabled in ACP CLI settings. Use a custom binary (e.g. keyhelp-php84).') + } + + await saveSetting('system.php_binary', value) + + // Read-back verification avoids silent non-persist situations. + const allSettings = await fetchSettings() + const settingsMap = new Map(allSettings.map((setting) => [setting.key, setting.value])) + const persisted = settingsMap.get('system.php_binary') || '' + if (persisted !== value) { + throw new Error(`Persist failed: expected "${value}", got "${persisted || 'empty'}".`) + } + + setSystemCliSettings((prev) => ({ + ...prev, + php_mode: mode, + php_custom: mode === 'custom' ? value : '', + })) + + setSystemCliToast({ + show: true, + variant: 'success', + message: `PHP interpreter saved: ${value}`, + }) + } + + const resolveSystemCliBinaryValue = (formElement) => { + if (systemCliSettings.php_mode !== 'custom') { + return 'php' + } + + if (formElement) { + const formData = new FormData(formElement) + const submitted = String(formData.get('system_php_custom') || '').trim() + if (submitted) { + return submitted + } + } + + return typeof systemCliSettings.php_custom === 'string' + ? systemCliSettings.php_custom.trim() + : String(systemCliSettings.php_custom || '').trim() + } + const handleSystemCliSave = async (event) => { event.preventDefault() setSystemCliSaving(true) setSystemCliError('') try { - let value = '' - if (systemCliSettings.php_mode === 'custom') { - value = typeof systemCliSettings.php_custom === 'string' - ? systemCliSettings.php_custom.trim() - : String(systemCliSettings.php_custom ?? '') - } else { - value = 'php' - } - if (value === 'keyhelp-php-domain') { - throw new Error('`keyhelp-php-domain` is disabled in ACP CLI settings. Use a custom binary (e.g. keyhelp-php84).') - } - await saveSetting('system.php_binary', value) - setSystemCliSettings((prev) => ({ - ...prev, - php_mode: systemCliSettings.php_mode, - php_custom: systemCliSettings.php_mode === 'custom' ? value : '', - })) + const value = resolveSystemCliBinaryValue(event.currentTarget) + await persistSystemPhpBinary(value, systemCliSettings.php_mode) + } catch (err) { + setSystemCliError(err.message) + } finally { + setSystemCliSaving(false) + } + } + + const handleSystemCliCheck = async (event) => { + setSystemCliChecking(true) + setSystemCliError('') + try { + const formElement = event?.currentTarget?.form || null + const value = resolveSystemCliBinaryValue(formElement) + const response = await validateSystemPhpBinary(value) + setSystemCliToast({ + show: true, + variant: 'success', + message: response?.message || `PHP interpreter check passed: ${value}`, + }) + } catch (err) { + setSystemCliError(err.message) + } finally { + setSystemCliChecking(false) + } + } + + const handleSystemCliModeChange = async (nextMode) => { + setSystemCliSettings((prev) => ({ + ...prev, + php_mode: nextMode, + php_custom: + nextMode === 'custom' && !String(prev.php_custom || '').trim() + ? suggestedPhpBinary + : prev.php_custom, + })) + setSystemCliError('') + + // "php" mode has no additional input, so persist immediately. + if (nextMode !== 'php') { + return + } + + setSystemCliSaving(true) + try { + await persistSystemPhpBinary('php', 'php') } catch (err) { setSystemCliError(err.message) } finally { @@ -468,14 +552,6 @@ function Acp({ isAdmin }) { return mins.reduce((lowest, current) => (compareSemver(current, lowest) < 0 ? current : lowest)) } - const cliDefaultPhpIsSufficient = useMemo(() => { - const minimum = parseMinPhpConstraint(systemStatus?.min_versions?.php) - const current = normalizeSemver(systemStatus?.php_default_version) - if (!minimum) return true - if (!current) return false - return compareSemver(current, minimum) >= 0 - }, [systemStatus]) - const phpSelectedIsSufficient = useMemo(() => { if (!systemStatus?.php_selected_ok) return false const minimum = parseMinPhpConstraint(systemStatus?.min_versions?.php) @@ -485,6 +561,41 @@ function Acp({ isAdmin }) { return compareSemver(current, minimum) >= 0 }, [systemStatus]) + const phpWebIsSufficient = useMemo(() => { + const minimum = parseMinPhpConstraint(systemStatus?.min_versions?.php) + const current = normalizeSemver(systemStatus?.php_web_version) + if (!minimum) return true + if (!current) return false + return compareSemver(current, minimum) >= 0 + }, [systemStatus]) + + const suggestedPhpBinary = useMemo(() => { + const minimum = parseMinPhpConstraint(systemStatus?.min_versions?.php) + if (!minimum) return 'php8.4' + return `php${minimum[0]}.${minimum[1]}` + }, [systemStatus]) + + const suggestedKeyhelpPhpBinary = useMemo(() => { + const minimum = parseMinPhpConstraint(systemStatus?.min_versions?.php) + if (!minimum) return 'keyhelp-php84' + return `keyhelp-php${minimum[0]}${minimum[1]}` + }, [systemStatus]) + + const systemUpdateMeta = useMemo(() => { + if (versionChecking) return t('version.checking') + if (versionCheckError) return t('version.unknown') + if (!versionCheck) return t('version.unknown') + if (versionCheck.is_latest === true) return t('version.up_to_date') + if (versionCheck.is_latest === false) { + return versionCheck.latest_version + ? t('version.update_available', { version: versionCheck.latest_version }) + : t('version.update_available_short') + } + return t('version.unknown') + }, [t, versionCheck, versionCheckError, versionChecking]) + + const systemUpdateAvailable = versionCheck?.is_latest === false + const systemChecks = useMemo(() => { if (!systemStatus) return [] return [ @@ -536,6 +647,30 @@ function Acp({ isAdmin }) { current: systemStatus.rsync_version || '—', status: systemStatus.rsync ? 'ok' : 'bad', }, + { + id: 'storage', + label: t('system.storage_writable'), + path: 'storage/', + min: '—', + current: '—', + status: systemStatus.storage_writable ? 'ok' : 'bad', + }, + { + id: 'updates', + label: t('system.updates_writable'), + path: 'storage/app/updates', + min: '—', + current: '—', + status: systemStatus.updates_writable ? 'ok' : 'bad', + }, + { + id: 'storage_link', + label: t('system.storage_linked'), + path: 'public/storage -> storage/app/public', + min: '—', + current: '—', + status: systemStatus.storage_public_linked ? 'ok' : 'bad', + }, { id: 'proc', label: 'proc_* functions', @@ -555,43 +690,38 @@ function Acp({ isAdmin }) { : 'bad', pathColSpan: 3, }, - { - id: 'storage', - label: t('system.storage_writable'), - path: 'storage/', - min: '—', - current: '—', - status: systemStatus.storage_writable ? 'ok' : 'bad', - }, - { - id: 'storage_link', - label: t('system.storage_linked'), - path: 'public/storage -> storage/app/public', - min: '—', - current: '—', - status: systemStatus.storage_public_linked ? 'ok' : 'bad', - }, - { - id: 'updates', - label: t('system.updates_writable'), - path: 'storage/app/updates', - min: '—', - current: '—', - status: systemStatus.updates_writable ? 'ok' : 'bad', - }, ] }, [phpSelectedIsSufficient, systemStatus, t]) + const systemHealthChecks = useMemo(() => { + if (!systemStatus) return [] + return systemChecks.map((check) => { + if (check.id !== 'php') { + return check + } + return { + ...check, + path: systemStatus.php_web_path || '—', + current: systemStatus.php_web_version || '—', + status: phpWebIsSufficient ? 'ok' : 'bad', + } + }) + }, [phpWebIsSufficient, systemChecks, systemStatus]) + const visibleSystemChecks = useMemo(() => { const visibilityBySection = { - insite: ['php', 'proc', 'storage', 'storage_link', 'updates'], + insite: ['php', 'tar', 'rsync', 'storage', 'updates', 'storage_link', 'proc'], cli: ['php', 'composer', 'node', 'npm', 'proc', 'storage', 'storage_link'], - ci: ['php', 'composer', 'node', 'npm', 'tar', 'rsync', 'proc', 'storage', 'storage_link', 'updates'], - info: [], + ci: ['php', 'composer', 'node', 'npm', 'storage', 'updates', 'storage_link', 'proc'], } - const allowed = new Set(visibilityBySection[systemSection] || []) + const allowed = new Set(visibilityBySection[systemUpdateSection] || []) return systemChecks.filter((check) => allowed.has(check.id)) - }, [systemChecks, systemSection]) + }, [systemChecks, systemUpdateSection]) + + const visibleHealthChecks = useMemo(() => { + const allowed = new Set(['php', 'storage', 'updates', 'storage_link', 'proc']) + return systemHealthChecks.filter((check) => allowed.has(check.id)) + }, [systemHealthChecks]) const handleLogoUpload = async (file, settingKey) => { @@ -1044,7 +1174,7 @@ function Acp({ isAdmin }) { } } - function renderSystemRequirementsPanel() { + function renderSystemRequirementsPanel(checks = visibleSystemChecks) { return (
- Placeholder: summary, upgrade guidance, and environment health notes will - live here. -
-Live update controls will appear here.
-
- CLI default php: {systemStatus?.php_default || '—'} (
- {systemStatus?.php_default_version || 'unknown'}){' '}
- {phpSelectedIsSufficient ? (
-
- ) : (
-
{systemCliError}
} -- Placeholder: CI/CD pipelines, runner requirements, and deployment logs will - live here. -
+
+ CLI default php: {systemStatus?.php_default || '—'} (
+ {systemStatus?.php_default_version || 'unknown'}){' '}
+ {phpSelectedIsSufficient ? (
+
+ ) : (
+
{systemCliError}
} +