Compare commits
99 Commits
acp-cleanu
...
94f665192d
| Author | SHA1 | Date | |
|---|---|---|---|
| 94f665192d | |||
| c894b1dfb2 | |||
| c19124741e | |||
| 66de3b31b1 | |||
| 1adb3308be | |||
| 1f26aa7fb5 | |||
| 41387be802 | |||
| 7b22d89dfd | |||
| 6a10087bee | |||
| 79f8077bd4 | |||
| 269248012b | |||
| e357cc3c48 | |||
| 1e227f6ba0 | |||
| 8e86fcdbd9 | |||
| 78bdd869ef | |||
| cd12ac676d | |||
| a5b55adf56 | |||
| 86190c9718 | |||
|
|
60c6718645 | ||
| 225dc391ff | |||
| 16e0444fa3 | |||
| 6a2316c6f4 | |||
| 0b4e0df305 | |||
| 2a69ee8258 | |||
| 1c2353cfe1 | |||
| 496b50ed12 | |||
| 50e3ff6ded | |||
| fdf8d65310 | |||
| c2140b4493 | |||
| 652cf8bd6a | |||
| 5fdc0d45e3 | |||
| 6cde90042e | |||
| 942ab7858b | |||
| d178b8da91 | |||
| 7ecb6378fe | |||
| 9496078644 | |||
| 3aab864c34 | |||
| 5eb5404061 | |||
| d9040f1e6c | |||
| 8270e635d6 | |||
| d724f80cad | |||
| 1f5f340ce4 | |||
| 40e111b3a6 | |||
| 506011f933 | |||
| 80a8b86a08 | |||
| c1cb3f394a | |||
| 31c8491aaf | |||
| 0ad5916504 | |||
| bac70c3927 | |||
| bf23e46e2d | |||
| 55b9a69c42 | |||
| b6ce5160f9 | |||
| d279e7f36f | |||
| a0d914ea24 | |||
| ce3b89d54e | |||
| 5cd8a1a9d6 | |||
| 6f9d9f9e7a | |||
| db7f088b36 | |||
| 54d4cd7f99 | |||
| af03c23c9f | |||
| 68dd17f895 | |||
| 8249df15df | |||
| f167e64d00 | |||
| 95ebc7778d | |||
| c67a3ec6d0 | |||
| bf278667bc | |||
| 30a06e18f0 | |||
| 0bc893dd35 | |||
| 88e4a70f88 | |||
| 160430e128 | |||
| 9c60a8944e | |||
| 64244567c0 | |||
| 7fbc566129 | |||
| c33cde6f04 | |||
| 2409feb06f | |||
| e3dcf99362 | |||
| 357f6fb755 | |||
| 2281b80980 | |||
| f23363fdcc | |||
| c1814c0d47 | |||
| 7489a3903d | |||
| b967aa912b | |||
| 67ae9517f4 | |||
| 653905d5e2 | |||
| bc893b644d | |||
| 662e00bec1 | |||
| a96913bffa | |||
| 79ac0cdca5 | |||
| fe4b7ccd7c | |||
| fc9de4c9fd | |||
| 6b6f787351 | |||
| d4fb86633b | |||
|
|
24c16ed0dd | ||
|
|
f9de433545 | ||
|
|
fd29b928d8 | ||
|
|
98094459e3 | ||
|
|
3bb2946656 | ||
|
|
bbbf8eb6c1 | ||
|
|
c8d2bd508e |
65
.gitea/workflows/commit.yaml
Normal file
65
.gitea/workflows/commit.yaml
Normal 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
9
.gitignore
vendored
@@ -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
|
||||
|
||||
3
.mailmap
Normal file
3
.mailmap
Normal file
@@ -0,0 +1,3 @@
|
||||
tracer <tracer@24unix.net> Micha <tracer@24unix.net>
|
||||
tracer <tracer@24unix.net> Micha <espey@smart-q.de>
|
||||
tracer <tracer@24unix.net> speedbb-ci <ci@24unix.net>
|
||||
79
CHANGELOG.md
79
CHANGELOG.md
@@ -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
15
NOTES.md
Normal 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.
|
||||
32
README.md
32
README.md
@@ -1,7 +1,31 @@
|
||||
# SpeedBB Forum
|
||||
# speedBB
|
||||
|
||||
Placeholder README for the forum application.
|
||||
speedBB is a modern forum application with a built-in Admin Control Panel (ACP), customizable branding, user/rank management, attachment support, and integrated update tooling.
|
||||
|
||||
## Status
|
||||
## What It Does
|
||||
|
||||
Work in progress.
|
||||
- Hosts classic forum discussions with categories, forums, topics, and replies.
|
||||
- Provides an ACP for everyday operations (settings, users, groups, ranks, attachments, and audit log).
|
||||
- Supports brand customization (name, theme, accents, logos, favicons).
|
||||
- Manages user media (avatars, rank badges, logos) with public delivery.
|
||||
- Includes built-in update and system-check workflows so admins can verify server health and apply updates from the ACP.
|
||||
|
||||
## ACP Areas
|
||||
|
||||
The ACP is organized into practical sections for day-to-day forum operations:
|
||||
|
||||
- `General`: core board identity and visual setup (name, theme defaults, accents, logos, favicons).
|
||||
- `Forums`: structure and ordering of categories/forums.
|
||||
- `Users`: account overview and moderation/admin user management actions.
|
||||
- `Groups`: role and permission group administration.
|
||||
- `Ranks`: rank definitions and badge management.
|
||||
- `Attachments`: attachment policy and extension/group controls.
|
||||
- `Audit log`: activity trail for administrative actions.
|
||||
- `System`: split into `Health` (live website health checks) and `Updates` (update-readiness checks and update actions, including CLI interpreter validation).
|
||||
- `Custom`: space for project-specific custom assets/overrides.
|
||||
|
||||
## Current Product Status
|
||||
|
||||
- Active version: `26.0.3`
|
||||
- Forum + ACP features are in active use.
|
||||
- Health and update checks are integrated directly into ACP System.
|
||||
|
||||
4
ansible/ansible.cfg
Normal file
4
ansible/ansible.cfg
Normal file
@@ -0,0 +1,4 @@
|
||||
[defaults]
|
||||
inventory = ./hosts.ini
|
||||
set_remote_user = yes
|
||||
allow_world_readable_tmpfiles=true
|
||||
15
ansible/deploy-to-prod.yaml
Normal file
15
ansible/deploy-to-prod.yaml
Normal 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
8
ansible/hosts.ini
Normal 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=
|
||||
|
||||
|
||||
|
||||
151
ansible/roles/speedBB/tasks/main.yaml
Normal file
151
ansible/roles/speedBB/tasks/main.yaml
Normal 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
5
ansible/vars/vars.yaml
Normal 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
9
ansible/vars/vault.yaml
Normal file
@@ -0,0 +1,9 @@
|
||||
$ANSIBLE_VAULT;1.1;AES256
|
||||
31623264303535663263613235356231623137333734626164376138656532623937316534333835
|
||||
3661666237386534373466356136393566333162326562330a383833363737323637363738616666
|
||||
62393164326465376634356666303861613362313430656161653531373733353530636265353738
|
||||
3863633131313834390a356663373338346137373662356161643336636534626130313466343566
|
||||
36653636333838633938323363646335663935646135613632356434396436326131323361366561
|
||||
32633939346163356131663266346539323330613536333838616332646139313731326133646165
|
||||
31343763636337306263646631353562646462323631383439353738333035623664623163303839
|
||||
34343261383738396534
|
||||
73
app/Actions/BbcodeFormatter.php
Normal file
73
app/Actions/BbcodeFormatter.php
Normal 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];
|
||||
}
|
||||
}
|
||||
@@ -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']),
|
||||
]);
|
||||
|
||||
@@ -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();
|
||||
|
||||
93
app/Console/Commands/CronRun.php
Normal file
93
app/Console/Commands/CronRun.php
Normal 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;
|
||||
}
|
||||
}
|
||||
89
app/Console/Commands/VersionBump.php
Normal file
89
app/Console/Commands/VersionBump.php
Normal 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;
|
||||
}
|
||||
}
|
||||
78
app/Console/Commands/VersionFetch.php
Normal file
78
app/Console/Commands/VersionFetch.php
Normal 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,
|
||||
];
|
||||
}
|
||||
}
|
||||
113
app/Console/Commands/VersionRelease.php
Normal file
113
app/Console/Commands/VersionRelease.php
Normal 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.';
|
||||
}
|
||||
}
|
||||
73
app/Console/Commands/VersionSet.php
Normal file
73
app/Console/Commands/VersionSet.php
Normal 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;
|
||||
}
|
||||
}
|
||||
351
app/Http/Controllers/AttachmentController.php
Normal file
351
app/Http/Controllers/AttachmentController.php
Normal 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(),
|
||||
];
|
||||
}
|
||||
|
||||
}
|
||||
147
app/Http/Controllers/AttachmentExtensionController.php
Normal file
147
app/Http/Controllers/AttachmentExtensionController.php
Normal 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,
|
||||
];
|
||||
}
|
||||
}
|
||||
190
app/Http/Controllers/AttachmentGroupController.php
Normal file
190
app/Http/Controllers/AttachmentGroupController.php
Normal 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;
|
||||
}
|
||||
}
|
||||
55
app/Http/Controllers/AuditLogController.php
Normal file
55
app/Http/Controllers/AuditLogController.php
Normal 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,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
140
app/Http/Controllers/InstallerController.php
Normal file
140
app/Http/Controllers/InstallerController.php
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
46
app/Http/Controllers/PingController.php
Normal file
46
app/Http/Controllers/PingController.php
Normal file
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Setting;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
|
||||
class PingController extends Controller
|
||||
{
|
||||
public function __invoke(): JsonResponse
|
||||
{
|
||||
$build = $this->readComposerBuild();
|
||||
if ($build === null) {
|
||||
$build = Setting::query()->where('key', 'build')->value('value');
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'connect' => 'ok',
|
||||
'version_status' => [
|
||||
'build' => $build !== null ? (int) $build : null,
|
||||
],
|
||||
'notification_state' => false,
|
||||
]);
|
||||
}
|
||||
|
||||
private function readComposerBuild(): ?int
|
||||
{
|
||||
$path = base_path('composer.json');
|
||||
if (!is_file($path) || !is_readable($path)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$raw = file_get_contents($path);
|
||||
if ($raw === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$data = json_decode($raw, true);
|
||||
if (!is_array($data)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$build = trim((string) ($data['build'] ?? ''));
|
||||
return ctype_digit($build) ? (int) $build : null;
|
||||
}
|
||||
}
|
||||
182
app/Http/Controllers/PortalController.php
Normal file
182
app/Http/Controllers/PortalController.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
122
app/Http/Controllers/PostThankController.php
Normal file
122
app/Http/Controllers/PostThankController.php
Normal 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;
|
||||
}
|
||||
}
|
||||
20
app/Http/Controllers/PreviewController.php
Normal file
20
app/Http/Controllers/PreviewController.php
Normal 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']),
|
||||
]);
|
||||
}
|
||||
}
|
||||
167
app/Http/Controllers/RankController.php
Normal file
167
app/Http/Controllers/RankController.php
Normal 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),
|
||||
]);
|
||||
}
|
||||
}
|
||||
141
app/Http/Controllers/RoleController.php
Normal file
141
app/Http/Controllers/RoleController.php
Normal 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}";
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
161
app/Http/Controllers/StatsController.php
Normal file
161
app/Http/Controllers/StatsController.php
Normal 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);
|
||||
}
|
||||
}
|
||||
210
app/Http/Controllers/SystemStatusController.php
Normal file
210
app/Http/Controllers/SystemStatusController.php
Normal 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 : [];
|
||||
}
|
||||
}
|
||||
222
app/Http/Controllers/SystemUpdateController.php
Normal file
222
app/Http/Controllers/SystemUpdateController.php
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
72
app/Http/Controllers/VersionCheckController.php
Normal file
72
app/Http/Controllers/VersionCheckController.php
Normal 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.',
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
74
app/Models/Attachment.php
Normal 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);
|
||||
}
|
||||
}
|
||||
31
app/Models/AttachmentExtension.php
Normal file
31
app/Models/AttachmentExtension.php
Normal 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');
|
||||
}
|
||||
}
|
||||
46
app/Models/AttachmentGroup.php
Normal file
46
app/Models/AttachmentGroup.php
Normal 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
41
app/Models/AuditLog.php
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
24
app/Models/PostThank.php
Normal 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
29
app/Models/Rank.php
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,7 @@ class Role extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'name',
|
||||
'color',
|
||||
];
|
||||
|
||||
public function users(): BelongsToMany
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
208
app/Services/AttachmentThumbnailService.php
Normal file
208
app/Services/AttachmentThumbnailService.php
Normal 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;
|
||||
}
|
||||
}
|
||||
34
app/Services/AuditLogger.php
Normal file
34
app/Services/AuditLogger.php
Normal 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
15
artisan
@@ -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);
|
||||
|
||||
@@ -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
0
bootstrap/cache/.gitkeep
vendored
Normal file
65
bootstrap/cache/packages.php
vendored
65
bootstrap/cache/packages.php
vendored
@@ -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',
|
||||
),
|
||||
),
|
||||
);
|
||||
275
bootstrap/cache/services.php
vendored
275
bootstrap/cache/services.php
vendored
@@ -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 (
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -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": "110"
|
||||
}
|
||||
|
||||
2307
composer.lock
generated
2307
composer.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||
),
|
||||
],
|
||||
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
28
database/migrations/2026_01_05_020000_create_ranks_table.php
Normal file
28
database/migrations/2026_01_05_020000_create_ranks_table.php
Normal 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');
|
||||
}
|
||||
};
|
||||
@@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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']);
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
@@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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.
|
||||
}
|
||||
};
|
||||
@@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
@@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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');
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -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.
|
||||
}
|
||||
};
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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']);
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
@@ -16,6 +16,7 @@ class DatabaseSeeder extends Seeder
|
||||
{
|
||||
$this->call([
|
||||
RoleSeeder::class,
|
||||
RankSeeder::class,
|
||||
UserSeeder::class,
|
||||
ForumSeeder::class,
|
||||
ThreadSeeder::class,
|
||||
|
||||
41
database/seeders/RankSeeder.php
Normal file
41
database/seeders/RankSeeder.php
Normal 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]);
|
||||
}
|
||||
}
|
||||
@@ -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
345
git_update.sh
Executable 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
298
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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"/>
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
112
resources/js/components/PortalTopicRow.jsx
Normal file
112
resources/js/components/PortalTopicRow.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user