Compare commits
24 Commits
dev
...
66de3b31b1
| Author | SHA1 | Date | |
|---|---|---|---|
| 66de3b31b1 | |||
| 1adb3308be | |||
| 1f26aa7fb5 | |||
| 41387be802 | |||
| 7b22d89dfd | |||
| 6a10087bee | |||
| 79f8077bd4 | |||
| 269248012b | |||
| e357cc3c48 | |||
| 1e227f6ba0 | |||
| 8e86fcdbd9 | |||
| 78bdd869ef | |||
| cd12ac676d | |||
| a5b55adf56 | |||
| 86190c9718 | |||
|
|
60c6718645 | ||
| 225dc391ff | |||
| 16e0444fa3 | |||
| 6a2316c6f4 | |||
| 0b4e0df305 | |||
| 2a69ee8258 | |||
| 1c2353cfe1 | |||
| 496b50ed12 | |||
| 50e3ff6ded |
@@ -6,26 +6,21 @@ on:
|
|||||||
- dev
|
- dev
|
||||||
- master
|
- master
|
||||||
jobs:
|
jobs:
|
||||||
dev_tests:
|
|
||||||
if: gitea.ref_name == 'dev'
|
|
||||||
runs-on: debian-latest
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
run: |
|
|
||||||
git clone --quiet --depth=1 --branch=${{ gitea.ref_name }} ${{ gitea.server_url }}/${{ gitea.repository }} repo
|
|
||||||
- name: Install Bats
|
|
||||||
run: |
|
|
||||||
sudo apt-get update
|
|
||||||
sudo apt-get install -y bats
|
|
||||||
- name: Run Shell Tests
|
|
||||||
run: |
|
|
||||||
cd repo
|
|
||||||
bats tests/shell/git_update.bats
|
|
||||||
|
|
||||||
deploy:
|
deploy:
|
||||||
if: gitea.ref_name == 'master'
|
if: gitea.ref_name == 'master'
|
||||||
runs-on: self-hosted
|
runs-on: self-hosted
|
||||||
steps:
|
steps:
|
||||||
|
- name: Deploy start marker
|
||||||
|
run: |
|
||||||
|
echo "=== speedBB deploy started ==="
|
||||||
|
echo "time: $(date -u +'%Y-%m-%dT%H:%M:%SZ')"
|
||||||
|
echo "branch: ${{ gitea.ref_name }}"
|
||||||
|
echo "sha: ${{ gitea.sha }}"
|
||||||
|
echo "actor: ${{ gitea.actor }}"
|
||||||
|
echo "message: ${{ gitea.event.head_commit.message }}"
|
||||||
|
echo "runner: $(hostname)"
|
||||||
|
echo "=============================="
|
||||||
|
|
||||||
- name: Custom Checkout
|
- name: Custom Checkout
|
||||||
env:
|
env:
|
||||||
ANSIBLE_VAULT_PASSWORD: ${{ secrets.ANSIBLE_VAULT_PASSWORD }}
|
ANSIBLE_VAULT_PASSWORD: ${{ secrets.ANSIBLE_VAULT_PASSWORD }}
|
||||||
|
|||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -27,6 +27,7 @@
|
|||||||
/storage/framework
|
/storage/framework
|
||||||
/storage/*.key
|
/storage/*.key
|
||||||
/storage/pail
|
/storage/pail
|
||||||
|
/backups
|
||||||
/storage/framework/views/*.php
|
/storage/framework/views/*.php
|
||||||
/bootstrap/cache/*.php
|
/bootstrap/cache/*.php
|
||||||
/custom
|
/custom
|
||||||
|
|||||||
31
CHANGELOG.md
31
CHANGELOG.md
@@ -1,5 +1,36 @@
|
|||||||
# Changelog
|
# 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`.
|
||||||
|
- Implemented reset-link request UI wired to `POST /api/forgot-password`.
|
||||||
|
- Implemented token-based new-password submission (`?token=...&email=...`) wired to `POST /api/reset-password`.
|
||||||
|
- Updated reset flow UX to return to `/login` after successful reset-link request and after successful password update.
|
||||||
|
- Added English and German translations for password reset screens/messages.
|
||||||
|
- Added new `/ping` endpoint returning connection status, build status, and notification state.
|
||||||
|
- Added frontend ping polling with active/hidden intervals and console diagnostics.
|
||||||
|
- Added update-available detection comparing loaded build vs ping build, with footer refresh CTA.
|
||||||
|
- Added update info modal prompting users to refresh when a newer build is detected.
|
||||||
|
- Tuned global dark mode palette to reduce overly bright text/surfaces in dark theme.
|
||||||
|
- Refined accent button state styling (hover/active/focus) to avoid Bootstrap blue fallback and preserve contrast.
|
||||||
|
- Fixed deployment storage path handling by ensuring `public/storage` symlink is created in Ansible.
|
||||||
|
- Changed `version:fetch` to sync DB version/build from `composer.json` metadata (host git recount removed).
|
||||||
|
- Updated runtime version/ping responses to prefer `composer.json` metadata over DB values to avoid drift.
|
||||||
|
- Restored local `master` as build metadata source of truth by removing CI write-back to `master`.
|
||||||
|
- Updated local pre-commit hook to stamp `composer.json` build from local git count and stage the file automatically.
|
||||||
|
- Bumped forum version to `26.0.3`.
|
||||||
|
|
||||||
## 2026-02-18
|
## 2026-02-18
|
||||||
- Added CLI default PHP version detection to system status (`php_default_version`) using the CLI `php` binary.
|
- Added CLI default PHP version detection to system status (`php_default_version`) using the CLI `php` binary.
|
||||||
- Updated ACP System -> CLI to show the CLI default PHP path/version in the panel header with sufficiency indicator and warning tooltip.
|
- Updated ACP System -> CLI to show the CLI default PHP path/version in the panel header with sufficiency indicator and warning tooltip.
|
||||||
|
|||||||
43
README.md
43
README.md
@@ -1,7 +1,42 @@
|
|||||||
# SpeedBB Forum
|
# speedBB
|
||||||
|
|
||||||
Placeholder README for the forum application.
|
speedBB is a modern forum application with a built-in Admin Control Panel (ACP), customizable branding, user/rank management, attachment support, and integrated update tooling.
|
||||||
|
|
||||||
## Status
|
## What It Does
|
||||||
|
|
||||||
Work in progress.
|
- Hosts classic forum discussions with categories, forums, topics, and replies.
|
||||||
|
- Provides an ACP for everyday operations (settings, users, groups, ranks, attachments, and audit log).
|
||||||
|
- Supports brand customization (name, theme, accents, logos, favicons).
|
||||||
|
- Manages user media (avatars, rank badges, logos) with public delivery.
|
||||||
|
- Includes built-in update and system-check workflows so admins can verify server health and apply updates from the ACP.
|
||||||
|
|
||||||
|
## ACP Areas
|
||||||
|
|
||||||
|
The ACP is organized into practical sections for day-to-day forum operations:
|
||||||
|
|
||||||
|
- `General`: core board identity and visual setup (name, theme defaults, accents, logos, favicons).
|
||||||
|
- `Forums`: structure and ordering of categories/forums.
|
||||||
|
- `Users`: account overview and moderation/admin user management actions.
|
||||||
|
- `Groups`: role and permission group administration.
|
||||||
|
- `Ranks`: rank definitions and badge management.
|
||||||
|
- `Attachments`: attachment policy and extension/group controls.
|
||||||
|
- `Audit log`: activity trail for administrative actions.
|
||||||
|
- `System`: environment checks and update operations.
|
||||||
|
- `Custom`: space for project-specific custom assets/overrides.
|
||||||
|
|
||||||
|
### System
|
||||||
|
|
||||||
|
The System area is split into two focused views:
|
||||||
|
|
||||||
|
- `Health`: shows whether the live website environment is healthy enough to run reliably.
|
||||||
|
- `Updates`: provides update-related checks and update actions, including CLI interpreter validation.
|
||||||
|
|
||||||
|
This separation is intentional:
|
||||||
|
- `Health` answers “Is the site healthy right now?”
|
||||||
|
- `Updates` answers “Is this server ready to run update operations?”
|
||||||
|
|
||||||
|
## Current Product Status
|
||||||
|
|
||||||
|
- Active version: `26.0.3`
|
||||||
|
- Forum + ACP features are in active use.
|
||||||
|
- Health and update checks are integrated directly into ACP System.
|
||||||
|
|||||||
@@ -27,6 +27,34 @@
|
|||||||
state: directory
|
state: directory
|
||||||
mode: "0775"
|
mode: "0775"
|
||||||
|
|
||||||
|
- name: Ensure public storage directory exists
|
||||||
|
file:
|
||||||
|
path: "{{ prod_base_dir }}/storage/app/public"
|
||||||
|
state: directory
|
||||||
|
mode: "0775"
|
||||||
|
|
||||||
|
- name: Migrate existing public/storage directory content before symlink
|
||||||
|
shell: |
|
||||||
|
set -e
|
||||||
|
cd "{{ prod_base_dir }}"
|
||||||
|
if [ -d public/storage ] && [ ! -L public/storage ]; then
|
||||||
|
if command -v rsync >/dev/null 2>&1; then
|
||||||
|
rsync -a public/storage/ storage/app/public/
|
||||||
|
else
|
||||||
|
cp -a public/storage/. storage/app/public/
|
||||||
|
fi
|
||||||
|
rm -rf public/storage
|
||||||
|
fi
|
||||||
|
args:
|
||||||
|
executable: /bin/bash
|
||||||
|
|
||||||
|
- name: Ensure public storage symlink exists
|
||||||
|
file:
|
||||||
|
src: "{{ prod_base_dir }}/storage/app/public"
|
||||||
|
dest: "{{ prod_base_dir }}/public/storage"
|
||||||
|
state: link
|
||||||
|
force: true
|
||||||
|
|
||||||
- name: Download and installs all libs and dependencies
|
- name: Download and installs all libs and dependencies
|
||||||
block:
|
block:
|
||||||
- name: Composer install
|
- name: Composer install
|
||||||
@@ -93,8 +121,8 @@
|
|||||||
msg: "Database backed up to: {{ backup_result.stdout }}"
|
msg: "Database backed up to: {{ backup_result.stdout }}"
|
||||||
when: env_file.stat.exists
|
when: env_file.stat.exists
|
||||||
|
|
||||||
- name: Run database migrations safely
|
- name: Run database migrations
|
||||||
command: "keyhelp-php84 artisan migrate:safe --force"
|
command: "keyhelp-php84 artisan migrate --force"
|
||||||
args:
|
args:
|
||||||
chdir: "{{ prod_base_dir }}"
|
chdir: "{{ prod_base_dir }}"
|
||||||
register: migrate_result
|
register: migrate_result
|
||||||
|
|||||||
@@ -4,94 +4,75 @@ namespace App\Console\Commands;
|
|||||||
|
|
||||||
use App\Models\Setting;
|
use App\Models\Setting;
|
||||||
use Illuminate\Console\Command;
|
use Illuminate\Console\Command;
|
||||||
use Symfony\Component\Process\Process;
|
|
||||||
|
|
||||||
class VersionFetch extends Command
|
class VersionFetch extends Command
|
||||||
{
|
{
|
||||||
protected $signature = 'version:fetch';
|
protected $signature = 'version:fetch';
|
||||||
|
|
||||||
protected $description = 'Update the build number based on the git commit count of master.';
|
protected $description = 'Sync version/build metadata into settings using composer.json as source of truth.';
|
||||||
|
|
||||||
public function handle(): int
|
public function handle(): int
|
||||||
{
|
{
|
||||||
$version = Setting::where('key', 'version')->value('value');
|
$meta = $this->resolveComposerMetadata();
|
||||||
$build = $this->resolveBuildCount();
|
if ($meta === null) {
|
||||||
|
$this->error('Unable to determine version/build from composer.json.');
|
||||||
if ($version === null) {
|
|
||||||
$this->error('Unable to determine version from settings.');
|
|
||||||
return self::FAILURE;
|
return self::FAILURE;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($build === null) {
|
$version = $meta['version'];
|
||||||
$this->error('Unable to determine build number from git.');
|
$build = $meta['build'];
|
||||||
return self::FAILURE;
|
|
||||||
}
|
Setting::updateOrCreate(
|
||||||
|
['key' => 'version'],
|
||||||
|
['value' => $version],
|
||||||
|
);
|
||||||
|
|
||||||
Setting::updateOrCreate(
|
Setting::updateOrCreate(
|
||||||
['key' => 'build'],
|
['key' => 'build'],
|
||||||
['value' => (string) $build],
|
['value' => (string) $build],
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!$this->syncComposerMetadata($version, $build)) {
|
$this->info("Version/build synced: {$version} (build {$build}).");
|
||||||
$this->error('Failed to sync version/build to composer.json.');
|
|
||||||
return self::FAILURE;
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->info("Build number updated to {$build}.");
|
|
||||||
|
|
||||||
return self::SUCCESS;
|
return self::SUCCESS;
|
||||||
}
|
}
|
||||||
|
|
||||||
private function resolveBuildCount(): ?int
|
private function resolveComposerMetadata(): ?array
|
||||||
{
|
|
||||||
$commands = [
|
|
||||||
['git', 'rev-list', '--count', 'master'],
|
|
||||||
['git', 'rev-list', '--count', 'HEAD'],
|
|
||||||
];
|
|
||||||
|
|
||||||
foreach ($commands as $command) {
|
|
||||||
$process = new Process($command, base_path());
|
|
||||||
$process->run();
|
|
||||||
|
|
||||||
if ($process->isSuccessful()) {
|
|
||||||
$output = trim($process->getOutput());
|
|
||||||
if (is_numeric($output)) {
|
|
||||||
return (int) $output;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function syncComposerMetadata(string $version, int $build): bool
|
|
||||||
{
|
{
|
||||||
$composerPath = base_path('composer.json');
|
$composerPath = base_path('composer.json');
|
||||||
|
|
||||||
if (!is_file($composerPath) || !is_readable($composerPath)) {
|
if (!is_file($composerPath) || !is_readable($composerPath)) {
|
||||||
return false;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
$raw = file_get_contents($composerPath);
|
$raw = file_get_contents($composerPath);
|
||||||
if ($raw === false) {
|
if ($raw === false) {
|
||||||
return false;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
$data = json_decode($raw, true);
|
$data = json_decode($raw, true);
|
||||||
if (!is_array($data)) {
|
if (!is_array($data)) {
|
||||||
return false;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
$data['version'] = $version;
|
$version = trim((string) ($data['version'] ?? ''));
|
||||||
$data['build'] = (string) $build;
|
$buildRaw = trim((string) ($data['build'] ?? ''));
|
||||||
|
|
||||||
$encoded = json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
|
if ($version === '' || $buildRaw === '') {
|
||||||
if ($encoded === false) {
|
return null;
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$encoded .= "\n";
|
if (!preg_match('/^\d+\.\d+(?:\.\d+)?(?:[-._][0-9A-Za-z.-]+)?$/', $version)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
return file_put_contents($composerPath, $encoded) !== false;
|
if (!ctype_digit($buildRaw)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'version' => $version,
|
||||||
|
'build' => (int) $buildRaw,
|
||||||
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
46
app/Http/Controllers/PingController.php
Normal file
46
app/Http/Controllers/PingController.php
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Models\Setting;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
|
||||||
|
class PingController extends Controller
|
||||||
|
{
|
||||||
|
public function __invoke(): JsonResponse
|
||||||
|
{
|
||||||
|
$build = $this->readComposerBuild();
|
||||||
|
if ($build === null) {
|
||||||
|
$build = Setting::query()->where('key', 'build')->value('value');
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'connect' => 'ok',
|
||||||
|
'version_status' => [
|
||||||
|
'build' => $build !== null ? (int) $build : null,
|
||||||
|
],
|
||||||
|
'notification_state' => false,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function readComposerBuild(): ?int
|
||||||
|
{
|
||||||
|
$path = base_path('composer.json');
|
||||||
|
if (!is_file($path) || !is_readable($path)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$raw = file_get_contents($path);
|
||||||
|
if ($raw === false) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = json_decode($raw, true);
|
||||||
|
if (!is_array($data)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$build = trim((string) ($data['build'] ?? ''));
|
||||||
|
return ctype_digit($build) ? (int) $build : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ namespace App\Http\Controllers;
|
|||||||
use App\Models\Setting;
|
use App\Models\Setting;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
|
use Symfony\Component\Process\Process;
|
||||||
|
|
||||||
class SettingController extends Controller
|
class SettingController extends Controller
|
||||||
{
|
{
|
||||||
@@ -38,6 +39,12 @@ class SettingController extends Controller
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
$value = $data['value'] ?? '';
|
$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(
|
$setting = Setting::updateOrCreate(
|
||||||
['key' => $data['key']],
|
['key' => $data['key']],
|
||||||
@@ -67,6 +74,12 @@ class SettingController extends Controller
|
|||||||
$updated = [];
|
$updated = [];
|
||||||
|
|
||||||
foreach ($data['settings'] as $entry) {
|
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(
|
$setting = Setting::updateOrCreate(
|
||||||
['key' => $entry['key']],
|
['key' => $entry['key']],
|
||||||
['value' => $entry['value'] ?? '']
|
['value' => $entry['value'] ?? '']
|
||||||
@@ -80,4 +93,66 @@ class SettingController extends Controller
|
|||||||
|
|
||||||
return response()->json($updated);
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,11 +19,22 @@ class SystemStatusController extends Controller
|
|||||||
$phpDefaultPath = $this->resolveBinary('php');
|
$phpDefaultPath = $this->resolveBinary('php');
|
||||||
$phpDefaultVersion = $phpDefaultPath ? $this->resolvePhpVersion($phpDefaultPath) : null;
|
$phpDefaultVersion = $phpDefaultPath ? $this->resolvePhpVersion($phpDefaultPath) : null;
|
||||||
$phpConfiguredPath = trim((string) Setting::where('key', 'system.php_binary')->value('value'));
|
$phpConfiguredPath = trim((string) Setting::where('key', 'system.php_binary')->value('value'));
|
||||||
$phpSelectedPath = $phpConfiguredPath ?: (PHP_BINARY ?: $phpDefaultPath);
|
$phpSelectedPath = null;
|
||||||
$phpSelectedOk = (bool) $phpSelectedPath;
|
$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
|
$phpSelectedVersion = $phpSelectedPath
|
||||||
? ($this->resolvePhpVersion($phpSelectedPath) ?? PHP_VERSION)
|
? ($this->resolvePhpVersion($phpSelectedPath) ?? $phpDefaultVersion ?? PHP_VERSION)
|
||||||
: PHP_VERSION;
|
: null;
|
||||||
|
$phpSelectedOk = $phpSelectedPath !== null && $phpSelectedVersion !== null;
|
||||||
|
}
|
||||||
$minVersions = $this->resolveMinVersions();
|
$minVersions = $this->resolveMinVersions();
|
||||||
$composerPath = $this->resolveBinary('composer');
|
$composerPath = $this->resolveBinary('composer');
|
||||||
$nodePath = $this->resolveBinary('node');
|
$nodePath = $this->resolveBinary('node');
|
||||||
@@ -44,6 +55,8 @@ class SystemStatusController extends Controller
|
|||||||
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'php' => PHP_VERSION,
|
'php' => PHP_VERSION,
|
||||||
|
'php_web_path' => PHP_BINARY ?: null,
|
||||||
|
'php_web_version' => PHP_VERSION ?: null,
|
||||||
'php_default' => $phpDefaultPath,
|
'php_default' => $phpDefaultPath,
|
||||||
'php_default_version' => $phpDefaultVersion,
|
'php_default_version' => $phpDefaultVersion,
|
||||||
'php_configured' => $phpConfiguredPath ?: null,
|
'php_configured' => $phpConfiguredPath ?: null,
|
||||||
@@ -63,10 +76,36 @@ class SystemStatusController extends Controller
|
|||||||
'rsync_version' => $this->resolveBinaryVersion($rsyncPath, ['--version']),
|
'rsync_version' => $this->resolveBinaryVersion($rsyncPath, ['--version']),
|
||||||
'proc_functions' => $procFunctionStatus,
|
'proc_functions' => $procFunctionStatus,
|
||||||
'storage_writable' => is_writable(storage_path()),
|
'storage_writable' => is_writable(storage_path()),
|
||||||
|
'storage_public_linked' => $this->isPublicStorageLinked(),
|
||||||
'updates_writable' => is_writable(storage_path('app/updates')) || @mkdir(storage_path('app/updates'), 0755, true),
|
'updates_writable' => is_writable(storage_path('app/updates')) || @mkdir(storage_path('app/updates'), 0755, true),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function isPublicStorageLinked(): bool
|
||||||
|
{
|
||||||
|
$publicStorage = public_path('storage');
|
||||||
|
$storagePublic = storage_path('app/public');
|
||||||
|
|
||||||
|
if (!is_link($publicStorage)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$target = readlink($publicStorage);
|
||||||
|
if ($target === false) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$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;
|
||||||
|
}
|
||||||
|
|
||||||
private function resolveBinary(string $name): ?string
|
private function resolveBinary(string $name): ?string
|
||||||
{
|
{
|
||||||
$process = new Process(['sh', '-lc', "command -v {$name}"]);
|
$process = new Process(['sh', '-lc', "command -v {$name}"]);
|
||||||
@@ -95,6 +134,20 @@ class SystemStatusController extends Controller
|
|||||||
return $output !== '' ? $output : null;
|
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
|
private function resolveBinaryVersion(?string $path, array $args): ?string
|
||||||
{
|
{
|
||||||
if (!$path) {
|
if (!$path) {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ use Illuminate\Http\Request;
|
|||||||
use Illuminate\Support\Facades\File;
|
use Illuminate\Support\Facades\File;
|
||||||
use Illuminate\Support\Facades\Http;
|
use Illuminate\Support\Facades\Http;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
|
use RuntimeException;
|
||||||
use Symfony\Component\Process\Process;
|
use Symfony\Component\Process\Process;
|
||||||
|
|
||||||
class SystemUpdateController extends Controller
|
class SystemUpdateController extends Controller
|
||||||
@@ -113,7 +114,7 @@ class SystemUpdateController extends Controller
|
|||||||
$append('Syncing files...');
|
$append('Syncing files...');
|
||||||
$usedRsync = false;
|
$usedRsync = false;
|
||||||
$rsyncPath = trim((string) shell_exec('command -v rsync'));
|
$rsyncPath = trim((string) shell_exec('command -v rsync'));
|
||||||
$protectedPaths = ['custom', 'public/custom'];
|
$protectedPaths = ['storage', 'public/storage', 'custom', 'public/custom'];
|
||||||
if ($rsyncPath !== '') {
|
if ($rsyncPath !== '') {
|
||||||
$usedRsync = true;
|
$usedRsync = true;
|
||||||
$rsync = new Process([
|
$rsync = new Process([
|
||||||
@@ -149,38 +150,9 @@ class SystemUpdateController extends Controller
|
|||||||
File::copyDirectory($sourceDir, base_path());
|
File::copyDirectory($sourceDir, base_path());
|
||||||
}
|
}
|
||||||
|
|
||||||
$append('Installing composer dependencies...');
|
$this->ensurePublicStorageLink();
|
||||||
$composer = new Process(['composer', 'install', '--no-dev', '--optimize-autoloader'], base_path());
|
|
||||||
$composer->setTimeout(600);
|
|
||||||
$composer->run();
|
|
||||||
if (!$composer->isSuccessful()) {
|
|
||||||
return response()->json([
|
|
||||||
'message' => 'Composer install failed.',
|
|
||||||
'log' => array_merge($log, [$composer->getErrorOutput()]),
|
|
||||||
], 500);
|
|
||||||
}
|
|
||||||
|
|
||||||
$append('Installing npm dependencies...');
|
$append('Using prebuilt release package (skipping composer/npm steps).');
|
||||||
$npmInstall = new Process(['npm', 'install'], base_path());
|
|
||||||
$npmInstall->setTimeout(600);
|
|
||||||
$npmInstall->run();
|
|
||||||
if (!$npmInstall->isSuccessful()) {
|
|
||||||
return response()->json([
|
|
||||||
'message' => 'npm install failed.',
|
|
||||||
'log' => array_merge($log, [$npmInstall->getErrorOutput()]),
|
|
||||||
], 500);
|
|
||||||
}
|
|
||||||
|
|
||||||
$append('Building assets...');
|
|
||||||
$npmBuild = new Process(['npm', 'run', 'build'], base_path());
|
|
||||||
$npmBuild->setTimeout(900);
|
|
||||||
$npmBuild->run();
|
|
||||||
if (!$npmBuild->isSuccessful()) {
|
|
||||||
return response()->json([
|
|
||||||
'message' => 'npm run build failed.',
|
|
||||||
'log' => array_merge($log, [$npmBuild->getErrorOutput()]),
|
|
||||||
], 500);
|
|
||||||
}
|
|
||||||
|
|
||||||
$phpBinary = trim((string) Setting::where('key', 'system.php_binary')->value('value'));
|
$phpBinary = trim((string) Setting::where('key', 'system.php_binary')->value('value'));
|
||||||
if ($phpBinary === '') {
|
if ($phpBinary === '') {
|
||||||
@@ -212,4 +184,39 @@ class SystemUpdateController extends Controller
|
|||||||
], 500);
|
], 500);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function ensurePublicStorageLink(): void
|
||||||
|
{
|
||||||
|
$storagePublic = storage_path('app/public');
|
||||||
|
$publicStorage = public_path('storage');
|
||||||
|
|
||||||
|
if (file_exists($storagePublic) && !is_dir($storagePublic)) {
|
||||||
|
@rename($storagePublic, $storagePublic.'.bak.'.date('Ymd_His'));
|
||||||
|
}
|
||||||
|
if (!is_dir($storagePublic) && !@mkdir($storagePublic, 0775, true) && !is_dir($storagePublic)) {
|
||||||
|
throw new RuntimeException('Failed to prepare storage/app/public directory.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is_link($publicStorage)) {
|
||||||
|
$target = readlink($publicStorage);
|
||||||
|
$resolved = $target !== false ? realpath(dirname($publicStorage).DIRECTORY_SEPARATOR.$target) : false;
|
||||||
|
$expected = realpath($storagePublic);
|
||||||
|
if ($resolved !== $expected) {
|
||||||
|
@unlink($publicStorage);
|
||||||
|
}
|
||||||
|
} elseif (is_dir($publicStorage)) {
|
||||||
|
File::copyDirectory($publicStorage, $storagePublic);
|
||||||
|
File::deleteDirectory($publicStorage);
|
||||||
|
} elseif (file_exists($publicStorage)) {
|
||||||
|
@rename($publicStorage, $publicStorage.'.bak.'.date('Ymd_His'));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!is_link($publicStorage) && !@symlink($storagePublic, $publicStorage)) {
|
||||||
|
throw new RuntimeException('Failed to recreate public/storage symlink.');
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (['avatars', 'logos', 'favicons', 'rank-badges'] as $dir) {
|
||||||
|
File::ensureDirectoryExists($storagePublic.DIRECTORY_SEPARATOR.$dir);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,9 @@ namespace App\Http\Controllers;
|
|||||||
|
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\File;
|
||||||
use Illuminate\Support\Facades\Storage;
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
use RuntimeException;
|
||||||
|
|
||||||
class UploadController extends Controller
|
class UploadController extends Controller
|
||||||
{
|
{
|
||||||
@@ -14,6 +16,7 @@ class UploadController extends Controller
|
|||||||
if (!$user) {
|
if (!$user) {
|
||||||
return response()->json(['message' => 'Unauthorized'], 401);
|
return response()->json(['message' => 'Unauthorized'], 401);
|
||||||
}
|
}
|
||||||
|
$this->ensurePublicStorageReady();
|
||||||
|
|
||||||
$data = $request->validate([
|
$data = $request->validate([
|
||||||
'file' => [
|
'file' => [
|
||||||
@@ -45,6 +48,7 @@ class UploadController extends Controller
|
|||||||
if (!$user || !$user->roles()->where('name', 'ROLE_ADMIN')->exists()) {
|
if (!$user || !$user->roles()->where('name', 'ROLE_ADMIN')->exists()) {
|
||||||
return response()->json(['message' => 'Forbidden'], 403);
|
return response()->json(['message' => 'Forbidden'], 403);
|
||||||
}
|
}
|
||||||
|
$this->ensurePublicStorageReady();
|
||||||
|
|
||||||
$data = $request->validate([
|
$data = $request->validate([
|
||||||
'file' => ['required', 'file', 'mimes:jpg,jpeg,png,gif,webp,svg,ico', 'max:5120'],
|
'file' => ['required', 'file', 'mimes:jpg,jpeg,png,gif,webp,svg,ico', 'max:5120'],
|
||||||
@@ -64,6 +68,7 @@ class UploadController extends Controller
|
|||||||
if (!$user || !$user->roles()->where('name', 'ROLE_ADMIN')->exists()) {
|
if (!$user || !$user->roles()->where('name', 'ROLE_ADMIN')->exists()) {
|
||||||
return response()->json(['message' => 'Forbidden'], 403);
|
return response()->json(['message' => 'Forbidden'], 403);
|
||||||
}
|
}
|
||||||
|
$this->ensurePublicStorageReady();
|
||||||
|
|
||||||
$data = $request->validate([
|
$data = $request->validate([
|
||||||
'file' => ['required', 'file', 'mimes:png,ico', 'max:2048'],
|
'file' => ['required', 'file', 'mimes:png,ico', 'max:2048'],
|
||||||
@@ -76,4 +81,49 @@ class UploadController extends Controller
|
|||||||
'url' => Storage::url($path),
|
'url' => Storage::url($path),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function ensurePublicStorageReady(): void
|
||||||
|
{
|
||||||
|
$storagePublic = storage_path('app/public');
|
||||||
|
$publicStorage = public_path('storage');
|
||||||
|
|
||||||
|
if (file_exists($storagePublic) && !is_dir($storagePublic)) {
|
||||||
|
@rename($storagePublic, $storagePublic.'.bak.'.date('Ymd_His'));
|
||||||
|
}
|
||||||
|
if (!is_dir($storagePublic) && !@mkdir($storagePublic, 0775, true) && !is_dir($storagePublic)) {
|
||||||
|
throw new RuntimeException('Failed to create storage/app/public directory.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is_link($publicStorage)) {
|
||||||
|
$target = readlink($publicStorage);
|
||||||
|
$resolved = $target !== false ? realpath(dirname($publicStorage).DIRECTORY_SEPARATOR.$target) : false;
|
||||||
|
$expected = realpath($storagePublic);
|
||||||
|
if ($resolved === $expected) {
|
||||||
|
$this->ensureUploadSubdirs($storagePublic);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
@unlink($publicStorage);
|
||||||
|
} elseif (is_dir($publicStorage)) {
|
||||||
|
File::copyDirectory($publicStorage, $storagePublic);
|
||||||
|
File::deleteDirectory($publicStorage);
|
||||||
|
} elseif (file_exists($publicStorage)) {
|
||||||
|
@rename($publicStorage, $publicStorage.'.bak.'.date('Ymd_His'));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!@symlink($storagePublic, $publicStorage) && !is_link($publicStorage)) {
|
||||||
|
throw new RuntimeException('Failed to create public/storage symlink.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->ensureUploadSubdirs($storagePublic);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function ensureUploadSubdirs(string $storagePublic): void
|
||||||
|
{
|
||||||
|
foreach (['avatars', 'favicons', 'logos', 'rank-badges'] as $dir) {
|
||||||
|
$path = $storagePublic.DIRECTORY_SEPARATOR.$dir;
|
||||||
|
if (!is_dir($path)) {
|
||||||
|
@mkdir($path, 0775, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -209,6 +209,10 @@ class UserController extends Controller
|
|||||||
->pluck('id')
|
->pluck('id')
|
||||||
->all();
|
->all();
|
||||||
$user->roles()->sync($roleIds);
|
$user->roles()->sync($roleIds);
|
||||||
|
|
||||||
|
if (in_array('ROLE_FOUNDER', $roleNames, true) && $user->email_verified_at === null) {
|
||||||
|
$user->forceFill(['email_verified_at' => now()])->save();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$user->loadMissing('rank');
|
$user->loadMissing('rank');
|
||||||
|
|||||||
@@ -9,12 +9,39 @@ class VersionController extends Controller
|
|||||||
{
|
{
|
||||||
public function __invoke(): JsonResponse
|
public function __invoke(): JsonResponse
|
||||||
{
|
{
|
||||||
$version = Setting::where('key', 'version')->value('value');
|
$composer = $this->readComposerMetadata();
|
||||||
$build = Setting::where('key', 'build')->value('value');
|
$version = $composer['version'] ?? Setting::where('key', 'version')->value('value');
|
||||||
|
$build = $composer['build'] ?? Setting::where('key', 'build')->value('value');
|
||||||
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'version' => $version,
|
'version' => $version,
|
||||||
'build' => $build !== null ? (int) $build : null,
|
'build' => $build !== null ? (int) $build : null,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function readComposerMetadata(): array
|
||||||
|
{
|
||||||
|
$path = base_path('composer.json');
|
||||||
|
if (!is_file($path) || !is_readable($path)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$raw = file_get_contents($path);
|
||||||
|
if ($raw === false) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = json_decode($raw, true);
|
||||||
|
if (!is_array($data)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$version = trim((string) ($data['version'] ?? ''));
|
||||||
|
$build = trim((string) ($data['build'] ?? ''));
|
||||||
|
|
||||||
|
return [
|
||||||
|
'version' => $version !== '' ? $version : null,
|
||||||
|
'build' => ctype_digit($build) ? (int) $build : null,
|
||||||
|
];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -97,6 +97,6 @@
|
|||||||
},
|
},
|
||||||
"minimum-stability": "stable",
|
"minimum-stability": "stable",
|
||||||
"prefer-stable": true,
|
"prefer-stable": true,
|
||||||
"version": "26.0.2",
|
"version": "26.0.3",
|
||||||
"build": "72"
|
"build": "107"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,39 @@ resolve_php_bin() {
|
|||||||
echo "php"
|
echo "php"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ensure_storage_link() {
|
||||||
|
local storage_public="storage/app/public"
|
||||||
|
local public_storage="public/storage"
|
||||||
|
|
||||||
|
echo "Ensuring public storage link..."
|
||||||
|
|
||||||
|
if [[ -e "$storage_public" && ! -d "$storage_public" ]]; then
|
||||||
|
local backup_path="${storage_public}.bak.$(date +%Y%m%d_%H%M%S)"
|
||||||
|
echo "Found invalid $storage_public (not a directory). Backing up to $backup_path"
|
||||||
|
mv "$storage_public" "$backup_path"
|
||||||
|
fi
|
||||||
|
|
||||||
|
mkdir -p "$storage_public"
|
||||||
|
|
||||||
|
# If public/storage is a real directory, migrate files before converting to symlink.
|
||||||
|
if [[ -d "$public_storage" && ! -L "$public_storage" ]]; then
|
||||||
|
echo "Migrating existing files from $public_storage to $storage_public"
|
||||||
|
if command -v rsync >/dev/null 2>&1; then
|
||||||
|
rsync -a "$public_storage"/ "$storage_public"/
|
||||||
|
else
|
||||||
|
cp -a "$public_storage"/. "$storage_public"/
|
||||||
|
fi
|
||||||
|
rm -rf "$public_storage"
|
||||||
|
elif [[ -e "$public_storage" && ! -L "$public_storage" ]]; then
|
||||||
|
local public_backup="${public_storage}.bak.$(date +%Y%m%d_%H%M%S)"
|
||||||
|
echo "Found invalid $public_storage (not a directory/symlink). Backing up to $public_backup"
|
||||||
|
mv "$public_storage" "$public_backup"
|
||||||
|
fi
|
||||||
|
|
||||||
|
ln -sfn ../storage/app/public "$public_storage"
|
||||||
|
mkdir -p "$storage_public/logos" "$storage_public/favicons" "$storage_public/rank-badges"
|
||||||
|
}
|
||||||
|
|
||||||
resolve_configured_php_bin() {
|
resolve_configured_php_bin() {
|
||||||
local configured="${1:-}"
|
local configured="${1:-}"
|
||||||
local current="${2:-php}"
|
local current="${2:-php}"
|
||||||
@@ -248,6 +281,8 @@ main() {
|
|||||||
echo "Running with PHP binary: $PHP_BIN artisan migrate --force"
|
echo "Running with PHP binary: $PHP_BIN artisan migrate --force"
|
||||||
"$PHP_BIN" artisan migrate --force
|
"$PHP_BIN" artisan migrate --force
|
||||||
|
|
||||||
|
ensure_storage_link
|
||||||
|
|
||||||
echo "Syncing version/build to settings..."
|
echo "Syncing version/build to settings..."
|
||||||
echo "Running with PHP binary: $PHP_BIN -r <read composer.json version>"
|
echo "Running with PHP binary: $PHP_BIN -r <read composer.json version>"
|
||||||
VERSION="$("$PHP_BIN" -r '$c=json_decode(file_get_contents("composer.json"), true); echo $c["version"] ?? "";')"
|
VERSION="$("$PHP_BIN" -r '$c=json_decode(file_get_contents("composer.json"), true); echo $c["version"] ?? "";')"
|
||||||
|
|||||||
@@ -1,18 +1,19 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import { BrowserRouter, Link, Route, Routes, useLocation } from 'react-router-dom'
|
import { BrowserRouter, Link, Route, Routes, useLocation } from 'react-router-dom'
|
||||||
import { Container, NavDropdown } from 'react-bootstrap'
|
import { Button, Container, Modal, NavDropdown } from 'react-bootstrap'
|
||||||
import { AuthProvider, useAuth } from './context/AuthContext'
|
import { AuthProvider, useAuth } from './context/AuthContext'
|
||||||
import Home from './pages/Home'
|
import Home from './pages/Home'
|
||||||
import ForumView from './pages/ForumView'
|
import ForumView from './pages/ForumView'
|
||||||
import ThreadView from './pages/ThreadView'
|
import ThreadView from './pages/ThreadView'
|
||||||
import Login from './pages/Login'
|
import Login from './pages/Login'
|
||||||
import Register from './pages/Register'
|
import Register from './pages/Register'
|
||||||
|
import ResetPassword from './pages/ResetPassword'
|
||||||
import { Acp } from './pages/Acp'
|
import { Acp } from './pages/Acp'
|
||||||
import BoardIndex from './pages/BoardIndex'
|
import BoardIndex from './pages/BoardIndex'
|
||||||
import Ucp from './pages/Ucp'
|
import Ucp from './pages/Ucp'
|
||||||
import Profile from './pages/Profile'
|
import Profile from './pages/Profile'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { fetchSettings, fetchVersion, getForum, getThread } from './api/client'
|
import { fetchPing, fetchSettings, fetchVersion, getForum, getThread } from './api/client'
|
||||||
|
|
||||||
function PortalHeader({
|
function PortalHeader({
|
||||||
userMenu,
|
userMenu,
|
||||||
@@ -240,9 +241,16 @@ function PortalHeader({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function AppShell() {
|
function AppShell() {
|
||||||
|
const PING_INTERVAL_MS = 15000
|
||||||
|
const PING_INTERVAL_HIDDEN_MS = 60000
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const { token, email, userId, logout, isAdmin, isModerator } = useAuth()
|
const { token, email, userId, logout, isAdmin, isModerator } = useAuth()
|
||||||
const [versionInfo, setVersionInfo] = useState(null)
|
const [versionInfo, setVersionInfo] = useState(null)
|
||||||
|
const [availableBuild, setAvailableBuild] = useState(null)
|
||||||
|
const [pingBuild, setPingBuild] = useState(null)
|
||||||
|
const [showUpdateModal, setShowUpdateModal] = useState(false)
|
||||||
|
const currentBuildRef = useRef(null)
|
||||||
|
const promptedBuildRef = useRef(null)
|
||||||
const [theme, setTheme] = useState('auto')
|
const [theme, setTheme] = useState('auto')
|
||||||
const [resolvedTheme, setResolvedTheme] = useState('light')
|
const [resolvedTheme, setResolvedTheme] = useState('light')
|
||||||
const [accentOverride, setAccentOverride] = useState(
|
const [accentOverride, setAccentOverride] = useState(
|
||||||
@@ -271,6 +279,73 @@ function AppShell() {
|
|||||||
.catch(() => setVersionInfo(null))
|
.catch(() => setVersionInfo(null))
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
currentBuildRef.current =
|
||||||
|
typeof versionInfo?.build === 'number' ? versionInfo.build : null
|
||||||
|
}, [versionInfo?.build])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const currentBuild =
|
||||||
|
typeof versionInfo?.build === 'number' ? versionInfo.build : null
|
||||||
|
if (currentBuild !== null && pingBuild !== null && pingBuild > currentBuild) {
|
||||||
|
setAvailableBuild(pingBuild)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setAvailableBuild(null)
|
||||||
|
}, [versionInfo?.build, pingBuild])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (availableBuild === null) return
|
||||||
|
if (promptedBuildRef.current === availableBuild) return
|
||||||
|
promptedBuildRef.current = availableBuild
|
||||||
|
setShowUpdateModal(true)
|
||||||
|
}, [availableBuild])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let active = true
|
||||||
|
let timeoutId = null
|
||||||
|
|
||||||
|
const scheduleNext = () => {
|
||||||
|
const delay = document.hidden ? PING_INTERVAL_HIDDEN_MS : PING_INTERVAL_MS
|
||||||
|
timeoutId = window.setTimeout(runPing, delay)
|
||||||
|
}
|
||||||
|
|
||||||
|
const runPing = async () => {
|
||||||
|
try {
|
||||||
|
const data = await fetchPing()
|
||||||
|
const currentBuild = currentBuildRef.current
|
||||||
|
const remoteBuild =
|
||||||
|
typeof data?.version_status?.build === 'number'
|
||||||
|
? data.version_status.build
|
||||||
|
: null
|
||||||
|
console.log('speedBB ping', {
|
||||||
|
...data,
|
||||||
|
current_version: currentBuild,
|
||||||
|
})
|
||||||
|
if (!active) return
|
||||||
|
if (remoteBuild !== null) {
|
||||||
|
setPingBuild(remoteBuild)
|
||||||
|
}
|
||||||
|
window.dispatchEvent(new CustomEvent('speedbb-ping', { detail: data }))
|
||||||
|
} catch {
|
||||||
|
// ignore transient ping failures
|
||||||
|
} finally {
|
||||||
|
if (active) {
|
||||||
|
scheduleNext()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
runPing()
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
active = false
|
||||||
|
if (timeoutId) {
|
||||||
|
window.clearTimeout(timeoutId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let active = true
|
let active = true
|
||||||
const loadSettings = async () => {
|
const loadSettings = async () => {
|
||||||
@@ -466,6 +541,7 @@ function AppShell() {
|
|||||||
<Route path="/forum/:id" element={<ForumView />} />
|
<Route path="/forum/:id" element={<ForumView />} />
|
||||||
<Route path="/thread/:id" element={<ThreadView />} />
|
<Route path="/thread/:id" element={<ThreadView />} />
|
||||||
<Route path="/login" element={<Login />} />
|
<Route path="/login" element={<Login />} />
|
||||||
|
<Route path="/reset-password" element={<ResetPassword />} />
|
||||||
<Route path="/register" element={<Register />} />
|
<Route path="/register" element={<Register />} />
|
||||||
<Route path="/profile/:id" element={<Profile />} />
|
<Route path="/profile/:id" element={<Profile />} />
|
||||||
<Route path="/acp/*" element={<Acp isAdmin={isAdmin} />} />
|
<Route path="/acp/*" element={<Acp isAdmin={isAdmin} />} />
|
||||||
@@ -493,8 +569,35 @@ function AppShell() {
|
|||||||
<span className="bb-version-label">)</span>
|
<span className="bb-version-label">)</span>
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
{availableBuild !== null && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
className="bb-accent-button"
|
||||||
|
onClick={() => window.location.reload()}
|
||||||
|
>
|
||||||
|
{t('version.update_available_short')} (build {availableBuild}) ·{' '}
|
||||||
|
{t('version.update_now')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
<Modal show={showUpdateModal} onHide={() => setShowUpdateModal(false)} centered>
|
||||||
|
<Modal.Header closeButton>
|
||||||
|
<Modal.Title>{t('version.refresh_prompt_title')}</Modal.Title>
|
||||||
|
</Modal.Header>
|
||||||
|
<Modal.Body>
|
||||||
|
{t('version.refresh_prompt_body', { build: availableBuild ?? '-' })}
|
||||||
|
</Modal.Body>
|
||||||
|
<Modal.Footer className="justify-content-between">
|
||||||
|
<Button variant="outline-secondary" onClick={() => setShowUpdateModal(false)}>
|
||||||
|
{t('version.remind_later')}
|
||||||
|
</Button>
|
||||||
|
<Button className="bb-accent-button" onClick={() => window.location.reload()}>
|
||||||
|
{t('version.update_now')}
|
||||||
|
</Button>
|
||||||
|
</Modal.Footer>
|
||||||
|
</Modal>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -62,6 +62,20 @@ export async function registerUser({ email, username, plainPassword }) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function requestPasswordReset(email) {
|
||||||
|
return apiFetch('/forgot-password', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ email }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resetPassword({ token, email, password, password_confirmation }) {
|
||||||
|
return apiFetch('/reset-password', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ token, email, password, password_confirmation }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
export async function logoutUser() {
|
export async function logoutUser() {
|
||||||
return apiFetch('/logout', {
|
return apiFetch('/logout', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -115,6 +129,10 @@ export async function fetchVersion() {
|
|||||||
return apiFetch('/version')
|
return apiFetch('/version')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function fetchPing() {
|
||||||
|
return apiFetch('/ping')
|
||||||
|
}
|
||||||
|
|
||||||
export async function fetchVersionCheck() {
|
export async function fetchVersionCheck() {
|
||||||
return apiFetch('/version/check')
|
return apiFetch('/version/check')
|
||||||
}
|
}
|
||||||
@@ -180,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) {
|
export async function uploadLogo(file) {
|
||||||
const body = new FormData()
|
const body = new FormData()
|
||||||
body.append('file', file)
|
body.append('file', file)
|
||||||
|
|||||||
@@ -862,10 +862,17 @@ a {
|
|||||||
}
|
}
|
||||||
|
|
||||||
[data-bs-theme="dark"] {
|
[data-bs-theme="dark"] {
|
||||||
--bb-ink: #aaaeb4;
|
--bb-ink: #a3acb9;
|
||||||
--bb-ink-muted: #6b7483;
|
--bb-ink-muted: #626d7e;
|
||||||
--bb-border: #2a2f3a;
|
--bb-border: #242a35;
|
||||||
--bb-page-bg: radial-gradient(circle at 10% 20%, #141823 0%, #10131a 45%, #0b0e14 100%);
|
--bb-page-bg: radial-gradient(circle at 10% 20%, #10151f 0%, #0b1018 45%, #080b11 100%);
|
||||||
|
--bs-body-bg: #080b11;
|
||||||
|
--bs-body-color: #b3bdca;
|
||||||
|
--bs-secondary-bg: #121822;
|
||||||
|
--bs-tertiary-bg: #0f141d;
|
||||||
|
--bs-border-color: #242a35;
|
||||||
|
--bs-modal-bg: #121822;
|
||||||
|
--bs-modal-color: #b3bdca;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-bs-theme="dark"] .bb-hero {
|
[data-bs-theme="dark"] .bb-hero {
|
||||||
@@ -875,11 +882,30 @@ a {
|
|||||||
|
|
||||||
[data-bs-theme="dark"] .bb-card,
|
[data-bs-theme="dark"] .bb-card,
|
||||||
[data-bs-theme="dark"] .bb-form {
|
[data-bs-theme="dark"] .bb-form {
|
||||||
background: #171b22;
|
background: #121822;
|
||||||
border-color: #2a2f3a;
|
border-color: #242a35;
|
||||||
box-shadow: 0 12px 24px rgba(0, 0, 0, 0.35);
|
box-shadow: 0 12px 24px rgba(0, 0, 0, 0.35);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[data-bs-theme="dark"] .modal-content {
|
||||||
|
background: #121822;
|
||||||
|
border-color: #242a35;
|
||||||
|
color: #b3bdca;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-bs-theme="dark"] .modal-content .modal-body,
|
||||||
|
[data-bs-theme="dark"] .modal-content .modal-footer {
|
||||||
|
background: #121822;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-bs-theme="dark"] .modal-content .modal-header {
|
||||||
|
color: #b7c0cc;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-bs-theme="dark"] .modal-content .modal-title {
|
||||||
|
color: #b7c0cc;
|
||||||
|
}
|
||||||
|
|
||||||
[data-bs-theme="light"] .bb-forum-row {
|
[data-bs-theme="light"] .bb-forum-row {
|
||||||
background: #fff;
|
background: #fff;
|
||||||
}
|
}
|
||||||
@@ -2172,16 +2198,25 @@ a {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.bb-accent-button {
|
.bb-accent-button {
|
||||||
background: var(--bb-accent, #f29b3f);
|
--bs-btn-bg: var(--bb-accent, #f29b3f);
|
||||||
border-color: var(--bb-accent, #f29b3f);
|
--bs-btn-border-color: var(--bb-accent, #f29b3f);
|
||||||
color: #0e121b;
|
--bs-btn-color: #0e121b;
|
||||||
|
--bs-btn-hover-bg: color-mix(in srgb, var(--bb-accent, #f29b3f) 85%, #000);
|
||||||
|
--bs-btn-hover-border-color: color-mix(in srgb, var(--bb-accent, #f29b3f) 85%, #000);
|
||||||
|
--bs-btn-hover-color: #fff;
|
||||||
|
--bs-btn-active-bg: color-mix(in srgb, var(--bb-accent, #f29b3f) 80%, #000);
|
||||||
|
--bs-btn-active-border-color: color-mix(in srgb, var(--bb-accent, #f29b3f) 80%, #000);
|
||||||
|
--bs-btn-active-color: #fff;
|
||||||
|
--bs-btn-disabled-bg: var(--bb-accent, #f29b3f);
|
||||||
|
--bs-btn-disabled-border-color: var(--bb-accent, #f29b3f);
|
||||||
|
--bs-btn-disabled-color: #0e121b;
|
||||||
|
background: var(--bs-btn-bg);
|
||||||
|
border-color: var(--bs-btn-border-color);
|
||||||
|
color: var(--bs-btn-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
.bb-accent-button:hover,
|
.bb-accent-button:focus-visible {
|
||||||
.bb-accent-button:focus {
|
box-shadow: 0 0 0 0.2rem color-mix(in srgb, var(--bb-accent, #f29b3f) 35%, transparent);
|
||||||
background: color-mix(in srgb, var(--bb-accent, #f29b3f) 85%, #000);
|
|
||||||
border-color: color-mix(in srgb, var(--bb-accent, #f29b3f) 85%, #000);
|
|
||||||
color: #0e121b;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.bb-accent-button:disabled,
|
.bb-accent-button:disabled,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useMemo, useRef, useState, useId } from 'react'
|
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 DataTable, { createTheme } from 'react-data-table-component'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { useDropzone } from 'react-dropzone'
|
import { useDropzone } from 'react-dropzone'
|
||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
reorderForums,
|
reorderForums,
|
||||||
saveSetting,
|
saveSetting,
|
||||||
saveSettings,
|
saveSettings,
|
||||||
|
validateSystemPhpBinary,
|
||||||
createRank,
|
createRank,
|
||||||
deleteRank,
|
deleteRank,
|
||||||
updateUserRank,
|
updateUserRank,
|
||||||
@@ -96,7 +97,8 @@ function Acp({ isAdmin }) {
|
|||||||
const [systemStatus, setSystemStatus] = useState(null)
|
const [systemStatus, setSystemStatus] = useState(null)
|
||||||
const [systemLoading, setSystemLoading] = useState(false)
|
const [systemLoading, setSystemLoading] = useState(false)
|
||||||
const [systemError, setSystemError] = useState('')
|
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 [usersPage, setUsersPage] = useState(1)
|
||||||
const [usersPerPage, setUsersPerPage] = useState(10)
|
const [usersPerPage, setUsersPerPage] = useState(10)
|
||||||
const [userSort, setUserSort] = useState({ columnId: 'name', direction: 'asc' })
|
const [userSort, setUserSort] = useState({ columnId: 'name', direction: 'asc' })
|
||||||
@@ -206,7 +208,9 @@ function Acp({ isAdmin }) {
|
|||||||
php_custom: '',
|
php_custom: '',
|
||||||
})
|
})
|
||||||
const [systemCliSaving, setSystemCliSaving] = useState(false)
|
const [systemCliSaving, setSystemCliSaving] = useState(false)
|
||||||
|
const [systemCliChecking, setSystemCliChecking] = useState(false)
|
||||||
const [systemCliError, setSystemCliError] = useState('')
|
const [systemCliError, setSystemCliError] = useState('')
|
||||||
|
const [systemCliToast, setSystemCliToast] = useState({ show: false, variant: 'success', message: '' })
|
||||||
const settingsDetailMap = {
|
const settingsDetailMap = {
|
||||||
forum_name: 'forumName',
|
forum_name: 'forumName',
|
||||||
default_theme: 'defaultTheme',
|
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) => {
|
const handleSystemCliSave = async (event) => {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
setSystemCliSaving(true)
|
setSystemCliSaving(true)
|
||||||
setSystemCliError('')
|
setSystemCliError('')
|
||||||
try {
|
try {
|
||||||
let value = ''
|
const value = resolveSystemCliBinaryValue(event.currentTarget)
|
||||||
if (systemCliSettings.php_mode === 'custom') {
|
await persistSystemPhpBinary(value, systemCliSettings.php_mode)
|
||||||
value = typeof systemCliSettings.php_custom === 'string'
|
} catch (err) {
|
||||||
? systemCliSettings.php_custom.trim()
|
setSystemCliError(err.message)
|
||||||
: String(systemCliSettings.php_custom ?? '')
|
} finally {
|
||||||
} else {
|
setSystemCliSaving(false)
|
||||||
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)
|
|
||||||
|
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) => ({
|
setSystemCliSettings((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
php_mode: systemCliSettings.php_mode,
|
php_mode: nextMode,
|
||||||
php_custom: systemCliSettings.php_mode === 'custom' ? value : '',
|
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) {
|
} catch (err) {
|
||||||
setSystemCliError(err.message)
|
setSystemCliError(err.message)
|
||||||
} finally {
|
} finally {
|
||||||
@@ -468,14 +552,6 @@ function Acp({ isAdmin }) {
|
|||||||
return mins.reduce((lowest, current) => (compareSemver(current, lowest) < 0 ? current : lowest))
|
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(() => {
|
const phpSelectedIsSufficient = useMemo(() => {
|
||||||
if (!systemStatus?.php_selected_ok) return false
|
if (!systemStatus?.php_selected_ok) return false
|
||||||
const minimum = parseMinPhpConstraint(systemStatus?.min_versions?.php)
|
const minimum = parseMinPhpConstraint(systemStatus?.min_versions?.php)
|
||||||
@@ -485,6 +561,41 @@ function Acp({ isAdmin }) {
|
|||||||
return compareSemver(current, minimum) >= 0
|
return compareSemver(current, minimum) >= 0
|
||||||
}, [systemStatus])
|
}, [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(() => {
|
const systemChecks = useMemo(() => {
|
||||||
if (!systemStatus) return []
|
if (!systemStatus) return []
|
||||||
return [
|
return [
|
||||||
@@ -536,6 +647,30 @@ function Acp({ isAdmin }) {
|
|||||||
current: systemStatus.rsync_version || '—',
|
current: systemStatus.rsync_version || '—',
|
||||||
status: systemStatus.rsync ? 'ok' : 'bad',
|
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',
|
id: 'proc',
|
||||||
label: 'proc_* functions',
|
label: 'proc_* functions',
|
||||||
@@ -555,35 +690,38 @@ function Acp({ isAdmin }) {
|
|||||||
: 'bad',
|
: 'bad',
|
||||||
pathColSpan: 3,
|
pathColSpan: 3,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
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',
|
|
||||||
},
|
|
||||||
]
|
]
|
||||||
}, [phpSelectedIsSufficient, systemStatus, t])
|
}, [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 visibleSystemChecks = useMemo(() => {
|
||||||
const visibilityBySection = {
|
const visibilityBySection = {
|
||||||
insite: ['php', 'proc', 'storage', 'updates'],
|
insite: ['php', 'tar', 'rsync', 'storage', 'updates', 'storage_link', 'proc'],
|
||||||
cli: ['php', 'composer', 'node', 'npm', 'proc', 'storage', 'updates'],
|
cli: ['php', 'composer', 'node', 'npm', 'proc', 'storage', 'storage_link'],
|
||||||
ci: ['php', 'composer', 'node', 'npm', 'tar', 'rsync', 'proc', 'storage', 'updates'],
|
ci: ['php', 'composer', 'node', 'npm', 'storage', 'updates', 'storage_link', 'proc'],
|
||||||
info: [],
|
|
||||||
}
|
}
|
||||||
const allowed = new Set(visibilityBySection[systemSection] || [])
|
const allowed = new Set(visibilityBySection[systemUpdateSection] || [])
|
||||||
return systemChecks.filter((check) => allowed.has(check.id))
|
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) => {
|
const handleLogoUpload = async (file, settingKey) => {
|
||||||
@@ -1036,7 +1174,7 @@ function Acp({ isAdmin }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderSystemRequirementsPanel() {
|
function renderSystemRequirementsPanel(checks = visibleSystemChecks) {
|
||||||
return (
|
return (
|
||||||
<div className="bb-acp-panel">
|
<div className="bb-acp-panel">
|
||||||
<div className="bb-acp-panel-header">
|
<div className="bb-acp-panel-header">
|
||||||
@@ -1068,7 +1206,7 @@ function Acp({ isAdmin }) {
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{visibleSystemChecks.map((check) => (
|
{checks.map((check) => (
|
||||||
<tr key={check.id}>
|
<tr key={check.id}>
|
||||||
<td>{check.label}</td>
|
<td>{check.label}</td>
|
||||||
<td className="bb-acp-stats-value text-start" colSpan={check.pathColSpan || 1}>
|
<td className="bb-acp-stats-value text-start" colSpan={check.pathColSpan || 1}>
|
||||||
@@ -1117,20 +1255,6 @@ function Acp({ isAdmin }) {
|
|||||||
}, [isAdmin])
|
}, [isAdmin])
|
||||||
|
|
||||||
const statsLeft = useMemo(() => {
|
const statsLeft = useMemo(() => {
|
||||||
const versionMeta = (() => {
|
|
||||||
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')
|
|
||||||
})()
|
|
||||||
const showUpdate = versionCheck?.is_latest === false
|
|
||||||
|
|
||||||
return [
|
return [
|
||||||
{ label: t('stats.board_started'), value: formatDateTime(boardStats?.board_started_at) },
|
{ label: t('stats.board_started'), value: formatDateTime(boardStats?.board_started_at) },
|
||||||
{ label: t('stats.avatar_directory_size'), value: formatBytes(boardStats?.avatar_directory_size_bytes) },
|
{ label: t('stats.avatar_directory_size'), value: formatBytes(boardStats?.avatar_directory_size_bytes) },
|
||||||
@@ -1142,33 +1266,10 @@ function Acp({ isAdmin }) {
|
|||||||
{ label: t('stats.orphan_attachments'), value: formatNumber(boardStats?.orphan_attachments) },
|
{ label: t('stats.orphan_attachments'), value: formatNumber(boardStats?.orphan_attachments) },
|
||||||
{
|
{
|
||||||
label: t('stats.board_version'),
|
label: t('stats.board_version'),
|
||||||
value: (
|
value: boardStats?.board_version || '—',
|
||||||
<div className="bb-acp-version-inline">
|
|
||||||
<span>{boardStats?.board_version || '—'}</span>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="btn btn-link p-0 bb-acp-version-link"
|
|
||||||
onClick={handleVersionCheck}
|
|
||||||
disabled={versionChecking}
|
|
||||||
>
|
|
||||||
{t('version.recheck')}
|
|
||||||
</button>
|
|
||||||
{showUpdate && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="btn btn-link p-0 bb-acp-version-link"
|
|
||||||
onClick={() => setUpdateModalOpen(true)}
|
|
||||||
disabled={updateRunning}
|
|
||||||
>
|
|
||||||
{t('version.update_now')}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
<span className="bb-acp-version-meta">{versionMeta}</span>
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
}, [t, boardStats, formatBool, versionCheck, versionChecking, versionCheckError, updateRunning])
|
}, [t, boardStats, formatBool])
|
||||||
|
|
||||||
const statsRight = useMemo(() => {
|
const statsRight = useMemo(() => {
|
||||||
return [
|
return [
|
||||||
@@ -3793,82 +3894,101 @@ function Acp({ isAdmin }) {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={`list-group-item list-group-item-action ${
|
className={`list-group-item list-group-item-action ${
|
||||||
systemSection === 'info' ? 'is-active' : ''
|
systemSection === 'overview' ? 'is-active' : ''
|
||||||
}`}
|
}`}
|
||||||
onClick={() => setSystemSection('info')}
|
onClick={() => setSystemSection('overview')}
|
||||||
>
|
>
|
||||||
Overview
|
Health
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={`list-group-item list-group-item-action ${
|
className={`list-group-item list-group-item-action ${
|
||||||
systemSection === 'insite' ? 'is-active' : ''
|
systemSection === 'updates' ? 'is-active' : ''
|
||||||
}`}
|
}`}
|
||||||
onClick={() => setSystemSection('insite')}
|
onClick={() => setSystemSection('updates')}
|
||||||
>
|
>
|
||||||
Live Update
|
Updates
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={`list-group-item list-group-item-action ${
|
|
||||||
systemSection === 'cli' ? 'is-active' : ''
|
|
||||||
}`}
|
|
||||||
onClick={() => setSystemSection('cli')}
|
|
||||||
>
|
|
||||||
CLI
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={`list-group-item list-group-item-action ${
|
|
||||||
systemSection === 'ci' ? 'is-active' : ''
|
|
||||||
}`}
|
|
||||||
onClick={() => setSystemSection('ci')}
|
|
||||||
>
|
|
||||||
CI/CD
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Col>
|
</Col>
|
||||||
<Col xs={12} lg>
|
<Col xs={12} lg>
|
||||||
{systemSection === 'info' && (
|
{systemSection === 'overview' && (
|
||||||
<div className="bb-acp-panel">
|
renderSystemRequirementsPanel(visibleHealthChecks)
|
||||||
<div className="bb-acp-panel-header">
|
|
||||||
<h5 className="mb-0">System overview</h5>
|
|
||||||
</div>
|
|
||||||
<div className="bb-acp-panel-body">
|
|
||||||
<p className="bb-muted mb-0">
|
|
||||||
Placeholder: summary, upgrade guidance, and environment health notes will
|
|
||||||
live here.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
{systemSection === 'insite' && (
|
{systemSection === 'updates' && (
|
||||||
<>
|
<>
|
||||||
<div className="bb-acp-panel mb-3">
|
<div className="bb-acp-panel mb-3">
|
||||||
<div className="bb-acp-panel-body">
|
<div className="bb-acp-panel-header">
|
||||||
<p className="bb-muted mb-0">Live update controls will appear here.</p>
|
<h5 className="mb-0">{t('version.update_title')}</h5>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div className="bb-acp-panel-body d-flex flex-wrap align-items-center gap-2">
|
||||||
{renderSystemRequirementsPanel()}
|
<Button
|
||||||
</>
|
type="button"
|
||||||
|
variant="dark"
|
||||||
|
onClick={handleVersionCheck}
|
||||||
|
disabled={versionChecking}
|
||||||
|
>
|
||||||
|
{t('version.recheck')}
|
||||||
|
</Button>
|
||||||
|
{systemUpdateAvailable && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="primary"
|
||||||
|
onClick={() => setUpdateModalOpen(true)}
|
||||||
|
disabled={updateRunning}
|
||||||
|
>
|
||||||
|
{t('version.update_now')}
|
||||||
|
</Button>
|
||||||
)}
|
)}
|
||||||
{systemSection === 'cli' && (
|
<span className="bb-acp-version-meta">{systemUpdateMeta}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="bb-acp-panel mb-3">
|
||||||
|
<div className="bb-acp-panel-body">
|
||||||
|
<ButtonGroup aria-label="Updates sections">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant={systemUpdateSection === 'insite' ? 'primary' : 'dark'}
|
||||||
|
onClick={() => setSystemUpdateSection('insite')}
|
||||||
|
>
|
||||||
|
Live Update
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant={systemUpdateSection === 'cli' ? 'primary' : 'dark'}
|
||||||
|
onClick={() => setSystemUpdateSection('cli')}
|
||||||
|
>
|
||||||
|
CLI
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant={systemUpdateSection === 'ci' ? 'primary' : 'dark'}
|
||||||
|
onClick={() => setSystemUpdateSection('ci')}
|
||||||
|
>
|
||||||
|
CI/CD
|
||||||
|
</Button>
|
||||||
|
</ButtonGroup>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{systemUpdateSection === 'insite' && renderSystemRequirementsPanel()}
|
||||||
|
{systemUpdateSection === 'cli' && (
|
||||||
<div className="bb-acp-panel">
|
<div className="bb-acp-panel">
|
||||||
<div className="bb-acp-panel-header">
|
<div className="bb-acp-panel-header">
|
||||||
<h5 className="mb-0">CLI</h5>
|
<h5 className="mb-0">CLI</h5>
|
||||||
<p className="bb-muted mb-0 mt-1">
|
<p className="bb-muted mb-0 mt-1">
|
||||||
CLI default php: {systemStatus?.php_default || '—'} (
|
CLI default php: {systemStatus?.php_default || '—'} (
|
||||||
{systemStatus?.php_default_version || 'unknown'}){' '}
|
{systemStatus?.php_default_version || 'unknown'}){' '}
|
||||||
{cliDefaultPhpIsSufficient ? (
|
{phpSelectedIsSufficient ? (
|
||||||
<i className="bi bi-check-circle-fill text-success" aria-hidden="true" />
|
<i className="bi bi-check-circle-fill text-success" aria-hidden="true" />
|
||||||
) : (
|
) : (
|
||||||
<OverlayTrigger
|
<OverlayTrigger
|
||||||
placement="top"
|
placement="top"
|
||||||
overlay={
|
overlay={
|
||||||
<Tooltip id="cli-default-php-warning" data-bs-theme="light">
|
<Tooltip id="cli-default-php-warning" data-bs-theme="light">
|
||||||
You must select a custom PHP interpreter, as the system default is not sufficient.
|
The selected PHP interpreter is not sufficient for the required
|
||||||
|
composer.json PHP version.
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
@@ -3883,6 +4003,27 @@ function Acp({ isAdmin }) {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="bb-acp-panel-body">
|
<div className="bb-acp-panel-body">
|
||||||
|
<ToastContainer position="top-end" className="p-0 mb-3">
|
||||||
|
<Toast
|
||||||
|
show={systemCliToast.show}
|
||||||
|
onClose={() =>
|
||||||
|
setSystemCliToast((prev) => ({ ...prev, show: false }))
|
||||||
|
}
|
||||||
|
delay={2800}
|
||||||
|
autohide
|
||||||
|
bg={systemCliToast.variant}
|
||||||
|
>
|
||||||
|
<Toast.Body
|
||||||
|
className={
|
||||||
|
systemCliToast.variant === 'danger'
|
||||||
|
? 'text-white'
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{systemCliToast.message}
|
||||||
|
</Toast.Body>
|
||||||
|
</Toast>
|
||||||
|
</ToastContainer>
|
||||||
{systemCliError && <p className="text-danger">{systemCliError}</p>}
|
{systemCliError && <p className="text-danger">{systemCliError}</p>}
|
||||||
<Form onSubmit={handleSystemCliSave}>
|
<Form onSubmit={handleSystemCliSave}>
|
||||||
<Form.Group className="mb-3">
|
<Form.Group className="mb-3">
|
||||||
@@ -3890,57 +4031,64 @@ function Acp({ isAdmin }) {
|
|||||||
<Form.Select
|
<Form.Select
|
||||||
className="mb-2"
|
className="mb-2"
|
||||||
value={systemCliSettings.php_mode}
|
value={systemCliSettings.php_mode}
|
||||||
onChange={(event) =>
|
onChange={(event) => handleSystemCliModeChange(event.target.value)}
|
||||||
setSystemCliSettings((prev) => ({
|
disabled={systemCliSaving || systemCliChecking}
|
||||||
...prev,
|
|
||||||
php_mode: event.target.value,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<option value="php">php (system default)</option>
|
<option value="php">php (system default)</option>
|
||||||
<option value="custom">Custom binary (e.g. keyhelp-php84)</option>
|
<option value="custom">
|
||||||
|
{`Custom binary (e.g. ${suggestedPhpBinary} or ${suggestedKeyhelpPhpBinary})`}
|
||||||
|
</option>
|
||||||
</Form.Select>
|
</Form.Select>
|
||||||
{systemCliSettings.php_mode === 'custom' && (
|
{systemCliSettings.php_mode === 'custom' && (
|
||||||
|
<div className="d-flex gap-2 align-items-start">
|
||||||
<Form.Control
|
<Form.Control
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="keyhelp-php84"
|
name="system_php_custom"
|
||||||
|
placeholder={`Enter binary (e.g. ${suggestedPhpBinary})`}
|
||||||
value={systemCliSettings.php_custom}
|
value={systemCliSettings.php_custom}
|
||||||
|
disabled={systemCliSaving || systemCliChecking}
|
||||||
onChange={(event) =>
|
onChange={(event) =>
|
||||||
setSystemCliSettings((prev) => ({
|
setSystemCliSettings((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
php_custom: event.target.value,
|
php_custom: event.target.value,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
onInput={() => setSystemCliError('')}
|
||||||
/>
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
className="bb-accent-button flex-shrink-0"
|
||||||
|
onClick={handleSystemCliCheck}
|
||||||
|
disabled={systemCliSaving || systemCliChecking}
|
||||||
|
>
|
||||||
|
<span className="d-inline-flex align-items-center gap-2">
|
||||||
|
<i className="bi bi-search" aria-hidden="true" />
|
||||||
|
Check
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
className="bb-accent-button flex-shrink-0"
|
||||||
|
disabled={systemCliSaving || systemCliChecking}
|
||||||
|
>
|
||||||
|
<span className="d-inline-flex align-items-center gap-2">
|
||||||
|
<i className="bi bi-floppy" aria-hidden="true" />
|
||||||
|
{t('acp.save')}
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
<Form.Text className="bb-muted">
|
<Form.Text className="bb-muted">
|
||||||
Minimum required PHP (from composer.json):{' '}
|
Minimum required PHP (from composer.json):{' '}
|
||||||
{systemStatus?.min_versions?.php || 'unknown'}. Use a custom binary
|
{systemStatus?.min_versions?.php || 'unknown'}. Use a custom binary
|
||||||
on like php84. On KeyHelp setups use e.g. `keyhelp-php84`.
|
e.g. {suggestedPhpBinary} or {suggestedKeyhelpPhpBinary}.
|
||||||
</Form.Text>
|
</Form.Text>
|
||||||
</Form.Group>
|
</Form.Group>
|
||||||
<Button type="submit" variant="dark" disabled={systemCliSaving}>
|
|
||||||
{t('acp.save')}
|
|
||||||
</Button>
|
|
||||||
</Form>
|
</Form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{systemSection === 'cli' && renderSystemRequirementsPanel()}
|
{(systemUpdateSection === 'cli' || systemUpdateSection === 'ci') && renderSystemRequirementsPanel()}
|
||||||
{systemSection === 'ci' && (
|
|
||||||
<>
|
|
||||||
<div className="bb-acp-panel">
|
|
||||||
<div className="bb-acp-panel-header">
|
|
||||||
<h5 className="mb-0">CI/CD</h5>
|
|
||||||
</div>
|
|
||||||
<div className="bb-acp-panel-body">
|
|
||||||
<p className="bb-muted mb-0">
|
|
||||||
Placeholder: CI/CD pipelines, runner requirements, and deployment logs will
|
|
||||||
live here.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{renderSystemRequirementsPanel()}
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Col>
|
</Col>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { Button, Card, Container, Form } from 'react-bootstrap'
|
import { Button, Card, Container, Form } from 'react-bootstrap'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { Link, useNavigate } from 'react-router-dom'
|
||||||
import { useAuth } from '../context/AuthContext'
|
import { useAuth } from '../context/AuthContext'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
@@ -53,10 +53,18 @@ export default function Login() {
|
|||||||
onChange={(event) => setPassword(event.target.value)}
|
onChange={(event) => setPassword(event.target.value)}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
|
<div className="mt-2 text-end">
|
||||||
|
<Link to="/reset-password">{t('auth.forgot_password')}</Link>
|
||||||
|
</div>
|
||||||
</Form.Group>
|
</Form.Group>
|
||||||
<Button type="submit" variant="dark" disabled={loading}>
|
<div className="d-flex w-100 align-items-center gap-2">
|
||||||
|
<Button as={Link} to="/" type="button" variant="outline-secondary" disabled={loading}>
|
||||||
|
{t('acp.cancel')}
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" className="ms-auto bb-accent-button" disabled={loading}>
|
||||||
{loading ? t('form.signing_in') : t('form.sign_in')}
|
{loading ? t('form.signing_in') : t('form.sign_in')}
|
||||||
</Button>
|
</Button>
|
||||||
|
</div>
|
||||||
</Form>
|
</Form>
|
||||||
</Card.Body>
|
</Card.Body>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
113
resources/js/pages/ResetPassword.jsx
Normal file
113
resources/js/pages/ResetPassword.jsx
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { Button, Card, Container, Form } from 'react-bootstrap'
|
||||||
|
import { Link, useNavigate, useSearchParams } from 'react-router-dom'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { requestPasswordReset, resetPassword } from '../api/client'
|
||||||
|
|
||||||
|
export default function ResetPassword() {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const [searchParams] = useSearchParams()
|
||||||
|
const token = searchParams.get('token') || ''
|
||||||
|
const emailFromLink = searchParams.get('email') || ''
|
||||||
|
const isResetFlow = token.length > 0
|
||||||
|
const [email, setEmail] = useState('')
|
||||||
|
const [password, setPassword] = useState('')
|
||||||
|
const [passwordConfirmation, setPasswordConfirmation] = useState('')
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (emailFromLink) {
|
||||||
|
setEmail(emailFromLink)
|
||||||
|
}
|
||||||
|
}, [emailFromLink])
|
||||||
|
|
||||||
|
const handleSubmit = async (event) => {
|
||||||
|
event.preventDefault()
|
||||||
|
setError('')
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
if (isResetFlow) {
|
||||||
|
await resetPassword({
|
||||||
|
token,
|
||||||
|
email,
|
||||||
|
password,
|
||||||
|
password_confirmation: passwordConfirmation,
|
||||||
|
})
|
||||||
|
navigate('/login')
|
||||||
|
} else {
|
||||||
|
await requestPasswordReset(email)
|
||||||
|
navigate('/')
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Container fluid className="py-5">
|
||||||
|
<Card className="bb-card mx-auto" style={{ maxWidth: '480px' }}>
|
||||||
|
<Card.Body>
|
||||||
|
<Card.Title className="mb-3">
|
||||||
|
{isResetFlow ? t('auth.reset_password_title') : t('auth.forgot_password')}
|
||||||
|
</Card.Title>
|
||||||
|
<Card.Text className="bb-muted">
|
||||||
|
{isResetFlow ? t('auth.reset_password_hint') : t('auth.forgot_password_hint')}
|
||||||
|
</Card.Text>
|
||||||
|
{error && <p className="text-danger">{error}</p>}
|
||||||
|
<Form onSubmit={handleSubmit}>
|
||||||
|
<Form.Group className="mb-4">
|
||||||
|
<Form.Label>{t('form.email')}</Form.Label>
|
||||||
|
<Form.Control
|
||||||
|
type="email"
|
||||||
|
value={email}
|
||||||
|
onChange={(event) => setEmail(event.target.value)}
|
||||||
|
placeholder={t('auth.reset_email_placeholder')}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</Form.Group>
|
||||||
|
{isResetFlow && (
|
||||||
|
<>
|
||||||
|
<Form.Group className="mb-3">
|
||||||
|
<Form.Label>{t('form.password')}</Form.Label>
|
||||||
|
<Form.Control
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(event) => setPassword(event.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</Form.Group>
|
||||||
|
<Form.Group className="mb-4">
|
||||||
|
<Form.Label>{t('auth.confirm_password')}</Form.Label>
|
||||||
|
<Form.Control
|
||||||
|
type="password"
|
||||||
|
value={passwordConfirmation}
|
||||||
|
onChange={(event) => setPasswordConfirmation(event.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</Form.Group>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<div className="d-flex w-100 align-items-center gap-2">
|
||||||
|
<Button as={Link} to="/login" type="button" variant="outline-secondary" disabled={loading}>
|
||||||
|
{t('acp.cancel')}
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" className="ms-auto bb-accent-button" disabled={loading}>
|
||||||
|
{loading
|
||||||
|
? isResetFlow
|
||||||
|
? t('auth.resetting_password')
|
||||||
|
: t('auth.sending_reset_link')
|
||||||
|
: isResetFlow
|
||||||
|
? t('auth.reset_password_submit')
|
||||||
|
: t('auth.send_reset_link')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Form>
|
||||||
|
</Card.Body>
|
||||||
|
</Card>
|
||||||
|
</Container>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -81,6 +81,18 @@
|
|||||||
"auth.login_title": "Anmelden",
|
"auth.login_title": "Anmelden",
|
||||||
"auth.login_identifier": "E-Mail oder Benutzername",
|
"auth.login_identifier": "E-Mail oder Benutzername",
|
||||||
"auth.login_placeholder": "name@example.com oder benutzername",
|
"auth.login_placeholder": "name@example.com oder benutzername",
|
||||||
|
"auth.forgot_password": "Passwort vergessen?",
|
||||||
|
"auth.forgot_password_hint": "Gib die E-Mail-Adresse deines Kontos ein, dann senden wir dir einen Link zum Zuruecksetzen.",
|
||||||
|
"auth.reset_email_placeholder": "name@example.com",
|
||||||
|
"auth.send_reset_link": "Reset-Link senden",
|
||||||
|
"auth.sending_reset_link": "Wird gesendet...",
|
||||||
|
"auth.reset_link_sent": "Falls ein Konto existiert, wurde ein Passwort-Reset-Link gesendet.",
|
||||||
|
"auth.reset_password_title": "Passwort zuruecksetzen",
|
||||||
|
"auth.reset_password_hint": "Gib deine E-Mail-Adresse ein und waehle ein neues Passwort.",
|
||||||
|
"auth.reset_password_submit": "Passwort zuruecksetzen",
|
||||||
|
"auth.resetting_password": "Wird zurueckgesetzt...",
|
||||||
|
"auth.password_reset_success": "Passwort erfolgreich zurueckgesetzt. Du kannst dich jetzt anmelden.",
|
||||||
|
"auth.confirm_password": "Passwort bestaetigen",
|
||||||
"auth.register_hint": "Registriere dich mit E-Mail und einem eindeutigen Benutzernamen.",
|
"auth.register_hint": "Registriere dich mit E-Mail und einem eindeutigen Benutzernamen.",
|
||||||
"auth.verify_notice": "Bitte bestätige deine E-Mail-Adresse, bevor du dich anmeldest.",
|
"auth.verify_notice": "Bitte bestätige deine E-Mail-Adresse, bevor du dich anmeldest.",
|
||||||
"auth.register_title": "Konto erstellen",
|
"auth.register_title": "Konto erstellen",
|
||||||
@@ -174,6 +186,9 @@
|
|||||||
"version.up_to_date": "Aktuell",
|
"version.up_to_date": "Aktuell",
|
||||||
"version.update_available": "Update verfügbar (v{{version}})",
|
"version.update_available": "Update verfügbar (v{{version}})",
|
||||||
"version.update_available_short": "Update verfügbar",
|
"version.update_available_short": "Update verfügbar",
|
||||||
|
"version.refresh_prompt_title": "Update verfuegbar",
|
||||||
|
"version.refresh_prompt_body": "Ein neuerer Build ({{build}}) ist verfuegbar. Jetzt neu laden, um die aktuelle Version zu verwenden?",
|
||||||
|
"version.remind_later": "Spaeter",
|
||||||
"version.unknown": "Version unbekannt",
|
"version.unknown": "Version unbekannt",
|
||||||
"version.update_now": "Jetzt aktualisieren",
|
"version.update_now": "Jetzt aktualisieren",
|
||||||
"version.update_title": "System aktualisieren",
|
"version.update_title": "System aktualisieren",
|
||||||
@@ -189,6 +204,7 @@
|
|||||||
"system.none": "Keine",
|
"system.none": "Keine",
|
||||||
"system.not_found": "Nicht gefunden",
|
"system.not_found": "Nicht gefunden",
|
||||||
"system.storage_writable": "Storage beschreibbar",
|
"system.storage_writable": "Storage beschreibbar",
|
||||||
|
"system.storage_linked": "Storage Public-Link",
|
||||||
"system.updates_writable": "Updates beschreibbar",
|
"system.updates_writable": "Updates beschreibbar",
|
||||||
"system.ok": "OK",
|
"system.ok": "OK",
|
||||||
"system.not_ok": "Nicht OK",
|
"system.not_ok": "Nicht OK",
|
||||||
|
|||||||
@@ -81,6 +81,18 @@
|
|||||||
"auth.login_title": "Log in",
|
"auth.login_title": "Log in",
|
||||||
"auth.login_identifier": "Email or username",
|
"auth.login_identifier": "Email or username",
|
||||||
"auth.login_placeholder": "name@example.com or username",
|
"auth.login_placeholder": "name@example.com or username",
|
||||||
|
"auth.forgot_password": "Forgot password?",
|
||||||
|
"auth.forgot_password_hint": "Enter your account email and we will send you a password reset link.",
|
||||||
|
"auth.reset_email_placeholder": "name@example.com",
|
||||||
|
"auth.send_reset_link": "Send reset link",
|
||||||
|
"auth.sending_reset_link": "Sending...",
|
||||||
|
"auth.reset_link_sent": "If an account exists, a password reset link has been sent.",
|
||||||
|
"auth.reset_password_title": "Reset password",
|
||||||
|
"auth.reset_password_hint": "Enter your email and choose a new password.",
|
||||||
|
"auth.reset_password_submit": "Reset password",
|
||||||
|
"auth.resetting_password": "Resetting...",
|
||||||
|
"auth.password_reset_success": "Password reset successful. You can now sign in.",
|
||||||
|
"auth.confirm_password": "Confirm password",
|
||||||
"auth.register_hint": "Register with an email and a unique username.",
|
"auth.register_hint": "Register with an email and a unique username.",
|
||||||
"auth.verify_notice": "Check your email to verify your account before logging in.",
|
"auth.verify_notice": "Check your email to verify your account before logging in.",
|
||||||
"auth.register_title": "Create account",
|
"auth.register_title": "Create account",
|
||||||
@@ -164,6 +176,9 @@
|
|||||||
"version.up_to_date": "Up to date",
|
"version.up_to_date": "Up to date",
|
||||||
"version.update_available": "Update available (v{{version}})",
|
"version.update_available": "Update available (v{{version}})",
|
||||||
"version.update_available_short": "Update available",
|
"version.update_available_short": "Update available",
|
||||||
|
"version.refresh_prompt_title": "Update available",
|
||||||
|
"version.refresh_prompt_body": "A newer build ({{build}}) is available. Refresh now to load the latest version?",
|
||||||
|
"version.remind_later": "Later",
|
||||||
"version.unknown": "Version unknown",
|
"version.unknown": "Version unknown",
|
||||||
"version.update_now": "Update now",
|
"version.update_now": "Update now",
|
||||||
"version.update_title": "Update system",
|
"version.update_title": "Update system",
|
||||||
@@ -179,6 +194,7 @@
|
|||||||
"system.none": "None",
|
"system.none": "None",
|
||||||
"system.not_found": "Not found",
|
"system.not_found": "Not found",
|
||||||
"system.storage_writable": "Storage writable",
|
"system.storage_writable": "Storage writable",
|
||||||
|
"system.storage_linked": "Storage public link",
|
||||||
"system.updates_writable": "Updates writable",
|
"system.updates_writable": "Updates writable",
|
||||||
"system.ok": "OK",
|
"system.ok": "OK",
|
||||||
"system.not_ok": "Not OK",
|
"system.not_ok": "Not OK",
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ use App\Http\Controllers\AuthController;
|
|||||||
use App\Http\Controllers\ForumController;
|
use App\Http\Controllers\ForumController;
|
||||||
use App\Http\Controllers\I18nController;
|
use App\Http\Controllers\I18nController;
|
||||||
use App\Http\Controllers\PortalController;
|
use App\Http\Controllers\PortalController;
|
||||||
|
use App\Http\Controllers\PingController;
|
||||||
use App\Http\Controllers\PostController;
|
use App\Http\Controllers\PostController;
|
||||||
use App\Http\Controllers\PostThankController;
|
use App\Http\Controllers\PostThankController;
|
||||||
use App\Http\Controllers\PreviewController;
|
use App\Http\Controllers\PreviewController;
|
||||||
@@ -35,6 +36,7 @@ Route::get('/email/verify/{id}/{hash}', [AuthController::class, 'verifyEmail'])
|
|||||||
Route::post('/logout', [AuthController::class, 'logout'])->middleware('auth:sanctum');
|
Route::post('/logout', [AuthController::class, 'logout'])->middleware('auth:sanctum');
|
||||||
Route::post('/user/password', [AuthController::class, 'updatePassword'])->middleware('auth:sanctum');
|
Route::post('/user/password', [AuthController::class, 'updatePassword'])->middleware('auth:sanctum');
|
||||||
|
|
||||||
|
Route::get('/ping', PingController::class);
|
||||||
Route::get('/version', VersionController::class);
|
Route::get('/version', VersionController::class);
|
||||||
Route::get('/version/check', VersionCheckController::class);
|
Route::get('/version/check', VersionCheckController::class);
|
||||||
Route::post('/system/update', SystemUpdateController::class)->middleware('auth:sanctum');
|
Route::post('/system/update', SystemUpdateController::class)->middleware('auth:sanctum');
|
||||||
@@ -44,6 +46,7 @@ Route::get('/stats', StatsController::class);
|
|||||||
Route::get('/settings', [SettingController::class, 'index']);
|
Route::get('/settings', [SettingController::class, 'index']);
|
||||||
Route::post('/settings', [SettingController::class, 'store'])->middleware('auth:sanctum');
|
Route::post('/settings', [SettingController::class, 'store'])->middleware('auth:sanctum');
|
||||||
Route::post('/settings/bulk', [SettingController::class, 'bulkStore'])->middleware('auth:sanctum');
|
Route::post('/settings/bulk', [SettingController::class, 'bulkStore'])->middleware('auth:sanctum');
|
||||||
|
Route::post('/settings/system/php-binary/validate', [SettingController::class, 'validateSystemPhpBinary'])->middleware('auth:sanctum');
|
||||||
Route::get('/audit-logs', [AuditLogController::class, 'index'])->middleware('auth:sanctum');
|
Route::get('/audit-logs', [AuditLogController::class, 'index'])->middleware('auth:sanctum');
|
||||||
Route::get('/user-settings', [UserSettingController::class, 'index'])->middleware('auth:sanctum');
|
Route::get('/user-settings', [UserSettingController::class, 'index'])->middleware('auth:sanctum');
|
||||||
Route::post('/user-settings', [UserSettingController::class, 'store'])->middleware('auth:sanctum');
|
Route::post('/user-settings', [UserSettingController::class, 'store'])->middleware('auth:sanctum');
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
use App\Http\Controllers\InstallerController;
|
use App\Http\Controllers\InstallerController;
|
||||||
|
use App\Http\Controllers\PingController;
|
||||||
use Illuminate\Foundation\Http\Middleware\VerifyCsrfToken;
|
use Illuminate\Foundation\Http\Middleware\VerifyCsrfToken;
|
||||||
use Illuminate\Support\Facades\Route;
|
use Illuminate\Support\Facades\Route;
|
||||||
|
|
||||||
@@ -29,6 +30,8 @@ Route::get('/reset-password', function () {
|
|||||||
return view('app');
|
return view('app');
|
||||||
})->name('password.reset');
|
})->name('password.reset');
|
||||||
|
|
||||||
|
Route::get('/ping', PingController::class);
|
||||||
|
|
||||||
Route::get('/{any}', function () {
|
Route::get('/{any}', function () {
|
||||||
if (!file_exists(base_path('.env'))) {
|
if (!file_exists(base_path('.env'))) {
|
||||||
return redirect('/install');
|
return redirect('/install');
|
||||||
|
|||||||
@@ -1,8 +1,28 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
# Keep commits possible when local DB is offline.
|
# Stamp composer.json build from local git commit count on master/HEAD.
|
||||||
if ! php artisan version:fetch >/dev/null 2>&1; then
|
BUILD="$(git rev-list --count master 2>/dev/null || git rev-list --count HEAD)"
|
||||||
echo "pre-commit: skipped 'php artisan version:fetch' (database unreachable)." >&2
|
|
||||||
echo "pre-commit: start MySQL and run it manually when needed." >&2
|
BUILD="$BUILD" php -r '
|
||||||
fi
|
$path = "composer.json";
|
||||||
|
$data = json_decode(file_get_contents($path), true);
|
||||||
|
if (!is_array($data)) {
|
||||||
|
fwrite(STDERR, "pre-commit: invalid composer.json\n");
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
$build = getenv("BUILD");
|
||||||
|
if ($build === false || $build === "") {
|
||||||
|
fwrite(STDERR, "pre-commit: missing BUILD value\n");
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
$current = (string)($data["build"] ?? "");
|
||||||
|
if ($current === $build) {
|
||||||
|
exit(0);
|
||||||
|
}
|
||||||
|
$data["build"] = $build;
|
||||||
|
file_put_contents($path, json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . PHP_EOL);
|
||||||
|
fwrite(STDOUT, "pre-commit: composer.json build updated to {$build}\n");
|
||||||
|
'
|
||||||
|
|
||||||
|
git add composer.json
|
||||||
|
|||||||
17
tests/Feature/PingControllerTest.php
Normal file
17
tests/Feature/PingControllerTest.php
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
it('returns ping status with build and notification state', function (): void {
|
||||||
|
$composer = json_decode((string) file_get_contents(base_path('composer.json')), true);
|
||||||
|
$expectedBuild = isset($composer['build']) ? (int) $composer['build'] : null;
|
||||||
|
|
||||||
|
$response = $this->getJson('/ping');
|
||||||
|
|
||||||
|
$response->assertOk();
|
||||||
|
$response->assertJson([
|
||||||
|
'connect' => 'ok',
|
||||||
|
'version_status' => [
|
||||||
|
'build' => $expectedBuild,
|
||||||
|
],
|
||||||
|
'notification_state' => false,
|
||||||
|
]);
|
||||||
|
});
|
||||||
@@ -285,3 +285,28 @@ it('updates user name and email as admin', function (): void {
|
|||||||
expect($target->email)->toBe('new@example.com');
|
expect($target->email)->toBe('new@example.com');
|
||||||
expect($target->email_verified_at)->toBeNull();
|
expect($target->email_verified_at)->toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('marks email verified when assigning founder role', function (): void {
|
||||||
|
$admin = makeAdmin();
|
||||||
|
$founderRole = Role::firstOrCreate(['name' => 'ROLE_FOUNDER'], ['color' => '#111111']);
|
||||||
|
$target = User::factory()->create([
|
||||||
|
'name' => 'Target',
|
||||||
|
'email' => 'target@example.com',
|
||||||
|
'email_verified_at' => null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$admin->roles()->syncWithoutDetaching([$founderRole->id]);
|
||||||
|
|
||||||
|
Sanctum::actingAs($admin);
|
||||||
|
$response = $this->patchJson("/api/users/{$target->id}", [
|
||||||
|
'name' => 'Target',
|
||||||
|
'email' => 'target@example.com',
|
||||||
|
'rank_id' => null,
|
||||||
|
'roles' => ['ROLE_FOUNDER'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response->assertOk();
|
||||||
|
|
||||||
|
$target->refresh();
|
||||||
|
expect($target->email_verified_at)->not()->toBeNull();
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,16 +1,15 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
use App\Models\Setting;
|
it('returns version and build info from composer metadata', function (): void {
|
||||||
|
$composer = json_decode((string) file_get_contents(base_path('composer.json')), true);
|
||||||
it('returns version and build info', function (): void {
|
$expectedVersion = (string) ($composer['version'] ?? '');
|
||||||
Setting::updateOrCreate(['key' => 'version'], ['value' => '1.2.3']);
|
$expectedBuild = isset($composer['build']) ? (int) $composer['build'] : null;
|
||||||
Setting::updateOrCreate(['key' => 'build'], ['value' => '42']);
|
|
||||||
|
|
||||||
$response = $this->getJson('/api/version');
|
$response = $this->getJson('/api/version');
|
||||||
|
|
||||||
$response->assertOk();
|
$response->assertOk();
|
||||||
$response->assertJsonFragment([
|
$response->assertJsonFragment([
|
||||||
'version' => '1.2.3',
|
'version' => $expectedVersion,
|
||||||
'build' => 42,
|
'build' => $expectedBuild,
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -20,10 +20,10 @@ it('version set fails when invalid version', function (): void {
|
|||||||
expect($exitCode)->toBe(1);
|
expect($exitCode)->toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('version fetch fails when no version', function (): void {
|
it('version fetch succeeds without db version when composer version exists', function (): void {
|
||||||
Setting::where('key', 'version')->delete();
|
Setting::where('key', 'version')->delete();
|
||||||
$exitCode = \Illuminate\Support\Facades\Artisan::call('version:fetch');
|
$exitCode = \Illuminate\Support\Facades\Artisan::call('version:fetch');
|
||||||
expect($exitCode)->toBe(1);
|
expect($exitCode)->toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('version release fails when missing config', function (): void {
|
it('version release fails when missing config', function (): void {
|
||||||
|
|||||||
@@ -86,6 +86,7 @@ it('returns system status for admins', function (): void {
|
|||||||
'rsync_version',
|
'rsync_version',
|
||||||
'proc_functions',
|
'proc_functions',
|
||||||
'storage_writable',
|
'storage_writable',
|
||||||
|
'storage_public_linked',
|
||||||
'updates_writable',
|
'updates_writable',
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -11,17 +11,6 @@ namespace App\Console\Commands {
|
|||||||
return \file_get_contents($path);
|
return \file_get_contents($path);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!function_exists(__NAMESPACE__ . '\\json_encode')) {
|
|
||||||
function json_encode($value, int $flags = 0): string|false
|
|
||||||
{
|
|
||||||
if (!empty($GLOBALS['version_fetch_json_encode_false']) && is_array($value) && array_key_exists('build', $value)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return \json_encode($value, $flags);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
namespace {
|
namespace {
|
||||||
@@ -40,84 +29,47 @@ namespace {
|
|||||||
file_put_contents($path, $original);
|
file_put_contents($path, $original);
|
||||||
}
|
}
|
||||||
$GLOBALS['version_fetch_file_get_contents_false'] = false;
|
$GLOBALS['version_fetch_file_get_contents_false'] = false;
|
||||||
$GLOBALS['version_fetch_json_encode_false'] = false;
|
|
||||||
$originalPath = $GLOBALS['version_fetch_path'] ?? null;
|
|
||||||
if ($originalPath !== null) {
|
|
||||||
putenv("PATH={$originalPath}");
|
|
||||||
$_ENV['PATH'] = $originalPath;
|
|
||||||
$_SERVER['PATH'] = $originalPath;
|
|
||||||
unset($GLOBALS['version_fetch_path']);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
it('fetches build count and syncs composer metadata', function (): void {
|
it('syncs version and build from composer metadata', function (): void {
|
||||||
withComposerBackupForFetch(function (): void {
|
withComposerBackupForFetch(function (): void {
|
||||||
Setting::updateOrCreate(['key' => 'version'], ['value' => '1.2.3']);
|
Setting::updateOrCreate(['key' => 'version'], ['value' => '0.0.0']);
|
||||||
|
Setting::updateOrCreate(['key' => 'build'], ['value' => '0']);
|
||||||
|
|
||||||
|
$composer = json_decode((string) file_get_contents(base_path('composer.json')), true);
|
||||||
|
$expectedVersion = (string) ($composer['version'] ?? '');
|
||||||
|
$expectedBuild = (string) ($composer['build'] ?? '');
|
||||||
|
|
||||||
$exitCode = Artisan::call('version:fetch');
|
$exitCode = Artisan::call('version:fetch');
|
||||||
expect($exitCode)->toBe(0);
|
expect($exitCode)->toBe(0);
|
||||||
|
expect(Setting::where('key', 'version')->value('value'))->toBe($expectedVersion);
|
||||||
$build = Setting::where('key', 'build')->value('value');
|
expect(Setting::where('key', 'build')->value('value'))->toBe($expectedBuild);
|
||||||
expect(is_numeric($build))->toBeTrue();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('fails when build count cannot be resolved', function (): void {
|
|
||||||
withComposerBackupForFetch(function (): void {
|
|
||||||
$GLOBALS['version_fetch_path'] = getenv('PATH') ?: '';
|
|
||||||
putenv('PATH=/nope');
|
|
||||||
$_ENV['PATH'] = '/nope';
|
|
||||||
$_SERVER['PATH'] = '/nope';
|
|
||||||
|
|
||||||
Setting::updateOrCreate(['key' => 'version'], ['value' => '1.2.3']);
|
|
||||||
|
|
||||||
$exitCode = Artisan::call('version:fetch');
|
|
||||||
expect($exitCode)->toBe(1);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('fails when composer.json cannot be decoded', function (): void {
|
it('fails when composer.json cannot be decoded', function (): void {
|
||||||
withComposerBackupForFetch(function (string $path): void {
|
withComposerBackupForFetch(function (string $path): void {
|
||||||
file_put_contents($path, 'not-json');
|
file_put_contents($path, 'not-json');
|
||||||
|
|
||||||
Setting::updateOrCreate(['key' => 'version'], ['value' => '1.2.3']);
|
|
||||||
|
|
||||||
$exitCode = Artisan::call('version:fetch');
|
$exitCode = Artisan::call('version:fetch');
|
||||||
expect($exitCode)->toBe(1);
|
expect($exitCode)->toBe(1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('fails when composer.json is not readable', function (): void {
|
it('fails when composer.json is missing build', function (): void {
|
||||||
withComposerBackupForFetch(function (string $path): void {
|
withComposerBackupForFetch(function (string $path): void {
|
||||||
chmod($path, 0000);
|
$data = json_decode((string) file_get_contents($path), true);
|
||||||
|
unset($data['build']);
|
||||||
Setting::updateOrCreate(['key' => 'version'], ['value' => '1.2.3']);
|
file_put_contents($path, json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . PHP_EOL);
|
||||||
|
|
||||||
$exitCode = Artisan::call('version:fetch');
|
$exitCode = Artisan::call('version:fetch');
|
||||||
expect($exitCode)->toBe(1);
|
expect($exitCode)->toBe(1);
|
||||||
|
|
||||||
chmod($path, 0644);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('fails when file_get_contents returns false', function (): void {
|
it('fails when file_get_contents returns false', function (): void {
|
||||||
withComposerBackupForFetch(function (): void {
|
withComposerBackupForFetch(function (): void {
|
||||||
$GLOBALS['version_fetch_file_get_contents_false'] = true;
|
$GLOBALS['version_fetch_file_get_contents_false'] = true;
|
||||||
|
|
||||||
Setting::updateOrCreate(['key' => 'version'], ['value' => '1.2.3']);
|
|
||||||
|
|
||||||
$exitCode = Artisan::call('version:fetch');
|
|
||||||
expect($exitCode)->toBe(1);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('fails when json_encode returns false', function (): void {
|
|
||||||
withComposerBackupForFetch(function (): void {
|
|
||||||
$GLOBALS['version_fetch_json_encode_false'] = true;
|
|
||||||
|
|
||||||
Setting::updateOrCreate(['key' => 'version'], ['value' => '1.2.3']);
|
|
||||||
|
|
||||||
$exitCode = Artisan::call('version:fetch');
|
$exitCode = Artisan::call('version:fetch');
|
||||||
expect($exitCode)->toBe(1);
|
expect($exitCode)->toBe(1);
|
||||||
});
|
});
|
||||||
|
|||||||
12
tests/run-shell-tests.sh
Normal file
12
tests/run-shell-tests.sh
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
cd "$ROOT_DIR"
|
||||||
|
|
||||||
|
if ! command -v bats >/dev/null 2>&1; then
|
||||||
|
echo "bats is not installed. Install with: brew install bats-core" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
bats tests/shell/git_update.bats
|
||||||
Reference in New Issue
Block a user