96 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
0bc893dd35 Restore artisan script
All checks were successful
CI/CD Pipeline / test (push) Successful in 3s
CI/CD Pipeline / deploy (push) Successful in 21s
2026-02-08 19:10:56 +01:00
88e4a70f88 Add comprehensive test coverage and update notes
Some checks failed
CI/CD Pipeline / test (push) Successful in 3s
CI/CD Pipeline / deploy (push) Failing after 15s
2026-02-08 19:04:12 +01:00
160430e128 Add extensive controller and model tests
All checks were successful
CI/CD Pipeline / test (push) Successful in 10s
CI/CD Pipeline / deploy (push) Successful in 25s
2026-02-07 22:14:42 +01:00
9c60a8944e feat: system tools and admin enhancements
All checks were successful
CI/CD Pipeline / test (push) Successful in 3s
CI/CD Pipeline / deploy (push) Successful in 20s
2026-01-31 20:12:09 +01:00
64244567c0 Add attachment thumbnails and ACP refinements
All checks were successful
CI/CD Pipeline / test (push) Successful in 3s
CI/CD Pipeline / deploy (push) Successful in 24s
2026-01-31 12:02:54 +01:00
7fbc566129 re-enabled autoseeding of attachment groups
All checks were successful
CI/CD Pipeline / test (push) Successful in 7s
CI/CD Pipeline / deploy (push) Successful in 19s
2026-01-28 20:01:02 +01:00
c33cde6f04 added attchments
All checks were successful
CI/CD Pipeline / test (push) Successful in 3s
CI/CD Pipeline / deploy (push) Successful in 24s
2026-01-28 19:34:25 +01:00
2409feb06f feat: add solved threads
All checks were successful
CI/CD Pipeline / test (push) Successful in 3s
CI/CD Pipeline / deploy (push) Successful in 26s
2026-01-24 14:11:55 +01:00
e3dcf99362 feat: derive build from git commit count 2026-01-24 13:23:44 +01:00
357f6fb755 test pipeline 2026-01-23 19:26:53 +01:00
2281b80980 test pipeline 2026-01-23 19:26:53 +01:00
f23363fdcc test pipeline 2026-01-23 19:26:52 +01:00
c1814c0d47 test pipeline 2026-01-23 19:26:51 +01:00
7489a3903d test pipeline 2026-01-23 19:26:50 +01:00
b967aa912b test pipeline 2026-01-23 19:26:49 +01:00
67ae9517f4 added ansible playbook 2026-01-23 19:26:48 +01:00
653905d5e2 fixed post count 2026-01-23 19:26:43 +01:00
bc893b644d fixed post count 2026-01-23 19:26:42 +01:00
662e00bec1 fixed post count 2026-01-23 19:26:41 +01:00
a96913bffa fixed post count 2026-01-23 19:26:40 +01:00
79ac0cdca5 fix rank creation error 2026-01-23 19:26:39 +01:00
fe4b7ccd7c fix rank creation error 2026-01-23 19:26:38 +01:00
fc9de4c9fd added gitea worker 2026-01-23 19:26:37 +01:00
6b6f787351 chore: remove tracked storage artifacts 2026-01-23 19:26:36 +01:00
d4fb86633b feat: add installer, ranks/groups enhancements, and founder protections 2026-01-23 19:26:35 +01:00
Micha
24c16ed0dd Unify portal thread rows and add summary API 2026-01-16 02:44:04 +01:00
Micha
f9de433545 fixing thsu frontend views 2026-01-16 01:43:07 +01:00
Micha
fd29b928d8 Add ranks and ACP user enhancements 2026-01-14 00:15:56 +01:00
Micha
98094459e3 Tighten ACP forum actions and avatar handling 2026-01-13 00:07:25 +01:00
Micha
3bb2946656 Add avatars, profiles, and auth flows 2026-01-12 23:40:11 +01:00
Micha
bbbf8eb6c1 Show post authors and action buttons 2026-01-11 01:54:45 +01:00
Micha
c8d2bd508e Restyle thread view like phpBB 2026-01-11 01:25:16 +01:00
193 changed files with 28310 additions and 4266 deletions

View File

@@ -0,0 +1,65 @@
name: CI/CD Pipeline
run-name: ${{ gitea.event.head_commit.message }}
on:
push:
branches:
- dev
- master
jobs:
deploy:
if: gitea.ref_name == 'master'
runs-on: self-hosted
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 }}
SPEEDBB_REPO: ${{ vars.SPEEDBB_REPO }}
PROD_BASE_DIR: ${{ vars.PROD_BASE_DIR }}
ANSIBLE_POSIX_ACL: false
run: |
git clone --quiet --no-checkout --depth=1 --branch=${{ gitea.ref_name }} ${{ vars.SPEEDBB_REPO }} ./repo
cd repo
git config core.sparseCheckout true
echo "ansible/" > .git/info/sparse-checkout
git checkout HEAD
ls -la
cd ansible
pwd
ls -la
cat hosts.ini
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

9
.gitignore vendored
View File

@@ -1,8 +1,10 @@
*.log
.DS_Store
._*
.env
.env.backup
.env.production
.env.test
.env.*.local
.phpactor.json
.phpunit.result.cache
@@ -20,8 +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,5 +1,84 @@
# 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.
- Added username-or-email login with case-insensitive unique usernames.
- Added SPA-friendly verification and password reset/update endpoints.
- Added user avatars (upload + display) and a basic profile page/API.
- Seeded a Micha test user with verified email.
- Added rank management with badge text/image options and ACP UI controls.
- Added user edit modal (name/email/rank) and rank assignment controls in ACP.
- Added ACP users search and improved sorting indicators.
- Added thread sidebar fields for posts count, registration date, and topic header.
- Linked header logo to the portal and fixed ACP breadcrumbs.
- Added profile location field with UCP editing and post sidebar display.
- Added per-thread replies and views counts, including view tracking.
- Added per-forum topics/views counts plus last-post details in board listings.
- Added portal summary API to load forums, stats, and recent posts in one request.
- Unified portal and forum thread list row styling with shared component.
## 2026-01-11
- Restyled the thread view to mimic phpBB: compact toolbar, title row, and post layout.
- Added phpBB-style post action buttons and post author info for replies.
## 2026-01-02
- Added ACP general settings for forum name, theme, accents, and logo (no reload required).
- Added admin-only upload endpoints and ACP UI for logos and favicons.

15
NOTES.md Normal file
View File

@@ -0,0 +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.

4
ansible/ansible.cfg Normal file
View File

@@ -0,0 +1,4 @@
[defaults]
inventory = ./hosts.ini
set_remote_user = yes
allow_world_readable_tmpfiles=true

View File

@@ -0,0 +1,15 @@
---
- name: Ping the hosts defined in hosts.ini
hosts: prod
vars_files:
- ./vars/vault.yaml
- ./vars/vars.yaml
gather_facts: yes
tasks:
- name: Ping the hosts
ping:
roles:
- speedBB

8
ansible/hosts.ini Normal file
View File

@@ -0,0 +1,8 @@
[dev]
fd20:2184:8045:4973:5054:ff:fe6c:13d1 ansible_connection=local
[prod]
support.24unix.net ansible_user=tracer ansible_become_password=

View File

@@ -0,0 +1,151 @@
---
- name: Check if base_dir exists
stat:
path: "{{ prod_base_dir }}"
register: base_dir_status
- name: Fetch latest code
git:
repo: "{{ git_repo }}"
dest: "{{ prod_base_dir }}"
version: "master"
update: yes
force: true
register: git_result
- debug:
var: git_result
- name: Check if .env exists
stat:
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:
path: "{{ prod_base_dir }}"
state: present
when: git_result.changed
- name: Build frontend
command: "npm run build"
args:
chdir: "{{ prod_base_dir }}"
- name: Clear config cache
command: "keyhelp-php84 artisan config:clear"
args:
chdir: "{{ prod_base_dir }}"
when: env_file.stat.exists
- name: Clear application cache
command: "keyhelp-php84 artisan cache:clear"
args:
chdir: "{{ prod_base_dir }}"
when: env_file.stat.exists
- name: Create database backup directory
file:
path: "{{ prod_base_dir }}/backups"
state: directory
mode: '0755'
- name: Backup database before migrations
shell: |
cd {{ prod_base_dir }}
DB_USERNAME=$(grep DB_USERNAME .env | cut -d '=' -f2)
DB_PASSWORD=$(grep DB_PASSWORD .env | cut -d '=' -f2)
DB_DATABASE=$(grep DB_DATABASE .env | cut -d '=' -f2)
BACKUP_FILE="{{ prod_base_dir }}/backups/db_backup_$(date +%Y%m%d_%H%M%S).sql"
mysqldump -u "$DB_USERNAME" -p"$DB_PASSWORD" "$DB_DATABASE" > "$BACKUP_FILE"
echo "$BACKUP_FILE"
register: backup_result
when: env_file.stat.exists
- name: Display backup location
debug:
msg: "Database backed up to: {{ backup_result.stdout }}"
when: env_file.stat.exists
- name: Run database migrations
command: "keyhelp-php84 artisan migrate --force"
args:
chdir: "{{ prod_base_dir }}"
register: migrate_result
failed_when: migrate_result.rc != 0
when: env_file.stat.exists
- name: Display migration result
debug:
var: migrate_result
when: env_file.stat.exists
- name: Remove old database backups (keep last 10)
shell: |
cd {{ prod_base_dir }}/backups
ls -t db_backup_*.sql | tail -n +11 | xargs -r rm
ignore_errors: yes
when: env_file.stat.exists
- name: Run version fetch command
command: "keyhelp-php84 artisan version:fetch"
args:
chdir: "{{ prod_base_dir }}"
when: env_file.stat.exists
- name: Reload PHP-FPM to clear OPcache
command: sudo /usr/bin/systemctl reload keyhelp-php84-fpm.service

5
ansible/vars/vars.yaml Normal file
View File

@@ -0,0 +1,5 @@
---
git_repo: "{{ lookup('env', 'SPEEDBB_REPO') }}"
prod_base_dir: "{{ lookup('env', 'PROD_BASE_DIR') }}"
prod_become_user: "{{ vault_prod_become_user }}"

9
ansible/vars/vault.yaml Normal file
View File

@@ -0,0 +1,9 @@
$ANSIBLE_VAULT;1.1;AES256
31623264303535663263613235356231623137333734626164376138656532623937316534333835
3661666237386534373466356136393566333162326562330a383833363737323637363738616666
62393164326465376634356666303861613362313430656161653531373733353530636265353738
3863633131313834390a356663373338346137373662356161643336636534626130313466343566
36653636333838633938323363646335663935646135613632356434396436326131323361366561
32633939346163356131663266346539323330613536333838616332646139313731326133646165
31343763636337306263646631353562646462323631383439353738333035623664623163303839
34343261383738396534

View File

@@ -0,0 +1,73 @@
<?php
namespace App\Actions;
use s9e\TextFormatter\Configurator;
use s9e\TextFormatter\Parser;
use s9e\TextFormatter\Renderer;
class BbcodeFormatter
{
private static ?Parser $parser = null;
private static ?Renderer $renderer = null;
public static function format(?string $text): string
{
if ($text === null || $text === '') {
return '';
}
if (!self::$parser || !self::$renderer) {
[$parser, $renderer] = self::build();
self::$parser = $parser;
self::$renderer = $renderer;
}
$xml = self::$parser->parse($text);
return self::$renderer->render($xml);
}
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');
$bbcodes->addFromRepository('I');
$bbcodes->addFromRepository('U');
$bbcodes->addFromRepository('S');
$bbcodes->addFromRepository('URL');
$bbcodes->addFromRepository('IMG');
$bbcodes->addFromRepository('QUOTE');
$bbcodes->addFromRepository('CODE');
$bbcodes->addFromRepository('LIST');
$bbcodes->addFromRepository('*');
$configurator->tags->add('BR')->template = '<br/>';
if (isset($configurator->tags['QUOTE'])) {
$configurator->tags['QUOTE']->template = <<<'XSL'
<blockquote>
<xsl:if test="@author">
<cite><xsl:value-of select="@author"/> wrote:</cite>
</xsl:if>
<div><xsl:apply-templates/></div>
</blockquote>
XSL;
}
$bundle = $configurator->finalize();
$parser = $bundle['parser'] ?? null;
$renderer = $bundle['renderer'] ?? null;
if (!$parser || !$renderer) {
throw new \RuntimeException('Unable to initialize BBCode formatter.');
}
return [$parser, $renderer];
}
}

View File

@@ -3,6 +3,7 @@
namespace App\Actions\Fortify;
use App\Models\User;
use Illuminate\Support\Str;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\Rule;
@@ -19,8 +20,16 @@ class CreateNewUser implements CreatesNewUsers
*/
public function create(array $input): User
{
$input['name_canonical'] = Str::lower(trim($input['name'] ?? ''));
Validator::make(data: $input, rules: [
'name' => ['required', 'string', 'max:255'],
'name_canonical' => [
'required',
'string',
'max:255',
Rule::unique(table: User::class, column: 'name_canonical'),
],
'email' => [
'required',
'string',
@@ -33,6 +42,7 @@ class CreateNewUser implements CreatesNewUsers
return User::create(attributes: [
'name' => $input['name'],
'name_canonical' => $input['name_canonical'],
'email' => $input['email'],
'password' => Hash::make(value: $input['password']),
]);

View File

@@ -4,6 +4,7 @@ namespace App\Actions\Fortify;
use App\Models\User;
use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Support\Str;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\Rule;
use Laravel\Fortify\Contracts\UpdatesUserProfileInformation;
@@ -17,8 +18,16 @@ class UpdateUserProfileInformation implements UpdatesUserProfileInformation
*/
public function update(User $user, array $input): void
{
$input['name_canonical'] = Str::lower(trim($input['name'] ?? ''));
Validator::make($input, [
'name' => ['required', 'string', 'max:255'],
'name_canonical' => [
'required',
'string',
'max:255',
Rule::unique('users', 'name_canonical')->ignore($user->id),
],
'email' => [
'required',
@@ -34,6 +43,7 @@ class UpdateUserProfileInformation implements UpdatesUserProfileInformation
} else {
$user->forceFill([
'name' => $input['name'],
'name_canonical' => $input['name_canonical'],
'email' => $input['email'],
])->save();
}
@@ -48,6 +58,7 @@ class UpdateUserProfileInformation implements UpdatesUserProfileInformation
{
$user->forceFill([
'name' => $input['name'],
'name_canonical' => $input['name_canonical'],
'email' => $input['email'],
'email_verified_at' => null,
])->save();

View File

@@ -0,0 +1,93 @@
<?php
namespace App\Console\Commands;
use App\Models\Attachment;
use App\Services\AttachmentThumbnailService;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Storage;
class CronRun extends Command
{
protected $signature = 'speedbb:cron {--force : Recreate thumbnails even if already present} {--dry-run : Report without writing}';
protected $description = 'Run periodic maintenance tasks (currently: attachment thumbnail recreation).';
public function handle(AttachmentThumbnailService $thumbnailService): int
{
$force = (bool) $this->option('force');
$dryRun = (bool) $this->option('dry-run');
$stats = [
'checked' => 0,
'created' => 0,
'skipped' => 0,
'missing' => 0,
'non_image' => 0,
];
$this->info('Processing attachment thumbnails...');
Attachment::query()
->orderBy('id')
->chunkById(200, function ($attachments) use ($thumbnailService, $force, $dryRun, &$stats) {
foreach ($attachments as $attachment) {
$stats['checked']++;
$mime = $attachment->mime_type ?? '';
if (!str_starts_with($mime, 'image/')) {
$stats['non_image']++;
continue;
}
$disk = Storage::disk($attachment->disk);
if (!$disk->exists($attachment->path)) {
$stats['missing']++;
continue;
}
$needsThumbnail = $force
|| !$attachment->thumbnail_path
|| !$disk->exists($attachment->thumbnail_path);
if (!$needsThumbnail) {
$stats['skipped']++;
continue;
}
if ($dryRun) {
$stats['created']++;
continue;
}
if ($force && $attachment->thumbnail_path && $disk->exists($attachment->thumbnail_path)) {
$disk->delete($attachment->thumbnail_path);
}
$payload = $thumbnailService->createForAttachment($attachment, $force);
if (!$payload) {
$stats['skipped']++;
continue;
}
$attachment->thumbnail_path = $payload['path'] ?? null;
$attachment->thumbnail_mime_type = $payload['mime'] ?? null;
$attachment->thumbnail_size_bytes = $payload['size'] ?? null;
$attachment->save();
$stats['created']++;
}
});
$this->info(sprintf(
'Checked: %d | Created: %d | Skipped: %d | Missing: %d | Non-image: %d',
$stats['checked'],
$stats['created'],
$stats['skipped'],
$stats['missing'],
$stats['non_image']
));
return self::SUCCESS;
}
}

View File

@@ -0,0 +1,89 @@
<?php
namespace App\Console\Commands;
use App\Models\Setting;
use Illuminate\Console\Command;
class VersionBump extends Command
{
protected $signature = 'version:bump';
protected $description = 'Bump the patch version (e.g. 26.0.1 -> 26.0.2).';
public function handle(): int
{
$current = Setting::query()->where('key', 'version')->value('value');
if (!$current) {
$this->error('Unable to determine current version from settings.');
return self::FAILURE;
}
$next = $this->bumpPatch($current);
if ($next === null) {
$this->error('Version format must be X.Y.Z (optionally with suffix).');
return self::FAILURE;
}
Setting::updateOrCreate(['key' => 'version'], ['value' => $next]);
if (!$this->syncComposerVersion($next)) {
$this->error('Failed to sync version to composer.json.');
return self::FAILURE;
}
$this->info("Version bumped: {$current} -> {$next}");
return self::SUCCESS;
}
private function bumpPatch(string $version): ?string
{
if (!preg_match('/^(\d+)\.(\d+)\.(\d+)(.*)?$/', $version, $matches)) {
return null;
}
$major = $matches[1];
$minor = $matches[2];
$patch = $matches[3];
$suffix = $matches[4] ?? '';
$patchWidth = strlen($patch);
$nextPatch = (string) ((int) $patch + 1);
if ($patchWidth > 1) {
$nextPatch = str_pad($nextPatch, $patchWidth, '0', STR_PAD_LEFT);
}
return "{$major}.{$minor}.{$nextPatch}{$suffix}";
}
private function syncComposerVersion(string $version): bool
{
$composerPath = base_path('composer.json');
if (!is_file($composerPath) || !is_readable($composerPath)) {
return false;
}
$raw = file_get_contents($composerPath);
if ($raw === false) {
return false;
}
$data = json_decode($raw, true);
if (!is_array($data)) {
return false;
}
$data['version'] = $version;
$encoded = json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
if ($encoded === false) {
return false;
}
$encoded .= "\n";
return file_put_contents($composerPath, $encoded) !== false;
}
}

View File

@@ -0,0 +1,78 @@
<?php
namespace App\Console\Commands;
use App\Models\Setting;
use Illuminate\Console\Command;
class VersionFetch extends Command
{
protected $signature = 'version:fetch';
protected $description = 'Sync version/build metadata into settings using composer.json as source of truth.';
public function handle(): int
{
$meta = $this->resolveComposerMetadata();
if ($meta === null) {
$this->error('Unable to determine version/build from composer.json.');
return self::FAILURE;
}
$version = $meta['version'];
$build = $meta['build'];
Setting::updateOrCreate(
['key' => 'version'],
['value' => $version],
);
Setting::updateOrCreate(
['key' => 'build'],
['value' => (string) $build],
);
$this->info("Version/build synced: {$version} (build {$build}).");
return self::SUCCESS;
}
private function resolveComposerMetadata(): ?array
{
$composerPath = base_path('composer.json');
if (!is_file($composerPath) || !is_readable($composerPath)) {
return null;
}
$raw = file_get_contents($composerPath);
if ($raw === false) {
return null;
}
$data = json_decode($raw, true);
if (!is_array($data)) {
return null;
}
$version = trim((string) ($data['version'] ?? ''));
$buildRaw = trim((string) ($data['build'] ?? ''));
if ($version === '' || $buildRaw === '') {
return null;
}
if (!preg_match('/^\d+\.\d+(?:\.\d+)?(?:[-._][0-9A-Za-z.-]+)?$/', $version)) {
return null;
}
if (!ctype_digit($buildRaw)) {
return null;
}
return [
'version' => $version,
'build' => (int) $buildRaw,
];
}
}

View File

@@ -0,0 +1,113 @@
<?php
namespace App\Console\Commands;
use App\Models\Setting;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Http;
class VersionRelease extends Command
{
protected $signature = 'version:release {--prerelease : Mark this release as a prerelease} {--target= : Override target commit (defaults to env GITEA_TARGET_COMMIT or master)}';
protected $description = 'Create or update a Gitea release for the current version.';
public function handle(): int
{
$version = Setting::query()->where('key', 'version')->value('value');
if (!$version) {
$this->error('Unable to determine version from settings.');
return self::FAILURE;
}
$token = env('GITEA_TOKEN');
$owner = env('GITEA_OWNER');
$repo = env('GITEA_REPO');
$apiBase = rtrim((string) env('GITEA_API_BASE', 'https://git.24unix.net/api/v1'), '/');
$target = $this->option('target') ?: env('GITEA_TARGET_COMMIT', 'master');
$prerelease = $this->option('prerelease') || filter_var(env('GITEA_PRERELEASE', false), FILTER_VALIDATE_BOOLEAN);
if (!$token || !$owner || !$repo) {
$this->error('Missing Gitea config. Set GITEA_TOKEN, GITEA_OWNER, and GITEA_REPO in .env.');
return self::FAILURE;
}
$tag = "v{$version}";
$body = $this->resolveChangelogBody($version);
$client = Http::withHeaders([
'Authorization' => "token {$token}",
'Accept' => 'application/json',
]);
$payload = [
'tag_name' => $tag,
'target_commitish' => $target,
'name' => $tag,
'body' => $body,
'prerelease' => (bool) $prerelease,
];
$createUrl = "{$apiBase}/repos/{$owner}/{$repo}/releases";
$response = $client->post($createUrl, $payload);
if ($response->successful()) {
$this->info("Release created: {$tag}");
return self::SUCCESS;
}
if ($response->status() === 409 || $response->status() === 422) {
$getUrl = "{$apiBase}/repos/{$owner}/{$repo}/releases/tags/{$tag}";
$existing = $client->get($getUrl);
if (!$existing->successful()) {
$this->error('Release already exists, but failed to fetch it for update.');
return self::FAILURE;
}
$id = $existing->json('id');
if (!$id) {
$this->error('Release already exists, but no ID was returned.');
return self::FAILURE;
}
$updateUrl = "{$apiBase}/repos/{$owner}/{$repo}/releases/{$id}";
$updatePayload = [
'name' => $tag,
'body' => $body,
'prerelease' => (bool) $prerelease,
'target_commitish' => $target,
];
$updated = $client->patch($updateUrl, $updatePayload);
if ($updated->successful()) {
$this->info("Release updated: {$tag}");
return self::SUCCESS;
}
$this->error("Failed to update release: {$updated->status()}");
return self::FAILURE;
}
$this->error("Failed to create release: {$response->status()}");
return self::FAILURE;
}
private function resolveChangelogBody(string $version): string
{
$path = base_path('CHANGELOG.md');
if (!is_file($path) || !is_readable($path)) {
return 'See commit history for details.';
}
$raw = file_get_contents($path);
if ($raw === false) {
return 'See commit history for details.';
}
$pattern = '/^##\\s+' . preg_quote($version, '/') . '\\s*\\R(.*?)(?=^##\\s+|\\z)/ms';
if (preg_match($pattern, $raw, $matches)) {
$body = trim($matches[1] ?? '');
return $body !== '' ? $body : 'See commit history for details.';
}
return 'See commit history for details.';
}
}

View File

@@ -0,0 +1,73 @@
<?php
namespace App\Console\Commands;
use App\Models\Setting;
use Illuminate\Console\Command;
class VersionSet extends Command
{
protected $signature = 'version:set {version}';
protected $description = 'Set the forum version (e.g. 26.0.1).';
public function handle(): int
{
$version = trim((string) $this->argument('version'));
if (!$this->isValidVersion($version)) {
$this->error('Version format must be X.Y or X.Y.Z (optionally with suffix).');
return self::FAILURE;
}
$current = Setting::query()->where('key', 'version')->value('value');
Setting::updateOrCreate(['key' => 'version'], ['value' => $version]);
if (!$this->syncComposerVersion($version)) {
$this->error('Failed to sync version to composer.json.');
return self::FAILURE;
}
if ($current) {
$this->info("Version updated: {$current} -> {$version}");
} else {
$this->info("Version set to {$version}");
}
return self::SUCCESS;
}
private function isValidVersion(string $version): bool
{
return (bool) preg_match('/^\d+\.\d+(?:\.\d+)?(?:[-._][0-9A-Za-z.-]+)?$/', $version);
}
private function syncComposerVersion(string $version): bool
{
$composerPath = base_path('composer.json');
if (!is_file($composerPath) || !is_readable($composerPath)) {
return false;
}
$raw = file_get_contents($composerPath);
if ($raw === false) {
return false;
}
$data = json_decode($raw, true);
if (!is_array($data)) {
return false;
}
$data['version'] = $version;
$encoded = json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
if ($encoded === false) {
return false;
}
$encoded .= "\n";
return file_put_contents($composerPath, $encoded) !== false;
}
}

View File

@@ -0,0 +1,351 @@
<?php
namespace App\Http\Controllers;
use App\Models\Attachment;
use App\Models\AttachmentExtension;
use App\Models\Post;
use App\Services\AttachmentThumbnailService;
use App\Services\AuditLogger;
use App\Models\Thread;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Symfony\Component\HttpFoundation\Response;
class AttachmentController extends Controller
{
public function index(Request $request): JsonResponse
{
$query = Attachment::query()
->with(['extension', 'group'])
->withoutTrashed();
$threadParam = $request->query('thread');
$postParam = $request->query('post');
if ($threadParam) {
$threadId = $this->parseThreadId($threadParam);
if ($threadId !== null) {
$query->where('thread_id', $threadId);
}
}
if ($postParam) {
$postId = $this->parsePostId($postParam);
if ($postId !== null) {
$query->where('post_id', $postId);
}
}
$attachments = $query
->latest('created_at')
->get()
->map(fn (Attachment $attachment) => $this->serializeAttachment($attachment));
return response()->json($attachments);
}
public function store(Request $request): JsonResponse
{
$user = $request->user();
if (!$user) {
return response()->json(['message' => 'Unauthorized.'], 401);
}
$data = $request->validate([
'thread' => ['nullable', 'string'],
'post' => ['nullable', 'string'],
'file' => ['required', 'file'],
]);
$threadId = $this->parseThreadId($data['thread'] ?? null);
$postId = $this->parsePostId($data['post'] ?? null);
if (($threadId && $postId) || (!$threadId && !$postId)) {
return response()->json(['message' => 'Provide either thread or post.'], 422);
}
$thread = null;
$post = null;
if ($threadId) {
$thread = Thread::query()->findOrFail($threadId);
if (!$this->canManageAttachments($user, $thread->user_id)) {
return response()->json(['message' => 'Not authorized to add attachments.'], 403);
}
}
if ($postId) {
$post = Post::query()->findOrFail($postId);
if (!$this->canManageAttachments($user, $post->user_id)) {
return response()->json(['message' => 'Not authorized to add attachments.'], 403);
}
}
$file = $request->file('file');
if (!$file) {
return response()->json(['message' => 'File missing.'], 422);
}
$mime = $file->getMimeType() ?? 'application/octet-stream';
$extension = strtolower((string) $file->getClientOriginalExtension());
$extensionRow = $this->resolveExtension($extension);
if (!$extensionRow || !$extensionRow->group || !$extensionRow->group->is_active) {
return response()->json(['message' => 'File type not allowed.'], 422);
}
$group = $extensionRow->group;
if (!$this->matchesAllowed($mime, $extensionRow->allowed_mimes)) {
return response()->json(['message' => 'File type not allowed.'], 422);
}
$maxSizeBytes = (int) $group->max_size_kb * 1024;
if ($file->getSize() > $maxSizeBytes) {
return response()->json(['message' => 'File exceeds allowed size.'], 422);
}
$scopeFolder = $threadId ? "threads/{$threadId}" : "posts/{$postId}";
$filename = Str::uuid()->toString();
if ($extension !== '') {
$filename .= ".{$extension}";
}
$disk = 'local';
$path = "attachments/{$scopeFolder}/{$filename}";
Storage::disk($disk)->putFileAs("attachments/{$scopeFolder}", $file, $filename);
$thumbnailPayload = app(AttachmentThumbnailService::class)
->createForUpload($file, $scopeFolder, $disk);
$attachment = Attachment::create([
'thread_id' => $threadId,
'post_id' => $postId,
'attachment_extension_id' => $extensionRow->id,
'attachment_group_id' => $group->id,
'user_id' => $user->id,
'disk' => $disk,
'path' => $path,
'thumbnail_path' => $thumbnailPayload['path'] ?? null,
'thumbnail_mime_type' => $thumbnailPayload['mime'] ?? null,
'thumbnail_size_bytes' => $thumbnailPayload['size'] ?? null,
'original_name' => $file->getClientOriginalName(),
'extension' => $extension !== '' ? $extension : null,
'mime_type' => $mime,
'size_bytes' => (int) $file->getSize(),
]);
app(AuditLogger::class)->log($request, 'attachment.created', $attachment, [
'thread_id' => $threadId,
'post_id' => $postId,
'original_name' => $attachment->original_name,
'size_bytes' => $attachment->size_bytes,
]);
$attachment->loadMissing(['extension', 'group']);
return response()->json($this->serializeAttachment($attachment), 201);
}
public function show(Attachment $attachment): JsonResponse
{
if (!$this->canViewAttachment($attachment)) {
return response()->json(['message' => 'Not found.'], 404);
}
$attachment->loadMissing(['extension', 'group']);
return response()->json($this->serializeAttachment($attachment));
}
public function download(Attachment $attachment): Response
{
if (!$this->canViewAttachment($attachment)) {
abort(404);
}
$disk = Storage::disk($attachment->disk);
if (!$disk->exists($attachment->path)) {
abort(404);
}
$mime = $attachment->mime_type ?: 'application/octet-stream';
return $disk->download($attachment->path, $attachment->original_name, [
'Content-Type' => $mime,
]);
}
public function thumbnail(Attachment $attachment): Response
{
if (!$this->canViewAttachment($attachment)) {
abort(404);
}
if (!$attachment->thumbnail_path) {
abort(404);
}
$disk = Storage::disk($attachment->disk);
if (!$disk->exists($attachment->thumbnail_path)) {
abort(404);
}
$mime = $attachment->thumbnail_mime_type ?: 'image/jpeg';
return $disk->response($attachment->thumbnail_path, null, [
'Content-Type' => $mime,
]);
}
public function destroy(Request $request, Attachment $attachment): JsonResponse
{
$user = $request->user();
if (!$user) {
return response()->json(['message' => 'Unauthorized.'], 401);
}
if (!$this->canManageAttachments($user, $attachment->user_id)) {
return response()->json(['message' => 'Not authorized to delete attachments.'], 403);
}
app(AuditLogger::class)->log($request, 'attachment.deleted', $attachment, [
'thread_id' => $attachment->thread_id,
'post_id' => $attachment->post_id,
'original_name' => $attachment->original_name,
'size_bytes' => $attachment->size_bytes,
]);
$attachment->delete();
return response()->json(null, 204);
}
private function resolveExtension(string $extension): ?AttachmentExtension
{
if ($extension === '') {
return null;
}
return AttachmentExtension::query()
->where('extension', strtolower($extension))
->with('group')
->first();
}
private function matchesAllowed(string $value, ?array $allowed): bool
{
if (!$allowed || count($allowed) === 0) {
return true;
}
$normalized = strtolower(trim($value));
foreach ($allowed as $entry) {
if (strtolower(trim((string) $entry)) === $normalized) {
return true;
}
}
return false;
}
private function parseThreadId(?string $value): ?int
{
if (!$value) {
return null;
}
if (preg_match('#/threads/(\d+)$#', $value, $matches)) {
return (int) $matches[1];
}
if (is_numeric($value)) {
return (int) $value;
}
return null;
}
private function parsePostId(?string $value): ?int
{
if (!$value) {
return null;
}
if (preg_match('#/posts/(\d+)$#', $value, $matches)) {
return (int) $matches[1];
}
if (is_numeric($value)) {
return (int) $value;
}
return null;
}
private function canViewAttachment(Attachment $attachment): bool
{
if ($attachment->trashed()) {
return false;
}
if ($attachment->thread_id) {
$thread = Thread::withTrashed()->find($attachment->thread_id);
return $thread && !$thread->trashed();
}
if ($attachment->post_id) {
$post = Post::withTrashed()->find($attachment->post_id);
if (!$post || $post->trashed()) {
return false;
}
$thread = Thread::withTrashed()->find($post->thread_id);
return $thread && !$thread->trashed();
}
return false;
}
private function canManageAttachments($user, ?int $ownerId): bool
{
if (!$user) {
return false;
}
if ($user->roles()->where('name', 'ROLE_ADMIN')->exists()) {
return true;
}
return $ownerId !== null && $ownerId === $user->id;
}
private function serializeAttachment(Attachment $attachment): array
{
$isImage = str_starts_with((string) $attachment->mime_type, 'image/');
return [
'id' => $attachment->id,
'thread_id' => $attachment->thread_id,
'post_id' => $attachment->post_id,
'extension' => $attachment->extension,
'group' => $attachment->group ? [
'id' => $attachment->group->id,
'name' => $attachment->group->name,
'category' => $attachment->group->category,
'max_size_kb' => $attachment->group->max_size_kb,
] : null,
'original_name' => $attachment->original_name,
'mime_type' => $attachment->mime_type,
'size_bytes' => $attachment->size_bytes,
'download_url' => "/api/attachments/{$attachment->id}/download",
'thumbnail_url' => $attachment->thumbnail_path
? "/api/attachments/{$attachment->id}/thumbnail"
: null,
'is_image' => $isImage,
'created_at' => $attachment->created_at?->toIso8601String(),
];
}
}

View File

@@ -0,0 +1,147 @@
<?php
namespace App\Http\Controllers;
use App\Models\Attachment;
use App\Models\AttachmentExtension;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class AttachmentExtensionController extends Controller
{
private function ensureAdmin(Request $request): ?JsonResponse
{
$user = $request->user();
if (!$user || !$user->roles()->where('name', 'ROLE_ADMIN')->exists()) {
return response()->json(['message' => 'Forbidden'], 403);
}
return null;
}
public function index(Request $request): JsonResponse
{
if ($error = $this->ensureAdmin($request)) {
return $error;
}
$extensions = AttachmentExtension::query()
->with('group')
->orderBy('extension')
->get()
->map(fn (AttachmentExtension $extension) => $this->serializeExtension($extension));
return response()->json($extensions);
}
public function publicIndex(): JsonResponse
{
$extensions = AttachmentExtension::query()
->whereNotNull('attachment_group_id')
->whereHas('group', fn ($query) => $query->where('is_active', true))
->orderBy('extension')
->pluck('extension')
->filter()
->values();
return response()->json($extensions);
}
public function store(Request $request): JsonResponse
{
if ($error = $this->ensureAdmin($request)) {
return $error;
}
$data = $this->validatePayload($request, true);
$extension = $this->normalizeExtension($data['extension']);
if ($extension === '') {
return response()->json(['message' => 'Invalid extension.'], 422);
}
if (AttachmentExtension::query()->where('extension', $extension)->exists()) {
return response()->json(['message' => 'Extension already exists.'], 422);
}
$created = AttachmentExtension::create([
'extension' => $extension,
'attachment_group_id' => $data['attachment_group_id'] ?? null,
'allowed_mimes' => $data['allowed_mimes'] ?? null,
]);
$created->load('group');
return response()->json($this->serializeExtension($created), 201);
}
public function update(Request $request, AttachmentExtension $attachmentExtension): JsonResponse
{
if ($error = $this->ensureAdmin($request)) {
return $error;
}
$data = $this->validatePayload($request, false);
if (array_key_exists('attachment_group_id', $data)) {
$attachmentExtension->attachment_group_id = $data['attachment_group_id'];
}
if (array_key_exists('allowed_mimes', $data)) {
$attachmentExtension->allowed_mimes = $data['allowed_mimes'];
}
$attachmentExtension->save();
$attachmentExtension->load('group');
return response()->json($this->serializeExtension($attachmentExtension));
}
public function destroy(Request $request, AttachmentExtension $attachmentExtension): JsonResponse
{
if ($error = $this->ensureAdmin($request)) {
return $error;
}
if (Attachment::query()->where('attachment_extension_id', $attachmentExtension->id)->exists()) {
return response()->json(['message' => 'Extension is in use.'], 422);
}
$attachmentExtension->delete();
return response()->json(null, 204);
}
private function validatePayload(Request $request, bool $requireExtension): array
{
$rules = [
'attachment_group_id' => ['nullable', 'integer', 'exists:attachment_groups,id'],
'allowed_mimes' => ['nullable', 'array'],
'allowed_mimes.*' => ['string', 'max:150'],
];
if ($requireExtension) {
$rules['extension'] = ['required', 'string', 'max:30'];
}
return $request->validate($rules);
}
private function normalizeExtension(string $value): string
{
return ltrim(strtolower(trim($value)), '.');
}
private function serializeExtension(AttachmentExtension $extension): array
{
return [
'id' => $extension->id,
'extension' => $extension->extension,
'attachment_group_id' => $extension->attachment_group_id,
'allowed_mimes' => $extension->allowed_mimes,
'group' => $extension->group ? [
'id' => $extension->group->id,
'name' => $extension->group->name,
'is_active' => $extension->group->is_active,
] : null,
];
}
}

View File

@@ -0,0 +1,190 @@
<?php
namespace App\Http\Controllers;
use App\Models\Attachment;
use App\Models\AttachmentGroup;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class AttachmentGroupController extends Controller
{
private function ensureAdmin(Request $request): ?JsonResponse
{
$user = $request->user();
if (!$user || !$user->roles()->where('name', 'ROLE_ADMIN')->exists()) {
return response()->json(['message' => 'Forbidden'], 403);
}
return null;
}
public function index(Request $request): JsonResponse
{
if ($error = $this->ensureAdmin($request)) {
return $error;
}
$groups = AttachmentGroup::query()
->withCount('extensions')
->orderBy('parent_id')
->orderBy('position')
->orderBy('name')
->get()
->map(fn (AttachmentGroup $group) => $this->serializeGroup($group));
return response()->json($groups);
}
public function store(Request $request): JsonResponse
{
if ($error = $this->ensureAdmin($request)) {
return $error;
}
$data = $this->validatePayload($request);
$name = trim($data['name']);
$parentId = $this->normalizeParentId($data['parent_id'] ?? null);
if (AttachmentGroup::query()->whereRaw('LOWER(name) = ?', [strtolower($name)])->exists()) {
return response()->json(['message' => 'Attachment group already exists.'], 422);
}
$position = (AttachmentGroup::query()
->where('parent_id', $parentId)
->max('position') ?? 0) + 1;
$group = AttachmentGroup::create([
'name' => $name,
'parent_id' => $parentId,
'position' => $position,
'max_size_kb' => $data['max_size_kb'],
'is_active' => $data['is_active'],
]);
$group->loadCount('extensions');
return response()->json($this->serializeGroup($group), 201);
}
public function update(Request $request, AttachmentGroup $attachmentGroup): JsonResponse
{
if ($error = $this->ensureAdmin($request)) {
return $error;
}
$data = $this->validatePayload($request);
$name = trim($data['name']);
$parentId = $this->normalizeParentId($data['parent_id'] ?? null);
$position = $attachmentGroup->position ?? 1;
if (AttachmentGroup::query()
->where('id', '!=', $attachmentGroup->id)
->whereRaw('LOWER(name) = ?', [strtolower($name)])
->exists()
) {
return response()->json(['message' => 'Attachment group already exists.'], 422);
}
if ($attachmentGroup->parent_id !== $parentId) {
$position = (AttachmentGroup::query()
->where('parent_id', $parentId)
->max('position') ?? 0) + 1;
}
$attachmentGroup->update([
'name' => $name,
'parent_id' => $parentId,
'position' => $position,
'max_size_kb' => $data['max_size_kb'],
'is_active' => $data['is_active'],
]);
$attachmentGroup->loadCount('extensions');
return response()->json($this->serializeGroup($attachmentGroup));
}
public function destroy(Request $request, AttachmentGroup $attachmentGroup): JsonResponse
{
if ($error = $this->ensureAdmin($request)) {
return $error;
}
if ($attachmentGroup->extensions()->exists()) {
return response()->json(['message' => 'Attachment group has extensions.'], 422);
}
if (Attachment::query()->where('attachment_group_id', $attachmentGroup->id)->exists()) {
return response()->json(['message' => 'Attachment group is in use.'], 422);
}
$attachmentGroup->delete();
return response()->json(null, 204);
}
public function reorder(Request $request): JsonResponse
{
if ($error = $this->ensureAdmin($request)) {
return $error;
}
$data = $request->validate([
'parentId' => ['nullable'],
'orderedIds' => ['required', 'array'],
'orderedIds.*' => ['integer'],
]);
$parentId = $data['parentId'] ?? null;
if ($parentId === '' || $parentId === 'null') {
$parentId = null;
} elseif ($parentId !== null) {
$parentId = (int) $parentId;
}
foreach ($data['orderedIds'] as $index => $groupId) {
AttachmentGroup::where('id', $groupId)
->where('parent_id', $parentId)
->update(['position' => $index + 1]);
}
return response()->json(['status' => 'ok']);
}
private function validatePayload(Request $request): array
{
return $request->validate([
'name' => ['required', 'string', 'max:150'],
'parent_id' => ['nullable', 'integer', 'exists:attachment_groups,id'],
'max_size_kb' => ['required', 'integer', 'min:1', 'max:512000'],
'is_active' => ['required', 'boolean'],
]);
}
private function serializeGroup(AttachmentGroup $group): array
{
return [
'id' => $group->id,
'name' => $group->name,
'parent_id' => $group->parent_id,
'position' => $group->position,
'max_size_kb' => $group->max_size_kb,
'is_active' => $group->is_active,
'extensions_count' => $group->extensions_count ?? null,
];
}
private function normalizeParentId($value): ?int
{
if ($value === '' || $value === 'null') {
return null;
}
if ($value === null) {
return null;
}
return (int) $value;
}
}

View File

@@ -0,0 +1,55 @@
<?php
namespace App\Http\Controllers;
use App\Models\AuditLog;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class AuditLogController extends Controller
{
public function index(Request $request): JsonResponse
{
$user = $request->user();
if (!$user) {
return response()->json(['message' => 'Unauthorized.'], 401);
}
$isAdmin = $user->roles()->where('name', 'ROLE_ADMIN')->exists();
if (!$isAdmin) {
return response()->json(['message' => 'Not authorized.'], 403);
}
$limit = (int) $request->query('limit', 200);
$limit = max(1, min(500, $limit));
$logs = AuditLog::query()
->with(['user.roles'])
->latest('created_at')
->limit($limit)
->get()
->map(fn (AuditLog $log) => $this->serializeLog($log));
return response()->json($logs);
}
private function serializeLog(AuditLog $log): array
{
return [
'id' => $log->id,
'action' => $log->action,
'subject_type' => $log->subject_type,
'subject_id' => $log->subject_id,
'metadata' => $log->metadata,
'ip_address' => $log->ip_address,
'user_agent' => $log->user_agent,
'created_at' => $log->created_at?->toIso8601String(),
'user' => $log->user ? [
'id' => $log->user->id,
'name' => $log->user->name,
'email' => $log->user->email,
'roles' => $log->user->roles?->pluck('name')->values(),
] : null,
];
}
}

View File

@@ -3,28 +3,41 @@
namespace App\Http\Controllers;
use App\Actions\Fortify\CreateNewUser;
use App\Actions\Fortify\PasswordValidationRules;
use App\Models\User;
use App\Services\AuditLogger;
use Illuminate\Auth\Events\Verified;
use Illuminate\Auth\Events\PasswordReset;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Password;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
class AuthController extends Controller
{
use PasswordValidationRules;
public function register(Request $request, CreateNewUser $creator): JsonResponse
{
$input = [
'name' => $request->input('name') ?? $request->input('username'),
'email' => $request->input('email'),
'password' => $request->input('password') ?? $request->input('plainPassword'),
'password_confirmation' => $request->input('password_confirmation') ?? $request->input('plainPassword'),
'name' => $request->input(key: 'name') ?? $request->input(key: 'username'),
'email' => $request->input(key: 'email'),
'password' => $request->input(key: 'password') ?? $request->input(key: 'plainPassword'),
'password_confirmation' => $request->input(key: 'password_confirmation')
?? $request->input(key: 'plainPassword'),
];
$user = $creator->create($input);
$user = $creator->create(input: $input);
$user->sendEmailVerificationNotification();
app(AuditLogger::class)->log($request, 'user.registered', $user, [
'email' => $user->email,
], $user);
return response()->json([
return response()->json(data: [
'user_id' => $user->id,
'email' => $user->email,
'message' => 'Verification email sent.',
@@ -33,39 +46,148 @@ class AuthController extends Controller
public function login(Request $request): JsonResponse
{
$request->validate([
'email' => ['required', 'email'],
$request->merge(input: [
'login' => $request->input(key: 'login', default: $request->input(key: 'email')),
]);
$request->validate(rules: [
'login' => ['required', 'string'],
'password' => ['required', 'string'],
]);
$user = User::where('email', $request->input('email'))->first();
$login = trim(string: (string) $request->input(key: 'login'));
$loginNormalized = Str::lower(value: $login);
$userQuery = User::query();
if (!$user || !Hash::check($request->input('password'), $user->password)) {
throw ValidationException::withMessages([
'email' => ['Invalid credentials.'],
if (filter_var(value: $login, filter: FILTER_VALIDATE_EMAIL)) {
$userQuery->whereRaw(sql: 'lower(email) = ?', bindings: [$loginNormalized]);
} else {
$userQuery->where(column: 'name_canonical', operator: $loginNormalized);
}
$user = $userQuery->first();
if (!$user || !Hash::check(value: $request->input(key: 'password'), hashedValue: $user->password)) {
throw ValidationException::withMessages(messages: [
'login' => ['Invalid credentials.'],
]);
}
if (!$user->hasVerifiedEmail()) {
return response()->json([
return response()->json(data : [
'message' => 'Email not verified.',
], 403);
], status: 403);
}
$token = $user->createToken('api')->plainTextToken;
$token = $user->createToken(name: 'api')->plainTextToken;
return response()->json([
app(AuditLogger::class)->log($request, 'user.login', $user, [
'login' => $login,
], $user);
return response()->json(data: [
'token' => $token,
'user_id' => $user->id,
'email' => $user->email,
'roles' => $user->roles()->pluck('name')->values(),
'roles' => $user->roles()->pluck(column: 'name')->values(),
]);
}
public function verifyEmail(Request $request, string $id, string $hash): RedirectResponse
{
$user = User::findOrFail(id: $id);
if (!hash_equals(known_string: $hash, user_string: sha1(string: $user->getEmailForVerification()))) {
abort(code: 403);
}
if (!$user->hasVerifiedEmail()) {
$user->markEmailAsVerified();
event(new Verified(user: $user));
}
return redirect(to: '/login');
}
public function forgotPassword(Request $request): JsonResponse
{
$request->validate(rules: [
'email' => ['required', 'email'],
]);
$status = Password::sendResetLink(
$request->only(keys: 'email')
);
if ($status !== Password::RESET_LINK_SENT) {
throw ValidationException::withMessages(messages: [
'email' => [__(key: $status)],
]);
}
return response()->json(data: ['message' => __(key: $status)]);
}
public function resetPassword(Request $request): JsonResponse
{
$request->validate(rules: [
'token' => ['required'],
'email' => ['required', 'email'],
'password' => $this->passwordRules(),
]);
$status = Password::reset(
$request->only('email', 'password', 'password_confirmation', 'token'),
function (User $user, string $password) use ($request) {
$user->forceFill(attributes: [
'password' => Hash::make(value: $password),
'remember_token' => Str::random(length: 60),
])->save();
event(new PasswordReset(user: $user));
app(AuditLogger::class)->log($request, 'user.password_reset', $user, [], $user);
}
);
if ($status !== Password::PASSWORD_RESET) {
throw ValidationException::withMessages(messages: [
'email' => [__(key: $status)],
]);
}
return response()->json(data: ['message' => __(key: $status)]);
}
public function updatePassword(Request $request): JsonResponse
{
$request->validate(rules: [
'current_password' => ['required'],
'password' => $this->passwordRules(),
]);
$user = $request->user();
if (!$user || !Hash::check(value: $request->input(key: 'current_password'), hashedValue: $user->password)) {
throw ValidationException::withMessages(messages: [
'current_password' => ['Invalid current password.'],
]);
}
$user->forceFill(attributes: [
'password' => Hash::make(value: $request->input(key: 'password')),
'remember_token' => Str::random(length: 60),
])->save();
app(AuditLogger::class)->log($request, 'user.password_changed', $user, [], $user);
return response()->json(data: ['message' => 'Password updated.']);
}
public function logout(Request $request): JsonResponse
{
app(AuditLogger::class)->log($request, 'user.logout', $request->user());
$request->user()?->currentAccessToken()?->delete();
return response()->json(null, 204);
return response()->json(data: null, status: 204);
}
}

View File

@@ -3,6 +3,8 @@
namespace App\Http\Controllers;
use App\Models\Forum;
use App\Models\Post;
use App\Models\User;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Validation\Rule;
@@ -11,39 +13,51 @@ class ForumController extends Controller
{
public function index(Request $request): JsonResponse
{
$query = Forum::query()->withoutTrashed();
$query = Forum::query()
->withoutTrashed()
->withCount(relations: ['threads', 'posts'])
->withSum(relation: 'threads', column: 'views_count');
$parentParam = $request->query('parent');
if (is_array($parentParam) && array_key_exists('exists', $parentParam)) {
$exists = filter_var($parentParam['exists'], FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE);
$parentParam = $request->query(key: 'parent');
if (is_array(value: $parentParam) && array_key_exists('exists', $parentParam)) {
$exists = filter_var(value: $parentParam['exists'], filter: FILTER_VALIDATE_BOOLEAN, options: FILTER_NULL_ON_FAILURE);
if ($exists === false) {
$query->whereNull('parent_id');
$query->whereNull(columns: 'parent_id');
} elseif ($exists === true) {
$query->whereNotNull('parent_id');
$query->whereNotNull(columns: 'parent_id');
}
} elseif (is_string($parentParam)) {
$parentId = $this->parseIriId($parentParam);
} elseif (is_string(value: $parentParam)) {
$parentId = $this->parseIriId(value: $parentParam);
if ($parentId !== null) {
$query->where('parent_id', $parentId);
$query->where(column: 'parent_id', operator: $parentId);
}
}
if ($request->filled('type')) {
$query->where('type', $request->query('type'));
if ($request->filled(key: 'type')) {
$query->where(column: 'type', operator: $request->query(key: 'type'));
}
$forums = $query
->orderBy('position')
->orderBy('name')
->get()
->map(fn (Forum $forum) => $this->serializeForum($forum));
->orderBy(column: 'position')
->orderBy(column: 'name')
->get();
return response()->json($forums);
$forumIds = $forums->pluck('id')->all();
$lastPostByForum = $this->loadLastPostsByForum($forumIds);
$payload = $forums->map(
fn (Forum $forum) => $this->serializeForum($forum, $lastPostByForum[$forum->id] ?? null)
);
return response()->json($payload);
}
public function show(Forum $forum): JsonResponse
{
return response()->json($this->serializeForum($forum));
$forum->loadCount(['threads', 'posts'])
->loadSum('threads', 'views_count');
$lastPost = $this->loadLastPostForForum($forum->id);
return response()->json($this->serializeForum($forum, $lastPost));
}
public function store(Request $request): JsonResponse
@@ -68,7 +82,12 @@ class ForumController extends Controller
}
}
if ($parentId === null) {
Forum::whereNull('parent_id')->increment('position');
$position = 0;
} else {
$position = Forum::where('parent_id', $parentId)->max('position');
}
$forum = Forum::create([
'name' => $data['name'],
@@ -78,7 +97,11 @@ class ForumController extends Controller
'position' => ($position ?? 0) + 1,
]);
return response()->json($this->serializeForum($forum), 201);
$forum->loadCount(['threads', 'posts'])
->loadSum('threads', 'views_count');
$lastPost = $this->loadLastPostForForum($forum->id);
return response()->json($this->serializeForum($forum, $lastPost), 201);
}
public function update(Request $request, Forum $forum): JsonResponse
@@ -122,7 +145,11 @@ class ForumController extends Controller
$forum->save();
return response()->json($this->serializeForum($forum));
$forum->loadCount(['threads', 'posts'])
->loadSum('threads', 'views_count');
$lastPost = $this->loadLastPostForForum($forum->id);
return response()->json($this->serializeForum($forum, $lastPost));
}
public function destroy(Request $request, Forum $forum): JsonResponse
@@ -175,7 +202,7 @@ class ForumController extends Controller
return null;
}
private function serializeForum(Forum $forum): array
private function serializeForum(Forum $forum, ?Post $lastPost): array
{
return [
'id' => $forum->id,
@@ -184,8 +211,76 @@ class ForumController extends Controller
'type' => $forum->type,
'parent' => $forum->parent_id ? "/api/forums/{$forum->parent_id}" : null,
'position' => $forum->position,
'threads_count' => $forum->threads_count ?? 0,
'posts_count' => ($forum->posts_count ?? 0) + ($forum->threads_count ?? 0),
'views_count' => (int) ($forum->threads_sum_views_count ?? 0),
'last_post_at' => $lastPost?->created_at?->toIso8601String(),
'last_post_user_id' => $lastPost?->user_id,
'last_post_user_name' => $lastPost?->user?->name,
'last_post_user_rank_color' => $lastPost?->user?->rank?->color,
'last_post_user_group_color' => $this->resolveGroupColor($lastPost?->user),
'created_at' => $forum->created_at?->toIso8601String(),
'updated_at' => $forum->updated_at?->toIso8601String(),
];
}
private function loadLastPostsByForum(array $forumIds): array
{
if (empty($forumIds)) {
return [];
}
$posts = Post::query()
->select('posts.*', 'threads.forum_id as forum_id')
->join('threads', 'posts.thread_id', '=', 'threads.id')
->whereIn('threads.forum_id', $forumIds)
->whereNull('posts.deleted_at')
->whereNull('threads.deleted_at')
->orderByDesc('posts.created_at')
->with(['user.rank', 'user.roles'])
->get();
$byForum = [];
foreach ($posts as $post) {
$forumId = (int) ($post->forum_id ?? 0);
if ($forumId && !array_key_exists($forumId, $byForum)) {
$byForum[$forumId] = $post;
}
}
return $byForum;
}
private function loadLastPostForForum(int $forumId): ?Post
{
return Post::query()
->select('posts.*')
->join('threads', 'posts.thread_id', '=', 'threads.id')
->where('threads.forum_id', $forumId)
->whereNull('posts.deleted_at')
->whereNull('threads.deleted_at')
->orderByDesc(column: 'posts.created_at')
->with(relations: ['user.rank', 'user.roles'])
->first();
}
private function resolveGroupColor(?User $user): ?string
{
if (!$user) {
return null;
}
$roles = $user->roles;
if (!$roles) {
return null;
}
foreach ($roles->sortBy(callback: 'name') as $role) {
if (!empty($role->color)) {
return $role->color;
}
}
return null;
}
}

View File

@@ -0,0 +1,140 @@
<?php
namespace App\Http\Controllers;
use App\Models\Role;
use App\Models\User;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str;
use Illuminate\View\View;
class InstallerController extends Controller
{
public function show(Request $request): View|RedirectResponse
{
if ($this->envExists()) {
return redirect('/');
}
return view('installer', [
'appUrl' => $request->getSchemeAndHttpHost(),
]);
}
public function store(Request $request): View|RedirectResponse
{
if ($this->envExists()) {
return redirect('/');
}
$data = $request->validate([
'app_url' => ['required', 'url'],
'db_host' => ['required', 'string', 'max:255'],
'db_port' => ['nullable', 'integer'],
'db_database' => ['required', 'string', 'max:255'],
'db_username' => ['required', 'string', 'max:255'],
'db_password' => ['nullable', 'string'],
'admin_name' => ['required', 'string', 'max:255'],
'admin_email' => ['required', 'email', 'max:255'],
'admin_password' => ['required', 'string', 'min:8'],
]);
$appKey = 'base64:' . base64_encode(random_bytes(32));
$envLines = [
'APP_NAME="speedBB"',
'APP_ENV=production',
'APP_DEBUG=false',
'APP_URL=' . $data['app_url'],
'APP_KEY=' . $appKey,
'',
'DB_CONNECTION=mysql',
'DB_HOST=' . $data['db_host'],
'DB_PORT=' . ($data['db_port'] ?: 3306),
'DB_DATABASE=' . $data['db_database'],
'DB_USERNAME=' . $data['db_username'],
'DB_PASSWORD=' . ($data['db_password'] ?? ''),
'',
'MAIL_MAILER=sendmail',
'MAIL_SENDMAIL_PATH="/usr/sbin/sendmail -bs -i"',
'MAIL_FROM_ADDRESS="hello@example.com"',
'MAIL_FROM_NAME="speedBB"',
];
$this->writeEnv(implode("\n", $envLines) . "\n");
config([
'app.key' => $appKey,
'app.url' => $data['app_url'],
'database.default' => 'mysql',
'database.connections.mysql.host' => $data['db_host'],
'database.connections.mysql.port' => (int) ($data['db_port'] ?: 3306),
'database.connections.mysql.database' => $data['db_database'],
'database.connections.mysql.username' => $data['db_username'],
'database.connections.mysql.password' => $data['db_password'] ?? '',
'mail.default' => 'sendmail',
'mail.mailers.sendmail.path' => '/usr/sbin/sendmail -bs -i',
]);
DB::purge('mysql');
try {
DB::connection('mysql')->getPdo();
} catch (\Throwable $e) {
$this->removeEnv();
return view('installer', [
'appUrl' => $data['app_url'],
'error' => 'Database connection failed: ' . $e->getMessage(),
'old' => $data,
]);
}
$migrateExit = Artisan::call('migrate', ['--force' => true]);
if ($migrateExit !== 0) {
$this->removeEnv();
return view('installer', [
'appUrl' => $data['app_url'],
'error' => 'Migration failed. Please check your database credentials.',
'old' => $data,
]);
}
$adminRole = Role::firstOrCreate(['name' => 'ROLE_ADMIN']);
$founderRole = Role::firstOrCreate(['name' => 'ROLE_FOUNDER']);
$user = User::create([
'name' => $data['admin_name'],
'name_canonical' => Str::lower(trim($data['admin_name'])),
'email' => $data['admin_email'],
'password' => Hash::make($data['admin_password']),
'email_verified_at' => now(),
]);
$user->roles()->sync([$adminRole->id, $founderRole->id]);
return view('installer-success');
}
private function envExists(): bool
{
return file_exists(base_path('.env'));
}
private function writeEnv(string $contents): void
{
$path = base_path('.env');
file_put_contents($path, $contents);
}
private function removeEnv(): void
{
$path = base_path('.env');
if (file_exists($path)) {
unlink($path);
}
}
}

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

@@ -0,0 +1,182 @@
<?php
namespace App\Http\Controllers;
use App\Models\Forum;
use App\Models\Post;
use App\Models\Thread;
use App\Models\User;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
class PortalController extends Controller
{
public function __invoke(Request $request): JsonResponse
{
$forums = Forum::query()
->withoutTrashed()
->withCount(['threads', 'posts'])
->withSum('threads', 'views_count')
->orderBy('position')
->orderBy('name')
->get();
$forumIds = $forums->pluck('id')->all();
$lastPostByForum = $this->loadLastPostsByForum($forumIds);
$forumPayload = $forums->map(
fn (Forum $forum) => $this->serializeForum($forum, $lastPostByForum[$forum->id] ?? null)
);
$threads = Thread::query()
->withoutTrashed()
->withCount('posts')
->with([
'user' => fn ($query) => $query->withCount(['posts', 'threads'])->with(['rank', 'roles']),
'latestPost.user.rank',
'latestPost.user.roles',
])
->latest('created_at')
->limit(12)
->get()
->map(fn (Thread $thread) => $this->serializeThread($thread));
$stats = [
'threads' => Thread::query()->withoutTrashed()->count(),
'posts' => Post::query()->withoutTrashed()->count()
+ Thread::query()->withoutTrashed()->count(),
'users' => User::query()->count(),
];
$user = auth('sanctum')->user();
return response()->json([
'forums' => $forumPayload,
'threads' => $threads,
'stats' => $stats,
'profile' => $user ? [
'id' => $user->id,
'name' => $user->name,
'email' => $user->email,
'avatar_url' => $user->avatar_path ? Storage::url($user->avatar_path) : null,
'location' => $user->location,
'rank' => $user->rank ? [
'id' => $user->rank->id,
'name' => $user->rank->name,
'color' => $user->rank->color,
] : null,
'group_color' => $this->resolveGroupColor($user),
] : null,
]);
}
private function serializeForum(Forum $forum, ?Post $lastPost): array
{
return [
'id' => $forum->id,
'name' => $forum->name,
'description' => $forum->description,
'type' => $forum->type,
'parent' => $forum->parent_id ? "/api/forums/{$forum->parent_id}" : null,
'position' => $forum->position,
'threads_count' => $forum->threads_count ?? 0,
'posts_count' => ($forum->posts_count ?? 0) + ($forum->threads_count ?? 0),
'views_count' => (int) ($forum->threads_sum_views_count ?? 0),
'last_post_at' => $lastPost?->created_at?->toIso8601String(),
'last_post_user_id' => $lastPost?->user_id,
'last_post_user_name' => $lastPost?->user?->name,
'last_post_user_rank_color' => $lastPost?->user?->rank?->color,
'last_post_user_group_color' => $this->resolveGroupColor($lastPost?->user),
'created_at' => $forum->created_at?->toIso8601String(),
'updated_at' => $forum->updated_at?->toIso8601String(),
];
}
private function serializeThread(Thread $thread): array
{
return [
'id' => $thread->id,
'title' => $thread->title,
'body' => $thread->body,
'solved' => (bool) $thread->solved,
'forum' => "/api/forums/{$thread->forum_id}",
'user_id' => $thread->user_id,
'posts_count' => ($thread->posts_count ?? 0) + 1,
'views_count' => $thread->views_count ?? 0,
'user_name' => $thread->user?->name,
'user_avatar_url' => $thread->user?->avatar_path
? Storage::url($thread->user->avatar_path)
: null,
'user_posts_count' => ($thread->user?->posts_count ?? 0) + ($thread->user?->threads_count ?? 0),
'user_created_at' => $thread->user?->created_at?->toIso8601String(),
'user_rank_name' => $thread->user?->rank?->name,
'user_rank_badge_type' => $thread->user?->rank?->badge_type,
'user_rank_badge_text' => $thread->user?->rank?->badge_text,
'user_rank_badge_url' => $thread->user?->rank?->badge_image_path
? Storage::url($thread->user->rank->badge_image_path)
: null,
'user_rank_color' => $thread->user?->rank?->color,
'user_group_color' => $this->resolveGroupColor($thread->user),
'last_post_at' => $thread->latestPost?->created_at?->toIso8601String()
?? $thread->created_at?->toIso8601String(),
'last_post_id' => $thread->latestPost?->id,
'last_post_user_id' => $thread->latestPost?->user_id ?? $thread->user_id,
'last_post_user_name' => $thread->latestPost?->user?->name
?? $thread->user?->name,
'last_post_user_rank_color' => $thread->latestPost?->user?->rank?->color
?? $thread->user?->rank?->color,
'last_post_user_group_color' => $this->resolveGroupColor($thread->latestPost?->user)
?? $this->resolveGroupColor($thread->user),
'created_at' => $thread->created_at?->toIso8601String(),
'updated_at' => $thread->updated_at?->toIso8601String(),
];
}
private function loadLastPostsByForum(array $forumIds): array
{
if (empty($forumIds)) {
return [];
}
$posts = Post::query()
->select('posts.*', 'threads.forum_id as forum_id')
->join('threads', 'posts.thread_id', '=', 'threads.id')
->whereIn('threads.forum_id', $forumIds)
->whereNull('posts.deleted_at')
->whereNull('threads.deleted_at')
->orderByDesc('posts.created_at')
->with(['user.rank', 'user.roles'])
->get();
$byForum = [];
foreach ($posts as $post) {
$forumId = (int) ($post->forum_id ?? 0);
if ($forumId && !array_key_exists($forumId, $byForum)) {
$byForum[$forumId] = $post;
}
}
return $byForum;
}
private function resolveGroupColor(?\App\Models\User $user): ?string
{
if (!$user) {
return null;
}
$roles = $user->roles;
if (!$roles) {
return null;
}
foreach ($roles->sortBy('name') as $role) {
if (!empty($role->color)) {
return $role->color;
}
}
return null;
}
}

View File

@@ -2,16 +2,26 @@
namespace App\Http\Controllers;
use App\Actions\BbcodeFormatter;
use App\Models\Post;
use App\Models\Thread;
use App\Models\Setting;
use App\Services\AuditLogger;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
class PostController extends Controller
{
public function index(Request $request): JsonResponse
{
$query = Post::query()->withoutTrashed();
$query = Post::query()->withoutTrashed()->with([
'user' => fn ($query) => $query
->withCount(['posts', 'threads', 'thanksGiven', 'thanksReceived'])
->with(['rank', 'roles']),
'attachments.extension',
'attachments.group',
]);
$threadParam = $request->query('thread');
if (is_string($threadParam)) {
@@ -45,11 +55,30 @@ class PostController extends Controller
'body' => $data['body'],
]);
app(AuditLogger::class)->log($request, 'post.created', $post, [
'thread_id' => $thread->id,
]);
$post->loadMissing([
'user' => fn ($query) => $query
->withCount(['posts', 'threads', 'thanksGiven', 'thanksReceived'])
->with(['rank', 'roles']),
'attachments.extension',
'attachments.group',
]);
return response()->json($this->serializePost($post), 201);
}
public function destroy(Request $request, Post $post): JsonResponse
{
$reason = $request->input('reason');
$reasonText = $request->input('reason_text');
app(AuditLogger::class)->log($request, 'post.deleted', $post, [
'thread_id' => $post->thread_id,
'reason' => $reason,
'reason_text' => $reasonText,
]);
$post->deleted_by = $request->user()?->id;
$post->save();
$post->delete();
@@ -57,6 +86,41 @@ class PostController extends Controller
return response()->json(null, 204);
}
public function update(Request $request, Post $post): JsonResponse
{
$user = $request->user();
if (!$user) {
return response()->json(['message' => 'Unauthorized.'], 401);
}
$isAdmin = $user->roles()->where('name', 'ROLE_ADMIN')->exists();
if (!$isAdmin && $post->user_id !== $user->id) {
return response()->json(['message' => 'Not authorized to edit posts.'], 403);
}
$data = $request->validate([
'body' => ['required', 'string'],
]);
$post->body = $data['body'];
$post->save();
$post->refresh();
app(AuditLogger::class)->log($request, 'post.edited', $post, [
'thread_id' => $post->thread_id,
]);
$post->loadMissing([
'user' => fn ($query) => $query
->withCount(['posts', 'threads', 'thanksGiven', 'thanksReceived'])
->with(['rank', 'roles']),
'attachments.extension',
'attachments.group',
]);
return response()->json($this->serializePost($post));
}
private function parseIriId(?string $value): ?int
{
if (!$value) {
@@ -76,13 +140,133 @@ class PostController extends Controller
private function serializePost(Post $post): array
{
$attachments = $post->relationLoaded('attachments') ? $post->attachments : collect();
$bodyHtml = $this->renderBody($post->body, $attachments);
return [
'id' => $post->id,
'body' => $post->body,
'body_html' => $bodyHtml,
'thread' => "/api/threads/{$post->thread_id}",
'user_id' => $post->user_id,
'user_name' => $post->user?->name,
'user_avatar_url' => $post->user?->avatar_path
? Storage::url($post->user->avatar_path)
: null,
'user_posts_count' => ($post->user?->posts_count ?? 0) + ($post->user?->threads_count ?? 0),
'user_created_at' => $post->user?->created_at?->toIso8601String(),
'user_location' => $post->user?->location,
'user_thanks_given_count' => $post->user?->thanks_given_count ?? 0,
'user_thanks_received_count' => $post->user?->thanks_received_count ?? 0,
'user_rank_name' => $post->user?->rank?->name,
'user_rank_badge_type' => $post->user?->rank?->badge_type,
'user_rank_badge_text' => $post->user?->rank?->badge_text,
'user_rank_badge_url' => $post->user?->rank?->badge_image_path
? Storage::url($post->user->rank->badge_image_path)
: null,
'user_rank_color' => $post->user?->rank?->color,
'user_group_color' => $this->resolveGroupColor($post->user),
'created_at' => $post->created_at?->toIso8601String(),
'updated_at' => $post->updated_at?->toIso8601String(),
'attachments' => $post->relationLoaded('attachments')
? $attachments
->map(fn ($attachment) => [
'id' => $attachment->id,
'group' => $attachment->group ? [
'id' => $attachment->group->id,
'name' => $attachment->group->name,
] : null,
'original_name' => $attachment->original_name,
'extension' => $attachment->extension,
'mime_type' => $attachment->mime_type,
'size_bytes' => $attachment->size_bytes,
'download_url' => "/api/attachments/{$attachment->id}/download",
'thumbnail_url' => $attachment->thumbnail_path
? "/api/attachments/{$attachment->id}/thumbnail"
: null,
'is_image' => str_starts_with((string) $attachment->mime_type, 'image/'),
'created_at' => $attachment->created_at?->toIso8601String(),
])
->values()
: [],
];
}
private function renderBody(string $body, $attachments): string
{
$replaced = $this->replaceAttachmentTags($body, $attachments);
return BbcodeFormatter::format($replaced);
}
private function replaceAttachmentTags(string $body, $attachments): string
{
if (!$attachments || count($attachments) === 0) {
return $body;
}
$map = [];
foreach ($attachments as $attachment) {
$name = strtolower($attachment->original_name ?? '');
if ($name !== '') {
$map[$name] = [
'url' => "/api/attachments/{$attachment->id}/download",
'mime' => $attachment->mime_type ?? '',
'thumb' => $attachment->thumbnail_path
? "/api/attachments/{$attachment->id}/thumbnail"
: null,
];
}
}
if (!$map) {
return $body;
}
return preg_replace_callback('/\\[attachment\\](.+?)\\[\\/attachment\\]/i', function ($matches) use ($map) {
$rawName = trim($matches[1]);
$key = strtolower($rawName);
if (!array_key_exists($key, $map)) {
return $matches[0];
}
$entry = $map[$key];
$url = $entry['url'];
$mime = $entry['mime'] ?? '';
if (str_starts_with($mime, 'image/') && $this->displayImagesInline()) {
if (!empty($entry['thumb'])) {
$thumb = $entry['thumb'];
return "[url={$url}][img]{$thumb}[/img][/url]";
}
return "[img]{$url}[/img]";
}
return "[url={$url}]{$rawName}[/url]";
}, $body) ?? $body;
}
private function displayImagesInline(): bool
{
$value = Setting::query()->where('key', 'attachments.display_images_inline')->value('value');
if ($value === null) {
return true;
}
return in_array(strtolower($value), ['1', 'true', 'yes', 'on'], true);
}
private function resolveGroupColor(?\App\Models\User $user): ?string
{
if (!$user) {
return null;
}
$roles = $user->roles;
if (!$roles) {
return null;
}
foreach ($roles->sortBy('name') as $role) {
if (!empty($role->color)) {
return $role->color;
}
}
return null;
}
}

View File

@@ -0,0 +1,122 @@
<?php
namespace App\Http\Controllers;
use App\Models\Post;
use App\Models\PostThank;
use App\Models\User;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
class PostThankController extends Controller
{
public function store(Request $request, Post $post): JsonResponse
{
$user = $request->user();
if (!$user) {
return response()->json(['message' => 'Unauthenticated.'], 401);
}
$thank = PostThank::firstOrCreate([
'post_id' => $post->id,
'user_id' => $user->id,
]);
return response()->json([
'id' => $thank->id,
'post_id' => $post->id,
'user_id' => $user->id,
], 201);
}
public function destroy(Request $request, Post $post): JsonResponse
{
$user = $request->user();
if (!$user) {
return response()->json(['message' => 'Unauthenticated.'], 401);
}
PostThank::where('post_id', $post->id)
->where('user_id', $user->id)
->delete();
return response()->json(null, 204);
}
public function given(User $user): JsonResponse
{
$thanks = PostThank::query()
->where('user_id', $user->id)
->with(['post.thread', 'post.user.rank', 'post.user.roles'])
->latest('created_at')
->get()
->map(fn (PostThank $thank) => $this->serializeGiven($thank));
return response()->json($thanks);
}
public function received(User $user): JsonResponse
{
$thanks = PostThank::query()
->whereHas('post', fn ($query) => $query->where('user_id', $user->id))
->with(['post.thread', 'user.rank', 'user.roles'])
->latest('created_at')
->get()
->map(fn (PostThank $thank) => $this->serializeReceived($thank));
return response()->json($thanks);
}
private function serializeGiven(PostThank $thank): array
{
return [
'id' => $thank->id,
'post_id' => $thank->post_id,
'thread_id' => $thank->post?->thread_id,
'thread_title' => $thank->post?->thread?->title,
'post_excerpt' => $thank->post?->body ? Str::limit($thank->post->body, 120) : null,
'post_author_id' => $thank->post?->user_id,
'post_author_name' => $thank->post?->user?->name,
'post_author_rank_color' => $thank->post?->user?->rank?->color,
'post_author_group_color' => $this->resolveGroupColor($thank->post?->user),
'thanked_at' => $thank->created_at?->toIso8601String(),
];
}
private function serializeReceived(PostThank $thank): array
{
return [
'id' => $thank->id,
'post_id' => $thank->post_id,
'thread_id' => $thank->post?->thread_id,
'thread_title' => $thank->post?->thread?->title,
'post_excerpt' => $thank->post?->body ? Str::limit($thank->post->body, 120) : null,
'thanker_id' => $thank->user_id,
'thanker_name' => $thank->user?->name,
'thanker_rank_color' => $thank->user?->rank?->color,
'thanker_group_color' => $this->resolveGroupColor($thank->user),
'thanked_at' => $thank->created_at?->toIso8601String(),
];
}
private function resolveGroupColor(?\App\Models\User $user): ?string
{
if (!$user) {
return null;
}
$roles = $user->roles;
if (!$roles) {
return null;
}
foreach ($roles->sortBy('name') as $role) {
if (!empty($role->color)) {
return $role->color;
}
}
return null;
}
}

View File

@@ -0,0 +1,20 @@
<?php
namespace App\Http\Controllers;
use App\Actions\BbcodeFormatter;
use Illuminate\Http\Request;
class PreviewController extends Controller
{
public function preview(Request $request)
{
$data = $request->validate([
'body' => ['required', 'string'],
]);
return response()->json([
'html' => BbcodeFormatter::format($data['body']),
]);
}
}

View File

@@ -0,0 +1,167 @@
<?php
namespace App\Http\Controllers;
use App\Models\Rank;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
class RankController extends Controller
{
private function ensureAdmin(Request $request): ?JsonResponse
{
$user = $request->user();
if (!$user || !$user->roles()->where(column: 'name', operator: 'ROLE_ADMIN')->exists()) {
return response()->json(data: ['message' => 'Forbidden'], status: 403);
}
return null;
}
public function index(Request $request): JsonResponse
{
$ranks = Rank::query()
->orderBy('name')
->get()
->map(fn (Rank $rank) => [
'id' => $rank->id,
'name' => $rank->name,
'badge_type' => $rank->badge_type,
'badge_text' => $rank->badge_text,
'color' => $rank->color,
'badge_image_url' => $rank->badge_image_path
? Storage::url($rank->badge_image_path)
: null,
]);
return response()->json($ranks);
}
public function store(Request $request): JsonResponse
{
if ($error = $this->ensureAdmin($request)) {
return $error;
}
$data = $request->validate([
'name' => ['required', 'string', 'max:100', 'unique:ranks,name'],
'badge_type' => ['nullable', 'in:text,image,none'],
'badge_text' => ['nullable', 'string', 'max:40'],
'color' => ['nullable', 'string', 'max:20', 'regex:/^#([0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/'],
]);
$badgeType = $data['badge_type'] ?? 'text';
$badgeText = $badgeType === 'text'
? ($data['badge_text'] ?? $data['name'])
: null;
if ($badgeType === 'none') {
$badgeText = null;
}
$rank = Rank::create([
'name' => $data['name'],
'badge_type' => $badgeType,
'badge_text' => $badgeText,
'color' => $data['color'] ?? null,
]);
return response()->json([
'id' => $rank->id,
'name' => $rank->name,
'badge_type' => $rank->badge_type,
'badge_text' => $rank->badge_text,
'color' => $rank->color,
'badge_image_url' => null,
], 201);
}
public function update(Request $request, Rank $rank): JsonResponse
{
if ($error = $this->ensureAdmin($request)) {
return $error;
}
$data = $request->validate([
'name' => ['required', 'string', 'max:100', "unique:ranks,name,{$rank->id}"],
'badge_type' => ['nullable', 'in:text,image,none'],
'badge_text' => ['nullable', 'string', 'max:40'],
'color' => ['nullable', 'string', 'max:20', 'regex:/^#([0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/'],
]);
$badgeType = $data['badge_type'] ?? $rank->badge_type ?? 'text';
$badgeText = $badgeType === 'text'
? ($data['badge_text'] ?? $rank->badge_text ?? $data['name'])
: null;
if ($badgeType === 'none') {
$badgeText = null;
}
$color = array_key_exists('color', $data) ? $data['color'] : $rank->color;
if ($badgeType !== 'image' && $rank->badge_image_path) {
Storage::disk('public')->delete($rank->badge_image_path);
$rank->badge_image_path = null;
}
$rank->update([
'name' => $data['name'],
'badge_type' => $badgeType,
'badge_text' => $badgeText,
'color' => $color,
]);
return response()->json([
'id' => $rank->id,
'name' => $rank->name,
'badge_type' => $rank->badge_type,
'badge_text' => $rank->badge_text,
'color' => $rank->color,
'badge_image_url' => $rank->badge_image_path
? Storage::url($rank->badge_image_path)
: null,
]);
}
public function destroy(Request $request, Rank $rank): JsonResponse
{
if ($error = $this->ensureAdmin($request)) {
return $error;
}
if ($rank->badge_image_path) {
Storage::disk('public')->delete($rank->badge_image_path);
}
$rank->delete();
return response()->json(null, 204);
}
public function uploadBadgeImage(Request $request, Rank $rank): JsonResponse
{
if ($error = $this->ensureAdmin($request)) {
return $error;
}
$data = $request->validate([
'file' => ['required', 'image', 'mimes:jpg,jpeg,png,gif,webp', 'max:2048'],
]);
if ($rank->badge_image_path) {
Storage::disk('public')->delete($rank->badge_image_path);
}
$path = $data['file']->store('rank-badges', 'public');
$rank->badge_type = 'image';
$rank->badge_text = null;
$rank->badge_image_path = $path;
$rank->save();
return response()->json([
'id' => $rank->id,
'badge_type' => $rank->badge_type,
'badge_text' => $rank->badge_text,
'badge_image_url' => Storage::url($path),
]);
}
}

View File

@@ -0,0 +1,141 @@
<?php
namespace App\Http\Controllers;
use App\Models\Role;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class RoleController extends Controller
{
private const CORE_ROLES = ['ROLE_ADMIN', 'ROLE_USER', 'ROLE_FOUNDER'];
private function ensureAdmin(Request $request): ?JsonResponse
{
$user = $request->user();
if (!$user || !$user->roles()->where('name', 'ROLE_ADMIN')->exists()) {
return response()->json(['message' => 'Forbidden'], 403);
}
return null;
}
public function index(Request $request): JsonResponse
{
if ($error = $this->ensureAdmin($request)) {
return $error;
}
$roles = Role::query()
->orderBy('name')
->get()
->map(fn (Role $role) => [
'id' => $role->id,
'name' => $role->name,
'color' => $role->color,
]);
return response()->json($roles);
}
public function store(Request $request): JsonResponse
{
if ($error = $this->ensureAdmin($request)) {
return $error;
}
$data = $request->validate([
'name' => ['required', 'string', 'max:100', 'unique:roles,name'],
'color' => ['nullable', 'string', 'max:20', 'regex:/^#([0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/'],
]);
$normalizedName = $this->normalizeRoleName($data['name']);
if (Role::query()->where('name', $normalizedName)->exists()) {
return response()->json(['message' => 'Role already exists.'], 422);
}
$role = Role::create([
'name' => $normalizedName,
'color' => $data['color'] ?? null,
]);
return response()->json([
'id' => $role->id,
'name' => $role->name,
'color' => $role->color,
], 201);
}
public function update(Request $request, Role $role): JsonResponse
{
if ($error = $this->ensureAdmin($request)) {
return $error;
}
$data = $request->validate([
'name' => ['required', 'string', 'max:100', "unique:roles,name,{$role->id}"],
'color' => ['nullable', 'string', 'max:20', 'regex:/^#([0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/'],
]);
$normalizedName = $this->normalizeRoleName($data['name']);
if (Role::query()
->where('id', '!=', $role->id)
->where('name', $normalizedName)
->exists()
) {
return response()->json(['message' => 'Role already exists.'], 422);
}
if (in_array($role->name, self::CORE_ROLES, true) && $normalizedName !== $role->name) {
return response()->json(['message' => 'Core roles cannot be renamed.'], 422);
}
$color = array_key_exists('color', $data) ? $data['color'] : $role->color;
$role->update([
'name' => $normalizedName,
'color' => $color,
]);
return response()->json([
'id' => $role->id,
'name' => $role->name,
'color' => $role->color,
]);
}
public function destroy(Request $request, Role $role): JsonResponse
{
if ($error = $this->ensureAdmin($request)) {
return $error;
}
if (in_array($role->name, self::CORE_ROLES, true)) {
return response()->json(['message' => 'Core roles cannot be deleted.'], 422);
}
if ($role->users()->exists()) {
return response()->json(['message' => 'Role is assigned to users.'], 422);
}
$role->delete();
return response()->json(null, 204);
}
private function normalizeRoleName(string $value): string
{
$raw = strtoupper(trim($value));
$raw = preg_replace('/\s+/', '_', $raw);
$raw = preg_replace('/[^A-Z0-9_]/', '_', $raw);
$raw = preg_replace('/_+/', '_', $raw);
$raw = trim($raw, '_');
if ($raw === '') {
return 'ROLE_';
}
if (str_starts_with($raw, 'ROLE_')) {
return $raw;
}
return "ROLE_{$raw}";
}
}

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

@@ -0,0 +1,161 @@
<?php
namespace App\Http\Controllers;
use App\Models\Post;
use App\Models\Thread;
use App\Models\User;
use App\Models\Attachment;
use App\Models\Setting;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
use Illuminate\Http\JsonResponse;
class StatsController extends Controller
{
public function __invoke(): JsonResponse
{
$threadsCount = Thread::query()->withoutTrashed()->count();
$postsCount = Post::query()->withoutTrashed()->count();
$usersCount = User::query()->count();
$attachmentsCount = Attachment::query()->withoutTrashed()->count();
$attachmentsSizeBytes = (int) Attachment::query()->withoutTrashed()->sum('size_bytes');
$boardStartedAt = $this->resolveBoardStartedAt();
$daysSinceStart = $boardStartedAt
? max(1, Carbon::parse($boardStartedAt)->diffInSeconds(now()) / 86400)
: null;
$dbSizeBytes = $this->resolveDatabaseSize();
$dbServer = $this->resolveDatabaseServer();
$avatarSizeBytes = $this->resolveAvatarDirectorySize();
$orphanAttachments = $this->resolveOrphanAttachments();
$version = Setting::query()->where('key', 'version')->value('value');
$build = Setting::query()->where('key', 'build')->value('value');
$boardVersion = $version
? ($build ? "{$version} (build {$build})" : $version)
: null;
return response()->json([
'threads' => $threadsCount,
'posts' => $postsCount + $threadsCount,
'users' => $usersCount,
'attachments' => $attachmentsCount,
'board_started_at' => $boardStartedAt,
'attachments_size_bytes' => $attachmentsSizeBytes,
'avatar_directory_size_bytes' => $avatarSizeBytes,
'database_size_bytes' => $dbSizeBytes,
'database_server' => $dbServer,
'gzip_compression' => $this->resolveGzipCompression(),
'php_version' => PHP_VERSION,
'orphan_attachments' => $orphanAttachments,
'board_version' => $boardVersion,
'posts_per_day' => $daysSinceStart ? ($postsCount + $threadsCount) / $daysSinceStart : null,
'topics_per_day' => $daysSinceStart ? $threadsCount / $daysSinceStart : null,
'users_per_day' => $daysSinceStart ? $usersCount / $daysSinceStart : null,
'attachments_per_day' => $daysSinceStart ? $attachmentsCount / $daysSinceStart : null,
]);
}
private function resolveBoardStartedAt(): ?string
{
$timestamps = [
User::query()->min('created_at'),
Thread::query()->min('created_at'),
Post::query()->min('created_at'),
];
$min = null;
foreach ($timestamps as $value) {
if (!$value) {
continue;
}
$time = Carbon::parse($value)->timestamp;
if ($min === null || $time < $min) {
$min = $time;
}
}
return $min !== null ? Carbon::createFromTimestamp($min)->toIso8601String() : null;
}
private function resolveDatabaseSize(): ?int
{
try {
$driver = DB::connection()->getDriverName();
if ($driver === 'mysql') {
$row = DB::selectOne('SELECT SUM(data_length + index_length) AS size FROM information_schema.tables WHERE table_schema = DATABASE()');
return $row && isset($row->size) ? (int) $row->size : null;
}
} catch (\Throwable) {
return null;
}
return null;
}
private function resolveDatabaseServer(): ?string
{
try {
$row = DB::selectOne('SELECT VERSION() AS version');
return $row && isset($row->version) ? (string) $row->version : null;
} catch (\Throwable) {
return null;
}
}
private function resolveAvatarDirectorySize(): ?int
{
try {
$disk = Storage::disk('public');
$files = $disk->allFiles('avatars');
$total = 0;
foreach ($files as $file) {
$total += $disk->size($file);
}
return $total;
} catch (\Throwable) {
return null;
}
}
private function resolveOrphanAttachments(): int
{
try {
return (int) DB::table('attachments')
->leftJoin('threads', 'attachments.thread_id', '=', 'threads.id')
->leftJoin('posts', 'attachments.post_id', '=', 'posts.id')
->whereNull('attachments.deleted_at')
->where(function ($query) {
$query
->whereNull('attachments.thread_id')
->whereNull('attachments.post_id')
->orWhere(function ($inner) {
$inner->whereNotNull('attachments.thread_id')
->where(function ($inner2) {
$inner2->whereNull('threads.id')
->orWhereNotNull('threads.deleted_at');
});
})
->orWhere(function ($inner) {
$inner->whereNotNull('attachments.post_id')
->where(function ($inner2) {
$inner2->whereNull('posts.id')
->orWhereNotNull('posts.deleted_at');
});
});
})
->count();
} catch (\Throwable) {
return 0;
}
}
private function resolveGzipCompression(): bool
{
$value = ini_get('zlib.output_compression');
return in_array(strtolower((string) $value), ['1', 'on', 'true'], true);
}
}

View File

@@ -0,0 +1,210 @@
<?php
namespace App\Http\Controllers;
use App\Models\Setting;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Symfony\Component\Process\Process;
class SystemStatusController extends Controller
{
public function __invoke(Request $request): JsonResponse
{
$user = $request->user();
if (!$user || !$user->roles()->where('name', 'ROLE_ADMIN')->exists()) {
return response()->json(['message' => 'Forbidden'], 403);
}
$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;
$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');
$npmPath = $this->resolveBinary('npm');
$tarPath = $this->resolveBinary('tar');
$rsyncPath = $this->resolveBinary('rsync');
$procFunctions = [
'proc_open',
'proc_get_status',
'proc_close',
];
$disabledFunctions = array_filter(array_map('trim', explode(',', (string) ini_get('disable_functions'))));
$disabledLookup = array_fill_keys($disabledFunctions, true);
$procFunctionStatus = [];
foreach ($procFunctions as $function) {
$procFunctionStatus[$function] = function_exists($function) && !isset($disabledLookup[$function]);
}
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,
'min_versions' => $minVersions,
'composer' => $composerPath,
'composer_version' => $this->resolveBinaryVersion($composerPath, ['--version']),
'node' => $nodePath,
'node_version' => $this->resolveBinaryVersion($nodePath, ['--version']),
'npm' => $npmPath,
'npm_version' => $this->resolveBinaryVersion($npmPath, ['--version']),
'tar' => $tarPath,
'tar_version' => $this->resolveBinaryVersion($tarPath, ['--version']),
'rsync' => $rsyncPath,
'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}"]);
$process->setTimeout(5);
$process->run();
if (!$process->isSuccessful()) {
return null;
}
$output = trim($process->getOutput());
return $output !== '' ? $output : null;
}
private function resolvePhpVersion(string $path): ?string
{
$process = new Process([$path, '-r', 'echo PHP_VERSION;']);
$process->setTimeout(5);
$process->run();
if (!$process->isSuccessful()) {
return null;
}
$output = trim($process->getOutput());
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) {
return null;
}
$process = new Process(array_merge([$path], $args));
$process->setTimeout(5);
$process->run();
if (!$process->isSuccessful()) {
return null;
}
$output = trim($process->getOutput());
if ($output === '') {
return null;
}
$line = strtok($output, "\n") ?: $output;
if (preg_match('/(\\d+\\.\\d+(?:\\.\\d+)?)/', $line, $matches)) {
return $matches[1];
}
return null;
}
private function resolveMinVersions(): array
{
$composerJson = $this->readJson(base_path('composer.json'));
$packageJson = $this->readJson(base_path('package.json'));
$php = $composerJson['require']['php'] ?? null;
$node = $packageJson['engines']['node'] ?? null;
$npm = $packageJson['engines']['npm'] ?? null;
$composer = $composerJson['require']['composer-runtime-api'] ?? null;
return [
'php' => is_string($php) ? $php : null,
'node' => is_string($node) ? $node : null,
'npm' => is_string($npm) ? $npm : null,
'composer' => is_string($composer) ? $composer : null,
];
}
private function readJson(string $path): array
{
if (!is_file($path)) {
return [];
}
$contents = file_get_contents($path);
if ($contents === false) {
return [];
}
$data = json_decode($contents, true);
return is_array($data) ? $data : [];
}
}

View File

@@ -0,0 +1,222 @@
<?php
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
{
public function __invoke(Request $request): JsonResponse
{
$user = $request->user();
if (!$user || !$user->roles()->where('name', 'ROLE_ADMIN')->exists()) {
return response()->json(['message' => 'Forbidden'], 403);
}
set_time_limit(0);
$owner = env('GITEA_OWNER');
$repo = env('GITEA_REPO');
$apiBase = rtrim((string) env('GITEA_API_BASE', 'https://git.24unix.net/api/v1'), '/');
$token = env('GITEA_TOKEN');
if (!$owner || !$repo) {
return response()->json(['message' => 'Missing Gitea configuration.'], 422);
}
$log = [];
$append = function (string $line) use (&$log) {
$log[] = $line;
};
try {
$client = Http::acceptJson();
if ($token) {
$client = $client->withHeaders(['Authorization' => "token {$token}"]);
}
$append('Fetching latest release...');
$response = $client->get("{$apiBase}/repos/{$owner}/{$repo}/releases/latest");
if (!$response->successful()) {
return response()->json([
'message' => "Release check failed: {$response->status()}",
'log' => $log,
], 500);
}
$tag = (string) ($response->json('tag_name') ?? '');
if ($tag === '') {
return response()->json([
'message' => 'Release tag not found.',
'log' => $log,
], 500);
}
$tarballUrl = (string) ($response->json('tarball_url') ?? '');
if ($tarballUrl === '') {
$tarballUrl = env('GITEA_TGZ_URL_TEMPLATE');
if ($tarballUrl) {
$tarballUrl = str_replace('{{TAG}}', $tag, $tarballUrl);
$tarballUrl = str_replace('{{VERSION}}', ltrim($tag, 'v'), $tarballUrl);
}
}
if ($tarballUrl === '') {
return response()->json([
'message' => 'No tarball URL available.',
'log' => $log,
], 500);
}
$append("Downloading {$tag}...");
$archivePath = storage_path('app/updates/' . $tag . '.tar.gz');
File::ensureDirectoryExists(dirname($archivePath));
$download = $client->withOptions(['stream' => true])->get($tarballUrl);
if (!$download->successful()) {
return response()->json([
'message' => "Download failed: {$download->status()}",
'log' => $log,
], 500);
}
File::put($archivePath, $download->body());
$extractDir = storage_path('app/updates/extract-' . Str::random(8));
File::ensureDirectoryExists($extractDir);
$append('Extracting archive...');
$tar = new Process(['tar', '-xzf', $archivePath, '-C', $extractDir]);
$tar->setTimeout(300);
$tar->run();
if (!$tar->isSuccessful()) {
return response()->json([
'message' => 'Failed to extract archive.',
'log' => array_merge($log, [$tar->getErrorOutput()]),
], 500);
}
$entries = collect(File::directories($extractDir))->values();
if ($entries->isEmpty()) {
return response()->json([
'message' => 'No extracted folder found.',
'log' => $log,
], 500);
}
$sourceDir = $entries->first();
$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([
'rsync',
'-a',
'--delete',
'--exclude=.env',
'--exclude=storage',
'--exclude=public/storage',
'--exclude=custom',
'--exclude=public/custom',
$sourceDir . '/',
base_path() . '/',
]);
$rsync->setTimeout(600);
$rsync->run();
if (!$rsync->isSuccessful()) {
return response()->json([
'message' => 'rsync failed.',
'log' => array_merge($log, [$rsync->getErrorOutput()]),
], 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());
}
$this->ensurePublicStorageLink();
$append('Using prebuilt release package (skipping composer/npm steps).');
$phpBinary = trim((string) Setting::where('key', 'system.php_binary')->value('value'));
if ($phpBinary === '') {
$phpBinary = env('SYSTEM_UPDATE_PHP_BINARY') ?: (PHP_BINARY ?: 'php');
}
$append("Running migrations (using {$phpBinary})...");
$migrate = new Process([$phpBinary, 'artisan', 'migrate', '--force'], base_path());
$migrate->setTimeout(600);
$migrate->run();
if (!$migrate->isSuccessful()) {
return response()->json([
'message' => 'Migrations failed.',
'log' => array_merge($log, [$migrate->getErrorOutput()]),
], 500);
}
$append('Update complete.');
return response()->json([
'message' => 'Update finished.',
'log' => $log,
'tag' => $tag,
'used_rsync' => $usedRsync,
]);
} catch (\Throwable $e) {
return response()->json([
'message' => 'Update failed.',
'log' => $log,
], 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,14 +4,29 @@ namespace App\Http\Controllers;
use App\Models\Forum;
use App\Models\Thread;
use App\Actions\BbcodeFormatter;
use App\Models\Setting;
use App\Services\AuditLogger;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
class ThreadController extends Controller
{
public function index(Request $request): JsonResponse
{
$query = Thread::query()->withoutTrashed()->with('user');
$query = Thread::query()
->withoutTrashed()
->withCount('posts')
->withMax('posts', 'created_at')
->with([
'user' => fn ($query) => $query
->withCount(['posts', 'threads', 'thanksGiven', 'thanksReceived'])
->with(['rank', 'roles']),
'latestPost.user.rank',
'latestPost.user.roles',
]);
$forumParam = $request->query('forum');
if (is_string($forumParam)) {
@@ -22,7 +37,7 @@ class ThreadController extends Controller
}
$threads = $query
->latest('created_at')
->orderByDesc(DB::raw('COALESCE(posts_max_created_at, threads.created_at)'))
->get()
->map(fn (Thread $thread) => $this->serializeThread($thread));
@@ -31,7 +46,17 @@ class ThreadController extends Controller
public function show(Thread $thread): JsonResponse
{
$thread->loadMissing('user');
$thread->increment('views_count');
$thread->refresh();
$thread->loadMissing([
'user' => fn ($query) => $query
->withCount(['posts', 'threads', 'thanksGiven', 'thanksReceived'])
->with(['rank', 'roles']),
'attachments.extension',
'attachments.group',
'latestPost.user.rank',
'latestPost.user.roles',
])->loadCount('posts');
return response()->json($this->serializeThread($thread));
}
@@ -57,11 +82,34 @@ class ThreadController extends Controller
'body' => $data['body'],
]);
app(AuditLogger::class)->log($request, 'thread.created', $thread, [
'forum_id' => $forum->id,
'title' => $thread->title,
]);
$thread->loadMissing([
'user' => fn ($query) => $query
->withCount(['posts', 'threads', 'thanksGiven', 'thanksReceived'])
->with(['rank', 'roles']),
'attachments.extension',
'attachments.group',
'latestPost.user.rank',
'latestPost.user.roles',
])->loadCount('posts');
return response()->json($this->serializeThread($thread), 201);
}
public function destroy(Request $request, Thread $thread): JsonResponse
{
$reason = $request->input('reason');
$reasonText = $request->input('reason_text');
app(AuditLogger::class)->log($request, 'thread.deleted', $thread, [
'forum_id' => $thread->forum_id,
'title' => $thread->title,
'reason' => $reason,
'reason_text' => $reasonText,
]);
$thread->deleted_by = $request->user()?->id;
$thread->save();
$thread->delete();
@@ -69,6 +117,86 @@ class ThreadController extends Controller
return response()->json(null, 204);
}
public function update(Request $request, Thread $thread): JsonResponse
{
$user = $request->user();
if (!$user) {
return response()->json(['message' => 'Unauthorized.'], 401);
}
$isAdmin = $user->roles()->where('name', 'ROLE_ADMIN')->exists();
if (!$isAdmin && $thread->user_id !== $user->id) {
return response()->json(['message' => 'Not authorized to edit threads.'], 403);
}
$data = $request->validate([
'title' => ['sometimes', 'required', 'string'],
'body' => ['sometimes', 'required', 'string'],
]);
if (array_key_exists('title', $data)) {
$thread->title = $data['title'];
}
if (array_key_exists('body', $data)) {
$thread->body = $data['body'];
}
$thread->save();
$thread->refresh();
app(AuditLogger::class)->log($request, 'thread.edited', $thread, [
'forum_id' => $thread->forum_id,
'title' => $thread->title,
]);
$thread->loadMissing([
'user' => fn ($query) => $query
->withCount(['posts', 'threads', 'thanksGiven', 'thanksReceived'])
->with(['rank', 'roles']),
'attachments.extension',
'attachments.group',
'latestPost.user.rank',
'latestPost.user.roles',
])->loadCount('posts');
return response()->json($this->serializeThread($thread));
}
public function updateSolved(Request $request, Thread $thread): JsonResponse
{
$user = $request->user();
if (!$user) {
return response()->json(['message' => 'Unauthorized.'], 401);
}
$isAdmin = $user->roles()->where('name', 'ROLE_ADMIN')->exists();
if (!$isAdmin && $thread->user_id !== $user->id) {
return response()->json(['message' => 'Not authorized to update solved status.'], 403);
}
$data = $request->validate([
'solved' => ['required', 'boolean'],
]);
$thread->solved = $data['solved'];
$thread->save();
app(AuditLogger::class)->log($request, 'thread.solved_updated', $thread, [
'solved' => $thread->solved,
]);
$thread->refresh();
$thread->loadMissing([
'user' => fn ($query) => $query
->withCount(['posts', 'threads', 'thanksGiven', 'thanksReceived'])
->with(['rank', 'roles']),
'attachments.extension',
'attachments.group',
'latestPost.user.rank',
'latestPost.user.roles',
])->loadCount('posts');
return response()->json($this->serializeThread($thread));
}
private function parseIriId(?string $value): ?int
{
if (!$value) {
@@ -88,15 +216,147 @@ class ThreadController extends Controller
private function serializeThread(Thread $thread): array
{
$attachments = $thread->relationLoaded('attachments') ? $thread->attachments : collect();
$bodyHtml = $this->renderBody($thread->body, $attachments);
return [
'id' => $thread->id,
'title' => $thread->title,
'body' => $thread->body,
'body_html' => $bodyHtml,
'solved' => (bool) $thread->solved,
'forum' => "/api/forums/{$thread->forum_id}",
'user_id' => $thread->user_id,
'posts_count' => ($thread->posts_count ?? 0) + 1,
'views_count' => $thread->views_count ?? 0,
'user_name' => $thread->user?->name,
'user_avatar_url' => $thread->user?->avatar_path
? Storage::url($thread->user->avatar_path)
: null,
'user_posts_count' => ($thread->user?->posts_count ?? 0) + ($thread->user?->threads_count ?? 0),
'user_created_at' => $thread->user?->created_at?->toIso8601String(),
'user_location' => $thread->user?->location,
'user_thanks_given_count' => $thread->user?->thanks_given_count ?? 0,
'user_thanks_received_count' => $thread->user?->thanks_received_count ?? 0,
'user_rank_name' => $thread->user?->rank?->name,
'user_rank_badge_type' => $thread->user?->rank?->badge_type,
'user_rank_badge_text' => $thread->user?->rank?->badge_text,
'user_rank_badge_url' => $thread->user?->rank?->badge_image_path
? Storage::url($thread->user->rank->badge_image_path)
: null,
'user_rank_color' => $thread->user?->rank?->color,
'user_group_color' => $this->resolveGroupColor($thread->user),
'last_post_at' => $thread->latestPost?->created_at?->toIso8601String()
?? $thread->created_at?->toIso8601String(),
'last_post_id' => $thread->latestPost?->id,
'last_post_user_id' => $thread->latestPost?->user_id ?? $thread->user_id,
'last_post_user_name' => $thread->latestPost?->user?->name
?? $thread->user?->name,
'last_post_user_rank_color' => $thread->latestPost?->user?->rank?->color
?? $thread->user?->rank?->color,
'last_post_user_group_color' => $this->resolveGroupColor($thread->latestPost?->user)
?? $this->resolveGroupColor($thread->user),
'created_at' => $thread->created_at?->toIso8601String(),
'updated_at' => $thread->updated_at?->toIso8601String(),
'attachments' => $thread->relationLoaded('attachments')
? $attachments
->map(fn ($attachment) => [
'id' => $attachment->id,
'group' => $attachment->group ? [
'id' => $attachment->group->id,
'name' => $attachment->group->name,
] : null,
'original_name' => $attachment->original_name,
'extension' => $attachment->extension,
'mime_type' => $attachment->mime_type,
'size_bytes' => $attachment->size_bytes,
'download_url' => "/api/attachments/{$attachment->id}/download",
'thumbnail_url' => $attachment->thumbnail_path
? "/api/attachments/{$attachment->id}/thumbnail"
: null,
'is_image' => str_starts_with((string) $attachment->mime_type, 'image/'),
'created_at' => $attachment->created_at?->toIso8601String(),
])
->values()
: [],
];
}
private function renderBody(string $body, $attachments): string
{
$replaced = $this->replaceAttachmentTags($body, $attachments);
return BbcodeFormatter::format($replaced);
}
private function replaceAttachmentTags(string $body, $attachments): string
{
if (!$attachments || count($attachments) === 0) {
return $body;
}
$map = [];
foreach ($attachments as $attachment) {
$name = strtolower($attachment->original_name ?? '');
if ($name !== '') {
$map[$name] = [
'url' => "/api/attachments/{$attachment->id}/download",
'mime' => $attachment->mime_type ?? '',
'thumb' => $attachment->thumbnail_path
? "/api/attachments/{$attachment->id}/thumbnail"
: null,
];
}
}
if (!$map) {
return $body;
}
return preg_replace_callback('/\\[attachment\\](.+?)\\[\\/attachment\\]/i', function ($matches) use ($map) {
$rawName = trim($matches[1]);
$key = strtolower($rawName);
if (!array_key_exists($key, $map)) {
return $matches[0];
}
$entry = $map[$key];
$url = $entry['url'];
$mime = $entry['mime'] ?? '';
if (str_starts_with($mime, 'image/') && $this->displayImagesInline()) {
if (!empty($entry['thumb'])) {
$thumb = $entry['thumb'];
return "[url={$url}][img]{$thumb}[/img][/url]";
}
return "[img]{$url}[/img]";
}
return "[url={$url}]{$rawName}[/url]";
}, $body) ?? $body;
}
private function displayImagesInline(): bool
{
$value = Setting::query()->where('key', 'attachments.display_images_inline')->value('value');
if ($value === null) {
return true;
}
return in_array(strtolower($value), ['1', 'true', 'yes', 'on'], true);
}
private function resolveGroupColor(?\App\Models\User $user): ?string
{
if (!$user) {
return null;
}
$roles = $user->roles;
if (!$roles) {
return null;
}
foreach ($roles->sortBy('name') as $role) {
if (!empty($role->color)) {
return $role->color;
}
}
return null;
}
}

View File

@@ -4,16 +4,51 @@ 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
{
public function storeAvatar(Request $request): JsonResponse
{
$user = $request->user();
if (!$user) {
return response()->json(['message' => 'Unauthorized'], 401);
}
$this->ensurePublicStorageReady();
$data = $request->validate([
'file' => [
'required',
'image',
'mimes:jpg,jpeg,png,gif,webp',
'max:2048',
'dimensions:max_width=150,max_height=150',
],
]);
if ($user->avatar_path) {
Storage::disk('public')->delete($user->avatar_path);
}
$path = $data['file']->store('avatars', 'public');
$user->avatar_path = $path;
$user->save();
return response()->json([
'path' => $path,
'url' => Storage::url($path),
]);
}
public function storeLogo(Request $request): JsonResponse
{
$user = $request->user();
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'],
@@ -33,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'],
@@ -45,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

@@ -2,24 +2,280 @@
namespace App\Http\Controllers;
use App\Models\Role;
use App\Models\User;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Illuminate\Validation\Rule;
class UserController extends Controller
{
public function index(): JsonResponse
{
$users = User::query()
->with('roles')
->with(['roles', 'rank'])
->orderBy('id')
->get()
->map(fn (User $user) => [
'id' => $user->id,
'name' => $user->name,
'email' => $user->email,
'avatar_url' => $this->resolveAvatarUrl($user),
'location' => $user->location,
'rank' => $user->rank ? [
'id' => $user->rank->id,
'name' => $user->rank->name,
'color' => $user->rank->color,
] : null,
'group_color' => $this->resolveGroupColor($user),
'roles' => $user->roles->pluck('name')->values(),
]);
return response()->json($users);
}
public function me(Request $request): JsonResponse
{
$user = $request->user();
if (!$user) {
return response()->json(['message' => 'Unauthenticated.'], 401);
}
return response()->json([
'id' => $user->id,
'name' => $user->name,
'email' => $user->email,
'avatar_url' => $this->resolveAvatarUrl($user),
'location' => $user->location,
'rank' => $user->rank ? [
'id' => $user->rank->id,
'name' => $user->rank->name,
'color' => $user->rank->color,
] : null,
'group_color' => $this->resolveGroupColor($user),
'roles' => $user->roles()->pluck('name')->values(),
]);
}
public function profile(User $user): JsonResponse
{
return response()->json([
'id' => $user->id,
'name' => $user->name,
'avatar_url' => $this->resolveAvatarUrl($user),
'location' => $user->location,
'rank' => $user->rank ? [
'id' => $user->rank->id,
'name' => $user->rank->name,
'color' => $user->rank->color,
] : null,
'group_color' => $this->resolveGroupColor($user),
'created_at' => $user->created_at?->toIso8601String(),
]);
}
public function updateMe(Request $request): JsonResponse
{
$user = $request->user();
if (!$user) {
return response()->json(['message' => 'Unauthenticated.'], 401);
}
$data = $request->validate([
'location' => ['nullable', 'string', 'max:255'],
]);
$location = isset($data['location']) ? trim($data['location']) : null;
if ($location === '') {
$location = null;
}
$user->forceFill([
'location' => $location,
])->save();
$user->loadMissing('rank');
return response()->json([
'id' => $user->id,
'name' => $user->name,
'email' => $user->email,
'avatar_url' => $this->resolveAvatarUrl($user),
'location' => $user->location,
'rank' => $user->rank ? [
'id' => $user->rank->id,
'name' => $user->rank->name,
'color' => $user->rank->color,
] : null,
'group_color' => $this->resolveGroupColor($user),
'roles' => $user->roles()->pluck('name')->values(),
]);
}
public function updateRank(Request $request, User $user): JsonResponse
{
$actor = $request->user();
if (!$actor || !$actor->roles()->where('name', 'ROLE_ADMIN')->exists()) {
return response()->json(['message' => 'Forbidden'], 403);
}
if ($this->isFounder($user) && !$this->isFounder($actor)) {
return response()->json(['message' => 'Forbidden'], 403);
}
$data = $request->validate([
'rank_id' => ['nullable', 'exists:ranks,id'],
]);
$user->rank_id = $data['rank_id'] ?? null;
$user->save();
$user->loadMissing('rank');
return response()->json([
'id' => $user->id,
'rank' => $user->rank ? [
'id' => $user->rank->id,
'name' => $user->rank->name,
'color' => $user->rank->color,
] : null,
'group_color' => $this->resolveGroupColor($user),
]);
}
public function update(Request $request, User $user): JsonResponse
{
$actor = $request->user();
if (!$actor || !$actor->roles()->where('name', 'ROLE_ADMIN')->exists()) {
return response()->json(['message' => 'Forbidden'], 403);
}
if ($this->isFounder($user) && !$this->isFounder($actor)) {
return response()->json(['message' => 'Forbidden'], 403);
}
$data = $request->validate([
'name' => ['required', 'string', 'max:255'],
'email' => [
'required',
'string',
'email',
'max:255',
Rule::unique('users', 'email')->ignore($user->id),
],
'rank_id' => ['nullable', 'exists:ranks,id'],
'roles' => ['nullable', 'array'],
'roles.*' => ['string', 'exists:roles,name'],
]);
if (array_key_exists('roles', $data) && !$this->isFounder($actor)) {
$requested = collect($data['roles'] ?? [])
->map(fn ($name) => $this->normalizeRoleName($name));
if ($requested->contains('ROLE_FOUNDER')) {
return response()->json(['message' => 'Forbidden'], 403);
}
}
$nameCanonical = Str::lower(trim($data['name']));
$nameConflict = User::query()
->where('id', '!=', $user->id)
->where('name_canonical', $nameCanonical)
->exists();
if ($nameConflict) {
return response()->json(['message' => 'Name already exists.'], 422);
}
if ($data['email'] !== $user->email) {
$user->email_verified_at = null;
}
$user->forceFill([
'name' => $data['name'],
'name_canonical' => $nameCanonical,
'email' => $data['email'],
'rank_id' => $data['rank_id'] ?? null,
])->save();
if (array_key_exists('roles', $data)) {
$roleNames = collect($data['roles'] ?? [])
->map(fn ($name) => $this->normalizeRoleName($name))
->unique()
->values()
->all();
$roleIds = Role::query()
->whereIn('name', $roleNames)
->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');
return response()->json([
'id' => $user->id,
'name' => $user->name,
'email' => $user->email,
'avatar_url' => $this->resolveAvatarUrl($user),
'rank' => $user->rank ? [
'id' => $user->rank->id,
'name' => $user->rank->name,
'color' => $user->rank->color,
] : null,
'group_color' => $this->resolveGroupColor($user),
'roles' => $user->roles()->pluck('name')->values(),
]);
}
private function resolveAvatarUrl(User $user): ?string
{
if (!$user->avatar_path) {
return null;
}
return Storage::url($user->avatar_path);
}
private function resolveGroupColor(User $user): ?string
{
$user->loadMissing('roles');
$roles = $user->roles;
if (!$roles) {
return null;
}
foreach ($roles->sortBy('name') as $role) {
if (!empty($role->color)) {
return $role->color;
}
}
return null;
}
private function normalizeRoleName(string $value): string
{
$raw = strtoupper(trim($value));
$raw = preg_replace('/\s+/', '_', $raw);
$raw = preg_replace('/[^A-Z0-9_]/', '_', $raw);
$raw = preg_replace('/_+/', '_', $raw);
$raw = trim($raw, '_');
if ($raw === '') {
return 'ROLE_';
}
if (str_starts_with($raw, 'ROLE_')) {
return $raw;
}
return "ROLE_{$raw}";
}
private function isFounder(User $user): bool
{
return $user->roles()->where('name', 'ROLE_FOUNDER')->exists();
}
}

View File

@@ -0,0 +1,72 @@
<?php
namespace App\Http\Controllers;
use App\Models\Setting;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Http;
class VersionCheckController extends Controller
{
public function __invoke(): JsonResponse
{
$current = Setting::query()->where('key', 'version')->value('value');
$build = Setting::query()->where('key', 'build')->value('value');
$owner = env('GITEA_OWNER');
$repo = env('GITEA_REPO');
$apiBase = rtrim((string) env('GITEA_API_BASE', 'https://git.24unix.net/api/v1'), '/');
$token = env('GITEA_TOKEN');
if (!$owner || !$repo) {
return response()->json([
'current_version' => $current,
'current_build' => $build !== null ? (int) $build : null,
'latest_tag' => null,
'latest_version' => null,
'is_latest' => null,
'error' => 'Missing GITEA_OWNER/GITEA_REPO configuration.',
]);
}
try {
$client = Http::acceptJson();
if ($token) {
$client = $client->withHeaders(['Authorization' => "token {$token}"]);
}
$response = $client->get("{$apiBase}/repos/{$owner}/{$repo}/releases/latest");
if (!$response->successful()) {
return response()->json([
'current_version' => $current,
'current_build' => $build !== null ? (int) $build : null,
'latest_tag' => null,
'latest_version' => null,
'is_latest' => null,
'error' => "Release check failed: {$response->status()}",
]);
}
$tag = (string) ($response->json('tag_name') ?? '');
$latestVersion = ltrim($tag, 'v');
$isLatest = $current && $latestVersion ? $current === $latestVersion : null;
return response()->json([
'current_version' => $current,
'current_build' => $build !== null ? (int) $build : null,
'latest_tag' => $tag ?: null,
'latest_version' => $latestVersion ?: null,
'is_latest' => $isLatest,
]);
} catch (\Throwable $e) {
return response()->json([
'current_version' => $current,
'current_build' => $build !== null ? (int) $build : null,
'latest_tag' => null,
'latest_version' => null,
'is_latest' => null,
'error' => 'Version check failed.',
]);
}
}
}

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

74
app/Models/Attachment.php Normal file
View File

@@ -0,0 +1,74 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\SoftDeletes;
/**
* @property int $id
* @property int|null $thread_id
* @property int|null $post_id
* @property int|null $attachment_extension_id
* @property int|null $attachment_group_id
* @property int|null $user_id
* @property string $disk
* @property string $path
* @property string $original_name
* @property string|null $extension
* @property string $mime_type
* @property int $size_bytes
* @mixin \Eloquent
*/
class Attachment extends Model
{
use SoftDeletes;
protected $fillable = [
'thread_id',
'post_id',
'attachment_extension_id',
'attachment_group_id',
'user_id',
'disk',
'path',
'thumbnail_path',
'thumbnail_mime_type',
'thumbnail_size_bytes',
'original_name',
'extension',
'mime_type',
'size_bytes',
];
protected $casts = [
'size_bytes' => 'int',
'thumbnail_size_bytes' => 'int',
];
public function thread(): BelongsTo
{
return $this->belongsTo(Thread::class);
}
public function post(): BelongsTo
{
return $this->belongsTo(Post::class);
}
public function extension(): BelongsTo
{
return $this->belongsTo(AttachmentExtension::class, 'attachment_extension_id');
}
public function group(): BelongsTo
{
return $this->belongsTo(AttachmentGroup::class, 'attachment_group_id');
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
}

View File

@@ -0,0 +1,31 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* @property int $id
* @property string $extension
* @property int|null $attachment_group_id
* @property array|null $allowed_mimes
* @mixin \Eloquent
*/
class AttachmentExtension extends Model
{
protected $fillable = [
'extension',
'attachment_group_id',
'allowed_mimes',
];
protected $casts = [
'allowed_mimes' => 'array',
];
public function group(): BelongsTo
{
return $this->belongsTo(AttachmentGroup::class, 'attachment_group_id');
}
}

View File

@@ -0,0 +1,46 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* @property int $id
* @property string $name
* @property int|null $parent_id
* @property int|null $position
* @property int $max_size_kb
* @property bool $is_active
* @mixin \Eloquent
*/
class AttachmentGroup extends Model
{
protected $fillable = [
'name',
'parent_id',
'position',
'max_size_kb',
'is_active',
];
protected $casts = [
'is_active' => 'bool',
];
public function extensions(): HasMany
{
return $this->hasMany(AttachmentExtension::class, 'attachment_group_id');
}
public function parent(): BelongsTo
{
return $this->belongsTo(self::class, 'parent_id');
}
public function children(): HasMany
{
return $this->hasMany(self::class, 'parent_id');
}
}

41
app/Models/AuditLog.php Normal file
View File

@@ -0,0 +1,41 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* @property int $id
* @property int|null $user_id
* @property string $action
* @property string|null $subject_type
* @property int|null $subject_id
* @property array|null $metadata
* @property string|null $ip_address
* @property string|null $user_agent
* @property \Illuminate\Support\Carbon|null $created_at
* @property \Illuminate\Support\Carbon|null $updated_at
* @mixin \Eloquent
*/
class AuditLog extends Model
{
protected $fillable = [
'user_id',
'action',
'subject_type',
'subject_id',
'metadata',
'ip_address',
'user_agent',
];
protected $casts = [
'metadata' => 'array',
];
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
}

View File

@@ -4,6 +4,9 @@ namespace App\Models;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasManyThrough;
use Illuminate\Database\Eloquent\Relations\HasOne;
use Illuminate\Database\Eloquent\Relations\HasOneThrough;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
@@ -60,4 +63,20 @@ class Forum extends Model
{
return $this->hasMany(Thread::class);
}
public function posts(): HasManyThrough
{
return $this->hasManyThrough(Post::class, Thread::class, 'forum_id', 'thread_id');
}
public function latestThread(): HasOne
{
return $this->hasOne(Thread::class)->latestOfMany();
}
public function latestPost(): HasOneThrough
{
return $this->hasOneThrough(Post::class, Thread::class, 'forum_id', 'thread_id')
->latestOfMany();
}
}

View File

@@ -3,6 +3,7 @@
namespace App\Models;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
@@ -13,6 +14,7 @@ use Illuminate\Database\Eloquent\SoftDeletes;
* @property string $body
* @property \Illuminate\Support\Carbon|null $created_at
* @property \Illuminate\Support\Carbon|null $updated_at
* @property-read \Illuminate\Database\Eloquent\Collection<int, \App\Models\Attachment> $attachments
* @property-read \App\Models\Thread $thread
* @property-read \App\Models\User|null $user
* @method static \Illuminate\Database\Eloquent\Builder<static>|Post newModelQuery()
@@ -45,4 +47,14 @@ class Post extends Model
{
return $this->belongsTo(User::class);
}
public function thanks(): HasMany
{
return $this->hasMany(PostThank::class);
}
public function attachments(): HasMany
{
return $this->hasMany(Attachment::class);
}
}

24
app/Models/PostThank.php Normal file
View File

@@ -0,0 +1,24 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class PostThank extends Model
{
protected $fillable = [
'post_id',
'user_id',
];
public function post(): BelongsTo
{
return $this->belongsTo(Post::class);
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
}

29
app/Models/Rank.php Normal file
View File

@@ -0,0 +1,29 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
/**
* @property int $id
* @property string $name
* @property \Illuminate\Support\Carbon|null $created_at
* @property \Illuminate\Support\Carbon|null $updated_at
* @property-read \Illuminate\Database\Eloquent\Collection<int, User> $users
*/
class Rank extends Model
{
protected $fillable = [
'name',
'badge_type',
'badge_text',
'badge_image_path',
'color',
];
public function users(): HasMany
{
return $this->hasMany(User::class);
}
}

View File

@@ -25,6 +25,7 @@ class Role extends Model
{
protected $fillable = [
'name',
'color',
];
public function users(): BelongsToMany

View File

@@ -4,6 +4,7 @@ namespace App\Models;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasOne;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
@@ -13,9 +14,11 @@ use Illuminate\Database\Eloquent\SoftDeletes;
* @property int|null $user_id
* @property string $title
* @property string $body
* @property bool $solved
* @property \Illuminate\Support\Carbon|null $created_at
* @property \Illuminate\Support\Carbon|null $updated_at
* @property-read \App\Models\Forum $forum
* @property-read \Illuminate\Database\Eloquent\Collection<int, \App\Models\Attachment> $attachments
* @property-read \Illuminate\Database\Eloquent\Collection<int, \App\Models\Post> $posts
* @property-read int|null $posts_count
* @property-read \App\Models\User|null $user
@@ -40,6 +43,11 @@ class Thread extends Model
'user_id',
'title',
'body',
'solved',
];
protected $casts = [
'solved' => 'bool',
];
public function forum(): BelongsTo
@@ -56,4 +64,14 @@ class Thread extends Model
{
return $this->hasMany(Post::class);
}
public function attachments(): HasMany
{
return $this->hasMany(Attachment::class);
}
public function latestPost(): HasOne
{
return $this->hasOne(Post::class)->latestOfMany();
}
}

View File

@@ -6,7 +6,9 @@ use Database\Factories\UserFactory;
use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasManyThrough;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\DatabaseNotification;
use Illuminate\Notifications\DatabaseNotificationCollection;
@@ -64,6 +66,10 @@ class User extends Authenticatable implements MustVerifyEmail
*/
protected $fillable = [
'name',
'name_canonical',
'avatar_path',
'location',
'rank_id',
'email',
'password',
];
@@ -95,4 +101,29 @@ class User extends Authenticatable implements MustVerifyEmail
{
return $this->belongsToMany(Role::class);
}
public function posts(): HasMany
{
return $this->hasMany(Post::class);
}
public function threads(): HasMany
{
return $this->hasMany(Thread::class);
}
public function thanksGiven(): HasMany
{
return $this->hasMany(PostThank::class);
}
public function thanksReceived(): HasManyThrough
{
return $this->hasManyThrough(PostThank::class, Post::class, 'user_id', 'post_id');
}
public function rank()
{
return $this->belongsTo(Rank::class);
}
}

View File

@@ -0,0 +1,208 @@
<?php
namespace App\Services;
use App\Models\Attachment;
use App\Models\Setting;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
class AttachmentThumbnailService
{
public function createForUpload(UploadedFile $file, string $scopeFolder, string $disk = 'local'): ?array
{
$mime = $file->getMimeType() ?? '';
if (!str_starts_with($mime, 'image/')) {
return null;
}
$sourcePath = $file->getPathname();
$extension = strtolower((string) $file->getClientOriginalExtension());
return $this->createThumbnail($sourcePath, $mime, $extension, $scopeFolder, $disk);
}
public function createForAttachment(Attachment $attachment, bool $force = false): ?array
{
if (!$force && $attachment->thumbnail_path) {
$thumbDisk = Storage::disk($attachment->disk);
if ($thumbDisk->exists($attachment->thumbnail_path)) {
return null;
}
}
$mime = $attachment->mime_type ?? '';
if (!str_starts_with($mime, 'image/')) {
return null;
}
$disk = Storage::disk($attachment->disk);
if (!$disk->exists($attachment->path)) {
return null;
}
$sourcePath = $disk->path($attachment->path);
$scopeFolder = $this->resolveScopeFolder($attachment);
$extension = strtolower((string) ($attachment->extension ?? ''));
return $this->createThumbnail($sourcePath, $mime, $extension, $scopeFolder, $attachment->disk);
}
private function resolveScopeFolder(Attachment $attachment): string
{
if ($attachment->thread_id) {
return "threads/{$attachment->thread_id}";
}
if ($attachment->post_id) {
return "posts/{$attachment->post_id}";
}
return 'misc';
}
private function createThumbnail(
string $sourcePath,
string $mime,
string $extension,
string $scopeFolder,
string $diskName
): ?array {
if (!$this->settingBool('attachments.create_thumbnails', true)) {
return null;
}
$maxWidth = $this->settingInt('attachments.thumbnail_max_width', 300);
$maxHeight = $this->settingInt('attachments.thumbnail_max_height', 300);
if ($maxWidth <= 0 || $maxHeight <= 0) {
return null;
}
$info = @getimagesize($sourcePath);
if (!$info) {
return null;
}
[$width, $height] = $info;
if ($width <= 0 || $height <= 0) {
return null;
}
if ($width <= $maxWidth && $height <= $maxHeight) {
return null;
}
$ratio = min($maxWidth / $width, $maxHeight / $height);
$targetWidth = max(1, (int) round($width * $ratio));
$targetHeight = max(1, (int) round($height * $ratio));
$sourceImage = $this->createImageFromFile($sourcePath, $mime);
if (!$sourceImage) {
return null;
}
$thumbImage = imagecreatetruecolor($targetWidth, $targetHeight);
if (!$thumbImage) {
imagedestroy($sourceImage);
return null;
}
if (in_array($mime, ['image/png', 'image/gif'], true)) {
imagecolortransparent($thumbImage, imagecolorallocatealpha($thumbImage, 0, 0, 0, 127));
imagealphablending($thumbImage, false);
imagesavealpha($thumbImage, true);
}
imagecopyresampled(
$thumbImage,
$sourceImage,
0,
0,
0,
0,
$targetWidth,
$targetHeight,
$width,
$height
);
$quality = $this->settingInt('attachments.thumbnail_quality', 85);
$thumbBinary = $this->renderImageBinary($thumbImage, $mime, $quality);
imagedestroy($sourceImage);
imagedestroy($thumbImage);
if ($thumbBinary === null) {
return null;
}
$filename = Str::uuid()->toString();
if ($extension !== '') {
$filename .= ".{$extension}";
}
$thumbPath = "attachments/{$scopeFolder}/thumbs/{$filename}";
Storage::disk($diskName)->put($thumbPath, $thumbBinary);
return [
'path' => $thumbPath,
'mime' => $mime,
'size' => strlen($thumbBinary),
];
}
private function createImageFromFile(string $path, string $mime)
{
return match ($mime) {
'image/jpeg', 'image/jpg' => @imagecreatefromjpeg($path),
'image/png' => @imagecreatefrompng($path),
'image/gif' => @imagecreatefromgif($path),
'image/webp' => function_exists('imagecreatefromwebp') ? @imagecreatefromwebp($path) : null,
default => null,
};
}
private function renderImageBinary($image, string $mime, int $quality): ?string
{
ob_start();
$success = false;
if (in_array($mime, ['image/jpeg', 'image/jpg'], true)) {
$success = imagejpeg($image, null, max(10, min(95, $quality)));
} elseif ($mime === 'image/png') {
$compression = (int) round(9 - (max(10, min(95, $quality)) / 100) * 9);
$success = imagepng($image, null, $compression);
} elseif ($mime === 'image/gif') {
$success = imagegif($image);
} elseif ($mime === 'image/webp' && function_exists('imagewebp')) {
$success = imagewebp($image, null, max(10, min(95, $quality)));
}
$data = ob_get_clean();
if (!$success) {
return null;
}
return $data !== false ? $data : null;
}
private function settingBool(string $key, bool $default): bool
{
$value = Setting::query()->where('key', $key)->value('value');
if ($value === null) {
return $default;
}
return in_array(strtolower($value), ['1', 'true', 'yes', 'on'], true);
}
private function settingInt(string $key, int $default): int
{
$value = Setting::query()->where('key', $key)->value('value');
if ($value === null) {
return $default;
}
return (int) $value;
}
}

View File

@@ -0,0 +1,34 @@
<?php
namespace App\Services;
use App\Models\AuditLog;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Http\Request;
class AuditLogger
{
public function log(
Request $request,
string $action,
?Model $subject = null,
array $metadata = [],
?Model $actor = null
): ?AuditLog {
try {
$actorUser = $actor ?? $request->user();
return AuditLog::create([
'user_id' => $actorUser?->id,
'action' => $action,
'subject_type' => $subject ? get_class($subject) : null,
'subject_id' => $subject?->getKey(),
'metadata' => $metadata ?: null,
'ip_address' => $request->ip(),
'user_agent' => $request->userAgent(),
]);
} catch (\Throwable) {
return null;
}
}
}

15
artisan
View File

@@ -1,18 +1,19 @@
#!/usr/bin/env php
<?php
use Illuminate\Foundation\Application;
use Symfony\Component\Console\Input\ArgvInput;
define('LARAVEL_START', microtime(true));
// Register the Composer autoloader...
require __DIR__.'/vendor/autoload.php';
// Bootstrap Laravel and handle the command...
/** @var Application $app */
$app = require_once __DIR__.'/bootstrap/app.php';
$status = $app->handleCommand(new ArgvInput);
$kernel = $app->make(Illuminate\Contracts\Console\Kernel::class);
$status = $kernel->handle(
$input = new Symfony\Component\Console\Input\ArgvInput,
new Symfony\Component\Console\Output\ConsoleOutput
);
$kernel->terminate($input, $status);
exit($status);

View File

@@ -11,6 +11,9 @@ return Application::configure(basePath: dirname(__DIR__))
commands: __DIR__.'/../routes/console.php',
health: '/up',
)
->withCommands([
__DIR__.'/../app/Console/Commands',
])
->withMiddleware(function (Middleware $middleware): void {
//
})

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

View File

@@ -1,65 +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',
),
),
);

View File

@@ -1,275 +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 => 'App\\Providers\\AppServiceProvider',
33 => '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 => 'App\\Providers\\AppServiceProvider',
17 => '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

@@ -3,14 +3,19 @@
"name": "laravel/laravel",
"type": "project",
"description": "The skeleton application for the Laravel framework.",
"keywords": ["laravel", "framework"],
"keywords": [
"laravel",
"framework"
],
"license": "MIT",
"require": {
"php": "^8.2",
"php": "^8.4",
"laravel/fortify": "*",
"laravel/framework": "^12.0",
"laravel/sanctum": "*",
"laravel/tinker": "^2.10.1",
"s9e/text-formatter": "^2.5",
"composer-runtime-api": "^2.2",
"ext-pdo": "*"
},
"require-dev": {
@@ -21,7 +26,9 @@
"laravel/sail": "^1.41",
"mockery/mockery": "^1.6",
"nunomaduro/collision": "^8.6",
"phpunit/phpunit": "^11.5.3",
"pestphp/pest": "^4.0",
"pestphp/pest-plugin-laravel": "^4.0",
"phpunit/phpunit": "^12.3",
"squizlabs/php_codesniffer": "^4.0"
},
"autoload": {
@@ -54,6 +61,7 @@
"@php artisan config:clear --ansi",
"@php artisan test"
],
"test:coverage": "./vendor/bin/pest --coverage",
"post-autoload-dump": [
"Illuminate\\Foundation\\ComposerScripts::postAutoloadDump",
"@php artisan package:discover --ansi"
@@ -88,5 +96,7 @@
}
},
"minimum-stability": "stable",
"prefer-stable": true
"prefer-stable": true,
"version": "26.0.3",
"build": "107"
}

2307
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -48,7 +48,7 @@ return [
'timeout' => null,
'local_domain' => env(
'MAIL_EHLO_DOMAIN',
parse_url((string) env('APP_URL', 'http://localhost'), PHP_URL_HOST)
parse_url(url: (string) env('APP_URL', 'http://localhost'), component: PHP_URL_HOST)
),
],

View File

@@ -23,8 +23,11 @@ class UserFactory extends Factory
*/
public function definition(): array
{
$name = fake()->unique()->userName();
return [
'name' => fake()->name(),
'name' => $name,
'name_canonical' => Str::lower($name),
'email' => fake()->unique()->safeEmail(),
'email_verified_at' => now(),
'password' => static::$password ??= Hash::make('password'),

View File

@@ -0,0 +1,38 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('users', function (Blueprint $table) {
$table->string('name_canonical')->nullable()->after('name');
});
DB::table('users')
->whereNull('name_canonical')
->update(['name_canonical' => DB::raw('lower(name)')]);
Schema::table('users', function (Blueprint $table) {
$table->unique('name_canonical');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('users', function (Blueprint $table) {
$table->dropUnique(['name_canonical']);
$table->dropColumn('name_canonical');
});
}
};

View File

@@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('users', function (Blueprint $table) {
$table->string('avatar_path')->nullable()->after('name_canonical');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('users', function (Blueprint $table) {
$table->dropColumn('avatar_path');
});
}
};

View File

@@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('ranks', function (Blueprint $table) {
$table->id();
$table->string('name')->unique();
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('ranks');
}
};

View File

@@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('users', function (Blueprint $table) {
$table->foreignId('rank_id')->nullable()->after('avatar_path')->constrained('ranks')->nullOnDelete();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('users', function (Blueprint $table) {
$table->dropConstrainedForeignId('rank_id');
});
}
};

View File

@@ -0,0 +1,30 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('ranks', function (Blueprint $table) {
$table->string('badge_type')->default('text')->after('name');
$table->string('badge_text')->nullable()->after('badge_type');
$table->string('badge_image_path')->nullable()->after('badge_text');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('ranks', function (Blueprint $table) {
$table->dropColumn(['badge_type', 'badge_text', 'badge_image_path']);
});
}
};

View File

@@ -0,0 +1,22 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('users', function (Blueprint $table) {
$table->string('location')->nullable()->after('avatar_path');
});
}
public function down(): void
{
Schema::table('users', function (Blueprint $table) {
$table->dropColumn('location');
});
}
};

View File

@@ -0,0 +1,22 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('threads', function (Blueprint $table) {
$table->unsignedInteger('views_count')->default(0)->after('body');
});
}
public function down(): void
{
Schema::table('threads', function (Blueprint $table) {
$table->dropColumn('views_count');
});
}
};

View File

@@ -0,0 +1,25 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('post_thanks', function (Blueprint $table) {
$table->id();
$table->foreignId('post_id')->constrained()->cascadeOnDelete();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->timestamps();
$table->unique(['post_id', 'user_id']);
});
}
public function down(): void
{
Schema::dropIfExists('post_thanks');
}
};

View File

@@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('ranks', function (Blueprint $table) {
$table->string('color', 20)->nullable()->after('badge_image_path');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('ranks', function (Blueprint $table) {
$table->dropColumn('color');
});
}
};

View File

@@ -0,0 +1,54 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
$roles = DB::table('roles')
->select(['id', 'name'])
->get();
foreach ($roles as $role) {
$name = (string) $role->name;
if (str_starts_with($name, 'ROLE_')) {
continue;
}
$raw = strtoupper(trim($name));
$raw = preg_replace('/\s+/', '_', $raw);
$raw = preg_replace('/[^A-Z0-9_]/', '_', $raw);
$raw = preg_replace('/_+/', '_', $raw);
$raw = trim($raw, '_');
if ($raw === '') {
continue;
}
$normalized = str_starts_with($raw, 'ROLE_') ? $raw : "ROLE_{$raw}";
$exists = DB::table('roles')
->where('id', '!=', $role->id)
->where('name', $normalized)
->exists();
if ($exists) {
continue;
}
DB::table('roles')
->where('id', $role->id)
->update(['name' => $normalized]);
}
}
/**
* Reverse the migrations.
*/
public function down(): void
{
// No safe reversal.
}
};

View File

@@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('roles', function (Blueprint $table) {
$table->string('color', 20)->nullable()->after('name');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('roles', function (Blueprint $table) {
$table->dropColumn('color');
});
}
};

View File

@@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('threads', function (Blueprint $table) {
$table->boolean('solved')->default(false)->after('body');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('threads', function (Blueprint $table) {
$table->dropColumn('solved');
});
}
};

View File

@@ -0,0 +1,40 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('attachments', function (Blueprint $table) {
$table->id();
$table->foreignId('thread_id')->nullable()->constrained('threads')->nullOnDelete();
$table->foreignId('post_id')->nullable()->constrained('posts')->nullOnDelete();
$table->foreignId('user_id')->nullable()->constrained('users')->nullOnDelete();
$table->string('disk', 50)->default('local');
$table->string('path');
$table->string('original_name');
$table->string('extension', 30)->nullable();
$table->string('mime_type', 150);
$table->unsignedBigInteger('size_bytes');
$table->timestamps();
$table->softDeletes();
$table->index('thread_id', 'idx_attachments_thread_id');
$table->index('post_id', 'idx_attachments_post_id');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('attachments');
}
};

View File

@@ -0,0 +1,54 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('attachment_groups', function (Blueprint $table) {
$table->id();
$table->string('name', 150);
$table->unsignedInteger('max_size_kb')->default(25600);
$table->boolean('is_active')->default(true);
$table->timestamps();
});
if (Schema::hasTable('attachment_types')) {
$types = DB::table('attachment_types')->orderBy('id')->get();
foreach ($types as $type) {
DB::table('attachment_groups')->insert([
'name' => $type->label ?? $type->key ?? 'General',
'max_size_kb' => $type->max_size_kb ?? 25600,
'is_active' => $type->is_active ?? true,
'created_at' => now(),
'updated_at' => now(),
]);
}
}
if (DB::table('attachment_groups')->count() === 0) {
DB::table('attachment_groups')->insert([
'name' => 'General',
'max_size_kb' => 25600,
'is_active' => true,
'created_at' => now(),
'updated_at' => now(),
]);
}
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('attachment_groups');
}
};

View File

@@ -0,0 +1,65 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('attachment_extensions', function (Blueprint $table) {
$table->id();
$table->string('extension', 30)->unique();
$table->foreignId('attachment_group_id')->nullable()->constrained('attachment_groups')->nullOnDelete();
$table->json('allowed_mimes')->nullable();
$table->timestamps();
});
if (Schema::hasTable('attachment_types') && Schema::hasTable('attachment_groups')) {
$groups = DB::table('attachment_groups')->orderBy('id')->get()->values();
$types = DB::table('attachment_types')->orderBy('id')->get()->values();
foreach ($types as $index => $type) {
$group = $groups[$index] ?? null;
if (!$group) {
continue;
}
$extensions = [];
if (!empty($type->allowed_extensions)) {
$decoded = json_decode($type->allowed_extensions, true);
if (is_array($decoded)) {
$extensions = $decoded;
}
}
foreach ($extensions as $ext) {
$ext = strtolower(trim((string) $ext));
if ($ext === '') {
continue;
}
DB::table('attachment_extensions')->updateOrInsert(
['extension' => $ext],
[
'attachment_group_id' => $group->id,
'allowed_mimes' => $type->allowed_mimes,
'created_at' => now(),
'updated_at' => now(),
]
);
}
}
}
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('attachment_extensions');
}
};

View File

@@ -0,0 +1,53 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('attachments', function (Blueprint $table) {
$table->foreignId('attachment_extension_id')->nullable()->constrained('attachment_extensions')->nullOnDelete();
$table->foreignId('attachment_group_id')->nullable()->constrained('attachment_groups')->nullOnDelete();
$table->index('attachment_extension_id', 'idx_attachments_extension_id');
$table->index('attachment_group_id', 'idx_attachments_group_id');
});
if (Schema::hasTable('attachment_extensions')) {
$extensions = DB::table('attachment_extensions')->get()->keyBy('extension');
$attachments = DB::table('attachments')->select('id', 'extension')->get();
foreach ($attachments as $attachment) {
$ext = strtolower(trim((string) $attachment->extension));
if ($ext === '' || !$extensions->has($ext)) {
continue;
}
$extRow = $extensions->get($ext);
DB::table('attachments')
->where('id', $attachment->id)
->update([
'attachment_extension_id' => $extRow->id,
'attachment_group_id' => $extRow->attachment_group_id,
]);
}
}
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('attachments', function (Blueprint $table) {
$table->dropIndex('idx_attachments_extension_id');
$table->dropIndex('idx_attachments_group_id');
$table->dropConstrainedForeignId('attachment_extension_id');
$table->dropConstrainedForeignId('attachment_group_id');
});
}
};

View File

@@ -0,0 +1,35 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
if (Schema::hasColumn('attachments', 'attachment_type_id')) {
Schema::table('attachments', function (Blueprint $table) {
$table->dropForeign(['attachment_type_id']);
$table->dropIndex('idx_attachments_type_id');
$table->dropColumn('attachment_type_id');
});
}
}
/**
* Reverse the migrations.
*/
public function down(): void
{
if (!Schema::hasColumn('attachments', 'attachment_type_id')) {
Schema::table('attachments', function (Blueprint $table) {
$table->foreignId('attachment_type_id')->constrained('attachment_types');
$table->index('attachment_type_id', 'idx_attachments_type_id');
});
}
}
};

View File

@@ -0,0 +1,23 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::dropIfExists('attachment_types');
}
/**
* Reverse the migrations.
*/
public function down(): void
{
// Intentionally left empty. attachment_types is deprecated.
}
};

View File

@@ -0,0 +1,77 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
if (Schema::hasColumn('attachment_groups', 'category')) {
Schema::table('attachment_groups', function (Blueprint $table) {
$table->dropColumn('category');
});
}
if (Schema::hasColumn('attachment_groups', 'allowed_mimes')) {
if (Schema::hasTable('attachment_extensions')) {
if (!Schema::hasColumn('attachment_extensions', 'allowed_mimes')) {
Schema::table('attachment_extensions', function (Blueprint $table) {
$table->json('allowed_mimes')->nullable();
});
}
$groups = DB::table('attachment_groups')
->select('id', 'allowed_mimes')
->get()
->keyBy('id');
$extensions = DB::table('attachment_extensions')
->select('id', 'attachment_group_id', 'allowed_mimes')
->get();
foreach ($extensions as $extension) {
if (!empty($extension->allowed_mimes)) {
continue;
}
$group = $groups->get($extension->attachment_group_id);
if (!$group || empty($group->allowed_mimes)) {
continue;
}
DB::table('attachment_extensions')
->where('id', $extension->id)
->update([
'allowed_mimes' => $group->allowed_mimes,
]);
}
}
Schema::table('attachment_groups', function (Blueprint $table) {
$table->dropColumn('allowed_mimes');
});
}
}
/**
* Reverse the migrations.
*/
public function down(): void
{
if (!Schema::hasColumn('attachment_groups', 'category')) {
Schema::table('attachment_groups', function (Blueprint $table) {
$table->string('category', 50)->default('other');
});
}
if (!Schema::hasColumn('attachment_groups', 'allowed_mimes')) {
Schema::table('attachment_groups', function (Blueprint $table) {
$table->json('allowed_mimes')->nullable();
});
}
}
};

View File

@@ -0,0 +1,41 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('attachment_groups', function (Blueprint $table) {
$table->foreignId('parent_id')->nullable()->constrained('attachment_groups')->nullOnDelete();
$table->unsignedInteger('position')->default(1);
$table->index(['parent_id', 'position'], 'idx_attachment_groups_parent_position');
});
$groups = DB::table('attachment_groups')->orderBy('id')->get();
$position = 1;
foreach ($groups as $group) {
DB::table('attachment_groups')
->where('id', $group->id)
->update(['position' => $position++]);
}
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('attachment_groups', function (Blueprint $table) {
$table->dropIndex('idx_attachment_groups_parent_position');
$table->dropConstrainedForeignId('parent_id');
$table->dropColumn('position');
});
}
};

View File

@@ -0,0 +1,24 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('attachments', function (Blueprint $table) {
$table->string('thumbnail_path')->nullable()->after('path');
$table->string('thumbnail_mime_type', 150)->nullable()->after('thumbnail_path');
$table->unsignedBigInteger('thumbnail_size_bytes')->nullable()->after('thumbnail_mime_type');
});
}
public function down(): void
{
Schema::table('attachments', function (Blueprint $table) {
$table->dropColumn(['thumbnail_path', 'thumbnail_mime_type', 'thumbnail_size_bytes']);
});
}
};

View File

@@ -0,0 +1,31 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration {
public function up(): void
{
Schema::create('audit_logs', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->nullable()->constrained()->nullOnDelete();
$table->string('action');
$table->string('subject_type')->nullable();
$table->unsignedBigInteger('subject_id')->nullable();
$table->json('metadata')->nullable();
$table->string('ip_address', 45)->nullable();
$table->string('user_agent', 255)->nullable();
$table->timestamps();
$table->index(['action', 'created_at']);
$table->index(['user_id', 'created_at']);
$table->index(['subject_type', 'subject_id']);
});
}
public function down(): void
{
Schema::dropIfExists('audit_logs');
}
};

View File

@@ -16,6 +16,7 @@ class DatabaseSeeder extends Seeder
{
$this->call([
RoleSeeder::class,
RankSeeder::class,
UserSeeder::class,
ForumSeeder::class,
ThreadSeeder::class,

View File

@@ -0,0 +1,41 @@
<?php
namespace Database\Seeders;
use App\Models\Rank;
use App\Models\User;
use Illuminate\Database\Seeder;
class RankSeeder extends Seeder
{
/**
* Run the database seeds.
*/
public function run(): void
{
$member = Rank::firstOrCreate(
['name' => 'Member'],
['badge_type' => 'text', 'badge_text' => 'Member']
);
$operator = Rank::firstOrCreate(
['name' => 'Operator'],
['badge_type' => 'text', 'badge_text' => 'Operator']
);
$moderator = Rank::firstOrCreate(
['name' => 'Moderator'],
['badge_type' => 'text', 'badge_text' => 'Moderator']
);
User::query()
->whereNull('rank_id')
->update(['rank_id' => $member->id]);
User::query()
->whereHas('roles', fn ($query) => $query->where('name', 'ROLE_ADMIN'))
->update(['rank_id' => $operator->id]);
User::query()
->whereHas('roles', fn ($query) => $query->where('name', 'ROLE_MODERATOR'))
->update(['rank_id' => $moderator->id]);
}
}

View File

@@ -4,6 +4,8 @@ namespace Database\Seeders;
use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str;
use App\Models\Rank;
use App\Models\Role;
use App\Models\User;
@@ -14,14 +16,29 @@ class UserSeeder extends Seeder
*/
public function run(): void
{
$adminRole = Role::where('name', 'ROLE_ADMIN')->first();
$userRole = Role::where('name', 'ROLE_USER')->first();
$adminRole = Role::where(column: 'name', operator: 'ROLE_ADMIN')->first();
$userRole = Role::where(column: 'name', operator: 'ROLE_USER')->first();
$operatorRank = Rank::where('name', 'Operator')->first();
$memberRank = Rank::where('name', 'Member')->first();
$admin = User::firstOrCreate(
['email' => 'tracer@24unix.net'],
[
$admin = User::updateOrCreate(
attributes: ['email' => 'tracer@24unix.net'],
values : [
'name' => 'tracer',
'password' => Hash::make('password'),
'name_canonical' => Str::lower('tracer'),
'rank_id' => $operatorRank?->id ?? $memberRank?->id,
'password' => Hash::make(value: 'password'),
'email_verified_at' => now(),
]
);
$micha = User::updateOrCreate(
attributes: ['email' => 'micha@24unix.net'],
values : [
'name' => 'Micha',
'name_canonical' => Str::lower('Micha'),
'rank_id' => $memberRank?->id,
'password' => Hash::make(value: 'password'),
'email_verified_at' => now(),
]
);
@@ -34,6 +51,10 @@ class UserSeeder extends Seeder
$admin->roles()->syncWithoutDetaching([$userRole->id]);
}
if ($userRole) {
$micha->roles()->syncWithoutDetaching([$userRole->id]);
}
$users = User::factory()->count(100)->create([
'email_verified_at' => now(),
]);

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

298
package-lock.json generated
View File

@@ -2072,18 +2072,6 @@
"node": ">=6"
}
},
"node_modules/detect-libc": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
"dev": true,
"license": "Apache-2.0",
"optional": true,
"peer": true,
"engines": {
"node": ">=8"
}
},
"node_modules/dom-helpers": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz",
@@ -2888,18 +2876,6 @@
"dev": true,
"license": "ISC"
},
"node_modules/jiti": {
"version": "2.6.1",
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz",
"integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"bin": {
"jiti": "lib/jiti-cli.mjs"
}
},
"node_modules/js-tokens": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
@@ -3010,280 +2986,6 @@
"node": ">= 0.8.0"
}
},
"node_modules/lightningcss": {
"version": "1.30.2",
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.2.tgz",
"integrity": "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==",
"dev": true,
"license": "MPL-2.0",
"optional": true,
"peer": true,
"dependencies": {
"detect-libc": "^2.0.3"
},
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
},
"optionalDependencies": {
"lightningcss-android-arm64": "1.30.2",
"lightningcss-darwin-arm64": "1.30.2",
"lightningcss-darwin-x64": "1.30.2",
"lightningcss-freebsd-x64": "1.30.2",
"lightningcss-linux-arm-gnueabihf": "1.30.2",
"lightningcss-linux-arm64-gnu": "1.30.2",
"lightningcss-linux-arm64-musl": "1.30.2",
"lightningcss-linux-x64-gnu": "1.30.2",
"lightningcss-linux-x64-musl": "1.30.2",
"lightningcss-win32-arm64-msvc": "1.30.2",
"lightningcss-win32-x64-msvc": "1.30.2"
}
},
"node_modules/lightningcss-android-arm64": {
"version": "1.30.2",
"resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.30.2.tgz",
"integrity": "sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"android"
],
"peer": true,
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-darwin-arm64": {
"version": "1.30.2",
"resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.30.2.tgz",
"integrity": "sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"darwin"
],
"peer": true,
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-darwin-x64": {
"version": "1.30.2",
"resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.30.2.tgz",
"integrity": "sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"darwin"
],
"peer": true,
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-freebsd-x64": {
"version": "1.30.2",
"resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.30.2.tgz",
"integrity": "sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"freebsd"
],
"peer": true,
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-linux-arm-gnueabihf": {
"version": "1.30.2",
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.30.2.tgz",
"integrity": "sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA==",
"cpu": [
"arm"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-linux-arm64-gnu": {
"version": "1.30.2",
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.30.2.tgz",
"integrity": "sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-linux-arm64-musl": {
"version": "1.30.2",
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.30.2.tgz",
"integrity": "sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-linux-x64-gnu": {
"version": "1.30.2",
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.30.2.tgz",
"integrity": "sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==",
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-linux-x64-musl": {
"version": "1.30.2",
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.30.2.tgz",
"integrity": "sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-win32-arm64-msvc": {
"version": "1.30.2",
"resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.30.2.tgz",
"integrity": "sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"win32"
],
"peer": true,
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-win32-x64-msvc": {
"version": "1.30.2",
"resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.30.2.tgz",
"integrity": "sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"win32"
],
"peer": true,
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/locate-path": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",

View File

@@ -2,10 +2,14 @@
"$schema": "https://www.schemastore.org/package.json",
"private": true,
"type": "module",
"engines": {
"node": ">=20",
"npm": ">=10"
},
"scripts": {
"build": "vite build",
"build:watch": "vite build --watch",
"dev": "vite",
"watch": "vite build --watch",
"lint": "eslint ."
},
"dependencies": {

View File

@@ -18,13 +18,11 @@
</include>
</source>
<php>
<env name="APP_ENV" value="testing"/>
<env name="APP_ENV" value="test"/>
<env name="APP_MAINTENANCE_DRIVER" value="file"/>
<env name="BCRYPT_ROUNDS" value="4"/>
<env name="BROADCAST_CONNECTION" value="null"/>
<env name="CACHE_STORE" value="array"/>
<env name="DB_CONNECTION" value="sqlite"/>
<env name="DB_DATABASE" value=":memory:"/>
<env name="MAIL_MAILER" value="array"/>
<env name="QUEUE_CONNECTION" value="sync"/>
<env name="SESSION_DRIVER" value="array"/>

View File

@@ -13,6 +13,23 @@ if (file_exists($maintenance = __DIR__.'/../storage/framework/maintenance.php'))
// Register the Composer autoloader...
require __DIR__.'/../vendor/autoload.php';
// Allow the installer to run without a .env file.
if (!file_exists(__DIR__.'/../.env')) {
$tempKey = 'base64:'.base64_encode(random_bytes(32));
$_ENV['APP_KEY'] = $tempKey;
$_SERVER['APP_KEY'] = $tempKey;
$_ENV['DB_CONNECTION'] = 'sqlite';
$_SERVER['DB_CONNECTION'] = 'sqlite';
$_ENV['DB_DATABASE'] = ':memory:';
$_SERVER['DB_DATABASE'] = ':memory:';
$_ENV['SESSION_DRIVER'] = 'array';
$_SERVER['SESSION_DRIVER'] = 'array';
$_ENV['SESSION_DOMAIN'] = null;
$_SERVER['SESSION_DOMAIN'] = null;
$_ENV['SESSION_SECURE_COOKIE'] = false;
$_SERVER['SESSION_SECURE_COOKIE'] = false;
}
// Bootstrap Laravel and handle the request...
/** @var Application $app */
$app = require_once __DIR__.'/../bootstrap/app.php';

View File

@@ -1,19 +1,29 @@
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 Acp from './pages/Acp'
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, isAuthenticated, forumName, logoUrl, showHeaderName }) {
function PortalHeader({
userMenu,
isAuthenticated,
forumName,
logoUrl,
showHeaderName,
canAccessAcp,
canAccessMcp,
}) {
const { t } = useTranslation()
const location = useLocation()
const [crumbs, setCrumbs] = useState([])
@@ -96,6 +106,33 @@ function PortalHeader({ userMenu, isAuthenticated, forumName, logoUrl, showHeade
}
}
if (location.pathname.startsWith('/acp')) {
setCrumbs([
{ ...base[0] },
{ ...base[1] },
{ label: t('portal.link_acp'), to: '/acp', current: true },
])
return
}
if (location.pathname.startsWith('/ucp')) {
setCrumbs([
{ ...base[0] },
{ ...base[1] },
{ label: t('portal.user_control_panel'), to: '/ucp', current: true },
])
return
}
if (location.pathname.startsWith('/profile/')) {
setCrumbs([
{ ...base[0] },
{ ...base[1] },
{ label: t('portal.user_profile'), to: location.pathname, current: true },
])
return
}
setCrumbs([{ ...base[0] }, { ...base[1], current: true }])
}
@@ -107,15 +144,17 @@ function PortalHeader({ userMenu, isAuthenticated, forumName, logoUrl, showHeade
}, [location.pathname, t])
return (
<Container className="pt-2 pb-2 bb-portal-shell">
<Container fluid className="pt-2 pb-2 bb-portal-shell">
<div className="bb-portal-banner">
<div className="bb-portal-brand">
<Link to="/" className="bb-portal-logo-link" aria-label={forumName || '24unix.net'}>
{logoUrl && (
<img src={logoUrl} alt={forumName || 'Forum'} className="bb-portal-logo-image" />
)}
{(showHeaderName || !logoUrl) && (
<div className="bb-portal-logo">{forumName || '24unix.net'}</div>
)}
</Link>
</div>
<div className="bb-portal-search">
<input type="text" placeholder={t('portal.search_placeholder')} disabled />
@@ -135,12 +174,18 @@ function PortalHeader({ userMenu, isAuthenticated, forumName, logoUrl, showHeade
<span>
<i className="bi bi-question-circle-fill" aria-hidden="true" /> {t('portal.link_faq')}
</span>
{isAuthenticated && canAccessAcp && (
<>
<Link to="/acp" className="bb-portal-link">
<i className="bi bi-gear-fill" aria-hidden="true" /> {t('portal.link_acp')}
</Link>
</>
)}
{isAuthenticated && canAccessMcp && (
<span>
<i className="bi bi-hammer" aria-hidden="true" /> {t('portal.link_mcp')}
</span>
)}
</div>
</div>
<div
@@ -174,11 +219,11 @@ function PortalHeader({ userMenu, isAuthenticated, forumName, logoUrl, showHeade
<span key={`${crumb.to}-${index}`} className="bb-portal-crumb">
{index > 0 && <span className="bb-portal-sep"></span>}
{crumb.current ? (
<span className="bb-portal-current">
<Link to={crumb.to} className="bb-portal-current bb-portal-link">
{index === 0 && <i className="bi bi-house-door-fill" aria-hidden="true" />}
{index === 1 && <i className="bi bi-chat-left-text" aria-hidden="true" />}
{crumb.label}
</span>
</Link>
) : (
<Link to={crumb.to} className="bb-portal-link">
{index === 0 && <i className="bi bi-house-door-fill" aria-hidden="true" />}
@@ -196,9 +241,16 @@ function PortalHeader({ userMenu, isAuthenticated, forumName, logoUrl, showHeade
}
function AppShell() {
const PING_INTERVAL_MS = 15000
const PING_INTERVAL_HIDDEN_MS = 60000
const { t } = useTranslation()
const { token, email, logout, isAdmin } = useAuth()
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(
@@ -227,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 () => {
@@ -382,7 +501,7 @@ function AppShell() {
])
return (
<div className="bb-shell">
<div className="bb-shell" id="top">
<PortalHeader
isAuthenticated={!!token}
forumName={settings.forumName}
@@ -403,7 +522,7 @@ function AppShell() {
<NavDropdown.Item as={Link} to="/ucp">
<i className="bi bi-sliders" aria-hidden="true" /> {t('portal.user_control_panel')}
</NavDropdown.Item>
<NavDropdown.Item as={Link} to="/ucp">
<NavDropdown.Item as={Link} to={`/profile/${userId ?? ''}`}>
<i className="bi bi-person" aria-hidden="true" /> {t('portal.user_profile')}
</NavDropdown.Item>
<NavDropdown.Divider />
@@ -413,6 +532,8 @@ function AppShell() {
</NavDropdown>
) : null
}
canAccessAcp={isAdmin}
canAccessMcp={isModerator}
/>
<Routes>
<Route path="/" element={<Home />} />
@@ -420,7 +541,9 @@ 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} />} />
<Route
path="/ucp"
@@ -446,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

@@ -48,10 +48,10 @@ export async function getCollection(path) {
return data?.['hydra:member'] || []
}
export async function login(email, password) {
export async function login(login, password) {
return apiFetch('/login', {
method: 'POST',
body: JSON.stringify({ email, password }),
body: JSON.stringify({ login, password }),
})
}
@@ -62,6 +62,26 @@ 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',
})
}
export async function listRootForums() {
return getCollection('/forums?parent[exists]=false')
}
@@ -70,10 +90,78 @@ export async function listAllForums() {
return getCollection('/forums?pagination=false')
}
export async function getCurrentUser() {
return apiFetch('/user/me')
}
export async function updateCurrentUser(payload) {
return apiFetch('/user/me', {
method: 'PATCH',
headers: {
'Content-Type': 'application/merge-patch+json',
},
body: JSON.stringify(payload),
})
}
export async function uploadAvatar(file) {
const body = new FormData()
body.append('file', file)
return apiFetch('/user/avatar', {
method: 'POST',
body,
})
}
export async function getUserProfile(id) {
return apiFetch(`/user/profile/${id}`)
}
export async function listUserThanksGiven(id) {
return apiFetch(`/user/${id}/thanks/given`)
}
export async function listUserThanksReceived(id) {
return apiFetch(`/user/${id}/thanks/received`)
}
export async function fetchVersion() {
return apiFetch('/version')
}
export async function fetchPing() {
return apiFetch('/ping')
}
export async function fetchVersionCheck() {
return apiFetch('/version/check')
}
export async function runSystemUpdate() {
return apiFetch('/system/update', {
method: 'POST',
})
}
export async function fetchSystemStatus() {
return apiFetch('/system/status')
}
export async function fetchStats() {
return apiFetch('/stats')
}
export async function fetchPortalSummary() {
return apiFetch('/portal/summary')
}
export async function previewBbcode(body) {
return apiFetch('/preview', {
method: 'POST',
body: JSON.stringify({ body }),
})
}
export async function fetchSetting(key) {
// TODO: Prefer fetchSettings() when multiple settings are needed.
const cacheBust = Date.now()
@@ -110,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)
@@ -203,14 +298,218 @@ export async function getThread(id) {
return apiFetch(`/threads/${id}`)
}
export async function deleteThread(id, payload = null) {
return apiFetch(`/threads/${id}`, {
method: 'DELETE',
...(payload ? { body: JSON.stringify(payload) } : {}),
})
}
export async function updateThread(id, payload) {
return apiFetch(`/threads/${id}`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/merge-patch+json',
},
body: JSON.stringify(payload),
})
}
export async function updateThreadSolved(threadId, solved) {
return apiFetch(`/threads/${threadId}/solved`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/merge-patch+json',
},
body: JSON.stringify({ solved }),
})
}
export async function listAttachmentsByThread(threadId) {
return getCollection(`/attachments?thread=/api/threads/${threadId}`)
}
export async function listAttachmentsByPost(postId) {
return getCollection(`/attachments?post=/api/posts/${postId}`)
}
export async function uploadAttachment({ threadId, postId, file }) {
const body = new FormData()
if (threadId) body.append('thread', `/api/threads/${threadId}`)
if (postId) body.append('post', `/api/posts/${postId}`)
body.append('file', file)
return apiFetch('/attachments', {
method: 'POST',
body,
})
}
export async function deleteAttachment(id) {
return apiFetch(`/attachments/${id}`, {
method: 'DELETE',
})
}
export async function listAttachmentGroups() {
return getCollection('/attachment-groups')
}
export async function createAttachmentGroup(payload) {
return apiFetch('/attachment-groups', {
method: 'POST',
body: JSON.stringify(payload),
})
}
export async function updateAttachmentGroup(id, payload) {
return apiFetch(`/attachment-groups/${id}`, {
method: 'PATCH',
body: JSON.stringify(payload),
})
}
export async function deleteAttachmentGroup(id) {
return apiFetch(`/attachment-groups/${id}`, {
method: 'DELETE',
})
}
export async function reorderAttachmentGroups(parentId, orderedIds) {
return apiFetch('/attachment-groups/reorder', {
method: 'POST',
body: JSON.stringify({ parentId, orderedIds }),
})
}
export async function listAttachmentExtensions() {
return getCollection('/attachment-extensions')
}
export async function listAttachmentExtensionsPublic() {
return getCollection('/attachment-extensions/public')
}
export async function createAttachmentExtension(payload) {
return apiFetch('/attachment-extensions', {
method: 'POST',
body: JSON.stringify(payload),
})
}
export async function updateAttachmentExtension(id, payload) {
return apiFetch(`/attachment-extensions/${id}`, {
method: 'PATCH',
body: JSON.stringify(payload),
})
}
export async function deleteAttachmentExtension(id) {
return apiFetch(`/attachment-extensions/${id}`, {
method: 'DELETE',
})
}
export async function listPostsByThread(threadId) {
return getCollection(`/posts?thread=/api/threads/${threadId}`)
}
export async function updatePost(id, payload) {
return apiFetch(`/posts/${id}`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/merge-patch+json',
},
body: JSON.stringify(payload),
})
}
export async function deletePost(id, payload = null) {
return apiFetch(`/posts/${id}`, {
method: 'DELETE',
...(payload ? { body: JSON.stringify(payload) } : {}),
})
}
export async function listUsers() {
return getCollection('/users')
}
export async function listAuditLogs(limit = 200) {
const query = Number.isFinite(limit) ? `?limit=${limit}` : ''
return getCollection(`/audit-logs${query}`)
}
export async function listRanks() {
return getCollection('/ranks')
}
export async function listRoles() {
return getCollection('/roles')
}
export async function createRole(payload) {
return apiFetch('/roles', {
method: 'POST',
body: JSON.stringify(payload),
})
}
export async function updateRole(roleId, payload) {
return apiFetch(`/roles/${roleId}`, {
method: 'PATCH',
body: JSON.stringify(payload),
})
}
export async function deleteRole(roleId) {
return apiFetch(`/roles/${roleId}`, {
method: 'DELETE',
})
}
export async function updateUserRank(userId, rankId) {
return apiFetch(`/users/${userId}/rank`, {
method: 'PATCH',
body: JSON.stringify({ rank_id: rankId }),
})
}
export async function createRank(payload) {
return apiFetch('/ranks', {
method: 'POST',
body: JSON.stringify(payload),
})
}
export async function updateRank(rankId, payload) {
return apiFetch(`/ranks/${rankId}`, {
method: 'PATCH',
body: JSON.stringify(payload),
})
}
export async function deleteRank(rankId) {
return apiFetch(`/ranks/${rankId}`, {
method: 'DELETE',
})
}
export async function uploadRankBadgeImage(rankId, file) {
const body = new FormData()
body.append('file', file)
return apiFetch(`/ranks/${rankId}/badge-image`, {
method: 'POST',
body,
})
}
export async function updateUser(userId, payload) {
return apiFetch(`/users/${userId}`, {
method: 'PATCH',
body: JSON.stringify(payload),
})
}
export async function createThread({ title, body, forumId }) {
return apiFetch('/threads', {
method: 'POST',

View File

@@ -0,0 +1,112 @@
import { Link } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
export default function PortalTopicRow({ thread, forumName, forumId, showForum = true }) {
const { t } = useTranslation()
const authorName = thread.user_name || t('thread.anonymous')
const lastAuthorName = thread.last_post_user_name || authorName
const lastPostAnchor = thread.last_post_id ? `#post-${thread.last_post_id}` : ''
const authorLinkColor = thread.user_rank_color || thread.user_group_color
const authorLinkStyle = authorLinkColor
? { '--bb-user-link-color': authorLinkColor }
: undefined
const lastAuthorLinkColor = thread.last_post_user_rank_color || thread.last_post_user_group_color
const lastAuthorLinkStyle = lastAuthorLinkColor
? { '--bb-user-link-color': lastAuthorLinkColor }
: undefined
const formatDateTime = (value) => {
if (!value) return '—'
const date = new Date(value)
if (Number.isNaN(date.getTime())) return '—'
const day = String(date.getDate()).padStart(2, '0')
const month = String(date.getMonth() + 1).padStart(2, '0')
const year = String(date.getFullYear())
const hours = String(date.getHours()).padStart(2, '0')
const minutes = String(date.getMinutes()).padStart(2, '0')
const seconds = String(date.getSeconds()).padStart(2, '0')
return `${day}.${month}.${year} ${hours}:${minutes}:${seconds}`
}
const repliesCount = Math.max((thread.posts_count ?? 0) - 1, 0)
return (
<div className="bb-portal-topic-row">
<div className="bb-portal-topic-main">
<span className="bb-portal-topic-icon" aria-hidden="true">
<i className="bi bi-chat-left-text" />
</span>
<div>
<Link to={`/thread/${thread.id}`} className="bb-portal-topic-title">
{thread.title}
{thread.solved && (
<span className="bb-thread-solved-badge">{t('thread.solved')}</span>
)}
</Link>
<div className="bb-portal-topic-meta">
<div className="bb-portal-topic-meta-line">
<span className="bb-portal-topic-meta-label">{t('portal.posted_by')}</span>
{thread.user_id ? (
<Link
to={`/profile/${thread.user_id}`}
className="bb-portal-topic-author"
style={authorLinkStyle}
>
{authorName}
</Link>
) : (
<span className="bb-portal-topic-author">{authorName}</span>
)}
<span className="bb-portal-topic-meta-sep">»</span>
<span className="bb-portal-topic-meta-date">{formatDateTime(thread.created_at)}</span>
</div>
{showForum && (
<div className="bb-portal-topic-meta-line">
<span className="bb-portal-topic-meta-label">{t('portal.forum_label')}</span>
<span className="bb-portal-topic-forum">
{forumId ? (
<Link to={`/forum/${forumId}`} className="bb-portal-topic-forum-link">
{forumName}
</Link>
) : (
forumName
)}
</span>
</div>
)}
</div>
</div>
</div>
<div className="bb-portal-topic-cell">{repliesCount}</div>
<div className="bb-portal-topic-cell">{thread.views_count ?? 0}</div>
<div className="bb-portal-topic-cell bb-portal-topic-cell--last">
<div className="bb-portal-last">
<span className="bb-portal-last-by">
{t('thread.by')}{' '}
{thread.last_post_user_id ? (
<Link
to={`/profile/${thread.last_post_user_id}`}
className="bb-portal-last-user"
style={lastAuthorLinkStyle}
>
{lastAuthorName}
</Link>
) : (
<span className="bb-portal-last-user">{lastAuthorName}</span>
)}
<Link
to={`/thread/${thread.id}${lastPostAnchor}`}
className="bb-portal-last-jump ms-2"
aria-label={t('thread.view')}
>
<i className="bi bi-eye" aria-hidden="true" />
</Link>
</span>
<span className="bb-portal-last-date">
{formatDateTime(thread.last_post_at || thread.created_at)}
</span>
</div>
</div>
</div>
)
}

View File

@@ -1,5 +1,5 @@
import { createContext, useContext, useMemo, useState, useEffect } from 'react'
import { login as apiLogin } from '../api/client'
import { login as apiLogin, logoutUser } from '../api/client'
const AuthContext = createContext(null)
@@ -27,10 +27,11 @@ export function AuthProvider({ children }) {
userId: effectiveUserId,
roles: effectiveRoles,
isAdmin: effectiveRoles.includes('ROLE_ADMIN'),
async login(emailInput, password) {
const data = await apiLogin(emailInput, password)
isModerator: effectiveRoles.includes('ROLE_MODERATOR') || effectiveRoles.includes('ROLE_ADMIN'),
async login(loginInput, password) {
const data = await apiLogin(loginInput, password)
localStorage.setItem('speedbb_token', data.token)
localStorage.setItem('speedbb_email', data.email || emailInput)
localStorage.setItem('speedbb_email', data.email || loginInput)
if (data.user_id) {
localStorage.setItem('speedbb_user_id', String(data.user_id))
setUserId(String(data.user_id))
@@ -43,9 +44,14 @@ export function AuthProvider({ children }) {
setRoles([])
}
setToken(data.token)
setEmail(data.email || emailInput)
setEmail(data.email || loginInput)
},
logout() {
async logout() {
try {
await logoutUser()
} catch {
// Ignore logout failures; client state is cleared regardless.
}
localStorage.removeItem('speedbb_token')
localStorage.removeItem('speedbb_email')
localStorage.removeItem('speedbb_user_id')
@@ -77,6 +83,7 @@ export function AuthProvider({ children }) {
userId: effectiveUserId,
roles: effectiveRoles,
isAdmin: effectiveRoles.includes('ROLE_ADMIN'),
isModerator: effectiveRoles.includes('ROLE_MODERATOR') || effectiveRoles.includes('ROLE_ADMIN'),
hasToken: Boolean(token),
})
}, [email, effectiveUserId, effectiveRoles, token])

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More