34 Commits

Author SHA1 Message Date
a2fe31925f Add ACP user deletion and split frontend bundles
All checks were successful
CI/CD Pipeline / deploy (push) Successful in 30s
CI/CD Pipeline / promote_stable (push) Successful in 2s
2026-03-17 16:49:11 +01:00
ef84b73cb5 Refine ACP general settings navigation and tabbed layout
All checks were successful
CI/CD Pipeline / deploy (push) Successful in 31s
CI/CD Pipeline / promote_stable (push) Successful in 2s
2026-02-28 19:13:33 +01:00
94f665192d Map CI author to tracer in mailmap
All checks were successful
CI/CD Pipeline / deploy (push) Successful in 25s
CI/CD Pipeline / promote_stable (push) Successful in 3s
2026-02-27 21:24:14 +01:00
c894b1dfb2 Normalize author identities via mailmap 2026-02-27 21:18:21 +01:00
c19124741e Refine README ACP System description 2026-02-27 21:15:29 +01:00
66de3b31b1 Update README with product-focused ACP overview
All checks were successful
CI/CD Pipeline / deploy (push) Successful in 24s
CI/CD Pipeline / promote_stable (push) Successful in 2s
2026-02-27 20:56:29 +01:00
1adb3308be Use prebuilt package in system updater
All checks were successful
CI/CD Pipeline / deploy (push) Successful in 29s
CI/CD Pipeline / promote_stable (push) Successful in 2s
2026-02-27 20:18:56 +01:00
1f26aa7fb5 Refine ACP system health/update checks and CLI PHP validation 2026-02-27 19:59:29 +01:00
41387be802 prepare public symlink
All checks were successful
CI/CD Pipeline / deploy (push) Successful in 24s
CI/CD Pipeline / promote_stable (push) Successful in 2s
2026-02-26 19:08:37 +01:00
7b22d89dfd ignoe backup folder on deployment
All checks were successful
CI/CD Pipeline / deploy (push) Successful in 25s
CI/CD Pipeline / promote_stable (push) Successful in 2s
2026-02-26 17:22:44 +01:00
6a10087bee some deployment fixes
All checks were successful
CI/CD Pipeline / deploy (push) Successful in 29s
CI/CD Pipeline / promote_stable (push) Successful in 2s
2026-02-26 17:05:23 +01:00
79f8077bd4 Sync build to 100 and stage build update in pre-commit hook
All checks were successful
CI/CD Pipeline / deploy (push) Successful in 24s
CI/CD Pipeline / promote_stable (push) Successful in 2s
2026-02-24 23:47:09 +01:00
269248012b chore: verify pre-commit hook 2026-02-24 23:44:31 +01:00
e357cc3c48 chore: trigger deploy
All checks were successful
CI/CD Pipeline / deploy (push) Successful in 24s
CI/CD Pipeline / promote_stable (push) Successful in 2s
2026-02-24 23:32:30 +01:00
1e227f6ba0 Bump version to 26.0.3
All checks were successful
CI/CD Pipeline / deploy (push) Successful in 24s
CI/CD Pipeline / promote_stable (push) Successful in 2s
2026-02-24 23:30:05 +01:00
8e86fcdbd9 Use composer metadata as primary source for version and ping build
All checks were successful
CI/CD Pipeline / deploy (push) Successful in 23s
CI/CD Pipeline / promote_stable (push) Successful in 2s
2026-02-24 23:26:30 +01:00
78bdd869ef chore: sync composer build to local master count (96)
All checks were successful
CI/CD Pipeline / deploy (push) Successful in 24s
CI/CD Pipeline / promote_stable (push) Successful in 2s
2026-02-24 23:20:59 +01:00
cd12ac676d ci: add deploy start marker logging
All checks were successful
CI/CD Pipeline / deploy (push) Successful in 23s
CI/CD Pipeline / promote_stable (push) Successful in 2s
2026-02-24 23:17:31 +01:00
a5b55adf56 chore: trigger deploy for support sync
All checks were successful
CI/CD Pipeline / deploy (push) Successful in 23s
CI/CD Pipeline / promote_stable (push) Successful in 2s
2026-02-24 23:14:20 +01:00
86190c9718 Make local master canonical for build metadata
All checks were successful
CI/CD Pipeline / deploy (push) Successful in 24s
CI/CD Pipeline / promote_stable (push) Successful in 2s
2026-02-24 23:12:37 +01:00
speedbb-ci
60c6718645 ci: sync composer build to 92 [skip ci] 2026-02-24 22:56:57 +01:00
225dc391ff Use composer.json as version/build source and stamp build in CI
All checks were successful
CI/CD Pipeline / stamp_build (push) Successful in 3s
CI/CD Pipeline / deploy (push) Successful in 24s
CI/CD Pipeline / promote_stable (push) Successful in 3s
2026-02-24 22:55:49 +01:00
16e0444fa3 modified version handling
All checks were successful
CI/CD Pipeline / deploy (push) Successful in 24s
CI/CD Pipeline / promote_stable (push) Successful in 2s
2026-02-24 19:29:04 +01:00
6a2316c6f4 fix storage on CI setup
All checks were successful
CI/CD Pipeline / deploy (push) Successful in 25s
CI/CD Pipeline / promote_stable (push) Successful in 2s
2026-02-24 19:16:55 +01:00
0b4e0df305 Add ping endpoint, update-refresh prompt, and dark-mode polish
All checks were successful
CI/CD Pipeline / deploy (push) Successful in 26s
CI/CD Pipeline / promote_stable (push) Successful in 2s
2026-02-24 18:48:54 +01:00
2a69ee8258 Add functional forgot-password flow and login modal UX updates 2026-02-24 17:59:51 +01:00
1c2353cfe1 fix artisan migration
All checks were successful
CI/CD Pipeline / deploy (push) Successful in 23s
CI/CD Pipeline / promote_stable (push) Successful in 2s
2026-02-23 12:23:53 +01:00
496b50ed12 added cancel to login
Some checks failed
CI/CD Pipeline / deploy (push) Failing after 28s
CI/CD Pipeline / promote_stable (push) Has been skipped
2026-02-23 12:11:44 +01:00
50e3ff6ded remove CI bats job and keep shell tests local 2026-02-19 18:22:43 +01:00
fdf8d65310 ci: trigger dev_tests
Some checks failed
CI/CD Pipeline / dev_tests (push) Failing after 2s
CI/CD Pipeline / deploy (push) Has been skipped
CI/CD Pipeline / promote_stable (push) Has been skipped
2026-02-19 18:17:37 +01:00
c2140b4493 shel test
Some checks failed
CI/CD Pipeline / dev_tests (push) Failing after 2s
CI/CD Pipeline / deploy (push) Has been skipped
CI/CD Pipeline / promote_stable (push) Has been skipped
2026-02-18 23:14:46 +01:00
652cf8bd6a fix dev CI checkout without node action
Some checks failed
CI/CD Pipeline / test (push) Failing after 3s
CI/CD Pipeline / deploy (push) Has been skipped
CI/CD Pipeline / promote_stable (push) Has been skipped
2026-02-18 23:08:01 +01:00
5fdc0d45e3 run bats on dev and enforce php requirement status in ACP
Some checks failed
CI/CD Pipeline / test (push) Failing after 4s
CI/CD Pipeline / deploy (push) Has been skipped
CI/CD Pipeline / promote_stable (push) Has been skipped
2026-02-18 22:57:19 +01:00
6cde90042e harden update script and add bats CI coverage
Some checks failed
CI/CD Pipeline / test (push) Failing after 13s
CI/CD Pipeline / deploy (push) Has been skipped
CI/CD Pipeline / promote_stable (push) Has been skipped
2026-02-18 22:49:28 +01:00
41 changed files with 2380 additions and 749 deletions

View File

@@ -3,20 +3,24 @@ run-name: ${{ gitea.event.head_commit.message }}
on: on:
push: push:
branches: branches:
- dev
- master - master
jobs: jobs:
test:
runs-on: debian-latest
steps:
- name: Show Debian version
run: cat /etc/os-release
- name: Test Deployment
run: echo "Deployment test"
deploy: deploy:
if: gitea.ref_name == 'master'
runs-on: self-hosted runs-on: self-hosted
needs: test
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 }}
@@ -39,6 +43,7 @@ jobs:
rm .vault_pass.txt rm .vault_pass.txt
promote_stable: promote_stable:
if: gitea.ref_name == 'master'
runs-on: self-hosted runs-on: self-hosted
needs: deploy needs: deploy
steps: steps:

1
.gitignore vendored
View File

@@ -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

3
.mailmap Normal file
View File

@@ -0,0 +1,3 @@
tracer <tracer@24unix.net> Micha <tracer@24unix.net>
tracer <tracer@24unix.net> Micha <espey@smart-q.de>
tracer <tracer@24unix.net> speedbb-ci <ci@24unix.net>

View File

@@ -1,10 +1,62 @@
# Changelog # Changelog
## 2026-03-17
- Added ACP user deletion end-to-end with admin/founder safeguards, self-delete protection, and backend test coverage.
- Replaced the ACP user delete browser confirm with a project-style confirmation modal and refined its header/body layout.
- Added inline clear (`x`) support to ACP user search inputs.
- Lazy-loaded major SPA routes, including ACP, to reduce the initial frontend bundle size.
- Added Vite manual vendor chunk splitting for ACP-heavy, React, router, UI, and i18n dependencies.
## 2026-02-28
- Updated ACP General to use section navigation with `Overview` as the default landing view and a dedicated `Settings` view.
- Reorganized ACP General placeholders by moving `Client communication` and `Server configuration` into the Settings area as dedicated sub-tabs.
- Added nested Settings tab grouping and bordered tab-content containers to match the ACP tabbed layout pattern.
- Refined ACP tab visual states so inactive tabs render muted and active tabs use the configured accent color.
- Standardized key ACP refresh actions with explicit icon + spacing so repeated controls render consistently.
- Added icon support to additional primary UI actions (update modal/footer actions, auth screens, and forum/thread actions).
- Synced board version/build display in stats from `composer.json` and added safe DB setting synchronization fallback logic.
- Applied global accent-based Bootstrap button variable overrides so primary button styling remains consistent across ACP and user-facing screens.
## 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.
- Simplified ACP CLI PHP selector to `php` or custom binary, and blocked saving `keyhelp-php-domain` from ACP. - Simplified ACP CLI PHP selector to `php` or custom binary, and blocked saving `keyhelp-php-domain` from ACP.
- Added test coverage expectation for `php_default_version` in system status unit tests. - Added test coverage expectation for `php_default_version` in system status unit tests.
- Hardened `git_update.sh` PHP selection flow with clearer logging (`initial fallback`, `bootstrap read`, `final binary`).
- Added strict PHP requirement enforcement in `git_update.sh` against `composer.json` and abort on insufficient CLI PHP.
- Refactored `git_update.sh` to `main()` for source-safe testing and added Bats shell tests for resolver/requirement behavior.
- Updated Gitea CI test job to install Bats and run `tests/shell/git_update.bats`.
## 2026-02-12 ## 2026-02-12
- Refined ACP System tab with left navigation, section-specific requirements, and CLI PHP selector. - Refined ACP System tab with left navigation, section-specific requirements, and CLI PHP selector.

View File

@@ -1,7 +1,31 @@
# 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`: split into `Health` (live website health checks) and `Updates` (update-readiness checks and update actions, including CLI interpreter validation).
- `Custom`: space for project-specific custom assets/overrides.
## 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.

View File

@@ -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

View File

@@ -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,
];
} }
} }

View 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;
}
}

View File

@@ -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;
}
} }

View File

@@ -32,8 +32,10 @@ class StatsController extends Controller
$avatarSizeBytes = $this->resolveAvatarDirectorySize(); $avatarSizeBytes = $this->resolveAvatarDirectorySize();
$orphanAttachments = $this->resolveOrphanAttachments(); $orphanAttachments = $this->resolveOrphanAttachments();
$version = Setting::query()->where('key', 'version')->value('value'); $composer = $this->readComposerMetadata();
$build = Setting::query()->where('key', 'build')->value('value'); $this->syncVersionBuildSettings($composer);
$version = $composer['version'] ?? Setting::query()->where('key', 'version')->value('value');
$build = $composer['build'] ?? Setting::query()->where('key', 'build')->value('value');
$boardVersion = $version $boardVersion = $version
? ($build ? "{$version} (build {$build})" : $version) ? ($build ? "{$version} (build {$build})" : $version)
: null; : null;
@@ -158,4 +160,59 @@ class StatsController extends Controller
$value = ini_get('zlib.output_compression'); $value = ini_get('zlib.output_compression');
return in_array(strtolower((string) $value), ['1', 'on', 'true'], true); return in_array(strtolower((string) $value), ['1', 'on', 'true'], true);
} }
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,
];
}
private function syncVersionBuildSettings(array $composer): void
{
$version = $composer['version'] ?? null;
$build = $composer['build'] ?? null;
if ($version === null && $build === null) {
return;
}
try {
if ($version !== null) {
$currentVersion = Setting::query()->where('key', 'version')->value('value');
if ((string) $currentVersion !== (string) $version) {
Setting::updateOrCreate(['key' => 'version'], ['value' => (string) $version]);
}
}
if ($build !== null) {
$buildString = (string) $build;
$currentBuild = Setting::query()->where('key', 'build')->value('value');
if ((string) $currentBuild !== $buildString) {
Setting::updateOrCreate(['key' => 'build'], ['value' => $buildString]);
}
}
} catch (\Throwable) {
// Stats endpoint should remain readable even if settings sync fails.
}
}
} }

View File

@@ -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) {

View File

@@ -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);
}
}
} }

View File

@@ -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);
}
}
}
} }

View File

@@ -4,6 +4,7 @@ namespace App\Http\Controllers;
use App\Models\Role; use App\Models\Role;
use App\Models\User; use App\Models\User;
use App\Services\AuditLogger;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Storage;
@@ -209,6 +210,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');
@@ -228,6 +233,29 @@ class UserController extends Controller
]); ]);
} }
public function destroy(Request $request, User $user): JsonResponse
{
$actor = $request->user();
if (!$actor || !$actor->roles()->where('name', 'ROLE_ADMIN')->exists()) {
return response()->json(['message' => 'Forbidden'], 403);
}
if ($this->isFounder($user) && !$this->isFounder($actor)) {
return response()->json(['message' => 'Forbidden'], 403);
}
if ($actor->is($user)) {
return response()->json(['message' => 'You cannot delete your own account.'], 422);
}
app(AuditLogger::class)->log($request, 'user.deleted', $user, [
'email' => $user->email,
'name' => $user->name,
], $actor);
$user->delete();
return response()->json(null, 204);
}
private function resolveAvatarUrl(User $user): ?string private function resolveAvatarUrl(User $user): ?string
{ {
if (!$user->avatar_path) { if (!$user->avatar_path) {

View File

@@ -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,
];
}
} }

View File

@@ -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": "112"
} }

View File

@@ -3,28 +3,6 @@
set -euo pipefail set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$ROOT_DIR"
git restore -q bootstrap/cache/packages.php bootstrap/cache/services.php 2>/dev/null || true
DIRTY="$(git status --porcelain)"
DIRTY_FILTERED="$(echo "$DIRTY" | grep -vE '^( M|M ) (bootstrap/cache/(packages|services)\.php|package-lock\.json)$' || true)"
if [[ -n "$DIRTY_FILTERED" ]]; then
echo "Working tree is dirty. Please commit or stash changes before updating."
echo "$DIRTY_FILTERED"
exit 1
fi
if echo "$DIRTY" | grep -qE 'package-lock\.json'; then
echo "Warning: package-lock.json is modified. Continuing anyway."
fi
echo "Fetching latest refs..."
git fetch --prune --tags
echo "Checking out stable branch..."
git checkout stable
echo "Pulling latest stable..."
git pull --ff-only
resolve_php_bin() { resolve_php_bin() {
if [[ -n "${PHP_BIN:-}" ]]; then if [[ -n "${PHP_BIN:-}" ]]; then
@@ -42,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}"
@@ -92,7 +103,7 @@ read_setting_php_bin() {
echo "" echo ""
return 0 return 0
fi fi
echo "Running with PHP binary: $PHP_BIN -r <read system.php_binary>" >&2 echo "Using bootstrap PHP binary to read system.php_binary: $PHP_BIN" >&2
"$PHP_BIN" -r ' "$PHP_BIN" -r '
require "vendor/autoload.php"; require "vendor/autoload.php";
$app = require "bootstrap/app.php"; $app = require "bootstrap/app.php";
@@ -102,54 +113,184 @@ echo trim($value);
' '
} }
PHP_BIN="$(resolve_php_bin)" enforce_php_requirement() {
echo "Resolved PHP binary: $PHP_BIN" local bin="${1:-php}"
if command -v "$PHP_BIN" >/dev/null 2>&1; then echo "Validating PHP requirement from composer.json with binary: $bin"
echo "PHP version ($PHP_BIN): $($PHP_BIN -v | head -n 1)" "$bin" -r '
else $composer = json_decode((string) file_get_contents("composer.json"), true);
echo "PHP binary '$PHP_BIN' not found in PATH." $constraint = (string) ($composer["require"]["php"] ?? "");
fi $current = PHP_VERSION;
echo "Installing PHP dependencies..." if ($constraint === "") {
COMPOSER_BIN="$(command -v composer || true)" fwrite(STDOUT, "No PHP requirement found in composer.json; skipping check.\n");
if [[ -z "$COMPOSER_BIN" ]]; then exit(0);
}
$normalize = static function (string $value): ?array {
if (!preg_match("/(\\d+)(?:\\.(\\d+))?(?:\\.(\\d+))?/", $value, $m)) {
return null;
}
return [(int) $m[1], (int) ($m[2] ?? 0), (int) ($m[3] ?? 0)];
};
$cmp = static function (array $a, array $b): int {
for ($i = 0; $i < 3; $i++) {
if ($a[$i] > $b[$i]) {
return 1;
}
if ($a[$i] < $b[$i]) {
return -1;
}
}
return 0;
};
$partMin = static function (string $part) use ($normalize): ?array {
$tokens = preg_split("/\\s+/", trim($part)) ?: [];
$tokens = array_values(array_filter($tokens, static fn ($t) => $t !== ""));
foreach ($tokens as $token) {
if (str_starts_with($token, ">=")) {
$parsed = $normalize(substr($token, 2));
if ($parsed) {
return $parsed;
}
}
}
foreach ($tokens as $token) {
if (str_starts_with($token, "^")) {
$parsed = $normalize(substr($token, 1));
if ($parsed) {
return $parsed;
}
}
if (str_starts_with($token, "~")) {
$parsed = $normalize(substr($token, 1));
if ($parsed) {
return $parsed;
}
}
}
return isset($tokens[0]) ? $normalize($tokens[0]) : null;
};
$parts = array_values(array_filter(array_map("trim", explode("||", $constraint)), static fn ($p) => $p !== ""));
$mins = [];
foreach ($parts as $part) {
$min = $partMin($part);
if ($min) {
$mins[] = $min;
}
}
if (!$mins) {
fwrite(STDOUT, "Could not parse PHP requirement \"$constraint\"; skipping strict check.\n");
exit(0);
}
$required = array_reduce($mins, static function ($carry, $item) use ($cmp) {
if ($carry === null) {
return $item;
}
return $cmp($item, $carry) < 0 ? $item : $carry;
});
$currentParts = $normalize($current);
if (!$currentParts) {
fwrite(STDERR, "Unable to parse current PHP version: $current\n");
exit(1);
}
$requiredString = implode(".", $required);
if ($cmp($currentParts, $required) < 0) {
fwrite(STDERR, "PHP requirement check failed: composer.json requires \"$constraint\" (>= $requiredString), current is $current.\n");
exit(1);
}
fwrite(STDOUT, "PHP requirement check passed: composer.json requires \"$constraint\" (>= $requiredString), current is $current.\n");
' || return 1
}
main() {
cd "$ROOT_DIR"
git restore -q bootstrap/cache/packages.php bootstrap/cache/services.php 2>/dev/null || true
DIRTY="$(git status --porcelain)"
DIRTY_FILTERED="$(echo "$DIRTY" | grep -vE '^( M|M ) (bootstrap/cache/(packages|services)\.php|package-lock\.json)$' || true)"
if [[ -n "$DIRTY_FILTERED" ]]; then
echo "Working tree is dirty. Please commit or stash changes before updating."
echo "$DIRTY_FILTERED"
exit 1
fi
if echo "$DIRTY" | grep -qE 'package-lock\.json'; then
echo "Warning: package-lock.json is modified. Continuing anyway."
fi
echo "Fetching latest refs..."
git fetch --prune --tags
echo "Checking out stable branch..."
git checkout stable
echo "Pulling latest stable..."
git pull --ff-only
PHP_BIN="$(resolve_php_bin)"
echo "Initial fallback PHP binary: $PHP_BIN"
if command -v "$PHP_BIN" >/dev/null 2>&1; then
echo "PHP version ($PHP_BIN): $($PHP_BIN -v | head -n 1)"
else
echo "PHP binary '$PHP_BIN' not found in PATH."
fi
echo "Installing PHP dependencies..."
COMPOSER_BIN="$(command -v composer || true)"
if [[ -z "$COMPOSER_BIN" ]]; then
echo "Composer not found in PATH." echo "Composer not found in PATH."
exit 1 exit 1
fi fi
echo "Running with PHP binary: $PHP_BIN $COMPOSER_BIN install --no-dev --optimize-autoloader" echo "Running with PHP binary: $PHP_BIN $COMPOSER_BIN install --no-dev --optimize-autoloader"
"$PHP_BIN" "$COMPOSER_BIN" install --no-dev --optimize-autoloader "$PHP_BIN" "$COMPOSER_BIN" install --no-dev --optimize-autoloader
if ! CONFIGURED_PHP="$(read_setting_php_bin)"; then if ! CONFIGURED_PHP="$(read_setting_php_bin)"; then
echo "Failed to read configured PHP binary from settings." >&2 echo "Failed to read configured PHP binary from settings." >&2
echo "Aborting to avoid running update with the wrong PHP binary." >&2 echo "Aborting to avoid running update with the wrong PHP binary." >&2
exit 1 exit 1
fi fi
echo "Configured PHP binary from settings: ${CONFIGURED_PHP:-<empty>}" echo "Configured PHP binary from settings: ${CONFIGURED_PHP:-<empty>}"
PHP_BIN="$(resolve_configured_php_bin "$CONFIGURED_PHP" "$PHP_BIN")" PHP_BIN="$(resolve_configured_php_bin "$CONFIGURED_PHP" "$PHP_BIN")"
echo "Final PHP binary: $PHP_BIN" echo "Final PHP binary: $PHP_BIN"
if command -v "$PHP_BIN" >/dev/null 2>&1; then if command -v "$PHP_BIN" >/dev/null 2>&1; then
echo "Final PHP version ($PHP_BIN): $($PHP_BIN -v | head -n 1)" echo "Final PHP version ($PHP_BIN): $($PHP_BIN -v | head -n 1)"
fi fi
if ! enforce_php_requirement "$PHP_BIN"; then
echo "Aborting update because selected PHP binary does not satisfy composer.json requirements." >&2
exit 1
fi
echo "Installing JS dependencies..." echo "Installing JS dependencies..."
npm install npm install
echo "Building assets..." echo "Building assets..."
npm run build npm run build
echo "Running migrations..." echo "Running migrations..."
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
echo "Syncing version/build to settings..." ensure_storage_link
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"] ?? "";')"
echo "Running with PHP binary: $PHP_BIN -r <read composer.json build>"
BUILD="$("$PHP_BIN" -r '$c=json_decode(file_get_contents("composer.json"), true); echo $c["build"] ?? "";')"
echo "Computed from composer.json: VERSION=$VERSION, BUILD=$BUILD"
if [[ -n "$VERSION" || -n "$BUILD" ]]; then echo "Syncing version/build to settings..."
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"] ?? "";')"
echo "Running with PHP binary: $PHP_BIN -r <read composer.json build>"
BUILD="$("$PHP_BIN" -r '$c=json_decode(file_get_contents("composer.json"), true); echo $c["build"] ?? "";')"
echo "Computed from composer.json: VERSION=$VERSION, BUILD=$BUILD"
if [[ -n "$VERSION" || -n "$BUILD" ]]; then
echo "Updating settings version/build (VERSION=$VERSION, BUILD=$BUILD)..." echo "Updating settings version/build (VERSION=$VERSION, BUILD=$BUILD)..."
echo "Running with PHP binary: $PHP_BIN -r <write settings version/build>" echo "Running with PHP binary: $PHP_BIN -r <write settings version/build>"
SPEEDBB_VERSION="$VERSION" SPEEDBB_BUILD="$BUILD" "$PHP_BIN" -r ' SPEEDBB_VERSION="$VERSION" SPEEDBB_BUILD="$BUILD" "$PHP_BIN" -r '
@@ -194,6 +335,11 @@ if [[ -n "$VERSION" || -n "$BUILD" ]]; then
$build = \App\Models\Setting::where("key", "build")->value("value"); $build = \App\Models\Setting::where("key", "build")->value("value");
echo "Settings now: version={$version}, build={$build}\n"; echo "Settings now: version={$version}, build={$build}\n";
' '
fi fi
echo "Update complete." echo "Update complete."
}
if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then
main "$@"
fi

View File

@@ -1,18 +1,20 @@
import { useEffect, useState } from 'react' import { Suspense, lazy, 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 ForumView from './pages/ForumView'
import ThreadView from './pages/ThreadView'
import Login from './pages/Login'
import Register from './pages/Register'
import { Acp } from './pages/Acp'
import BoardIndex from './pages/BoardIndex'
import Ucp from './pages/Ucp'
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'
const Home = lazy(() => import('./pages/Home'))
const ForumView = lazy(() => import('./pages/ForumView'))
const ThreadView = lazy(() => import('./pages/ThreadView'))
const Login = lazy(() => import('./pages/Login'))
const Register = lazy(() => import('./pages/Register'))
const ResetPassword = lazy(() => import('./pages/ResetPassword'))
const Acp = lazy(() => import('./pages/Acp').then((module) => ({ default: module.Acp ?? module.default })))
const BoardIndex = lazy(() => import('./pages/BoardIndex'))
const Ucp = lazy(() => import('./pages/Ucp'))
const Profile = lazy(() => import('./pages/Profile'))
function PortalHeader({ function PortalHeader({
userMenu, userMenu,
@@ -240,9 +242,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(
@@ -265,12 +274,85 @@ function AppShell() {
favicon256: '', favicon256: '',
}) })
const routeFallback = (
<Container fluid className="py-5">
<p className="bb-muted mb-0">{t('acp.loading')}</p>
</Container>
)
useEffect(() => { useEffect(() => {
fetchVersion() fetchVersion()
.then((data) => setVersionInfo(data)) .then((data) => setVersionInfo(data))
.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 () => {
@@ -460,12 +542,14 @@ function AppShell() {
canAccessAcp={isAdmin} canAccessAcp={isAdmin}
canAccessMcp={isModerator} canAccessMcp={isModerator}
/> />
<Suspense fallback={routeFallback}>
<Routes> <Routes>
<Route path="/" element={<Home />} /> <Route path="/" element={<Home />} />
<Route path="/forums" element={<BoardIndex />} /> <Route path="/forums" element={<BoardIndex />} />
<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} />} />
@@ -481,6 +565,7 @@ function AppShell() {
} }
/> />
</Routes> </Routes>
</Suspense>
<footer className="bb-footer"> <footer className="bb-footer">
<div className="ms-3 d-flex align-items-center gap-3"> <div className="ms-3 d-flex align-items-center gap-3">
<span>{t('footer.copy')}</span> <span>{t('footer.copy')}</span>
@@ -493,8 +578,38 @@ 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()}
>
<i className="bi bi-arrow-clockwise me-2" aria-hidden="true" />
{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)}>
<i className="bi bi-clock me-2" aria-hidden="true" />
{t('version.remind_later')}
</Button>
<Button className="bb-accent-button" onClick={() => window.location.reload()}>
<i className="bi bi-arrow-repeat me-2" aria-hidden="true" />
{t('version.update_now')}
</Button>
</Modal.Footer>
</Modal>
</div> </div>
) )
} }

View File

@@ -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)
@@ -409,6 +434,12 @@ export async function listUsers() {
return getCollection('/users') return getCollection('/users')
} }
export async function deleteUser(userId) {
return apiFetch(`/users/${userId}`, {
method: 'DELETE',
})
}
export async function listAuditLogs(limit = 200) { export async function listAuditLogs(limit = 200) {
const query = Number.isFinite(limit) ? `?limit=${limit}` : '' const query = Number.isFinite(limit) ? `?limit=${limit}` : ''
return getCollection(`/audit-logs${query}`) return getCollection(`/audit-logs${query}`)

View 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;
} }
@@ -921,7 +947,7 @@ a {
} }
.nav-tabs .nav-link { .nav-tabs .nav-link {
color: var(--bb-accent, #f29b3f); color: var(--bb-ink-muted);
border: 1px solid var(--bb-border); border: 1px solid var(--bb-border);
border-bottom-color: transparent; border-bottom-color: transparent;
border-radius: 10px 10px 0 0; border-radius: 10px 10px 0 0;
@@ -930,7 +956,7 @@ a {
} }
.nav-tabs .nav-link.active { .nav-tabs .nav-link.active {
color: inherit; color: var(--bb-accent, #f29b3f);
background: rgba(255, 255, 255, 0.04); background: rgba(255, 255, 255, 0.04);
border-color: var(--bb-border); border-color: var(--bb-border);
border-bottom-color: transparent; border-bottom-color: transparent;
@@ -949,7 +975,7 @@ a {
} }
.bb-acp-action.btn-outline-dark { .bb-acp-action.btn-outline-dark {
--bs-btn-color: var(--bb-accent, #f29b3f); --bs-btn-color: #0f1218;
--bs-btn-border-color: var(--bb-accent, #f29b3f); --bs-btn-border-color: var(--bb-accent, #f29b3f);
--bs-btn-hover-color: #0f1218; --bs-btn-hover-color: #0f1218;
--bs-btn-hover-bg: var(--bb-accent, #f29b3f); --bs-btn-hover-bg: var(--bb-accent, #f29b3f);
@@ -958,7 +984,7 @@ a {
--bs-btn-active-bg: var(--bb-accent, #f29b3f); --bs-btn-active-bg: var(--bb-accent, #f29b3f);
--bs-btn-active-border-color: var(--bb-accent, #f29b3f); --bs-btn-active-border-color: var(--bb-accent, #f29b3f);
--bs-btn-focus-shadow-rgb: 242, 155, 63; --bs-btn-focus-shadow-rgb: 242, 155, 63;
color: var(--bb-accent, #f29b3f) !important; color: #0f1218 !important;
border-color: var(--bb-accent, #f29b3f) !important; border-color: var(--bb-accent, #f29b3f) !important;
} }
@@ -1046,7 +1072,7 @@ a {
} }
[data-bs-theme="dark"] .bb-acp-action.btn-outline-dark { [data-bs-theme="dark"] .bb-acp-action.btn-outline-dark {
--bs-btn-color: var(--bb-accent, #f29b3f); --bs-btn-color: #0f1218;
--bs-btn-border-color: var(--bb-accent, #f29b3f); --bs-btn-border-color: var(--bb-accent, #f29b3f);
--bs-btn-hover-color: #0f1218; --bs-btn-hover-color: #0f1218;
--bs-btn-hover-bg: var(--bb-accent, #f29b3f); --bs-btn-hover-bg: var(--bb-accent, #f29b3f);
@@ -1054,7 +1080,7 @@ a {
--bs-btn-active-color: #0f1218; --bs-btn-active-color: #0f1218;
--bs-btn-active-bg: var(--bb-accent, #f29b3f); --bs-btn-active-bg: var(--bb-accent, #f29b3f);
--bs-btn-active-border-color: var(--bb-accent, #f29b3f); --bs-btn-active-border-color: var(--bb-accent, #f29b3f);
color: var(--bb-accent, #f29b3f) !important; color: #0f1218 !important;
border-color: var(--bb-accent, #f29b3f) !important; border-color: var(--bb-accent, #f29b3f) !important;
} }
@@ -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,
@@ -2192,6 +2227,25 @@ a {
opacity: 0.6; opacity: 0.6;
} }
.btn:not(.btn-close) {
--bs-btn-bg: var(--bb-accent, #f29b3f) !important;
--bs-btn-border-color: var(--bb-accent, #f29b3f) !important;
--bs-btn-color: #0e121b !important;
--bs-btn-hover-bg: color-mix(in srgb, var(--bb-accent, #f29b3f) 85%, #000) !important;
--bs-btn-hover-border-color: color-mix(in srgb, var(--bb-accent, #f29b3f) 85%, #000) !important;
--bs-btn-hover-color: #fff !important;
--bs-btn-active-bg: color-mix(in srgb, var(--bb-accent, #f29b3f) 80%, #000) !important;
--bs-btn-active-border-color: color-mix(in srgb, var(--bb-accent, #f29b3f) 80%, #000) !important;
--bs-btn-active-color: #fff !important;
--bs-btn-disabled-bg: var(--bb-accent, #f29b3f) !important;
--bs-btn-disabled-border-color: var(--bb-accent, #f29b3f) !important;
--bs-btn-disabled-color: #0e121b !important;
}
.btn:not(.btn-close):focus-visible {
box-shadow: 0 0 0 0.2rem color-mix(in srgb, var(--bb-accent, #f29b3f) 35%, transparent);
}
.modal-content .modal-header { .modal-content .modal-header {
background: #0f1218; background: #0f1218;
color: #e6e8eb; color: #e6e8eb;
@@ -2208,6 +2262,24 @@ a {
margin: 0; margin: 0;
} }
.bb-confirm-modal .modal-content .modal-header {
display: flex;
align-items: center;
justify-content: flex-start;
}
.bb-confirm-modal .modal-content .modal-title {
position: absolute;
left: 50%;
transform: translateX(-50%);
margin: 0;
max-width: none;
font-size: clamp(1.1rem, 2vw, 1.9rem);
line-height: 1.2;
text-align: center;
white-space: nowrap;
}
.modal-content .modal-header .btn-close { .modal-content .modal-header .btn-close {
filter: none; filter: none;
opacity: 1; opacity: 1;
@@ -2809,6 +2881,36 @@ a {
max-width: 320px; max-width: 320px;
} }
.bb-search-field {
position: relative;
width: 100%;
max-width: 320px;
}
.bb-search-field-input {
padding-right: 2.75rem;
}
.bb-search-clear {
position: absolute;
top: 50%;
right: 0.7rem;
transform: translateY(-50%);
border: 0;
background: transparent;
color: var(--bb-ink-muted);
padding: 0;
line-height: 1;
display: inline-flex;
align-items: center;
justify-content: center;
}
.bb-search-clear:hover,
.bb-search-clear:focus-visible {
color: var(--bb-accent, #f29b3f);
}
.bb-audit-limit { .bb-audit-limit {
max-width: 120px; max-width: 120px;
} }

File diff suppressed because it is too large Load Diff

View File

@@ -397,6 +397,7 @@ export default function ForumView() {
className={`bb-attachment-tab ${attachmentTab === 'options' ? 'is-active' : ''}`} className={`bb-attachment-tab ${attachmentTab === 'options' ? 'is-active' : ''}`}
onClick={() => setAttachmentTab('options')} onClick={() => setAttachmentTab('options')}
> >
<i className="bi bi-sliders me-2" aria-hidden="true" />
{t('attachment.tab_options')} {t('attachment.tab_options')}
</button> </button>
<button <button
@@ -404,6 +405,7 @@ export default function ForumView() {
className={`bb-attachment-tab ${attachmentTab === 'attachments' ? 'is-active' : ''}`} className={`bb-attachment-tab ${attachmentTab === 'attachments' ? 'is-active' : ''}`}
onClick={() => setAttachmentTab('attachments')} onClick={() => setAttachmentTab('attachments')}
> >
<i className="bi bi-paperclip me-2" aria-hidden="true" />
{t('attachment.tab_attachments')} {t('attachment.tab_attachments')}
</button> </button>
</div> </div>
@@ -499,6 +501,7 @@ export default function ForumView() {
variant="outline-secondary" variant="outline-secondary"
onClick={() => document.getElementById('bb-thread-attachment-input')?.click()} onClick={() => document.getElementById('bb-thread-attachment-input')?.click()}
> >
<i className="bi bi-upload me-2" aria-hidden="true" />
{t('attachment.add_files')} {t('attachment.add_files')}
</Button> </Button>
</div> </div>
@@ -635,13 +638,14 @@ export default function ForumView() {
</span> </span>
<div className="bb-topic-pagination"> <div className="bb-topic-pagination">
<Button size="sm" variant="outline-secondary" disabled> <Button size="sm" variant="outline-secondary" disabled>
<i className="bi bi-chevron-left" aria-hidden="true" />
</Button> </Button>
<Button size="sm" variant="outline-secondary" className="is-active" disabled> <Button size="sm" variant="outline-secondary" className="is-active" disabled>
<i className="bi bi-dot me-1" aria-hidden="true" />
1 1
</Button> </Button>
<Button size="sm" variant="outline-secondary" disabled> <Button size="sm" variant="outline-secondary" disabled>
<i className="bi bi-chevron-right" aria-hidden="true" />
</Button> </Button>
</div> </div>
</div> </div>
@@ -755,6 +759,7 @@ export default function ForumView() {
document.getElementById('bb-thread-attachment-input')?.click() document.getElementById('bb-thread-attachment-input')?.click()
}} }}
> >
<i className="bi bi-folder2-open me-2" aria-hidden="true" />
{t('attachment.drop_browse')} {t('attachment.drop_browse')}
</button> </button>
</span> </span>
@@ -762,6 +767,7 @@ export default function ForumView() {
{renderAttachmentFooter()} {renderAttachmentFooter()}
<Modal.Footer className="d-flex gap-2 justify-content-between mt-auto pt-2 px-0 border-0 mb-0 pb-0"> <Modal.Footer className="d-flex gap-2 justify-content-between mt-auto pt-2 px-0 border-0 mb-0 pb-0">
<Button type="button" variant="outline-secondary" onClick={() => setShowModal(false)}> <Button type="button" variant="outline-secondary" onClick={() => setShowModal(false)}>
<i className="bi bi-x-circle me-2" aria-hidden="true" />
{t('acp.cancel')} {t('acp.cancel')}
</Button> </Button>
<div className="d-flex gap-2"> <div className="d-flex gap-2">
@@ -771,6 +777,7 @@ export default function ForumView() {
onClick={handlePreview} onClick={handlePreview}
disabled={!token || saving || uploading || previewLoading} disabled={!token || saving || uploading || previewLoading}
> >
<i className="bi bi-eye me-2" aria-hidden="true" />
{t('form.preview')} {t('form.preview')}
</Button> </Button>
<Button <Button
@@ -778,6 +785,7 @@ export default function ForumView() {
className="bb-accent-button" className="bb-accent-button"
disabled={!token || saving || uploading} disabled={!token || saving || uploading}
> >
<i className="bi bi-plus-circle me-2" aria-hidden="true" />
{saving || uploading ? t('form.posting') : t('form.create_thread')} {saving || uploading ? t('form.posting') : t('form.create_thread')}
</Button> </Button>
</div> </div>

View File

@@ -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,20 @@ 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}>
<i className="bi bi-x-circle me-2" aria-hidden="true" />
{t('acp.cancel')}
</Button>
<Button type="submit" className="ms-auto bb-accent-button" disabled={loading}>
<i className="bi bi-box-arrow-in-right me-2" aria-hidden="true" />
{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>

View File

@@ -70,6 +70,7 @@ export default function Register() {
/> />
</Form.Group> </Form.Group>
<Button type="submit" variant="dark" disabled={loading}> <Button type="submit" variant="dark" disabled={loading}>
<i className="bi bi-person-plus me-2" aria-hidden="true" />
{loading ? t('form.registering') : t('form.create_account')} {loading ? t('form.registering') : t('form.create_account')}
</Button> </Button>
</Form> </Form>

View File

@@ -0,0 +1,118 @@
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}>
<i className="bi bi-x-circle me-2" aria-hidden="true" />
{t('acp.cancel')}
</Button>
<Button type="submit" className="ms-auto bb-accent-button" disabled={loading}>
<i
className={`bi ${isResetFlow ? 'bi-key-fill' : 'bi-envelope-arrow-up-fill'} me-2`}
aria-hidden="true"
/>
{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>
)
}

View File

@@ -284,6 +284,7 @@ export default function ThreadView() {
className={`bb-attachment-tab ${replyAttachmentTab === 'options' ? 'is-active' : ''}`} className={`bb-attachment-tab ${replyAttachmentTab === 'options' ? 'is-active' : ''}`}
onClick={() => setReplyAttachmentTab('options')} onClick={() => setReplyAttachmentTab('options')}
> >
<i className="bi bi-sliders me-2" aria-hidden="true" />
{t('attachment.tab_options')} {t('attachment.tab_options')}
</button> </button>
<button <button
@@ -291,6 +292,7 @@ export default function ThreadView() {
className={`bb-attachment-tab ${replyAttachmentTab === 'attachments' ? 'is-active' : ''}`} className={`bb-attachment-tab ${replyAttachmentTab === 'attachments' ? 'is-active' : ''}`}
onClick={() => setReplyAttachmentTab('attachments')} onClick={() => setReplyAttachmentTab('attachments')}
> >
<i className="bi bi-paperclip me-2" aria-hidden="true" />
{t('attachment.tab_attachments')} {t('attachment.tab_attachments')}
</button> </button>
</div> </div>
@@ -374,6 +376,7 @@ export default function ThreadView() {
variant="outline-secondary" variant="outline-secondary"
onClick={() => document.getElementById('bb-reply-attachment-input')?.click()} onClick={() => document.getElementById('bb-reply-attachment-input')?.click()}
> >
<i className="bi bi-upload me-2" aria-hidden="true" />
{t('attachment.add_files')} {t('attachment.add_files')}
</Button> </Button>
</div> </div>
@@ -1040,6 +1043,7 @@ export default function ThreadView() {
document.getElementById('bb-reply-attachment-input')?.click() document.getElementById('bb-reply-attachment-input')?.click()
}} }}
> >
<i className="bi bi-folder2-open me-2" aria-hidden="true" />
{t('attachment.drop_browse')} {t('attachment.drop_browse')}
</button> </button>
</span> </span>
@@ -1053,6 +1057,7 @@ export default function ThreadView() {
onClick={handlePreview} onClick={handlePreview}
disabled={!token || saving || replyUploading || previewLoading} disabled={!token || saving || replyUploading || previewLoading}
> >
<i className="bi bi-eye me-2" aria-hidden="true" />
{t('form.preview')} {t('form.preview')}
</Button> </Button>
<Button <Button
@@ -1060,6 +1065,7 @@ export default function ThreadView() {
className="bb-accent-button" className="bb-accent-button"
disabled={!token || saving || replyUploading} disabled={!token || saving || replyUploading}
> >
<i className="bi bi-reply-fill me-2" aria-hidden="true" />
{saving || replyUploading ? t('form.posting') : t('form.post_reply')} {saving || replyUploading ? t('form.posting') : t('form.post_reply')}
</Button> </Button>
</div> </div>
@@ -1119,6 +1125,7 @@ export default function ThreadView() {
</Modal.Body> </Modal.Body>
<Modal.Footer className="justify-content-between"> <Modal.Footer className="justify-content-between">
<Button variant="outline-secondary" onClick={() => setEditPost(null)}> <Button variant="outline-secondary" onClick={() => setEditPost(null)}>
<i className="bi bi-x-circle me-2" aria-hidden="true" />
{t('acp.cancel')} {t('acp.cancel')}
</Button> </Button>
<Button <Button
@@ -1126,6 +1133,7 @@ export default function ThreadView() {
onClick={handleEditSave} onClick={handleEditSave}
disabled={editSaving || !editBody.trim() || (editPost?.isRoot && !editTitle.trim())} disabled={editSaving || !editBody.trim() || (editPost?.isRoot && !editTitle.trim())}
> >
<i className="bi bi-floppy me-2" aria-hidden="true" />
{editSaving ? t('form.saving') : t('acp.save')} {editSaving ? t('form.saving') : t('acp.save')}
</Button> </Button>
</Modal.Footer> </Modal.Footer>
@@ -1180,6 +1188,7 @@ export default function ThreadView() {
onClick={() => setDeleteTarget(null)} onClick={() => setDeleteTarget(null)}
disabled={deleteLoading} disabled={deleteLoading}
> >
<i className="bi bi-x-circle me-2" aria-hidden="true" />
{t('acp.cancel')} {t('acp.cancel')}
</Button> </Button>
<Button <Button
@@ -1187,6 +1196,7 @@ export default function ThreadView() {
onClick={handleDeleteConfirm} onClick={handleDeleteConfirm}
disabled={deleteLoading} disabled={deleteLoading}
> >
<i className="bi bi-trash me-2" aria-hidden="true" />
{deleteLoading ? t('form.saving') : t('acp.delete')} {deleteLoading ? t('form.saving') : t('acp.delete')}
</Button> </Button>
</Modal.Footer> </Modal.Footer>

View File

@@ -116,6 +116,7 @@ export default function Ucp({ theme, setTheme, accentOverride, setAccentOverride
} }
}} }}
> >
<i className="bi bi-floppy me-2" aria-hidden="true" />
{profileSaving ? t('form.saving') : t('ucp.save_profile')} {profileSaving ? t('form.saving') : t('ucp.save_profile')}
</Button> </Button>
</Col> </Col>

View File

@@ -1,6 +1,7 @@
{ {
"acp.cancel": "Abbrechen", "acp.cancel": "Abbrechen",
"acp.collapse_all": "Alle einklappen", "acp.collapse_all": "Alle einklappen",
"acp.clear": "Leeren",
"acp.create": "Erstellen", "acp.create": "Erstellen",
"acp.delete": "Löschen", "acp.delete": "Löschen",
"acp.drag_handle": "Zum Sortieren ziehen", "acp.drag_handle": "Zum Sortieren ziehen",
@@ -81,6 +82,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 +187,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 +205,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",
@@ -218,6 +235,8 @@
"user.impersonate": "Imitieren", "user.impersonate": "Imitieren",
"user.edit": "Bearbeiten", "user.edit": "Bearbeiten",
"user.delete": "Löschen", "user.delete": "Löschen",
"user.delete_title": "Benutzer löschen",
"user.delete_confirm": "Diesen Benutzer löschen? Das kann nicht rückgängig gemacht werden.",
"user.founder_locked": "Nur Gründer können die Gründerrolle bearbeiten oder zuweisen.", "user.founder_locked": "Nur Gründer können die Gründerrolle bearbeiten oder zuweisen.",
"group.create": "Gruppe erstellen", "group.create": "Gruppe erstellen",
"group.create_title": "Gruppe erstellen", "group.create_title": "Gruppe erstellen",

View File

@@ -1,6 +1,7 @@
{ {
"acp.cancel": "Cancel", "acp.cancel": "Cancel",
"acp.collapse_all": "Collapse all", "acp.collapse_all": "Collapse all",
"acp.clear": "Clear",
"acp.create": "Create", "acp.create": "Create",
"acp.delete": "Delete", "acp.delete": "Delete",
"acp.drag_handle": "Drag to reorder", "acp.drag_handle": "Drag to reorder",
@@ -81,6 +82,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 +177,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 +195,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",
@@ -218,6 +235,8 @@
"user.impersonate": "Impersonate", "user.impersonate": "Impersonate",
"user.edit": "Edit", "user.edit": "Edit",
"user.delete": "Delete", "user.delete": "Delete",
"user.delete_title": "Delete User",
"user.delete_confirm": "Delete this user? This cannot be undone.",
"user.founder_locked": "Only founders can edit or assign the Founder role.", "user.founder_locked": "Only founders can edit or assign the Founder role.",
"group.create": "Create group", "group.create": "Create group",
"group.create_title": "Create group", "group.create_title": "Create group",

View File

@@ -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');
@@ -53,6 +56,7 @@ Route::post('/user/avatar', [UploadController::class, 'storeAvatar'])->middlewar
Route::get('/i18n/{locale}', I18nController::class); Route::get('/i18n/{locale}', I18nController::class);
Route::get('/users', [UserController::class, 'index'])->middleware('auth:sanctum'); Route::get('/users', [UserController::class, 'index'])->middleware('auth:sanctum');
Route::patch('/users/{user}', [UserController::class, 'update'])->middleware('auth:sanctum'); Route::patch('/users/{user}', [UserController::class, 'update'])->middleware('auth:sanctum');
Route::delete('/users/{user}', [UserController::class, 'destroy'])->middleware('auth:sanctum');
Route::get('/user/me', [UserController::class, 'me'])->middleware('auth:sanctum'); Route::get('/user/me', [UserController::class, 'me'])->middleware('auth:sanctum');
Route::patch('/user/me', [UserController::class, 'updateMe'])->middleware('auth:sanctum'); Route::patch('/user/me', [UserController::class, 'updateMe'])->middleware('auth:sanctum');
Route::get('/user/profile/{user}', [UserController::class, 'profile'])->middleware('auth:sanctum'); Route::get('/user/profile/{user}', [UserController::class, 'profile'])->middleware('auth:sanctum');

View File

@@ -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');

View File

@@ -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

View 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,
]);
});

View File

@@ -164,6 +164,39 @@ it('allows admins to update user rank', function (): void {
expect($target->rank_id)->toBe($rank->id); expect($target->rank_id)->toBe($rank->id);
}); });
it('allows admins to delete users', function (): void {
$admin = makeAdmin();
$target = User::factory()->create();
Sanctum::actingAs($admin);
$response = $this->deleteJson("/api/users/{$target->id}");
$response->assertStatus(204);
$this->assertDatabaseMissing('users', ['id' => $target->id]);
});
it('forbids deleting founder user when actor is not founder', function (): void {
$admin = makeAdmin();
$founderRole = Role::firstOrCreate(['name' => 'ROLE_FOUNDER'], ['color' => '#111111']);
$founder = User::factory()->create();
$founder->roles()->attach($founderRole);
Sanctum::actingAs($admin);
$response = $this->deleteJson("/api/users/{$founder->id}");
$response->assertStatus(403);
});
it('prevents admins from deleting their own account', function (): void {
$admin = makeAdmin();
Sanctum::actingAs($admin);
$response = $this->deleteJson("/api/users/{$admin->id}");
$response->assertStatus(422);
$response->assertJsonFragment(['message' => 'You cannot delete your own account.']);
});
it('rejects update without admin role', function (): void { it('rejects update without admin role', function (): void {
$user = User::factory()->create(); $user = User::factory()->create();
$target = User::factory()->create(); $target = User::factory()->create();
@@ -285,3 +318,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();
});

View File

@@ -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,
]); ]);
}); });

View File

@@ -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 {

View File

@@ -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',
]); ]);
}); });

View File

@@ -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
View 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

View File

@@ -0,0 +1,52 @@
#!/usr/bin/env bats
setup() {
REPO_ROOT="$(cd "$BATS_TEST_DIRNAME/../.." && pwd)"
SCRIPT_PATH="$REPO_ROOT/git_update.sh"
TMP_ROOT="$BATS_TEST_TMPDIR/work"
mkdir -p "$TMP_ROOT/bin"
}
@test "git_update.sh can be sourced without running main" {
run bash -lc "source '$SCRIPT_PATH' >/dev/null 2>&1; echo sourced"
[ "$status" -eq 0 ]
[[ "$output" == "sourced" ]]
}
@test "resolve_configured_php_bin accepts command name from PATH" {
cat >"$TMP_ROOT/bin/php84" <<'SH'
#!/usr/bin/env sh
exit 0
SH
chmod +x "$TMP_ROOT/bin/php84"
run bash -lc "PATH='$TMP_ROOT/bin':\$PATH; source '$SCRIPT_PATH'; resolve_configured_php_bin 'php84' 'php'"
[ "$status" -eq 0 ]
[ "$output" = "php84" ]
}
@test "resolve_configured_php_bin rejects unknown custom command" {
run bash -lc "PATH='$TMP_ROOT/bin'; source '$SCRIPT_PATH'; resolve_configured_php_bin 'does-not-exist' 'php'"
[ "$status" -eq 1 ]
[[ "$output" == *"Configured PHP binary 'does-not-exist' is not executable/resolvable."* ]]
}
@test "enforce_php_requirement passes for satisfied constraint" {
cat >"$TMP_ROOT/composer.json" <<'JSON'
{"require":{"php":">=8.0"}}
JSON
run bash -lc "cd '$TMP_ROOT'; source '$SCRIPT_PATH'; enforce_php_requirement php"
[ "$status" -eq 0 ]
[[ "$output" == *"PHP requirement check passed"* ]]
}
@test "enforce_php_requirement fails for unsatisfied constraint" {
cat >"$TMP_ROOT/composer.json" <<'JSON'
{"require":{"php":">=99.0"}}
JSON
run bash -lc "cd '$TMP_ROOT'; source '$SCRIPT_PATH'; enforce_php_requirement php"
[ "$status" -eq 1 ]
[[ "$output" == *"PHP requirement check failed"* ]]
}

View File

@@ -10,6 +10,58 @@ export default defineConfig({
}), }),
react(), react(),
], ],
build: {
rollupOptions: {
output: {
manualChunks(id) {
if (!id.includes('/node_modules/')) {
return undefined;
}
if (
id.includes('/react-data-table-component/') ||
id.includes('/react-dropzone/')
) {
return 'acp-vendor';
}
if (
id.includes('/react-router/') ||
id.includes('/react-router-dom/') ||
id.includes('/@remix-run/')
) {
return 'router-vendor';
}
if (
id.includes('/react/') ||
id.includes('/react-dom/') ||
id.includes('/scheduler/')
) {
return 'react-vendor';
}
if (
id.includes('/react-bootstrap/') ||
id.includes('/bootstrap/') ||
id.includes('/bootstrap-icons/')
) {
return 'ui-vendor';
}
if (
id.includes('/i18next/') ||
id.includes('/react-i18next/') ||
id.includes('/i18next-http-backend/')
) {
return 'i18n-vendor';
}
return 'vendor';
},
},
},
},
server: { server: {
watch: { watch: {
ignored: ['**/storage/framework/views/**'], ignored: ['**/storage/framework/views/**'],