64 Commits

Author SHA1 Message Date
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
942ab7858b update changelog and CLI php status handling
All checks were successful
CI/CD Pipeline / test (push) Successful in 3s
CI/CD Pipeline / deploy (push) Successful in 24s
CI/CD Pipeline / promote_stable (push) Successful in 3s
2026-02-18 18:50:25 +01:00
d178b8da91 make the update more verbose for testing
All checks were successful
CI/CD Pipeline / test (push) Successful in 3s
CI/CD Pipeline / deploy (push) Successful in 20s
CI/CD Pipeline / promote_stable (push) Successful in 2s
2026-02-15 23:37:48 +01:00
7ecb6378fe make the update more verbose for testing
All checks were successful
CI/CD Pipeline / test (push) Successful in 3s
CI/CD Pipeline / deploy (push) Successful in 20s
CI/CD Pipeline / promote_stable (push) Successful in 3s
2026-02-15 23:34:18 +01:00
9496078644 make the update more verbose for testing
All checks were successful
CI/CD Pipeline / test (push) Successful in 2s
CI/CD Pipeline / deploy (push) Successful in 20s
CI/CD Pipeline / promote_stable (push) Successful in 2s
2026-02-15 23:27:49 +01:00
3aab864c34 make the update more verbose for testing
All checks were successful
CI/CD Pipeline / test (push) Successful in 2s
CI/CD Pipeline / deploy (push) Successful in 19s
CI/CD Pipeline / promote_stable (push) Successful in 3s
2026-02-15 23:22:14 +01:00
5eb5404061 refactor the update
All checks were successful
CI/CD Pipeline / test (push) Successful in 3s
CI/CD Pipeline / deploy (push) Successful in 24s
CI/CD Pipeline / promote_stable (push) Successful in 2s
2026-02-15 23:13:37 +01:00
d9040f1e6c refactor the update 2026-02-15 23:11:23 +01:00
8270e635d6 fix version display
All checks were successful
CI/CD Pipeline / test (push) Successful in 3s
CI/CD Pipeline / deploy (push) Successful in 20s
CI/CD Pipeline / promote_stable (push) Successful in 2s
2026-02-14 11:45:07 +01:00
d724f80cad fix version display
All checks were successful
CI/CD Pipeline / test (push) Successful in 3s
CI/CD Pipeline / deploy (push) Successful in 25s
CI/CD Pipeline / promote_stable (push) Successful in 3s
2026-02-14 11:41:06 +01:00
1f5f340ce4 fix version updates
All checks were successful
CI/CD Pipeline / test (push) Successful in 3s
CI/CD Pipeline / deploy (push) Successful in 20s
CI/CD Pipeline / promote_stable (push) Successful in 2s
2026-02-13 18:28:18 +01:00
40e111b3a6 added version log in updater
All checks were successful
CI/CD Pipeline / test (push) Successful in 2s
CI/CD Pipeline / deploy (push) Successful in 19s
CI/CD Pipeline / promote_stable (push) Successful in 2s
2026-02-13 10:50:33 +01:00
506011f933 added cache folder
All checks were successful
CI/CD Pipeline / test (push) Successful in 3s
CI/CD Pipeline / deploy (push) Successful in 20s
CI/CD Pipeline / promote_stable (push) Successful in 2s
2026-02-13 10:19:31 +01:00
80a8b86a08 fixed bootstrap/cache
All checks were successful
CI/CD Pipeline / test (push) Successful in 2s
CI/CD Pipeline / deploy (push) Successful in 21s
CI/CD Pipeline / promote_stable (push) Successful in 2s
2026-02-13 10:15:30 +01:00
c1cb3f394a prepare for cli updates with custom php binary
Some checks failed
CI/CD Pipeline / test (push) Successful in 3s
CI/CD Pipeline / deploy (push) Failing after 12s
CI/CD Pipeline / promote_stable (push) Has been skipped
2026-02-13 10:11:34 +01:00
31c8491aaf Update composer.lock
Some checks failed
CI/CD Pipeline / test (push) Successful in 2s
CI/CD Pipeline / deploy (push) Failing after 10s
CI/CD Pipeline / promote_stable (push) Has been skipped
2026-02-13 10:08:16 +01:00
0ad5916504 Add custom paths and ACP tab
Some checks failed
CI/CD Pipeline / test (push) Successful in 12s
CI/CD Pipeline / deploy (push) Failing after 11s
CI/CD Pipeline / promote_stable (push) Has been skipped
2026-02-13 10:04:28 +01:00
bac70c3927 Empty
Some checks failed
CI/CD Pipeline / test (push) Successful in 3s
CI/CD Pipeline / deploy (push) Failing after 15s
CI/CD Pipeline / promote_stable (push) Has been skipped
2026-02-13 10:03:14 +01:00
bf23e46e2d Polish ACP system layout
Some checks failed
CI/CD Pipeline / test (push) Successful in 2s
CI/CD Pipeline / deploy (push) Failing after 11s
CI/CD Pipeline / promote_stable (push) Has been skipped
2026-02-12 20:02:28 +01:00
55b9a69c42 Refine ACP system settings
Some checks failed
CI/CD Pipeline / test (push) Successful in 4s
CI/CD Pipeline / deploy (push) Failing after 19s
CI/CD Pipeline / promote_stable (push) Has been skipped
2026-02-12 19:44:23 +01:00
b6ce5160f9 prepare for cli updates with custom php binary
Some checks failed
CI/CD Pipeline / test (push) Successful in 3s
CI/CD Pipeline / deploy (push) Failing after 10s
CI/CD Pipeline / promote_stable (push) Has been skipped
2026-02-11 19:46:33 +01:00
d279e7f36f prepare for cli updates with custom php binary
Some checks failed
CI/CD Pipeline / test (push) Successful in 2s
CI/CD Pipeline / deploy (push) Failing after 9s
CI/CD Pipeline / promote_stable (push) Has been skipped
2026-02-11 19:13:59 +01:00
a0d914ea24 Stop tracking bootstrap cache
Some checks failed
CI/CD Pipeline / test (push) Successful in 2s
CI/CD Pipeline / deploy (push) Failing after 10s
CI/CD Pipeline / promote_stable (push) Has been skipped
2026-02-11 19:13:27 +01:00
ce3b89d54e prepare for cli updates with custom php binary
All checks were successful
CI/CD Pipeline / test (push) Successful in 2s
CI/CD Pipeline / deploy (push) Successful in 20s
CI/CD Pipeline / promote_stable (push) Successful in 3s
2026-02-11 19:01:23 +01:00
5cd8a1a9d6 prepare for cli updates with custom php binary
All checks were successful
CI/CD Pipeline / test (push) Successful in 2s
CI/CD Pipeline / deploy (push) Successful in 20s
CI/CD Pipeline / promote_stable (push) Successful in 2s
2026-02-11 18:48:26 +01:00
6f9d9f9e7a prepare for cli updates with custom php binary
All checks were successful
CI/CD Pipeline / test (push) Successful in 2s
CI/CD Pipeline / deploy (push) Successful in 20s
CI/CD Pipeline / promote_stable (push) Successful in 3s
2026-02-11 18:44:01 +01:00
db7f088b36 prepare for cli updates with custom php binary
All checks were successful
CI/CD Pipeline / test (push) Successful in 3s
CI/CD Pipeline / deploy (push) Successful in 19s
CI/CD Pipeline / promote_stable (push) Successful in 3s
2026-02-11 18:36:33 +01:00
54d4cd7f99 prepare for cli updates with custom php binary
All checks were successful
CI/CD Pipeline / test (push) Successful in 2s
CI/CD Pipeline / deploy (push) Successful in 18s
CI/CD Pipeline / promote_stable (push) Successful in 2s
2026-02-11 18:31:06 +01:00
af03c23c9f Fix stable promotion fetch/merge
All checks were successful
CI/CD Pipeline / test (push) Successful in 2s
CI/CD Pipeline / deploy (push) Successful in 19s
CI/CD Pipeline / promote_stable (push) Successful in 3s
2026-02-11 17:46:56 +01:00
68dd17f895 Promote stable on successful deploy
Some checks failed
CI/CD Pipeline / test (push) Successful in 3s
CI/CD Pipeline / deploy (push) Successful in 23s
CI/CD Pipeline / promote_stable (push) Failing after 1s
2026-02-11 17:42:31 +01:00
8249df15df update stable if push to master is successful
Some checks failed
CI/CD Pipeline / test (push) Successful in 3s
CI/CD Pipeline / deploy (push) Successful in 18s
CI/CD Pipeline / promote_stable (push) Failing after 3s
2026-02-10 20:20:20 +01:00
f167e64d00 make the cli update executable
All checks were successful
CI/CD Pipeline / test (push) Successful in 2s
CI/CD Pipeline / deploy (push) Successful in 18s
2026-02-10 19:58:09 +01:00
95ebc7778d Update ACP system navigation
All checks were successful
CI/CD Pipeline / test (push) Successful in 2s
CI/CD Pipeline / deploy (push) Successful in 19s
2026-02-10 19:52:24 +01:00
c67a3ec6d0 Add pre-commit hook to verify DB
All checks were successful
CI/CD Pipeline / test (push) Successful in 3s
CI/CD Pipeline / deploy (push) Successful in 19s
2026-02-10 18:46:43 +01:00
bf278667bc Add git_update.sh and adjust update/test hooks
All checks were successful
CI/CD Pipeline / test (push) Successful in 3s
CI/CD Pipeline / deploy (push) Successful in 23s
2026-02-10 18:38:51 +01:00
30a06e18f0 Bump version
All checks were successful
CI/CD Pipeline / test (push) Successful in 2s
CI/CD Pipeline / deploy (push) Successful in 19s
2026-02-08 19:16:21 +01:00
42 changed files with 2180 additions and 1021 deletions

View File

@@ -3,20 +3,24 @@ run-name: ${{ gitea.event.head_commit.message }}
on:
push:
branches:
- dev
- master
jobs:
test:
runs-on: debian-latest
steps:
- name: Show Debian version
run: cat /etc/os-release
- name: Test Deployment
run: echo "Deployment test"
deploy:
if: gitea.ref_name == 'master'
runs-on: self-hosted
needs: test
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
env:
ANSIBLE_VAULT_PASSWORD: ${{ secrets.ANSIBLE_VAULT_PASSWORD }}
@@ -37,3 +41,25 @@ jobs:
echo "$ANSIBLE_VAULT_PASSWORD" > .vault_pass.txt
ansible-playbook --vault-password-file .vault_pass.txt deploy-to-prod.yaml
rm .vault_pass.txt
promote_stable:
if: gitea.ref_name == 'master'
runs-on: self-hosted
needs: deploy
steps:
- name: Promote master to stable
env:
SPEEDBB_REPO: ${{ vars.SPEEDBB_REPO }}
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
GITEA_ACTOR: ${{ gitea.actor }}
run: |
set -e
REPO="$SPEEDBB_REPO"
if [ -n "$GITEA_TOKEN" ]; then
REPO=$(echo "$SPEEDBB_REPO" | sed "s#https://#https://${GITEA_ACTOR}:${GITEA_TOKEN}@#")
fi
git clone --quiet --depth=1 --branch=stable "$REPO" repo
cd repo
git fetch origin master
git merge --ff-only FETCH_HEAD
git push origin stable

3
.gitignore vendored
View File

@@ -22,12 +22,15 @@
/public/build
/public/hot
/public/storage
/public/custom
/storage/app
/storage/framework
/storage/*.key
/storage/pail
/backups
/storage/framework/views/*.php
/bootstrap/cache/*.php
/custom
/vendor
Homestead.json
Homestead.yaml

View File

@@ -1,9 +1,62 @@
# Changelog
## 2026-02-27
- Reworked ACP System navigation into `Health` and `Updates`.
- Moved update/version actions into the new `Updates` area and grouped update checks under `Live Update`, `CLI`, and `CI/CD`.
- Added CLI PHP interpreter `Check` action (no persistence) plus save-time validation endpoint.
- Updated CLI PHP save UX to keep persistent inline errors and avoid duplicate danger toasts.
- Added iconized, accent-styled `Check` and `Save` actions in ACP CLI settings.
- Fixed system-status PHP detection to avoid false positives when a configured CLI binary is invalid.
- Switched `Health` PHP requirement checks to the web runtime interpreter (`PHP_BINARY`/`PHP_VERSION`) instead of configured CLI binary.
- Limited `Health` checks to runtime-relevant items (removed `tar`/`rsync` from Health view).
- Fixed `public/storage` symlink health check to correctly resolve absolute and relative symlink targets.
## 2026-02-24
- Added login modal actions: `Cancel` button and accent-styled, right-aligned `Sign in` button.
- Added functional `Forgot password?` flow with dedicated SPA route/page at `/reset-password`.
- Implemented reset-link request UI wired to `POST /api/forgot-password`.
- Implemented token-based new-password submission (`?token=...&email=...`) wired to `POST /api/reset-password`.
- Updated reset flow UX to return to `/login` after successful reset-link request and after successful password update.
- Added English and German translations for password reset screens/messages.
- Added new `/ping` endpoint returning connection status, build status, and notification state.
- Added frontend ping polling with active/hidden intervals and console diagnostics.
- Added update-available detection comparing loaded build vs ping build, with footer refresh CTA.
- Added update info modal prompting users to refresh when a newer build is detected.
- Tuned global dark mode palette to reduce overly bright text/surfaces in dark theme.
- Refined accent button state styling (hover/active/focus) to avoid Bootstrap blue fallback and preserve contrast.
- Fixed deployment storage path handling by ensuring `public/storage` symlink is created in Ansible.
- Changed `version:fetch` to sync DB version/build from `composer.json` metadata (host git recount removed).
- Updated runtime version/ping responses to prefer `composer.json` metadata over DB values to avoid drift.
- Restored local `master` as build metadata source of truth by removing CI write-back to `master`.
- Updated local pre-commit hook to stamp `composer.json` build from local git count and stage the file automatically.
- Bumped forum version to `26.0.3`.
## 2026-02-18
- 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.
- 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.
- 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
- Refined ACP System tab with left navigation, section-specific requirements, and CLI PHP selector.
- Added CLI PHP interpreter options (php, keyhelp-php-domain, custom) with KeyHelp guidance.
- Updated CLI update tooling and automation notes (KeyHelp PHP handling, CI runner requirements).
- Adjusted ACP layout and tab styling for better dark-mode readability and auto-sizing sidebars.
- Added Custom top-level ACP tab and preserved /custom paths during in-app updates.
## 2026-02-10
- Reshaped ACP System tab with left navigation and dedicated views (Overview, Live Update, CLI, CI/CD).
- Moved system requirements table into the CI/CD view with refresh controls.
## 2026-02-08
- Achieved 100% test coverage across the backend.
- Added comprehensive Feature and Unit tests for controllers, models, services, and console commands.
- Added extensive edge-case and error-path coverage (system update/status, versioning, attachments, forums, roles, ranks, settings, portal, etc.).
- Added `git_update.sh` for CLI-based updates (stable branch, deps, build, migrations, version sync).
## 2026-01-12
- Switched main SPA layouts to fluid containers to reduce wasted space.

View File

@@ -1,7 +1,15 @@
TODO: Remove remaining IIFEs in ACP UI; prefer plain components/helpers.
Add git_update.sh script to update the forum and core.
Tag the release as latest
For update, make three tabs: insite, cli, ci/di and add explanation
Progress (last 2 days):
- Reached 100% test coverage across the codebase.
- Added extensive Feature and Unit tests for controllers, models, services, and console commands.
- Added coverage scripts and cleanup (tests for update/version flows, system update/status, attachments, forums, roles, ranks, settings, portal, etc.).
- Hardened tests with fakes/mocks to cover error paths and edge cases.
TODO: Make the PHP binary path configurable for updates if the default PHP is outdated (ACP -> System).
CI/CD: Runner must have PHP 8.4+ as the default CLI interpreter.
KeyHelp: `keyhelp-php-domain` can select the PHP version based on the domain of the script location.
KeyHelp: `keyhelp-php-domain` is a Pro feature; on non-Pro setups we must fake the command.

View File

@@ -1,7 +1,42 @@
# SpeedBB Forum
# speedBB
Placeholder README for the forum application.
speedBB is a modern forum application with a built-in Admin Control Panel (ACP), customizable branding, user/rank management, attachment support, and integrated update tooling.
## Status
## What It Does
Work in progress.
- Hosts classic forum discussions with categories, forums, topics, and replies.
- Provides an ACP for everyday operations (settings, users, groups, ranks, attachments, and audit log).
- Supports brand customization (name, theme, accents, logos, favicons).
- Manages user media (avatars, rank badges, logos) with public delivery.
- Includes built-in update and system-check workflows so admins can verify server health and apply updates from the ACP.
## ACP Areas
The ACP is organized into practical sections for day-to-day forum operations:
- `General`: core board identity and visual setup (name, theme defaults, accents, logos, favicons).
- `Forums`: structure and ordering of categories/forums.
- `Users`: account overview and moderation/admin user management actions.
- `Groups`: role and permission group administration.
- `Ranks`: rank definitions and badge management.
- `Attachments`: attachment policy and extension/group controls.
- `Audit log`: activity trail for administrative actions.
- `System`: environment checks and update operations.
- `Custom`: space for project-specific custom assets/overrides.
### System
The System area is split into two focused views:
- `Health`: shows whether the live website environment is healthy enough to run reliably.
- `Updates`: provides update-related checks and update actions, including CLI interpreter validation.
This separation is intentional:
- `Health` answers “Is the site healthy right now?”
- `Updates` answers “Is this server ready to run update operations?”
## Current Product Status
- Active version: `26.0.3`
- Forum + ACP features are in active use.
- Health and update checks are integrated directly into ACP System.

View File

@@ -21,12 +21,59 @@
path: "{{ prod_base_dir }}/.env"
register: env_file
- name: Ensure bootstrap cache directory exists
file:
path: "{{ prod_base_dir }}/bootstrap/cache"
state: directory
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
block:
- name: Composer install
community.general.composer:
command: install
arguments: --no-dev --optimize-autoloader
working_dir: "{{ prod_base_dir }}"
php_path: /usr/bin/keyhelp-php84
rescue:
- name: Debug package discovery
shell: |
keyhelp-php84 artisan package:discover -v --ansi 2>&1 | tail -n 200
args:
chdir: "{{ prod_base_dir }}"
register: package_discover_debug
- debug:
var: package_discover_debug.stdout_lines
- fail:
msg: "Composer install failed; see package:discover output above."
- name: Install node_modules
npm:
@@ -74,8 +121,8 @@
msg: "Database backed up to: {{ backup_result.stdout }}"
when: env_file.stat.exists
- name: Run database migrations safely
command: "keyhelp-php84 artisan migrate:safe --force"
- name: Run database migrations
command: "keyhelp-php84 artisan migrate --force"
args:
chdir: "{{ prod_base_dir }}"
register: migrate_result

View File

@@ -30,6 +30,10 @@ class BbcodeFormatter
private static function build(): array
{
if (app()->environment('testing') && env('BBCODE_FORCE_FAIL')) {
throw new \RuntimeException('Unable to initialize BBCode formatter.');
}
$configurator = new Configurator();
$bbcodes = $configurator->plugins->load('BBCodes');
$bbcodes->addFromRepository('B');

View File

@@ -4,94 +4,75 @@ namespace App\Console\Commands;
use App\Models\Setting;
use Illuminate\Console\Command;
use Symfony\Component\Process\Process;
class VersionFetch extends Command
{
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
{
$version = Setting::where('key', 'version')->value('value');
$build = $this->resolveBuildCount();
if ($version === null) {
$this->error('Unable to determine version from settings.');
$meta = $this->resolveComposerMetadata();
if ($meta === null) {
$this->error('Unable to determine version/build from composer.json.');
return self::FAILURE;
}
if ($build === null) {
$this->error('Unable to determine build number from git.');
return self::FAILURE;
}
$version = $meta['version'];
$build = $meta['build'];
Setting::updateOrCreate(
['key' => 'version'],
['value' => $version],
);
Setting::updateOrCreate(
['key' => 'build'],
['value' => (string) $build],
);
if (!$this->syncComposerMetadata($version, $build)) {
$this->error('Failed to sync version/build to composer.json.');
return self::FAILURE;
}
$this->info("Build number updated to {$build}.");
$this->info("Version/build synced: {$version} (build {$build}).");
return self::SUCCESS;
}
private function resolveBuildCount(): ?int
{
$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
private function resolveComposerMetadata(): ?array
{
$composerPath = base_path('composer.json');
if (!is_file($composerPath) || !is_readable($composerPath)) {
return false;
return null;
}
$raw = file_get_contents($composerPath);
if ($raw === false) {
return false;
return null;
}
$data = json_decode($raw, true);
if (!is_array($data)) {
return false;
return null;
}
$data['version'] = $version;
$data['build'] = (string) $build;
$version = trim((string) ($data['version'] ?? ''));
$buildRaw = trim((string) ($data['build'] ?? ''));
$encoded = json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
if ($encoded === false) {
return false;
if ($version === '' || $buildRaw === '') {
return null;
}
$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 Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Symfony\Component\Process\Process;
class SettingController extends Controller
{
@@ -38,6 +39,12 @@ class SettingController extends Controller
]);
$value = $data['value'] ?? '';
if ($data['key'] === 'system.php_binary') {
$validationError = $this->validatePhpBinarySetting($value);
if ($validationError !== null) {
return response()->json(['message' => $validationError], 422);
}
}
$setting = Setting::updateOrCreate(
['key' => $data['key']],
@@ -67,6 +74,12 @@ class SettingController extends Controller
$updated = [];
foreach ($data['settings'] as $entry) {
if (($entry['key'] ?? '') === 'system.php_binary') {
$validationError = $this->validatePhpBinarySetting($entry['value'] ?? '');
if ($validationError !== null) {
return response()->json(['message' => $validationError], 422);
}
}
$setting = Setting::updateOrCreate(
['key' => $entry['key']],
['value' => $entry['value'] ?? '']
@@ -80,4 +93,66 @@ class SettingController extends Controller
return response()->json($updated);
}
public function validateSystemPhpBinary(Request $request): JsonResponse
{
$user = $request->user();
if (!$user || !$user->roles()->where('name', 'ROLE_ADMIN')->exists()) {
return response()->json(['message' => 'Forbidden'], 403);
}
$data = $request->validate([
'value' => ['required', 'string'],
]);
$validationError = $this->validatePhpBinarySetting($data['value']);
if ($validationError !== null) {
return response()->json(['message' => $validationError], 422);
}
return response()->json([
'message' => 'PHP interpreter is valid.',
]);
}
private function validatePhpBinarySetting(string $value): ?string
{
$binary = trim($value);
if ($binary === '' || $binary === 'php') {
return null;
}
if ($binary === 'keyhelp-php-domain') {
return '`keyhelp-php-domain` is disabled. Use a concrete binary (e.g. keyhelp-php84).';
}
$resolved = null;
if (str_contains($binary, '/')) {
if (!is_executable($binary)) {
return "Configured PHP binary '{$binary}' is not executable.";
}
$resolved = $binary;
} else {
$escapedBinary = escapeshellarg($binary);
$process = new Process(['sh', '-lc', "command -v {$escapedBinary}"]);
$process->setTimeout(5);
$process->run();
if (!$process->isSuccessful()) {
return "Configured PHP binary '{$binary}' was not found in PATH.";
}
$resolved = trim($process->getOutput());
if ($resolved === '') {
return "Configured PHP binary '{$binary}' was not found in PATH.";
}
}
$phpCheck = new Process([$resolved, '-r', 'echo PHP_VERSION;']);
$phpCheck->setTimeout(5);
$phpCheck->run();
if (!$phpCheck->isSuccessful() || trim($phpCheck->getOutput()) === '') {
return "Configured binary '{$binary}' is not a working PHP CLI executable.";
}
return null;
}
}

View File

@@ -2,6 +2,7 @@
namespace App\Http\Controllers;
use App\Models\Setting;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Symfony\Component\Process\Process;
@@ -16,9 +17,24 @@ class SystemStatusController extends Controller
}
$phpDefaultPath = $this->resolveBinary('php');
$phpDefaultVersion = $phpDefaultPath ? $this->resolvePhpVersion($phpDefaultPath) : null;
$phpConfiguredPath = trim((string) Setting::where('key', 'system.php_binary')->value('value'));
$phpSelectedPath = null;
$phpSelectedVersion = null;
$phpSelectedOk = false;
if ($phpConfiguredPath !== '') {
$resolvedConfiguredPhpPath = $this->resolveConfiguredPhpBinaryPath($phpConfiguredPath);
$phpSelectedPath = $resolvedConfiguredPhpPath ?: $phpConfiguredPath;
$phpSelectedVersion = $resolvedConfiguredPhpPath ? $this->resolvePhpVersion($resolvedConfiguredPhpPath) : null;
$phpSelectedOk = $resolvedConfiguredPhpPath !== null && $phpSelectedVersion !== null;
} else {
$phpSelectedPath = PHP_BINARY ?: $phpDefaultPath;
$phpSelectedOk = (bool) $phpSelectedPath;
$phpSelectedVersion = PHP_VERSION;
$phpSelectedVersion = $phpSelectedPath
? ($this->resolvePhpVersion($phpSelectedPath) ?? $phpDefaultVersion ?? PHP_VERSION)
: null;
$phpSelectedOk = $phpSelectedPath !== null && $phpSelectedVersion !== null;
}
$minVersions = $this->resolveMinVersions();
$composerPath = $this->resolveBinary('composer');
$nodePath = $this->resolveBinary('node');
@@ -39,7 +55,11 @@ class SystemStatusController extends Controller
return response()->json([
'php' => PHP_VERSION,
'php_web_path' => PHP_BINARY ?: null,
'php_web_version' => PHP_VERSION ?: null,
'php_default' => $phpDefaultPath,
'php_default_version' => $phpDefaultVersion,
'php_configured' => $phpConfiguredPath ?: null,
'php_selected_path' => $phpSelectedPath,
'php_selected_ok' => $phpSelectedOk,
'php_selected_version' => $phpSelectedVersion,
@@ -56,10 +76,36 @@ class SystemStatusController extends Controller
'rsync_version' => $this->resolveBinaryVersion($rsyncPath, ['--version']),
'proc_functions' => $procFunctionStatus,
'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),
]);
}
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
{
$process = new Process(['sh', '-lc', "command -v {$name}"]);
@@ -88,6 +134,20 @@ class SystemStatusController extends Controller
return $output !== '' ? $output : null;
}
private function resolveConfiguredPhpBinaryPath(string $binary): ?string
{
$value = trim($binary);
if ($value === '') {
return null;
}
if (str_contains($value, '/')) {
return is_executable($value) ? $value : null;
}
return $this->resolveBinary($value);
}
private function resolveBinaryVersion(?string $path, array $args): ?string
{
if (!$path) {

View File

@@ -2,11 +2,13 @@
namespace App\Http\Controllers;
use App\Models\Setting;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Str;
use RuntimeException;
use Symfony\Component\Process\Process;
class SystemUpdateController extends Controller
@@ -112,6 +114,7 @@ class SystemUpdateController extends Controller
$append('Syncing files...');
$usedRsync = false;
$rsyncPath = trim((string) shell_exec('command -v rsync'));
$protectedPaths = ['storage', 'public/storage', 'custom', 'public/custom'];
if ($rsyncPath !== '') {
$usedRsync = true;
$rsync = new Process([
@@ -121,6 +124,8 @@ class SystemUpdateController extends Controller
'--exclude=.env',
'--exclude=storage',
'--exclude=public/storage',
'--exclude=custom',
'--exclude=public/custom',
$sourceDir . '/',
base_path() . '/',
]);
@@ -133,43 +138,26 @@ class SystemUpdateController extends Controller
], 500);
}
} else {
foreach ($protectedPaths as $path) {
$sourcePath = $sourceDir . DIRECTORY_SEPARATOR . $path;
if (File::exists($sourcePath)) {
File::deleteDirectory($sourcePath);
if (File::exists($sourcePath)) {
File::delete($sourcePath);
}
}
}
File::copyDirectory($sourceDir, base_path());
}
$append('Installing composer dependencies...');
$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);
}
$this->ensurePublicStorageLink();
$append('Installing npm dependencies...');
$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('Using prebuilt release package (skipping composer/npm steps).');
$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'));
if ($phpBinary === '') {
$phpBinary = env('SYSTEM_UPDATE_PHP_BINARY') ?: (PHP_BINARY ?: 'php');
}
$phpBinary = PHP_BINARY ?: 'php';
$append("Running migrations (using {$phpBinary})...");
$migrate = new Process([$phpBinary, 'artisan', 'migrate', '--force'], base_path());
$migrate->setTimeout(600);
@@ -196,4 +184,39 @@ class SystemUpdateController extends Controller
], 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\Request;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Storage;
use RuntimeException;
class UploadController extends Controller
{
@@ -14,6 +16,7 @@ class UploadController extends Controller
if (!$user) {
return response()->json(['message' => 'Unauthorized'], 401);
}
$this->ensurePublicStorageReady();
$data = $request->validate([
'file' => [
@@ -45,6 +48,7 @@ class UploadController extends Controller
if (!$user || !$user->roles()->where('name', 'ROLE_ADMIN')->exists()) {
return response()->json(['message' => 'Forbidden'], 403);
}
$this->ensurePublicStorageReady();
$data = $request->validate([
'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()) {
return response()->json(['message' => 'Forbidden'], 403);
}
$this->ensurePublicStorageReady();
$data = $request->validate([
'file' => ['required', 'file', 'mimes:png,ico', 'max:2048'],
@@ -76,4 +81,49 @@ class UploadController extends Controller
'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

@@ -209,6 +209,10 @@ class UserController extends Controller
->pluck('id')
->all();
$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');

View File

@@ -9,12 +9,39 @@ class VersionController extends Controller
{
public function __invoke(): JsonResponse
{
$version = Setting::where('key', 'version')->value('value');
$build = Setting::where('key', 'build')->value('value');
$composer = $this->readComposerMetadata();
$version = $composer['version'] ?? Setting::where('key', 'version')->value('value');
$build = $composer['build'] ?? Setting::where('key', 'build')->value('value');
return response()->json([
'version' => $version,
'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,
];
}
}

0
bootstrap/cache/.gitkeep vendored Normal file
View File

View File

@@ -1,72 +0,0 @@
<?php return array (
'barryvdh/laravel-ide-helper' =>
array (
'providers' =>
array (
0 => 'Barryvdh\\LaravelIdeHelper\\IdeHelperServiceProvider',
),
),
'laravel/fortify' =>
array (
'providers' =>
array (
0 => 'Laravel\\Fortify\\FortifyServiceProvider',
),
),
'laravel/pail' =>
array (
'providers' =>
array (
0 => 'Laravel\\Pail\\PailServiceProvider',
),
),
'laravel/sail' =>
array (
'providers' =>
array (
0 => 'Laravel\\Sail\\SailServiceProvider',
),
),
'laravel/sanctum' =>
array (
'providers' =>
array (
0 => 'Laravel\\Sanctum\\SanctumServiceProvider',
),
),
'laravel/tinker' =>
array (
'providers' =>
array (
0 => 'Laravel\\Tinker\\TinkerServiceProvider',
),
),
'nesbot/carbon' =>
array (
'providers' =>
array (
0 => 'Carbon\\Laravel\\ServiceProvider',
),
),
'nunomaduro/collision' =>
array (
'providers' =>
array (
0 => 'NunoMaduro\\Collision\\Adapters\\Laravel\\CollisionServiceProvider',
),
),
'nunomaduro/termwind' =>
array (
'providers' =>
array (
0 => 'Termwind\\Laravel\\TermwindServiceProvider',
),
),
'pestphp/pest-plugin-laravel' =>
array (
'providers' =>
array (
0 => 'Pest\\Laravel\\PestServiceProvider',
),
),
);

View File

@@ -1,277 +0,0 @@
<?php return array (
'providers' =>
array (
0 => 'Illuminate\\Auth\\AuthServiceProvider',
1 => 'Illuminate\\Broadcasting\\BroadcastServiceProvider',
2 => 'Illuminate\\Bus\\BusServiceProvider',
3 => 'Illuminate\\Cache\\CacheServiceProvider',
4 => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
5 => 'Illuminate\\Concurrency\\ConcurrencyServiceProvider',
6 => 'Illuminate\\Cookie\\CookieServiceProvider',
7 => 'Illuminate\\Database\\DatabaseServiceProvider',
8 => 'Illuminate\\Encryption\\EncryptionServiceProvider',
9 => 'Illuminate\\Filesystem\\FilesystemServiceProvider',
10 => 'Illuminate\\Foundation\\Providers\\FoundationServiceProvider',
11 => 'Illuminate\\Hashing\\HashServiceProvider',
12 => 'Illuminate\\Mail\\MailServiceProvider',
13 => 'Illuminate\\Notifications\\NotificationServiceProvider',
14 => 'Illuminate\\Pagination\\PaginationServiceProvider',
15 => 'Illuminate\\Auth\\Passwords\\PasswordResetServiceProvider',
16 => 'Illuminate\\Pipeline\\PipelineServiceProvider',
17 => 'Illuminate\\Queue\\QueueServiceProvider',
18 => 'Illuminate\\Redis\\RedisServiceProvider',
19 => 'Illuminate\\Session\\SessionServiceProvider',
20 => 'Illuminate\\Translation\\TranslationServiceProvider',
21 => 'Illuminate\\Validation\\ValidationServiceProvider',
22 => 'Illuminate\\View\\ViewServiceProvider',
23 => 'Barryvdh\\LaravelIdeHelper\\IdeHelperServiceProvider',
24 => 'Laravel\\Fortify\\FortifyServiceProvider',
25 => 'Laravel\\Pail\\PailServiceProvider',
26 => 'Laravel\\Sail\\SailServiceProvider',
27 => 'Laravel\\Sanctum\\SanctumServiceProvider',
28 => 'Laravel\\Tinker\\TinkerServiceProvider',
29 => 'Carbon\\Laravel\\ServiceProvider',
30 => 'NunoMaduro\\Collision\\Adapters\\Laravel\\CollisionServiceProvider',
31 => 'Termwind\\Laravel\\TermwindServiceProvider',
32 => 'Pest\\Laravel\\PestServiceProvider',
33 => 'App\\Providers\\AppServiceProvider',
34 => 'App\\Providers\\FortifyServiceProvider',
),
'eager' =>
array (
0 => 'Illuminate\\Auth\\AuthServiceProvider',
1 => 'Illuminate\\Cookie\\CookieServiceProvider',
2 => 'Illuminate\\Database\\DatabaseServiceProvider',
3 => 'Illuminate\\Encryption\\EncryptionServiceProvider',
4 => 'Illuminate\\Filesystem\\FilesystemServiceProvider',
5 => 'Illuminate\\Foundation\\Providers\\FoundationServiceProvider',
6 => 'Illuminate\\Notifications\\NotificationServiceProvider',
7 => 'Illuminate\\Pagination\\PaginationServiceProvider',
8 => 'Illuminate\\Session\\SessionServiceProvider',
9 => 'Illuminate\\View\\ViewServiceProvider',
10 => 'Laravel\\Fortify\\FortifyServiceProvider',
11 => 'Laravel\\Pail\\PailServiceProvider',
12 => 'Laravel\\Sanctum\\SanctumServiceProvider',
13 => 'Carbon\\Laravel\\ServiceProvider',
14 => 'NunoMaduro\\Collision\\Adapters\\Laravel\\CollisionServiceProvider',
15 => 'Termwind\\Laravel\\TermwindServiceProvider',
16 => 'Pest\\Laravel\\PestServiceProvider',
17 => 'App\\Providers\\AppServiceProvider',
18 => 'App\\Providers\\FortifyServiceProvider',
),
'deferred' =>
array (
'Illuminate\\Broadcasting\\BroadcastManager' => 'Illuminate\\Broadcasting\\BroadcastServiceProvider',
'Illuminate\\Contracts\\Broadcasting\\Factory' => 'Illuminate\\Broadcasting\\BroadcastServiceProvider',
'Illuminate\\Contracts\\Broadcasting\\Broadcaster' => 'Illuminate\\Broadcasting\\BroadcastServiceProvider',
'Illuminate\\Bus\\Dispatcher' => 'Illuminate\\Bus\\BusServiceProvider',
'Illuminate\\Contracts\\Bus\\Dispatcher' => 'Illuminate\\Bus\\BusServiceProvider',
'Illuminate\\Contracts\\Bus\\QueueingDispatcher' => 'Illuminate\\Bus\\BusServiceProvider',
'Illuminate\\Bus\\BatchRepository' => 'Illuminate\\Bus\\BusServiceProvider',
'Illuminate\\Bus\\DatabaseBatchRepository' => 'Illuminate\\Bus\\BusServiceProvider',
'cache' => 'Illuminate\\Cache\\CacheServiceProvider',
'cache.store' => 'Illuminate\\Cache\\CacheServiceProvider',
'cache.psr6' => 'Illuminate\\Cache\\CacheServiceProvider',
'memcached.connector' => 'Illuminate\\Cache\\CacheServiceProvider',
'Illuminate\\Cache\\RateLimiter' => 'Illuminate\\Cache\\CacheServiceProvider',
'Illuminate\\Foundation\\Console\\AboutCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Cache\\Console\\ClearCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Cache\\Console\\ForgetCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Foundation\\Console\\ClearCompiledCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Auth\\Console\\ClearResetsCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Foundation\\Console\\ConfigCacheCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Foundation\\Console\\ConfigClearCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Foundation\\Console\\ConfigShowCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Database\\Console\\DbCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Database\\Console\\MonitorCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Database\\Console\\PruneCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Database\\Console\\ShowCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Database\\Console\\TableCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Database\\Console\\WipeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Foundation\\Console\\DownCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Foundation\\Console\\EnvironmentCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Foundation\\Console\\EnvironmentDecryptCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Foundation\\Console\\EnvironmentEncryptCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Foundation\\Console\\EventCacheCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Foundation\\Console\\EventClearCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Foundation\\Console\\EventListCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Concurrency\\Console\\InvokeSerializedClosureCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Foundation\\Console\\KeyGenerateCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Foundation\\Console\\OptimizeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Foundation\\Console\\OptimizeClearCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Foundation\\Console\\PackageDiscoverCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Cache\\Console\\PruneStaleTagsCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Queue\\Console\\ClearCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Queue\\Console\\ListFailedCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Queue\\Console\\FlushFailedCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Queue\\Console\\ForgetFailedCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Queue\\Console\\ListenCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Queue\\Console\\MonitorCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Queue\\Console\\PauseCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Queue\\Console\\PruneBatchesCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Queue\\Console\\PruneFailedJobsCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Queue\\Console\\RestartCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Queue\\Console\\ResumeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Queue\\Console\\RetryCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Queue\\Console\\RetryBatchCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Queue\\Console\\WorkCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Foundation\\Console\\ReloadCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Foundation\\Console\\RouteCacheCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Foundation\\Console\\RouteClearCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Foundation\\Console\\RouteListCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Database\\Console\\DumpCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Database\\Console\\Seeds\\SeedCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Console\\Scheduling\\ScheduleFinishCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Console\\Scheduling\\ScheduleListCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Console\\Scheduling\\ScheduleRunCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Console\\Scheduling\\ScheduleClearCacheCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Console\\Scheduling\\ScheduleTestCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Console\\Scheduling\\ScheduleWorkCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Console\\Scheduling\\ScheduleInterruptCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Database\\Console\\ShowModelCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Foundation\\Console\\StorageLinkCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Foundation\\Console\\StorageUnlinkCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Foundation\\Console\\UpCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Foundation\\Console\\ViewCacheCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Foundation\\Console\\ViewClearCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Foundation\\Console\\ApiInstallCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Foundation\\Console\\BroadcastingInstallCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Cache\\Console\\CacheTableCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Foundation\\Console\\CastMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Foundation\\Console\\ChannelListCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Foundation\\Console\\ChannelMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Foundation\\Console\\ClassMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Foundation\\Console\\ComponentMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Foundation\\Console\\ConfigMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Foundation\\Console\\ConfigPublishCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Foundation\\Console\\ConsoleMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Routing\\Console\\ControllerMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Foundation\\Console\\DocsCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Foundation\\Console\\EnumMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Foundation\\Console\\EventGenerateCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Foundation\\Console\\EventMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Foundation\\Console\\ExceptionMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Database\\Console\\Factories\\FactoryMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Foundation\\Console\\InterfaceMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Foundation\\Console\\JobMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Foundation\\Console\\JobMiddlewareMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Foundation\\Console\\LangPublishCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Foundation\\Console\\ListenerMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Foundation\\Console\\MailMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Routing\\Console\\MiddlewareMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Foundation\\Console\\ModelMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Foundation\\Console\\NotificationMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Notifications\\Console\\NotificationTableCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Foundation\\Console\\ObserverMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Foundation\\Console\\PolicyMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Foundation\\Console\\ProviderMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Queue\\Console\\FailedTableCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Queue\\Console\\TableCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Queue\\Console\\BatchesTableCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Foundation\\Console\\RequestMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Foundation\\Console\\ResourceMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Foundation\\Console\\RuleMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Foundation\\Console\\ScopeMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Database\\Console\\Seeds\\SeederMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Session\\Console\\SessionTableCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Foundation\\Console\\ServeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Foundation\\Console\\StubPublishCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Foundation\\Console\\TestMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Foundation\\Console\\TraitMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Foundation\\Console\\VendorPublishCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Foundation\\Console\\ViewMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'migrator' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'migration.repository' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'migration.creator' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Database\\Migrations\\Migrator' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Database\\Console\\Migrations\\MigrateCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Database\\Console\\Migrations\\FreshCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Database\\Console\\Migrations\\InstallCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Database\\Console\\Migrations\\RefreshCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Database\\Console\\Migrations\\ResetCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Database\\Console\\Migrations\\RollbackCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Database\\Console\\Migrations\\StatusCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Database\\Console\\Migrations\\MigrateMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'composer' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Concurrency\\ConcurrencyManager' => 'Illuminate\\Concurrency\\ConcurrencyServiceProvider',
'hash' => 'Illuminate\\Hashing\\HashServiceProvider',
'hash.driver' => 'Illuminate\\Hashing\\HashServiceProvider',
'mail.manager' => 'Illuminate\\Mail\\MailServiceProvider',
'mailer' => 'Illuminate\\Mail\\MailServiceProvider',
'Illuminate\\Mail\\Markdown' => 'Illuminate\\Mail\\MailServiceProvider',
'auth.password' => 'Illuminate\\Auth\\Passwords\\PasswordResetServiceProvider',
'auth.password.broker' => 'Illuminate\\Auth\\Passwords\\PasswordResetServiceProvider',
'Illuminate\\Contracts\\Pipeline\\Hub' => 'Illuminate\\Pipeline\\PipelineServiceProvider',
'pipeline' => 'Illuminate\\Pipeline\\PipelineServiceProvider',
'queue' => 'Illuminate\\Queue\\QueueServiceProvider',
'queue.connection' => 'Illuminate\\Queue\\QueueServiceProvider',
'queue.failer' => 'Illuminate\\Queue\\QueueServiceProvider',
'queue.listener' => 'Illuminate\\Queue\\QueueServiceProvider',
'queue.worker' => 'Illuminate\\Queue\\QueueServiceProvider',
'redis' => 'Illuminate\\Redis\\RedisServiceProvider',
'redis.connection' => 'Illuminate\\Redis\\RedisServiceProvider',
'translator' => 'Illuminate\\Translation\\TranslationServiceProvider',
'translation.loader' => 'Illuminate\\Translation\\TranslationServiceProvider',
'validator' => 'Illuminate\\Validation\\ValidationServiceProvider',
'validation.presence' => 'Illuminate\\Validation\\ValidationServiceProvider',
'Illuminate\\Contracts\\Validation\\UncompromisedVerifier' => 'Illuminate\\Validation\\ValidationServiceProvider',
'Barryvdh\\LaravelIdeHelper\\Console\\GeneratorCommand' => 'Barryvdh\\LaravelIdeHelper\\IdeHelperServiceProvider',
'Barryvdh\\LaravelIdeHelper\\Console\\ModelsCommand' => 'Barryvdh\\LaravelIdeHelper\\IdeHelperServiceProvider',
'Barryvdh\\LaravelIdeHelper\\Console\\MetaCommand' => 'Barryvdh\\LaravelIdeHelper\\IdeHelperServiceProvider',
'Barryvdh\\LaravelIdeHelper\\Console\\EloquentCommand' => 'Barryvdh\\LaravelIdeHelper\\IdeHelperServiceProvider',
'Laravel\\Sail\\Console\\InstallCommand' => 'Laravel\\Sail\\SailServiceProvider',
'Laravel\\Sail\\Console\\PublishCommand' => 'Laravel\\Sail\\SailServiceProvider',
'command.tinker' => 'Laravel\\Tinker\\TinkerServiceProvider',
),
'when' =>
array (
'Illuminate\\Broadcasting\\BroadcastServiceProvider' =>
array (
),
'Illuminate\\Bus\\BusServiceProvider' =>
array (
),
'Illuminate\\Cache\\CacheServiceProvider' =>
array (
),
'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider' =>
array (
),
'Illuminate\\Concurrency\\ConcurrencyServiceProvider' =>
array (
),
'Illuminate\\Hashing\\HashServiceProvider' =>
array (
),
'Illuminate\\Mail\\MailServiceProvider' =>
array (
),
'Illuminate\\Auth\\Passwords\\PasswordResetServiceProvider' =>
array (
),
'Illuminate\\Pipeline\\PipelineServiceProvider' =>
array (
),
'Illuminate\\Queue\\QueueServiceProvider' =>
array (
),
'Illuminate\\Redis\\RedisServiceProvider' =>
array (
),
'Illuminate\\Translation\\TranslationServiceProvider' =>
array (
),
'Illuminate\\Validation\\ValidationServiceProvider' =>
array (
),
'Barryvdh\\LaravelIdeHelper\\IdeHelperServiceProvider' =>
array (
),
'Laravel\\Sail\\SailServiceProvider' =>
array (
),
'Laravel\\Tinker\\TinkerServiceProvider' =>
array (
),
),
);

View File

@@ -97,5 +97,6 @@
},
"minimum-stability": "stable",
"prefer-stable": true,
"version": "26.0.1"
"version": "26.0.3",
"build": "107"
}

2
composer.lock generated
View File

@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "83b577a02e99a4e17696941851d13cc2",
"content-hash": "e6076a6989b155fddbc675cab28fdd50",
"packages": [
{
"name": "bacon/bacon-qr-code",

345
git_update.sh Executable file
View File

@@ -0,0 +1,345 @@
#!/usr/bin/env bash
# shellcheck disable=SC2016
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
resolve_php_bin() {
if [[ -n "${PHP_BIN:-}" ]]; then
echo "$PHP_BIN"
return
fi
if command -v keyhelp-php84 >/dev/null 2>&1; then
echo "keyhelp-php84"
return
fi
if command -v php >/dev/null 2>&1; then
echo "php"
return
fi
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() {
local configured="${1:-}"
local current="${2:-php}"
local trimmed="$configured"
trimmed="${trimmed#"${trimmed%%[![:space:]]*}"}"
trimmed="${trimmed%"${trimmed##*[![:space:]]}"}"
if [[ -z "$trimmed" ]]; then
echo "$current"
return
fi
if [[ "$trimmed" == "keyhelp-php-domain" ]]; then
if command -v keyhelp-php-domain >/dev/null 2>&1; then
echo "keyhelp-php-domain"
return
fi
if [[ -x "/usr/bin/keyhelp-php-domain" ]]; then
echo "/usr/bin/keyhelp-php-domain"
return
fi
if [[ -x "/usr/local/bin/keyhelp-php-domain" ]]; then
echo "/usr/local/bin/keyhelp-php-domain"
return
fi
echo "Configured PHP binary 'keyhelp-php-domain' was not found." >&2
echo "Set ACP -> System -> CLI to a working custom binary (e.g. keyhelp-php84)." >&2
exit 1
fi
if command -v "$trimmed" >/dev/null 2>&1; then
echo "$trimmed"
return
fi
if [[ "$trimmed" == */* && -x "$trimmed" ]]; then
echo "$trimmed"
return
fi
echo "Configured PHP binary '$trimmed' is not executable/resolvable." >&2
echo "Set ACP -> System -> CLI to a valid command or absolute executable path." >&2
exit 1
}
read_setting_php_bin() {
if [[ ! -f artisan ]]; then
echo ""
return 0
fi
echo "Using bootstrap PHP binary to read system.php_binary: $PHP_BIN" >&2
"$PHP_BIN" -r '
require "vendor/autoload.php";
$app = require "bootstrap/app.php";
$app->make(Illuminate\Contracts\Console\Kernel::class)->bootstrap();
$value = (string) \App\Models\Setting::where("key", "system.php_binary")->value("value");
echo trim($value);
'
}
enforce_php_requirement() {
local bin="${1:-php}"
echo "Validating PHP requirement from composer.json with binary: $bin"
"$bin" -r '
$composer = json_decode((string) file_get_contents("composer.json"), true);
$constraint = (string) ($composer["require"]["php"] ?? "");
$current = PHP_VERSION;
if ($constraint === "") {
fwrite(STDOUT, "No PHP requirement found in composer.json; skipping check.\n");
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."
exit 1
fi
echo "Running with PHP binary: $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
echo "Failed to read configured PHP binary from settings." >&2
echo "Aborting to avoid running update with the wrong PHP binary." >&2
exit 1
fi
echo "Configured PHP binary from settings: ${CONFIGURED_PHP:-<empty>}"
PHP_BIN="$(resolve_configured_php_bin "$CONFIGURED_PHP" "$PHP_BIN")"
echo "Final PHP binary: $PHP_BIN"
if command -v "$PHP_BIN" >/dev/null 2>&1; then
echo "Final PHP version ($PHP_BIN): $($PHP_BIN -v | head -n 1)"
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..."
npm install
echo "Building assets..."
npm run build
echo "Running migrations..."
echo "Running with PHP binary: $PHP_BIN artisan migrate --force"
"$PHP_BIN" artisan migrate --force
ensure_storage_link
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 "Running with PHP binary: $PHP_BIN -r <write settings version/build>"
SPEEDBB_VERSION="$VERSION" SPEEDBB_BUILD="$BUILD" "$PHP_BIN" -r '
require "vendor/autoload.php";
$app = require "bootstrap/app.php";
$app->make(Illuminate\Contracts\Console\Kernel::class)->bootstrap();
$version = getenv("SPEEDBB_VERSION");
$build = getenv("SPEEDBB_BUILD");
if ($version !== false && $version !== "") {
\Illuminate\Support\Facades\DB::table("settings")->upsert(
[[
"key" => "version",
"value" => $version,
"created_at" => now(),
"updated_at" => now(),
]],
["key"],
["value", "updated_at"]
);
echo "Upserted version setting.\n";
}
if ($build !== false && $build !== "") {
\Illuminate\Support\Facades\DB::table("settings")->upsert(
[[
"key" => "build",
"value" => $build,
"created_at" => now(),
"updated_at" => now(),
]],
["key"],
["value", "updated_at"]
);
echo "Upserted build setting.\n";
}
' \
&& echo "Running with PHP binary: $PHP_BIN -r <verify settings version/build>" \
&& "$PHP_BIN" -r '
require "vendor/autoload.php";
$app = require "bootstrap/app.php";
$app->make(Illuminate\Contracts\Console\Kernel::class)->bootstrap();
$version = \App\Models\Setting::where("key", "version")->value("value");
$build = \App\Models\Setting::where("key", "build")->value("value");
echo "Settings now: version={$version}, build={$build}\n";
'
fi
echo "Update complete."
}
if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then
main "$@"
fi

View File

@@ -1,18 +1,19 @@
import { useEffect, useState } from 'react'
import { useEffect, useRef, useState } from 'react'
import { BrowserRouter, Link, Route, Routes, useLocation } from 'react-router-dom'
import { Container, NavDropdown } from 'react-bootstrap'
import { Button, Container, Modal, NavDropdown } from 'react-bootstrap'
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 ResetPassword from './pages/ResetPassword'
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 { fetchSettings, fetchVersion, getForum, getThread } from './api/client'
import { fetchPing, fetchSettings, fetchVersion, getForum, getThread } from './api/client'
function PortalHeader({
userMenu,
@@ -240,9 +241,16 @@ function PortalHeader({
}
function AppShell() {
const PING_INTERVAL_MS = 15000
const PING_INTERVAL_HIDDEN_MS = 60000
const { t } = useTranslation()
const { token, email, userId, logout, isAdmin, isModerator } = useAuth()
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 [resolvedTheme, setResolvedTheme] = useState('light')
const [accentOverride, setAccentOverride] = useState(
@@ -271,6 +279,73 @@ function AppShell() {
.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(() => {
let active = true
const loadSettings = async () => {
@@ -466,6 +541,7 @@ function AppShell() {
<Route path="/forum/:id" element={<ForumView />} />
<Route path="/thread/:id" element={<ThreadView />} />
<Route path="/login" element={<Login />} />
<Route path="/reset-password" element={<ResetPassword />} />
<Route path="/register" element={<Register />} />
<Route path="/profile/:id" element={<Profile />} />
<Route path="/acp/*" element={<Acp isAdmin={isAdmin} />} />
@@ -493,8 +569,35 @@ function AppShell() {
<span className="bb-version-label">)</span>
</span>
)}
{availableBuild !== null && (
<Button
type="button"
size="sm"
className="bb-accent-button"
onClick={() => window.location.reload()}
>
{t('version.update_available_short')} (build {availableBuild}) ·{' '}
{t('version.update_now')}
</Button>
)}
</div>
</footer>
<Modal show={showUpdateModal} onHide={() => setShowUpdateModal(false)} centered>
<Modal.Header closeButton>
<Modal.Title>{t('version.refresh_prompt_title')}</Modal.Title>
</Modal.Header>
<Modal.Body>
{t('version.refresh_prompt_body', { build: availableBuild ?? '-' })}
</Modal.Body>
<Modal.Footer className="justify-content-between">
<Button variant="outline-secondary" onClick={() => setShowUpdateModal(false)}>
{t('version.remind_later')}
</Button>
<Button className="bb-accent-button" onClick={() => window.location.reload()}>
{t('version.update_now')}
</Button>
</Modal.Footer>
</Modal>
</div>
)
}

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() {
return apiFetch('/logout', {
method: 'POST',
@@ -115,6 +129,10 @@ export async function fetchVersion() {
return apiFetch('/version')
}
export async function fetchPing() {
return apiFetch('/ping')
}
export async function fetchVersionCheck() {
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) {
const body = new FormData()
body.append('file', file)

View File

@@ -862,10 +862,17 @@ a {
}
[data-bs-theme="dark"] {
--bb-ink: #e6e8eb;
--bb-ink-muted: #9aa4b2;
--bb-border: #2a2f3a;
--bb-page-bg: radial-gradient(circle at 10% 20%, #141823 0%, #10131a 45%, #0b0e14 100%);
--bb-ink: #a3acb9;
--bb-ink-muted: #626d7e;
--bb-border: #242a35;
--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 {
@@ -875,11 +882,30 @@ a {
[data-bs-theme="dark"] .bb-card,
[data-bs-theme="dark"] .bb-form {
background: #171b22;
border-color: #2a2f3a;
background: #121822;
border-color: #242a35;
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 {
background: #fff;
}
@@ -922,10 +948,18 @@ a {
.nav-tabs .nav-link {
color: var(--bb-accent, #f29b3f);
border: 1px solid var(--bb-border);
border-bottom-color: transparent;
border-radius: 10px 10px 0 0;
margin-right: 0.35rem;
background: transparent;
}
.nav-tabs .nav-link.active {
color: inherit;
background: rgba(255, 255, 255, 0.04);
border-color: var(--bb-border);
border-bottom-color: transparent;
}
.bb-version {
@@ -2164,16 +2198,25 @@ a {
}
.bb-accent-button {
background: var(--bb-accent, #f29b3f);
border-color: var(--bb-accent, #f29b3f);
color: #0e121b;
--bs-btn-bg: var(--bb-accent, #f29b3f);
--bs-btn-border-color: var(--bb-accent, #f29b3f);
--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 {
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:focus-visible {
box-shadow: 0 0 0 0.2rem color-mix(in srgb, var(--bb-accent, #f29b3f) 35%, transparent);
}
.bb-accent-button:disabled,

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
import { useState } from 'react'
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 { useTranslation } from 'react-i18next'
@@ -53,10 +53,18 @@ export default function Login() {
onChange={(event) => setPassword(event.target.value)}
required
/>
<div className="mt-2 text-end">
<Link to="/reset-password">{t('auth.forgot_password')}</Link>
</div>
</Form.Group>
<Button type="submit" variant="dark" disabled={loading}>
<div className="d-flex w-100 align-items-center gap-2">
<Button as={Link} to="/" type="button" variant="outline-secondary" disabled={loading}>
{t('acp.cancel')}
</Button>
<Button type="submit" className="ms-auto bb-accent-button" disabled={loading}>
{loading ? t('form.signing_in') : t('form.sign_in')}
</Button>
</div>
</Form>
</Card.Body>
</Card>

View File

@@ -0,0 +1,113 @@
import { useEffect, useState } from 'react'
import { Button, Card, Container, Form } from 'react-bootstrap'
import { Link, useNavigate, useSearchParams } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import { requestPasswordReset, resetPassword } from '../api/client'
export default function ResetPassword() {
const { t } = useTranslation()
const navigate = useNavigate()
const [searchParams] = useSearchParams()
const token = searchParams.get('token') || ''
const emailFromLink = searchParams.get('email') || ''
const isResetFlow = token.length > 0
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [passwordConfirmation, setPasswordConfirmation] = useState('')
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
useEffect(() => {
if (emailFromLink) {
setEmail(emailFromLink)
}
}, [emailFromLink])
const handleSubmit = async (event) => {
event.preventDefault()
setError('')
setLoading(true)
try {
if (isResetFlow) {
await resetPassword({
token,
email,
password,
password_confirmation: passwordConfirmation,
})
navigate('/login')
} else {
await requestPasswordReset(email)
navigate('/')
}
} catch (err) {
setError(err.message)
} finally {
setLoading(false)
}
}
return (
<Container fluid className="py-5">
<Card className="bb-card mx-auto" style={{ maxWidth: '480px' }}>
<Card.Body>
<Card.Title className="mb-3">
{isResetFlow ? t('auth.reset_password_title') : t('auth.forgot_password')}
</Card.Title>
<Card.Text className="bb-muted">
{isResetFlow ? t('auth.reset_password_hint') : t('auth.forgot_password_hint')}
</Card.Text>
{error && <p className="text-danger">{error}</p>}
<Form onSubmit={handleSubmit}>
<Form.Group className="mb-4">
<Form.Label>{t('form.email')}</Form.Label>
<Form.Control
type="email"
value={email}
onChange={(event) => setEmail(event.target.value)}
placeholder={t('auth.reset_email_placeholder')}
required
/>
</Form.Group>
{isResetFlow && (
<>
<Form.Group className="mb-3">
<Form.Label>{t('form.password')}</Form.Label>
<Form.Control
type="password"
value={password}
onChange={(event) => setPassword(event.target.value)}
required
/>
</Form.Group>
<Form.Group className="mb-4">
<Form.Label>{t('auth.confirm_password')}</Form.Label>
<Form.Control
type="password"
value={passwordConfirmation}
onChange={(event) => setPasswordConfirmation(event.target.value)}
required
/>
</Form.Group>
</>
)}
<div className="d-flex w-100 align-items-center gap-2">
<Button as={Link} to="/login" type="button" variant="outline-secondary" disabled={loading}>
{t('acp.cancel')}
</Button>
<Button type="submit" className="ms-auto bb-accent-button" disabled={loading}>
{loading
? isResetFlow
? t('auth.resetting_password')
: t('auth.sending_reset_link')
: isResetFlow
? t('auth.reset_password_submit')
: t('auth.send_reset_link')}
</Button>
</div>
</Form>
</Card.Body>
</Card>
</Container>
)
}

View File

@@ -81,6 +81,18 @@
"auth.login_title": "Anmelden",
"auth.login_identifier": "E-Mail 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.verify_notice": "Bitte bestätige deine E-Mail-Adresse, bevor du dich anmeldest.",
"auth.register_title": "Konto erstellen",
@@ -174,6 +186,9 @@
"version.up_to_date": "Aktuell",
"version.update_available": "Update verfügbar (v{{version}})",
"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.update_now": "Jetzt aktualisieren",
"version.update_title": "System aktualisieren",
@@ -189,6 +204,7 @@
"system.none": "Keine",
"system.not_found": "Nicht gefunden",
"system.storage_writable": "Storage beschreibbar",
"system.storage_linked": "Storage Public-Link",
"system.updates_writable": "Updates beschreibbar",
"system.ok": "OK",
"system.not_ok": "Nicht OK",

View File

@@ -81,6 +81,18 @@
"auth.login_title": "Log in",
"auth.login_identifier": "Email 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.verify_notice": "Check your email to verify your account before logging in.",
"auth.register_title": "Create account",
@@ -164,6 +176,9 @@
"version.up_to_date": "Up to date",
"version.update_available": "Update available (v{{version}})",
"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.update_now": "Update now",
"version.update_title": "Update system",
@@ -179,6 +194,7 @@
"system.none": "None",
"system.not_found": "Not found",
"system.storage_writable": "Storage writable",
"system.storage_linked": "Storage public link",
"system.updates_writable": "Updates writable",
"system.ok": "OK",
"system.not_ok": "Not OK",

View File

@@ -8,6 +8,7 @@ use App\Http\Controllers\AuthController;
use App\Http\Controllers\ForumController;
use App\Http\Controllers\I18nController;
use App\Http\Controllers\PortalController;
use App\Http\Controllers\PingController;
use App\Http\Controllers\PostController;
use App\Http\Controllers\PostThankController;
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('/user/password', [AuthController::class, 'updatePassword'])->middleware('auth:sanctum');
Route::get('/ping', PingController::class);
Route::get('/version', VersionController::class);
Route::get('/version/check', VersionCheckController::class);
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::post('/settings', [SettingController::class, 'store'])->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('/user-settings', [UserSettingController::class, 'index'])->middleware('auth:sanctum');
Route::post('/user-settings', [UserSettingController::class, 'store'])->middleware('auth:sanctum');

View File

@@ -1,6 +1,7 @@
<?php
use App\Http\Controllers\InstallerController;
use App\Http\Controllers\PingController;
use Illuminate\Foundation\Http\Middleware\VerifyCsrfToken;
use Illuminate\Support\Facades\Route;
@@ -29,6 +30,8 @@ Route::get('/reset-password', function () {
return view('app');
})->name('password.reset');
Route::get('/ping', PingController::class);
Route::get('/{any}', function () {
if (!file_exists(base_path('.env'))) {
return redirect('/install');

28
scripts/hooks/pre-commit Normal file
View File

@@ -0,0 +1,28 @@
#!/usr/bin/env bash
set -euo pipefail
# Stamp composer.json build from local git commit count on master/HEAD.
BUILD="$(git rev-list --count master 2>/dev/null || git rev-list --count HEAD)"
BUILD="$BUILD" php -r '
$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

@@ -398,25 +398,20 @@ it('handles migration failure', function (): void {
File::shouldReceive('directories')->andReturn(['/tmp/extract/speedbb']);
File::shouldReceive('copyDirectory')->andReturnTrue();
$artisanPath = base_path('artisan');
$originalArtisan = file_get_contents($artisanPath);
file_put_contents($artisanPath, "#!/usr/bin/env php\n<?php exit(1);\n");
chmod($artisanPath, 0755);
putenv('SYSTEM_UPDATE_PHP_BINARY=/nope');
$_ENV['SYSTEM_UPDATE_PHP_BINARY'] = '/nope';
$_SERVER['SYSTEM_UPDATE_PHP_BINARY'] = '/nope';
withFakeBin([
'tar' => "#!/bin/sh\nexit 0\n",
'composer' => "#!/bin/sh\nexit 0\n",
'npm' => "#!/bin/sh\nexit 0\n",
], function () use ($artisanPath, $originalArtisan): void {
try {
], function (): void {
Sanctum::actingAs(makeAdminForSystemUpdate());
$response = $this->postJson('/api/system/update');
$response->assertStatus(500);
$response->assertJsonFragment(['message' => 'Migrations failed.']);
} finally {
file_put_contents($artisanPath, $originalArtisan);
}
});
});
@@ -424,6 +419,9 @@ it('handles fallback copyDirectory update success', function (): void {
putenv('GITEA_OWNER=acme');
putenv('GITEA_REPO=speedbb');
putenv('GITEA_API_BASE=https://git.example.test/api/v1');
putenv('SYSTEM_UPDATE_PHP_BINARY=php');
$_ENV['SYSTEM_UPDATE_PHP_BINARY'] = 'php';
$_SERVER['SYSTEM_UPDATE_PHP_BINARY'] = 'php';
Http::fake([
'https://git.example.test/api/v1/repos/acme/speedbb/releases/latest' => Http::response([
@@ -438,25 +436,21 @@ it('handles fallback copyDirectory update success', function (): void {
File::shouldReceive('directories')->andReturn(['/tmp/extract/speedbb']);
File::shouldReceive('copyDirectory')->andReturnTrue();
$artisanPath = base_path('artisan');
$originalArtisan = file_get_contents($artisanPath);
file_put_contents($artisanPath, "#!/usr/bin/env php\n<?php exit(0);\n");
chmod($artisanPath, 0755);
putenv('SYSTEM_UPDATE_PHP_BINARY=php');
$_ENV['SYSTEM_UPDATE_PHP_BINARY'] = 'php';
$_SERVER['SYSTEM_UPDATE_PHP_BINARY'] = 'php';
withFakeBin([
'tar' => "#!/bin/sh\nexit 0\n",
'composer' => "#!/bin/sh\nexit 0\n",
'npm' => "#!/bin/sh\nexit 0\n",
], function () use ($artisanPath, $originalArtisan): void {
try {
'php' => "#!/bin/sh\nexit 0\n",
], function (): void {
Sanctum::actingAs(makeAdminForSystemUpdate());
$response = $this->postJson('/api/system/update');
$response->assertOk();
$response->assertJsonFragment(['message' => 'Update finished.']);
$response->assertJsonStructure(['used_rsync']);
} finally {
file_put_contents($artisanPath, $originalArtisan);
}
});
});

View File

@@ -285,3 +285,28 @@ it('updates user name and email as admin', function (): void {
expect($target->email)->toBe('new@example.com');
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
use App\Models\Setting;
it('returns version and build info', function (): void {
Setting::updateOrCreate(['key' => 'version'], ['value' => '1.2.3']);
Setting::updateOrCreate(['key' => 'build'], ['value' => '42']);
it('returns version and build info from composer metadata', function (): void {
$composer = json_decode((string) file_get_contents(base_path('composer.json')), true);
$expectedVersion = (string) ($composer['version'] ?? '');
$expectedBuild = isset($composer['build']) ? (int) $composer['build'] : null;
$response = $this->getJson('/api/version');
$response->assertOk();
$response->assertJsonFragment([
'version' => '1.2.3',
'build' => 42,
'version' => $expectedVersion,
'build' => $expectedBuild,
]);
});

View File

@@ -1,86 +1,5 @@
<?php
namespace s9e\TextFormatter {
class Parser
{
public function parse(string $text): string
{
return '<r/>';
}
}
class Renderer
{
public function render(string $xml): string
{
return '<p>ok</p>';
}
}
class Configurator
{
public static bool $returnEmpty = false;
public object $plugins;
public object $tags;
public function __construct()
{
$this->plugins = new class {
public function load(string $name): object
{
return new class {
public function addFromRepository(string $name): self
{
return $this;
}
};
}
};
$this->tags = new class implements \ArrayAccess {
public array $store = [];
public function add($name)
{
$obj = new \stdClass();
$this->store[$name] = $obj;
return $obj;
}
public function offsetExists($offset): bool
{
return array_key_exists($offset, $this->store);
}
public function offsetGet($offset): mixed
{
return $this->store[$offset] ?? null;
}
public function offsetSet($offset, $value): void
{
$this->store[$offset] = $value;
}
public function offsetUnset($offset): void
{
unset($this->store[$offset]);
}
};
$this->tags['QUOTE'] = new \stdClass();
}
public function finalize(): array
{
if (self::$returnEmpty) {
return [];
}
return [
'parser' => new Parser(),
'renderer' => new Renderer(),
];
}
}
}
namespace {
use App\Actions\BbcodeFormatter;
it('returns empty string for null and empty input', function (): void {
@@ -128,8 +47,43 @@ namespace {
expect($rendererProp->getValue())->not->toBeNull();
});
it('build returns parser and renderer', function (): void {
putenv('BBCODE_FORCE_FAIL');
unset($_ENV['BBCODE_FORCE_FAIL'], $_SERVER['BBCODE_FORCE_FAIL']);
$ref = new ReflectionMethod(BbcodeFormatter::class, 'build');
$ref->setAccessible(true);
$result = $ref->invoke(null);
expect($result)->toBeArray();
expect($result)->toHaveCount(2);
expect($result[0])->toBeInstanceOf(\s9e\TextFormatter\Parser::class);
expect($result[1])->toBeInstanceOf(\s9e\TextFormatter\Renderer::class);
});
it('formats with real build when parser is reset', function (): void {
putenv('BBCODE_FORCE_FAIL');
unset($_ENV['BBCODE_FORCE_FAIL'], $_SERVER['BBCODE_FORCE_FAIL']);
$parserProp = new ReflectionProperty(BbcodeFormatter::class, 'parser');
$parserProp->setAccessible(true);
$parserProp->setValue(null);
$rendererProp = new ReflectionProperty(BbcodeFormatter::class, 'renderer');
$rendererProp->setAccessible(true);
$rendererProp->setValue(null);
$html = BbcodeFormatter::format('[b]Bold[/b]');
expect($html)->toBeString();
expect($parserProp->getValue())->not->toBeNull();
expect($rendererProp->getValue())->not->toBeNull();
});
it('throws when bbcode formatter cannot initialize', function (): void {
\s9e\TextFormatter\Configurator::$returnEmpty = true;
putenv('BBCODE_FORCE_FAIL=1');
$_ENV['BBCODE_FORCE_FAIL'] = '1';
$_SERVER['BBCODE_FORCE_FAIL'] = '1';
$parserProp = new ReflectionProperty(BbcodeFormatter::class, 'parser');
$parserProp->setAccessible(true);
@@ -145,11 +99,11 @@ namespace {
} catch (Throwable $e) {
expect($e)->toBeInstanceOf(RuntimeException::class);
} finally {
\s9e\TextFormatter\Configurator::$returnEmpty = false;
putenv('BBCODE_FORCE_FAIL');
unset($_ENV['BBCODE_FORCE_FAIL'], $_SERVER['BBCODE_FORCE_FAIL']);
}
});
afterEach(function (): void {
\Mockery::close();
});
}

View File

@@ -20,10 +20,10 @@ it('version set fails when invalid version', function (): void {
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();
$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 {

View File

@@ -73,6 +73,7 @@ it('returns system status for admins', function (): void {
expect($payload)->toHaveKeys([
'php',
'php_default',
'php_default_version',
'composer',
'composer_version',
'node',
@@ -85,6 +86,7 @@ it('returns system status for admins', function (): void {
'rsync_version',
'proc_functions',
'storage_writable',
'storage_public_linked',
'updates_writable',
]);
});

View File

@@ -11,17 +11,6 @@ namespace App\Console\Commands {
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 {
@@ -40,84 +29,47 @@ namespace {
file_put_contents($path, $original);
}
$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 {
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');
expect($exitCode)->toBe(0);
$build = Setting::where('key', 'build')->value('value');
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);
expect(Setting::where('key', 'version')->value('value'))->toBe($expectedVersion);
expect(Setting::where('key', 'build')->value('value'))->toBe($expectedBuild);
});
});
it('fails when composer.json cannot be decoded', function (): void {
withComposerBackupForFetch(function (string $path): void {
file_put_contents($path, 'not-json');
Setting::updateOrCreate(['key' => 'version'], ['value' => '1.2.3']);
$exitCode = Artisan::call('version:fetch');
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 {
chmod($path, 0000);
Setting::updateOrCreate(['key' => 'version'], ['value' => '1.2.3']);
$data = json_decode((string) file_get_contents($path), true);
unset($data['build']);
file_put_contents($path, json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . PHP_EOL);
$exitCode = Artisan::call('version:fetch');
expect($exitCode)->toBe(1);
chmod($path, 0644);
});
});
it('fails when file_get_contents returns false', function (): void {
withComposerBackupForFetch(function (): void {
$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');
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"* ]]
}