Compare commits
49 Commits
laravel
...
ce3b89d54e
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | ||
| eef3262a53 | |||
| fe1015bff1 | |||
|
|
8604cdf95d | ||
| f83748cc76 |
60
.gitea/workflows/commit.yaml
Normal file
60
.gitea/workflows/commit.yaml
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
name: CI/CD Pipeline
|
||||||
|
run-name: ${{ gitea.event.head_commit.message }}
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- master
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
runs-on: debian-latest
|
||||||
|
steps:
|
||||||
|
- name: Show Debian version
|
||||||
|
run: cat /etc/os-release
|
||||||
|
- name: Test Deployment
|
||||||
|
run: echo "Deployment test"
|
||||||
|
|
||||||
|
deploy:
|
||||||
|
runs-on: self-hosted
|
||||||
|
needs: test
|
||||||
|
steps:
|
||||||
|
- 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:
|
||||||
|
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
|
||||||
6
.gitignore
vendored
6
.gitignore
vendored
@@ -1,8 +1,10 @@
|
|||||||
*.log
|
*.log
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
._*
|
||||||
.env
|
.env
|
||||||
.env.backup
|
.env.backup
|
||||||
.env.production
|
.env.production
|
||||||
|
.env.test
|
||||||
.env.*.local
|
.env.*.local
|
||||||
.phpactor.json
|
.phpactor.json
|
||||||
.phpunit.result.cache
|
.phpunit.result.cache
|
||||||
@@ -20,8 +22,12 @@
|
|||||||
/public/build
|
/public/build
|
||||||
/public/hot
|
/public/hot
|
||||||
/public/storage
|
/public/storage
|
||||||
|
/storage/app
|
||||||
|
/storage/framework
|
||||||
/storage/*.key
|
/storage/*.key
|
||||||
/storage/pail
|
/storage/pail
|
||||||
|
/storage/framework/views/*.php
|
||||||
|
/bootstrap/cache/*.php
|
||||||
/vendor
|
/vendor
|
||||||
Homestead.json
|
Homestead.json
|
||||||
Homestead.yaml
|
Homestead.yaml
|
||||||
|
|||||||
80
CHANGELOG.md
80
CHANGELOG.md
@@ -1,5 +1,72 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
- Applied forum branding, theme defaults, accents, logos, and favicon links in the SPA header.
|
||||||
|
|
||||||
|
## 2025-12-30
|
||||||
|
- Added soft deletes with audit metadata (deleted_at/deleted_by) for forums, threads, and posts.
|
||||||
|
- Ensured API listings and ACP forum tree omit soft-deleted records by default.
|
||||||
|
- Added thread seeding for forum test data.
|
||||||
|
- Enforced category-only roots for forums (API validation, UI, and database constraint).
|
||||||
|
- Added portal header with phpBB-style breadcrumb + quick links, plus notifications/messages + user menu.
|
||||||
|
- Replaced the home page with a portal-style layout and latest posts list.
|
||||||
|
- Added a dedicated board index page with phpBB-like sections and per-category collapse toggles.
|
||||||
|
- Persisted board index collapse state per user via user_settings (DB + API + client cache).
|
||||||
|
- Added category view rendering for subcategories in ForumView.
|
||||||
|
- Updated thread list UI (icons, spacing) and New topic button styling in ForumView.
|
||||||
|
- Added ACP per-category quick-create buttons for child categories and forums.
|
||||||
|
- Removed the legacy navbar and cleaned up related styling.
|
||||||
|
|
||||||
|
## 2025-12-29
|
||||||
|
- Merged the React app into the Laravel codebase under `resources/js`.
|
||||||
|
- Moved the Laravel app to the repo root and removed the separate `api/` and `frontend/` folders.
|
||||||
|
- Serve the SPA shell via Blade with Vite-managed React assets.
|
||||||
|
- Dropped Tailwind tooling to keep the frontend Bootstrap-only.
|
||||||
|
- Replaced the default Laravel README with a forum placeholder.
|
||||||
|
- Updated ACP forum tools with accent-tinted buttons and larger action spacing.
|
||||||
|
- Defaulted the ACP forum tree to collapsed on page load.
|
||||||
|
- Improved ACP create/edit dialog copy based on forum vs category and hid type selection during creation.
|
||||||
|
|
||||||
|
## 2025-12-26
|
||||||
|
- Replaced the Symfony backend with a fresh Laravel app in `api/`.
|
||||||
|
- Added Fortify + Sanctum, API routes, and controllers for forums/threads/posts/settings/version.
|
||||||
|
- Added Laravel migrations for forums, threads, posts, and settings (seeded with version/build/accent).
|
||||||
|
- Added Laravel i18n endpoint backed by JSON translation files.
|
||||||
|
- Updated frontend auth/register flow to work with Laravel tokens.
|
||||||
|
|
||||||
## 2025-12-24
|
## 2025-12-24
|
||||||
- Reworked the domain model into a single forum tree (category/forum types) with parent/child hierarchy and threads restricted to forum nodes.
|
- Reworked the domain model into a single forum tree (category/forum types) with parent/child hierarchy and threads restricted to forum nodes.
|
||||||
- Updated API Platform resources, filters, migrations, and JSON format support.
|
- Updated API Platform resources, filters, migrations, and JSON format support.
|
||||||
@@ -22,16 +89,3 @@
|
|||||||
- Improved ACP drag-and-drop hover reordering and visual drop target feedback.
|
- Improved ACP drag-and-drop hover reordering and visual drop target feedback.
|
||||||
- Hardened ACP access so admin tools require authentication.
|
- Hardened ACP access so admin tools require authentication.
|
||||||
- Updated the home page to render the forum tree with ACP-style rows and icons.
|
- Updated the home page to render the forum tree with ACP-style rows and icons.
|
||||||
|
|
||||||
## 2025-12-26
|
|
||||||
- Replaced the Symfony backend with a fresh Laravel app in `api/`.
|
|
||||||
- Added Fortify + Sanctum, API routes, and controllers for forums/threads/posts/settings/version.
|
|
||||||
- Added Laravel migrations for forums, threads, posts, and settings (seeded with version/build/accent).
|
|
||||||
- Added Laravel i18n endpoint backed by JSON translation files.
|
|
||||||
- Updated frontend auth/register flow to work with Laravel tokens.
|
|
||||||
|
|
||||||
## 2025-12-29
|
|
||||||
- Merged the React app into the Laravel codebase under `resources/js`.
|
|
||||||
- Moved the Laravel app to the repo root and removed the separate `api/` and `frontend/` folders.
|
|
||||||
- Serve the SPA shell via Blade with Vite-managed React assets.
|
|
||||||
- Dropped Tailwind tooling to keep the frontend Bootstrap-only.
|
|
||||||
|
|||||||
12
NOTES.md
Normal file
12
NOTES.md
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
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 PHP binary path configurable for updates if default PHP is outdated (ACP -> System).
|
||||||
60
README.md
60
README.md
@@ -1,59 +1,7 @@
|
|||||||
<p align="center"><a href="https://laravel.com" target="_blank"><img src="https://raw.githubusercontent.com/laravel/art/master/logo-lockup/5%20SVG/2%20CMYK/1%20Full%20Color/laravel-logolockup-cmyk-red.svg" width="400" alt="Laravel Logo"></a></p>
|
# SpeedBB Forum
|
||||||
|
|
||||||
<p align="center">
|
Placeholder README for the forum application.
|
||||||
<a href="https://github.com/laravel/framework/actions"><img src="https://github.com/laravel/framework/workflows/tests/badge.svg" alt="Build Status"></a>
|
|
||||||
<a href="https://packagist.org/packages/laravel/framework"><img src="https://img.shields.io/packagist/dt/laravel/framework" alt="Total Downloads"></a>
|
|
||||||
<a href="https://packagist.org/packages/laravel/framework"><img src="https://img.shields.io/packagist/v/laravel/framework" alt="Latest Stable Version"></a>
|
|
||||||
<a href="https://packagist.org/packages/laravel/framework"><img src="https://img.shields.io/packagist/l/laravel/framework" alt="License"></a>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
## About Laravel
|
## Status
|
||||||
|
|
||||||
Laravel is a web application framework with expressive, elegant syntax. We believe development must be an enjoyable and creative experience to be truly fulfilling. Laravel takes the pain out of development by easing common tasks used in many web projects, such as:
|
Work in progress.
|
||||||
|
|
||||||
- [Simple, fast routing engine](https://laravel.com/docs/routing).
|
|
||||||
- [Powerful dependency injection container](https://laravel.com/docs/container).
|
|
||||||
- Multiple back-ends for [session](https://laravel.com/docs/session) and [cache](https://laravel.com/docs/cache) storage.
|
|
||||||
- Expressive, intuitive [database ORM](https://laravel.com/docs/eloquent).
|
|
||||||
- Database agnostic [schema migrations](https://laravel.com/docs/migrations).
|
|
||||||
- [Robust background job processing](https://laravel.com/docs/queues).
|
|
||||||
- [Real-time event broadcasting](https://laravel.com/docs/broadcasting).
|
|
||||||
|
|
||||||
Laravel is accessible, powerful, and provides tools required for large, robust applications.
|
|
||||||
|
|
||||||
## Learning Laravel
|
|
||||||
|
|
||||||
Laravel has the most extensive and thorough [documentation](https://laravel.com/docs) and video tutorial library of all modern web application frameworks, making it a breeze to get started with the framework. You can also check out [Laravel Learn](https://laravel.com/learn), where you will be guided through building a modern Laravel application.
|
|
||||||
|
|
||||||
If you don't feel like reading, [Laracasts](https://laracasts.com) can help. Laracasts contains thousands of video tutorials on a range of topics including Laravel, modern PHP, unit testing, and JavaScript. Boost your skills by digging into our comprehensive video library.
|
|
||||||
|
|
||||||
## Laravel Sponsors
|
|
||||||
|
|
||||||
We would like to extend our thanks to the following sponsors for funding Laravel development. If you are interested in becoming a sponsor, please visit the [Laravel Partners program](https://partners.laravel.com).
|
|
||||||
|
|
||||||
### Premium Partners
|
|
||||||
|
|
||||||
- **[Vehikl](https://vehikl.com)**
|
|
||||||
- **[Tighten Co.](https://tighten.co)**
|
|
||||||
- **[Kirschbaum Development Group](https://kirschbaumdevelopment.com)**
|
|
||||||
- **[64 Robots](https://64robots.com)**
|
|
||||||
- **[Curotec](https://www.curotec.com/services/technologies/laravel)**
|
|
||||||
- **[DevSquad](https://devsquad.com/hire-laravel-developers)**
|
|
||||||
- **[Redberry](https://redberry.international/laravel-development)**
|
|
||||||
- **[Active Logic](https://activelogic.com)**
|
|
||||||
|
|
||||||
## Contributing
|
|
||||||
|
|
||||||
Thank you for considering contributing to the Laravel framework! The contribution guide can be found in the [Laravel documentation](https://laravel.com/docs/contributions).
|
|
||||||
|
|
||||||
## Code of Conduct
|
|
||||||
|
|
||||||
In order to ensure that the Laravel community is welcoming to all, please review and abide by the [Code of Conduct](https://laravel.com/docs/contributions#code-of-conduct).
|
|
||||||
|
|
||||||
## Security Vulnerabilities
|
|
||||||
|
|
||||||
If you discover a security vulnerability within Laravel, please send an e-mail to Taylor Otwell via [taylor@laravel.com](mailto:taylor@laravel.com). All security vulnerabilities will be promptly addressed.
|
|
||||||
|
|
||||||
## License
|
|
||||||
|
|
||||||
The Laravel framework is open-sourced software licensed under the [MIT license](https://opensource.org/licenses/MIT).
|
|
||||||
|
|||||||
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=
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
104
ansible/roles/speedBB/tasks/main.yaml
Normal file
104
ansible/roles/speedBB/tasks/main.yaml
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
---
|
||||||
|
- 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: Download and installs all libs and dependencies
|
||||||
|
community.general.composer:
|
||||||
|
command: install
|
||||||
|
arguments: --no-dev --optimize-autoloader
|
||||||
|
working_dir: "{{ prod_base_dir }}"
|
||||||
|
php_path: /usr/bin/keyhelp-php84
|
||||||
|
|
||||||
|
- 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 safely
|
||||||
|
command: "keyhelp-php84 artisan migrate:safe --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;
|
namespace App\Actions\Fortify;
|
||||||
|
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
use Illuminate\Support\Facades\Hash;
|
use Illuminate\Support\Facades\Hash;
|
||||||
use Illuminate\Support\Facades\Validator;
|
use Illuminate\Support\Facades\Validator;
|
||||||
use Illuminate\Validation\Rule;
|
use Illuminate\Validation\Rule;
|
||||||
@@ -19,22 +20,31 @@ class CreateNewUser implements CreatesNewUsers
|
|||||||
*/
|
*/
|
||||||
public function create(array $input): User
|
public function create(array $input): User
|
||||||
{
|
{
|
||||||
Validator::make($input, [
|
$input['name_canonical'] = Str::lower(trim($input['name'] ?? ''));
|
||||||
|
|
||||||
|
Validator::make(data: $input, rules: [
|
||||||
'name' => ['required', 'string', 'max:255'],
|
'name' => ['required', 'string', 'max:255'],
|
||||||
|
'name_canonical' => [
|
||||||
|
'required',
|
||||||
|
'string',
|
||||||
|
'max:255',
|
||||||
|
Rule::unique(table: User::class, column: 'name_canonical'),
|
||||||
|
],
|
||||||
'email' => [
|
'email' => [
|
||||||
'required',
|
'required',
|
||||||
'string',
|
'string',
|
||||||
'email',
|
'email',
|
||||||
'max:255',
|
'max:255',
|
||||||
Rule::unique(User::class),
|
Rule::unique(table: User::class),
|
||||||
],
|
],
|
||||||
'password' => $this->passwordRules(),
|
'password' => $this->passwordRules(),
|
||||||
])->validate();
|
])->validate();
|
||||||
|
|
||||||
return User::create([
|
return User::create(attributes: [
|
||||||
'name' => $input['name'],
|
'name' => $input['name'],
|
||||||
|
'name_canonical' => $input['name_canonical'],
|
||||||
'email' => $input['email'],
|
'email' => $input['email'],
|
||||||
'password' => Hash::make($input['password']),
|
'password' => Hash::make(value: $input['password']),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
namespace App\Actions\Fortify;
|
namespace App\Actions\Fortify;
|
||||||
|
|
||||||
|
use Illuminate\Contracts\Validation\ValidationRule;
|
||||||
use Illuminate\Validation\Rules\Password;
|
use Illuminate\Validation\Rules\Password;
|
||||||
|
|
||||||
trait PasswordValidationRules
|
trait PasswordValidationRules
|
||||||
@@ -9,7 +10,7 @@ trait PasswordValidationRules
|
|||||||
/**
|
/**
|
||||||
* Get the validation rules used to validate passwords.
|
* Get the validation rules used to validate passwords.
|
||||||
*
|
*
|
||||||
* @return array<int, \Illuminate\Contracts\Validation\Rule|array<mixed>|string>
|
* @return array<int, ValidationRule|array<mixed>|string>
|
||||||
*/
|
*/
|
||||||
protected function passwordRules(): array
|
protected function passwordRules(): array
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ namespace App\Actions\Fortify;
|
|||||||
|
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use Illuminate\Contracts\Auth\MustVerifyEmail;
|
use Illuminate\Contracts\Auth\MustVerifyEmail;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
use Illuminate\Support\Facades\Validator;
|
use Illuminate\Support\Facades\Validator;
|
||||||
use Illuminate\Validation\Rule;
|
use Illuminate\Validation\Rule;
|
||||||
use Laravel\Fortify\Contracts\UpdatesUserProfileInformation;
|
use Laravel\Fortify\Contracts\UpdatesUserProfileInformation;
|
||||||
@@ -17,8 +18,16 @@ class UpdateUserProfileInformation implements UpdatesUserProfileInformation
|
|||||||
*/
|
*/
|
||||||
public function update(User $user, array $input): void
|
public function update(User $user, array $input): void
|
||||||
{
|
{
|
||||||
|
$input['name_canonical'] = Str::lower(trim($input['name'] ?? ''));
|
||||||
|
|
||||||
Validator::make($input, [
|
Validator::make($input, [
|
||||||
'name' => ['required', 'string', 'max:255'],
|
'name' => ['required', 'string', 'max:255'],
|
||||||
|
'name_canonical' => [
|
||||||
|
'required',
|
||||||
|
'string',
|
||||||
|
'max:255',
|
||||||
|
Rule::unique('users', 'name_canonical')->ignore($user->id),
|
||||||
|
],
|
||||||
|
|
||||||
'email' => [
|
'email' => [
|
||||||
'required',
|
'required',
|
||||||
@@ -29,12 +38,12 @@ class UpdateUserProfileInformation implements UpdatesUserProfileInformation
|
|||||||
],
|
],
|
||||||
])->validateWithBag('updateProfileInformation');
|
])->validateWithBag('updateProfileInformation');
|
||||||
|
|
||||||
if ($input['email'] !== $user->email &&
|
if ($input['email'] !== $user->email && $user instanceof MustVerifyEmail) {
|
||||||
$user instanceof MustVerifyEmail) {
|
|
||||||
$this->updateVerifiedUser($user, $input);
|
$this->updateVerifiedUser($user, $input);
|
||||||
} else {
|
} else {
|
||||||
$user->forceFill([
|
$user->forceFill([
|
||||||
'name' => $input['name'],
|
'name' => $input['name'],
|
||||||
|
'name_canonical' => $input['name_canonical'],
|
||||||
'email' => $input['email'],
|
'email' => $input['email'],
|
||||||
])->save();
|
])->save();
|
||||||
}
|
}
|
||||||
@@ -49,6 +58,7 @@ class UpdateUserProfileInformation implements UpdatesUserProfileInformation
|
|||||||
{
|
{
|
||||||
$user->forceFill([
|
$user->forceFill([
|
||||||
'name' => $input['name'],
|
'name' => $input['name'],
|
||||||
|
'name_canonical' => $input['name_canonical'],
|
||||||
'email' => $input['email'],
|
'email' => $input['email'],
|
||||||
'email_verified_at' => null,
|
'email_verified_at' => null,
|
||||||
])->save();
|
])->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;
|
||||||
|
}
|
||||||
|
}
|
||||||
97
app/Console/Commands/VersionFetch.php
Normal file
97
app/Console/Commands/VersionFetch.php
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use App\Models\Setting;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
use Symfony\Component\Process\Process;
|
||||||
|
|
||||||
|
class VersionFetch extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'version:fetch';
|
||||||
|
|
||||||
|
protected $description = 'Update the build number based on the git commit count of master.';
|
||||||
|
|
||||||
|
public function handle(): int
|
||||||
|
{
|
||||||
|
$version = Setting::where('key', 'version')->value('value');
|
||||||
|
$build = $this->resolveBuildCount();
|
||||||
|
|
||||||
|
if ($version === null) {
|
||||||
|
$this->error('Unable to determine version from settings.');
|
||||||
|
return self::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($build === null) {
|
||||||
|
$this->error('Unable to determine build number from git.');
|
||||||
|
return self::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
Setting::updateOrCreate(
|
||||||
|
['key' => 'build'],
|
||||||
|
['value' => (string) $build],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!$this->syncComposerMetadata($version, $build)) {
|
||||||
|
$this->error('Failed to sync version/build to composer.json.');
|
||||||
|
return self::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->info("Build number updated to {$build}.");
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function resolveBuildCount(): ?int
|
||||||
|
{
|
||||||
|
$commands = [
|
||||||
|
['git', 'rev-list', '--count', 'master'],
|
||||||
|
['git', 'rev-list', '--count', 'HEAD'],
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach ($commands as $command) {
|
||||||
|
$process = new Process($command, base_path());
|
||||||
|
$process->run();
|
||||||
|
|
||||||
|
if ($process->isSuccessful()) {
|
||||||
|
$output = trim($process->getOutput());
|
||||||
|
if (is_numeric($output)) {
|
||||||
|
return (int) $output;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function syncComposerMetadata(string $version, int $build): bool
|
||||||
|
{
|
||||||
|
$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;
|
||||||
|
$data['build'] = (string) $build;
|
||||||
|
|
||||||
|
$encoded = json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
|
||||||
|
if ($encoded === false) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$encoded .= "\n";
|
||||||
|
|
||||||
|
return file_put_contents($composerPath, $encoded) !== false;
|
||||||
|
}
|
||||||
|
}
|
||||||
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;
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
use App\Actions\Fortify\CreateNewUser;
|
use App\Actions\Fortify\CreateNewUser;
|
||||||
|
use App\Actions\Fortify\PasswordValidationRules;
|
||||||
use App\Models\User;
|
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\JsonResponse;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\Hash;
|
use Illuminate\Support\Facades\Hash;
|
||||||
|
use Illuminate\Support\Facades\Password;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
use Illuminate\Validation\ValidationException;
|
use Illuminate\Validation\ValidationException;
|
||||||
|
|
||||||
class AuthController extends Controller
|
class AuthController extends Controller
|
||||||
{
|
{
|
||||||
|
use PasswordValidationRules;
|
||||||
|
|
||||||
public function register(Request $request, CreateNewUser $creator): JsonResponse
|
public function register(Request $request, CreateNewUser $creator): JsonResponse
|
||||||
{
|
{
|
||||||
$input = [
|
$input = [
|
||||||
'name' => $request->input('name') ?? $request->input('username'),
|
'name' => $request->input(key: 'name') ?? $request->input(key: 'username'),
|
||||||
'email' => $request->input('email'),
|
'email' => $request->input(key: 'email'),
|
||||||
'password' => $request->input('password') ?? $request->input('plainPassword'),
|
'password' => $request->input(key: 'password') ?? $request->input(key: 'plainPassword'),
|
||||||
'password_confirmation' => $request->input('password_confirmation') ?? $request->input('plainPassword'),
|
'password_confirmation' => $request->input(key: 'password_confirmation')
|
||||||
|
?? $request->input(key: 'plainPassword'),
|
||||||
];
|
];
|
||||||
|
|
||||||
$user = $creator->create($input);
|
$user = $creator->create(input: $input);
|
||||||
|
|
||||||
$user->sendEmailVerificationNotification();
|
$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,
|
'user_id' => $user->id,
|
||||||
'email' => $user->email,
|
'email' => $user->email,
|
||||||
'message' => 'Verification email sent.',
|
'message' => 'Verification email sent.',
|
||||||
@@ -33,39 +46,148 @@ class AuthController extends Controller
|
|||||||
|
|
||||||
public function login(Request $request): JsonResponse
|
public function login(Request $request): JsonResponse
|
||||||
{
|
{
|
||||||
$request->validate([
|
$request->merge(input: [
|
||||||
'email' => ['required', 'email'],
|
'login' => $request->input(key: 'login', default: $request->input(key: 'email')),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$request->validate(rules: [
|
||||||
|
'login' => ['required', 'string'],
|
||||||
'password' => ['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)) {
|
if (filter_var(value: $login, filter: FILTER_VALIDATE_EMAIL)) {
|
||||||
throw ValidationException::withMessages([
|
$userQuery->whereRaw(sql: 'lower(email) = ?', bindings: [$loginNormalized]);
|
||||||
'email' => ['Invalid credentials.'],
|
} 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()) {
|
if (!$user->hasVerifiedEmail()) {
|
||||||
return response()->json([
|
return response()->json(data : [
|
||||||
'message' => 'Email not verified.',
|
'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,
|
'token' => $token,
|
||||||
'user_id' => $user->id,
|
'user_id' => $user->id,
|
||||||
'email' => $user->email,
|
'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
|
public function logout(Request $request): JsonResponse
|
||||||
{
|
{
|
||||||
|
app(AuditLogger::class)->log($request, 'user.logout', $request->user());
|
||||||
$request->user()?->currentAccessToken()?->delete();
|
$request->user()?->currentAccessToken()?->delete();
|
||||||
|
|
||||||
return response()->json(null, 204);
|
return response()->json(data: null, status: 204);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,8 @@
|
|||||||
namespace App\Http\Controllers;
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
use App\Models\Forum;
|
use App\Models\Forum;
|
||||||
|
use App\Models\Post;
|
||||||
|
use App\Models\User;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Validation\Rule;
|
use Illuminate\Validation\Rule;
|
||||||
@@ -11,39 +13,51 @@ class ForumController extends Controller
|
|||||||
{
|
{
|
||||||
public function index(Request $request): JsonResponse
|
public function index(Request $request): JsonResponse
|
||||||
{
|
{
|
||||||
$query = Forum::query();
|
$query = Forum::query()
|
||||||
|
->withoutTrashed()
|
||||||
|
->withCount(relations: ['threads', 'posts'])
|
||||||
|
->withSum(relation: 'threads', column: 'views_count');
|
||||||
|
|
||||||
$parentParam = $request->query('parent');
|
$parentParam = $request->query(key: 'parent');
|
||||||
if (is_array($parentParam) && array_key_exists('exists', $parentParam)) {
|
if (is_array(value: $parentParam) && array_key_exists('exists', $parentParam)) {
|
||||||
$exists = filter_var($parentParam['exists'], FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE);
|
$exists = filter_var(value: $parentParam['exists'], filter: FILTER_VALIDATE_BOOLEAN, options: FILTER_NULL_ON_FAILURE);
|
||||||
if ($exists === false) {
|
if ($exists === false) {
|
||||||
$query->whereNull('parent_id');
|
$query->whereNull(columns: 'parent_id');
|
||||||
} elseif ($exists === true) {
|
} elseif ($exists === true) {
|
||||||
$query->whereNotNull('parent_id');
|
$query->whereNotNull(columns: 'parent_id');
|
||||||
}
|
}
|
||||||
} elseif (is_string($parentParam)) {
|
} elseif (is_string(value: $parentParam)) {
|
||||||
$parentId = $this->parseIriId($parentParam);
|
$parentId = $this->parseIriId(value: $parentParam);
|
||||||
if ($parentId !== null) {
|
if ($parentId !== null) {
|
||||||
$query->where('parent_id', $parentId);
|
$query->where(column: 'parent_id', operator: $parentId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($request->filled('type')) {
|
if ($request->filled(key: 'type')) {
|
||||||
$query->where('type', $request->query('type'));
|
$query->where(column: 'type', operator: $request->query(key: 'type'));
|
||||||
}
|
}
|
||||||
|
|
||||||
$forums = $query
|
$forums = $query
|
||||||
->orderBy('position')
|
->orderBy(column: 'position')
|
||||||
->orderBy('name')
|
->orderBy(column: 'name')
|
||||||
->get()
|
->get();
|
||||||
->map(fn (Forum $forum) => $this->serializeForum($forum));
|
|
||||||
|
|
||||||
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
|
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
|
public function store(Request $request): JsonResponse
|
||||||
@@ -57,6 +71,10 @@ class ForumController extends Controller
|
|||||||
|
|
||||||
$parentId = $this->parseIriId($data['parent'] ?? null);
|
$parentId = $this->parseIriId($data['parent'] ?? null);
|
||||||
|
|
||||||
|
if ($data['type'] === 'forum' && !$parentId) {
|
||||||
|
return response()->json(['message' => 'Forums must belong to a category.'], 422);
|
||||||
|
}
|
||||||
|
|
||||||
if ($parentId) {
|
if ($parentId) {
|
||||||
$parent = Forum::findOrFail($parentId);
|
$parent = Forum::findOrFail($parentId);
|
||||||
if ($parent->type !== 'category') {
|
if ($parent->type !== 'category') {
|
||||||
@@ -64,7 +82,12 @@ class ForumController extends Controller
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$position = Forum::where('parent_id', $parentId)->max('position');
|
if ($parentId === null) {
|
||||||
|
Forum::whereNull('parent_id')->increment('position');
|
||||||
|
$position = 0;
|
||||||
|
} else {
|
||||||
|
$position = Forum::where('parent_id', $parentId)->max('position');
|
||||||
|
}
|
||||||
|
|
||||||
$forum = Forum::create([
|
$forum = Forum::create([
|
||||||
'name' => $data['name'],
|
'name' => $data['name'],
|
||||||
@@ -74,7 +97,11 @@ class ForumController extends Controller
|
|||||||
'position' => ($position ?? 0) + 1,
|
'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
|
public function update(Request $request, Forum $forum): JsonResponse
|
||||||
@@ -87,6 +114,12 @@ class ForumController extends Controller
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
$parentId = $this->parseIriId($data['parent'] ?? null);
|
$parentId = $this->parseIriId($data['parent'] ?? null);
|
||||||
|
$nextType = $data['type'] ?? $forum->type;
|
||||||
|
$nextParentId = array_key_exists('parent', $data) ? $parentId : $forum->parent_id;
|
||||||
|
|
||||||
|
if ($nextType === 'forum' && !$nextParentId) {
|
||||||
|
return response()->json(['message' => 'Forums must belong to a category.'], 422);
|
||||||
|
}
|
||||||
|
|
||||||
if (array_key_exists('parent', $data)) {
|
if (array_key_exists('parent', $data)) {
|
||||||
if ($parentId) {
|
if ($parentId) {
|
||||||
@@ -112,11 +145,17 @@ class ForumController extends Controller
|
|||||||
|
|
||||||
$forum->save();
|
$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(Forum $forum): JsonResponse
|
public function destroy(Request $request, Forum $forum): JsonResponse
|
||||||
{
|
{
|
||||||
|
$forum->deleted_by = $request->user()?->id;
|
||||||
|
$forum->save();
|
||||||
$forum->delete();
|
$forum->delete();
|
||||||
|
|
||||||
return response()->json(null, 204);
|
return response()->json(null, 204);
|
||||||
@@ -163,7 +202,7 @@ class ForumController extends Controller
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private function serializeForum(Forum $forum): array
|
private function serializeForum(Forum $forum, ?Post $lastPost): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'id' => $forum->id,
|
'id' => $forum->id,
|
||||||
@@ -172,8 +211,76 @@ class ForumController extends Controller
|
|||||||
'type' => $forum->type,
|
'type' => $forum->type,
|
||||||
'parent' => $forum->parent_id ? "/api/forums/{$forum->parent_id}" : null,
|
'parent' => $forum->parent_id ? "/api/forums/{$forum->parent_id}" : null,
|
||||||
'position' => $forum->position,
|
'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(),
|
'created_at' => $forum->created_at?->toIso8601String(),
|
||||||
'updated_at' => $forum->updated_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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
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;
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Actions\BbcodeFormatter;
|
||||||
use App\Models\Post;
|
use App\Models\Post;
|
||||||
use App\Models\Thread;
|
use App\Models\Thread;
|
||||||
|
use App\Models\Setting;
|
||||||
|
use App\Services\AuditLogger;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
|
||||||
class PostController extends Controller
|
class PostController extends Controller
|
||||||
{
|
{
|
||||||
public function index(Request $request): JsonResponse
|
public function index(Request $request): JsonResponse
|
||||||
{
|
{
|
||||||
$query = Post::query();
|
$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');
|
$threadParam = $request->query('thread');
|
||||||
if (is_string($threadParam)) {
|
if (is_string($threadParam)) {
|
||||||
@@ -45,16 +55,72 @@ class PostController extends Controller
|
|||||||
'body' => $data['body'],
|
'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);
|
return response()->json($this->serializePost($post), 201);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function destroy(Post $post): JsonResponse
|
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();
|
$post->delete();
|
||||||
|
|
||||||
return response()->json(null, 204);
|
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
|
private function parseIriId(?string $value): ?int
|
||||||
{
|
{
|
||||||
if (!$value) {
|
if (!$value) {
|
||||||
@@ -74,13 +140,133 @@ class PostController extends Controller
|
|||||||
|
|
||||||
private function serializePost(Post $post): array
|
private function serializePost(Post $post): array
|
||||||
{
|
{
|
||||||
|
$attachments = $post->relationLoaded('attachments') ? $post->attachments : collect();
|
||||||
|
$bodyHtml = $this->renderBody($post->body, $attachments);
|
||||||
return [
|
return [
|
||||||
'id' => $post->id,
|
'id' => $post->id,
|
||||||
'body' => $post->body,
|
'body' => $post->body,
|
||||||
|
'body_html' => $bodyHtml,
|
||||||
'thread' => "/api/threads/{$post->thread_id}",
|
'thread' => "/api/threads/{$post->thread_id}",
|
||||||
'user_id' => $post->user_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(),
|
'created_at' => $post->created_at?->toIso8601String(),
|
||||||
'updated_at' => $post->updated_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}";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,4 +24,60 @@ class SettingController extends Controller
|
|||||||
|
|
||||||
return response()->json($settings);
|
return response()->json($settings);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function store(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$user = $request->user();
|
||||||
|
if (!$user || !$user->roles()->where('name', 'ROLE_ADMIN')->exists()) {
|
||||||
|
return response()->json(['message' => 'Forbidden'], 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = $request->validate([
|
||||||
|
'key' => ['required', 'string', 'max:191'],
|
||||||
|
'value' => ['nullable', 'string'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$value = $data['value'] ?? '';
|
||||||
|
|
||||||
|
$setting = Setting::updateOrCreate(
|
||||||
|
['key' => $data['key']],
|
||||||
|
['value' => $value]
|
||||||
|
);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'id' => $setting->id,
|
||||||
|
'key' => $setting->key,
|
||||||
|
'value' => $setting->value,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function bulkStore(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$user = $request->user();
|
||||||
|
if (!$user || !$user->roles()->where('name', 'ROLE_ADMIN')->exists()) {
|
||||||
|
return response()->json(['message' => 'Forbidden'], 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = $request->validate([
|
||||||
|
'settings' => ['required', 'array'],
|
||||||
|
'settings.*.key' => ['required', 'string', 'max:191'],
|
||||||
|
'settings.*.value' => ['nullable', 'string'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$updated = [];
|
||||||
|
|
||||||
|
foreach ($data['settings'] as $entry) {
|
||||||
|
$setting = Setting::updateOrCreate(
|
||||||
|
['key' => $entry['key']],
|
||||||
|
['value' => $entry['value'] ?? '']
|
||||||
|
);
|
||||||
|
$updated[] = [
|
||||||
|
'id' => $setting->id,
|
||||||
|
'key' => $setting->key,
|
||||||
|
'value' => $setting->value,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json($updated);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
155
app/Http/Controllers/SystemStatusController.php
Normal file
155
app/Http/Controllers/SystemStatusController.php
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
<?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');
|
||||||
|
$phpConfiguredPath = trim((string) Setting::where('key', 'system.php_binary')->value('value'));
|
||||||
|
$phpSelectedPath = $phpConfiguredPath ?: (PHP_BINARY ?: $phpDefaultPath);
|
||||||
|
$phpSelectedOk = (bool) $phpSelectedPath;
|
||||||
|
$phpSelectedVersion = $phpSelectedPath
|
||||||
|
? ($this->resolvePhpVersion($phpSelectedPath) ?? PHP_VERSION)
|
||||||
|
: PHP_VERSION;
|
||||||
|
$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_default' => $phpDefaultPath,
|
||||||
|
'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()),
|
||||||
|
'updates_writable' => is_writable(storage_path('app/updates')) || @mkdir(storage_path('app/updates'), 0755, true),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 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 : [];
|
||||||
|
}
|
||||||
|
}
|
||||||
203
app/Http/Controllers/SystemUpdateController.php
Normal file
203
app/Http/Controllers/SystemUpdateController.php
Normal file
@@ -0,0 +1,203 @@
|
|||||||
|
<?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 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'));
|
||||||
|
if ($rsyncPath !== '') {
|
||||||
|
$usedRsync = true;
|
||||||
|
$rsync = new Process([
|
||||||
|
'rsync',
|
||||||
|
'-a',
|
||||||
|
'--delete',
|
||||||
|
'--exclude=.env',
|
||||||
|
'--exclude=storage',
|
||||||
|
'--exclude=public/storage',
|
||||||
|
$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 {
|
||||||
|
File::copyDirectory($sourceDir, base_path());
|
||||||
|
}
|
||||||
|
|
||||||
|
$append('Installing composer dependencies...');
|
||||||
|
$composer = new Process(['composer', 'install', '--no-dev', '--optimize-autoloader'], base_path());
|
||||||
|
$composer->setTimeout(600);
|
||||||
|
$composer->run();
|
||||||
|
if (!$composer->isSuccessful()) {
|
||||||
|
return response()->json([
|
||||||
|
'message' => 'Composer install failed.',
|
||||||
|
'log' => array_merge($log, [$composer->getErrorOutput()]),
|
||||||
|
], 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
$append('Installing npm dependencies...');
|
||||||
|
$npmInstall = new Process(['npm', 'install'], base_path());
|
||||||
|
$npmInstall->setTimeout(600);
|
||||||
|
$npmInstall->run();
|
||||||
|
if (!$npmInstall->isSuccessful()) {
|
||||||
|
return response()->json([
|
||||||
|
'message' => 'npm install failed.',
|
||||||
|
'log' => array_merge($log, [$npmInstall->getErrorOutput()]),
|
||||||
|
], 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
$append('Building assets...');
|
||||||
|
$npmBuild = new Process(['npm', 'run', 'build'], base_path());
|
||||||
|
$npmBuild->setTimeout(900);
|
||||||
|
$npmBuild->run();
|
||||||
|
if (!$npmBuild->isSuccessful()) {
|
||||||
|
return response()->json([
|
||||||
|
'message' => 'npm run build failed.',
|
||||||
|
'log' => array_merge($log, [$npmBuild->getErrorOutput()]),
|
||||||
|
], 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
$phpBinary = trim((string) Setting::where('key', 'system.php_binary')->value('value'));
|
||||||
|
if ($phpBinary === '') {
|
||||||
|
$phpBinary = env('SYSTEM_UPDATE_PHP_BINARY') ?: (PHP_BINARY ?: 'php');
|
||||||
|
}
|
||||||
|
$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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,14 +4,29 @@ namespace App\Http\Controllers;
|
|||||||
|
|
||||||
use App\Models\Forum;
|
use App\Models\Forum;
|
||||||
use App\Models\Thread;
|
use App\Models\Thread;
|
||||||
|
use App\Actions\BbcodeFormatter;
|
||||||
|
use App\Models\Setting;
|
||||||
|
use App\Services\AuditLogger;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
|
||||||
class ThreadController extends Controller
|
class ThreadController extends Controller
|
||||||
{
|
{
|
||||||
public function index(Request $request): JsonResponse
|
public function index(Request $request): JsonResponse
|
||||||
{
|
{
|
||||||
$query = Thread::query();
|
$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');
|
$forumParam = $request->query('forum');
|
||||||
if (is_string($forumParam)) {
|
if (is_string($forumParam)) {
|
||||||
@@ -22,7 +37,7 @@ class ThreadController extends Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
$threads = $query
|
$threads = $query
|
||||||
->latest('created_at')
|
->orderByDesc(DB::raw('COALESCE(posts_max_created_at, threads.created_at)'))
|
||||||
->get()
|
->get()
|
||||||
->map(fn (Thread $thread) => $this->serializeThread($thread));
|
->map(fn (Thread $thread) => $this->serializeThread($thread));
|
||||||
|
|
||||||
@@ -31,6 +46,17 @@ class ThreadController extends Controller
|
|||||||
|
|
||||||
public function show(Thread $thread): JsonResponse
|
public function show(Thread $thread): JsonResponse
|
||||||
{
|
{
|
||||||
|
$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));
|
return response()->json($this->serializeThread($thread));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -56,16 +82,121 @@ class ThreadController extends Controller
|
|||||||
'body' => $data['body'],
|
'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);
|
return response()->json($this->serializeThread($thread), 201);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function destroy(Thread $thread): JsonResponse
|
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();
|
$thread->delete();
|
||||||
|
|
||||||
return response()->json(null, 204);
|
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
|
private function parseIriId(?string $value): ?int
|
||||||
{
|
{
|
||||||
if (!$value) {
|
if (!$value) {
|
||||||
@@ -85,14 +216,147 @@ class ThreadController extends Controller
|
|||||||
|
|
||||||
private function serializeThread(Thread $thread): array
|
private function serializeThread(Thread $thread): array
|
||||||
{
|
{
|
||||||
|
$attachments = $thread->relationLoaded('attachments') ? $thread->attachments : collect();
|
||||||
|
$bodyHtml = $this->renderBody($thread->body, $attachments);
|
||||||
return [
|
return [
|
||||||
'id' => $thread->id,
|
'id' => $thread->id,
|
||||||
'title' => $thread->title,
|
'title' => $thread->title,
|
||||||
'body' => $thread->body,
|
'body' => $thread->body,
|
||||||
|
'body_html' => $bodyHtml,
|
||||||
|
'solved' => (bool) $thread->solved,
|
||||||
'forum' => "/api/forums/{$thread->forum_id}",
|
'forum' => "/api/forums/{$thread->forum_id}",
|
||||||
'user_id' => $thread->user_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(),
|
'created_at' => $thread->created_at?->toIso8601String(),
|
||||||
'updated_at' => $thread->updated_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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
79
app/Http/Controllers/UploadController.php
Normal file
79
app/Http/Controllers/UploadController.php
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
|
||||||
|
class UploadController extends Controller
|
||||||
|
{
|
||||||
|
public function storeAvatar(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$user = $request->user();
|
||||||
|
if (!$user) {
|
||||||
|
return response()->json(['message' => 'Unauthorized'], 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
$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);
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = $request->validate([
|
||||||
|
'file' => ['required', 'file', 'mimes:jpg,jpeg,png,gif,webp,svg,ico', 'max:5120'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$path = $data['file']->store('logos', 'public');
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'path' => $path,
|
||||||
|
'url' => Storage::url($path),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function storeFavicon(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$user = $request->user();
|
||||||
|
if (!$user || !$user->roles()->where('name', 'ROLE_ADMIN')->exists()) {
|
||||||
|
return response()->json(['message' => 'Forbidden'], 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = $request->validate([
|
||||||
|
'file' => ['required', 'file', 'mimes:png,ico', 'max:2048'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$path = $data['file']->store('favicons', 'public');
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'path' => $path,
|
||||||
|
'url' => Storage::url($path),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,24 +2,276 @@
|
|||||||
|
|
||||||
namespace App\Http\Controllers;
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Models\Role;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use Illuminate\Http\JsonResponse;
|
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
|
class UserController extends Controller
|
||||||
{
|
{
|
||||||
public function index(): JsonResponse
|
public function index(): JsonResponse
|
||||||
{
|
{
|
||||||
$users = User::query()
|
$users = User::query()
|
||||||
->with('roles')
|
->with(['roles', 'rank'])
|
||||||
->orderBy('id')
|
->orderBy('id')
|
||||||
->get()
|
->get()
|
||||||
->map(fn (User $user) => [
|
->map(fn (User $user) => [
|
||||||
'id' => $user->id,
|
'id' => $user->id,
|
||||||
'name' => $user->name,
|
'name' => $user->name,
|
||||||
'email' => $user->email,
|
'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(),
|
'roles' => $user->roles->pluck('name')->values(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return response()->json($users);
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
$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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
47
app/Http/Controllers/UserSettingController.php
Normal file
47
app/Http/Controllers/UserSettingController.php
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Models\UserSetting;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
class UserSettingController extends Controller
|
||||||
|
{
|
||||||
|
public function index(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$user = $request->user();
|
||||||
|
$query = UserSetting::query()->where('user_id', $user->id);
|
||||||
|
|
||||||
|
if ($request->filled('key')) {
|
||||||
|
$query->where('key', $request->query('key'));
|
||||||
|
}
|
||||||
|
|
||||||
|
$settings = $query->get()->map(fn (UserSetting $setting) => [
|
||||||
|
'id' => $setting->id,
|
||||||
|
'key' => $setting->key,
|
||||||
|
'value' => $setting->value,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return response()->json($settings);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function store(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$data = $request->validate([
|
||||||
|
'key' => ['required', 'string', 'max:191'],
|
||||||
|
'value' => ['nullable', 'array'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$setting = UserSetting::updateOrCreate(
|
||||||
|
['user_id' => $request->user()->id, 'key' => $data['key']],
|
||||||
|
['value' => $data['value'] ?? []]
|
||||||
|
);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'id' => $setting->id,
|
||||||
|
'key' => $setting->key,
|
||||||
|
'value' => $setting->value,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
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.',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
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,7 +4,11 @@ namespace App\Models;
|
|||||||
|
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
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\Model;
|
||||||
|
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @property int $id
|
* @property int $id
|
||||||
@@ -35,6 +39,8 @@ use Illuminate\Database\Eloquent\Model;
|
|||||||
*/
|
*/
|
||||||
class Forum extends Model
|
class Forum extends Model
|
||||||
{
|
{
|
||||||
|
use SoftDeletes;
|
||||||
|
|
||||||
protected $fillable = [
|
protected $fillable = [
|
||||||
'name',
|
'name',
|
||||||
'description',
|
'description',
|
||||||
@@ -57,4 +63,20 @@ class Forum extends Model
|
|||||||
{
|
{
|
||||||
return $this->hasMany(Thread::class);
|
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,7 +3,9 @@
|
|||||||
namespace App\Models;
|
namespace App\Models;
|
||||||
|
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @property int $id
|
* @property int $id
|
||||||
@@ -12,6 +14,7 @@ use Illuminate\Database\Eloquent\Model;
|
|||||||
* @property string $body
|
* @property string $body
|
||||||
* @property \Illuminate\Support\Carbon|null $created_at
|
* @property \Illuminate\Support\Carbon|null $created_at
|
||||||
* @property \Illuminate\Support\Carbon|null $updated_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\Thread $thread
|
||||||
* @property-read \App\Models\User|null $user
|
* @property-read \App\Models\User|null $user
|
||||||
* @method static \Illuminate\Database\Eloquent\Builder<static>|Post newModelQuery()
|
* @method static \Illuminate\Database\Eloquent\Builder<static>|Post newModelQuery()
|
||||||
@@ -27,6 +30,8 @@ use Illuminate\Database\Eloquent\Model;
|
|||||||
*/
|
*/
|
||||||
class Post extends Model
|
class Post extends Model
|
||||||
{
|
{
|
||||||
|
use SoftDeletes;
|
||||||
|
|
||||||
protected $fillable = [
|
protected $fillable = [
|
||||||
'thread_id',
|
'thread_id',
|
||||||
'user_id',
|
'user_id',
|
||||||
@@ -42,4 +47,14 @@ class Post extends Model
|
|||||||
{
|
{
|
||||||
return $this->belongsTo(User::class);
|
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 = [
|
protected $fillable = [
|
||||||
'name',
|
'name',
|
||||||
|
'color',
|
||||||
];
|
];
|
||||||
|
|
||||||
public function users(): BelongsToMany
|
public function users(): BelongsToMany
|
||||||
|
|||||||
@@ -4,7 +4,9 @@ namespace App\Models;
|
|||||||
|
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasOne;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @property int $id
|
* @property int $id
|
||||||
@@ -12,9 +14,11 @@ use Illuminate\Database\Eloquent\Model;
|
|||||||
* @property int|null $user_id
|
* @property int|null $user_id
|
||||||
* @property string $title
|
* @property string $title
|
||||||
* @property string $body
|
* @property string $body
|
||||||
|
* @property bool $solved
|
||||||
* @property \Illuminate\Support\Carbon|null $created_at
|
* @property \Illuminate\Support\Carbon|null $created_at
|
||||||
* @property \Illuminate\Support\Carbon|null $updated_at
|
* @property \Illuminate\Support\Carbon|null $updated_at
|
||||||
* @property-read \App\Models\Forum $forum
|
* @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 \Illuminate\Database\Eloquent\Collection<int, \App\Models\Post> $posts
|
||||||
* @property-read int|null $posts_count
|
* @property-read int|null $posts_count
|
||||||
* @property-read \App\Models\User|null $user
|
* @property-read \App\Models\User|null $user
|
||||||
@@ -32,11 +36,18 @@ use Illuminate\Database\Eloquent\Model;
|
|||||||
*/
|
*/
|
||||||
class Thread extends Model
|
class Thread extends Model
|
||||||
{
|
{
|
||||||
|
use SoftDeletes;
|
||||||
|
|
||||||
protected $fillable = [
|
protected $fillable = [
|
||||||
'forum_id',
|
'forum_id',
|
||||||
'user_id',
|
'user_id',
|
||||||
'title',
|
'title',
|
||||||
'body',
|
'body',
|
||||||
|
'solved',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'solved' => 'bool',
|
||||||
];
|
];
|
||||||
|
|
||||||
public function forum(): BelongsTo
|
public function forum(): BelongsTo
|
||||||
@@ -53,4 +64,14 @@ class Thread extends Model
|
|||||||
{
|
{
|
||||||
return $this->hasMany(Post::class);
|
return $this->hasMany(Post::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function attachments(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(Attachment::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function latestPost(): HasOne
|
||||||
|
{
|
||||||
|
return $this->hasOne(Post::class)->latestOfMany();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,32 +2,40 @@
|
|||||||
|
|
||||||
namespace App\Models;
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Database\Factories\UserFactory;
|
||||||
use Illuminate\Contracts\Auth\MustVerifyEmail;
|
use Illuminate\Contracts\Auth\MustVerifyEmail;
|
||||||
|
use Illuminate\Database\Eloquent\Collection;
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasManyThrough;
|
||||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||||
|
use Illuminate\Notifications\DatabaseNotification;
|
||||||
|
use Illuminate\Notifications\DatabaseNotificationCollection;
|
||||||
use Illuminate\Notifications\Notifiable;
|
use Illuminate\Notifications\Notifiable;
|
||||||
|
use Illuminate\Support\Carbon;
|
||||||
use Laravel\Sanctum\HasApiTokens;
|
use Laravel\Sanctum\HasApiTokens;
|
||||||
|
use Laravel\Sanctum\PersonalAccessToken;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @property int $id
|
* @property int $id
|
||||||
* @property string $name
|
* @property string $name
|
||||||
* @property string $email
|
* @property string $email
|
||||||
* @property \Illuminate\Support\Carbon|null $email_verified_at
|
* @property Carbon|null $email_verified_at
|
||||||
* @property string $password
|
* @property string $password
|
||||||
* @property string|null $two_factor_secret
|
* @property string|null $two_factor_secret
|
||||||
* @property string|null $two_factor_recovery_codes
|
* @property string|null $two_factor_recovery_codes
|
||||||
* @property string|null $two_factor_confirmed_at
|
* @property string|null $two_factor_confirmed_at
|
||||||
* @property string|null $remember_token
|
* @property string|null $remember_token
|
||||||
* @property \Illuminate\Support\Carbon|null $created_at
|
* @property Carbon|null $created_at
|
||||||
* @property \Illuminate\Support\Carbon|null $updated_at
|
* @property Carbon|null $updated_at
|
||||||
* @property-read \Illuminate\Notifications\DatabaseNotificationCollection<int, \Illuminate\Notifications\DatabaseNotification> $notifications
|
* @property-read DatabaseNotificationCollection<int, DatabaseNotification> $notifications
|
||||||
* @property-read int|null $notifications_count
|
* @property-read int|null $notifications_count
|
||||||
* @property-read \Illuminate\Database\Eloquent\Collection<int, \App\Models\Role> $roles
|
* @property-read Collection<int, Role> $roles
|
||||||
* @property-read int|null $roles_count
|
* @property-read int|null $roles_count
|
||||||
* @property-read \Illuminate\Database\Eloquent\Collection<int, \Laravel\Sanctum\PersonalAccessToken> $tokens
|
* @property-read Collection<int, PersonalAccessToken> $tokens
|
||||||
* @property-read int|null $tokens_count
|
* @property-read int|null $tokens_count
|
||||||
* @method static \Database\Factories\UserFactory factory($count = null, $state = [])
|
* @method static UserFactory factory($count = null, $state = [])
|
||||||
* @method static \Illuminate\Database\Eloquent\Builder<static>|User newModelQuery()
|
* @method static \Illuminate\Database\Eloquent\Builder<static>|User newModelQuery()
|
||||||
* @method static \Illuminate\Database\Eloquent\Builder<static>|User newQuery()
|
* @method static \Illuminate\Database\Eloquent\Builder<static>|User newQuery()
|
||||||
* @method static \Illuminate\Database\Eloquent\Builder<static>|User query()
|
* @method static \Illuminate\Database\Eloquent\Builder<static>|User query()
|
||||||
@@ -46,8 +54,10 @@ use Laravel\Sanctum\HasApiTokens;
|
|||||||
*/
|
*/
|
||||||
class User extends Authenticatable implements MustVerifyEmail
|
class User extends Authenticatable implements MustVerifyEmail
|
||||||
{
|
{
|
||||||
/** @use HasFactory<\Database\Factories\UserFactory> */
|
/** @use HasFactory<UserFactory> */
|
||||||
use HasApiTokens, HasFactory, Notifiable;
|
use HasApiTokens;
|
||||||
|
use HasFactory;
|
||||||
|
use Notifiable;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The attributes that are mass assignable.
|
* The attributes that are mass assignable.
|
||||||
@@ -56,6 +66,10 @@ class User extends Authenticatable implements MustVerifyEmail
|
|||||||
*/
|
*/
|
||||||
protected $fillable = [
|
protected $fillable = [
|
||||||
'name',
|
'name',
|
||||||
|
'name_canonical',
|
||||||
|
'avatar_path',
|
||||||
|
'location',
|
||||||
|
'rank_id',
|
||||||
'email',
|
'email',
|
||||||
'password',
|
'password',
|
||||||
];
|
];
|
||||||
@@ -87,4 +101,29 @@ class User extends Authenticatable implements MustVerifyEmail
|
|||||||
{
|
{
|
||||||
return $this->belongsToMany(Role::class);
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
18
app/Models/UserSetting.php
Normal file
18
app/Models/UserSetting.php
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
|
||||||
|
class UserSetting extends Model
|
||||||
|
{
|
||||||
|
protected $fillable = [
|
||||||
|
'user_id',
|
||||||
|
'key',
|
||||||
|
'value',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'value' => 'array',
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
namespace App\Providers;
|
namespace App\Providers;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Support\ServiceProvider;
|
use Illuminate\Support\ServiceProvider;
|
||||||
|
|
||||||
class AppServiceProvider extends ServiceProvider
|
class AppServiceProvider extends ServiceProvider
|
||||||
@@ -19,6 +20,6 @@ class AppServiceProvider extends ServiceProvider
|
|||||||
*/
|
*/
|
||||||
public function boot(): void
|
public function boot(): void
|
||||||
{
|
{
|
||||||
//
|
Model::preventLazyLoading(true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ class FortifyServiceProvider extends ServiceProvider
|
|||||||
Fortify::redirectUserForTwoFactorAuthenticationUsing(RedirectIfTwoFactorAuthenticatable::class);
|
Fortify::redirectUserForTwoFactorAuthenticationUsing(RedirectIfTwoFactorAuthenticatable::class);
|
||||||
|
|
||||||
RateLimiter::for('login', function (Request $request) {
|
RateLimiter::for('login', function (Request $request) {
|
||||||
$throttleKey = Str::transliterate(Str::lower($request->input(Fortify::username())).'|'.$request->ip());
|
$throttleKey = Str::transliterate(Str::lower($request->input(Fortify::username())) . '|' . $request->ip());
|
||||||
|
|
||||||
return Limit::perMinute(5)->by($throttleKey);
|
return Limit::perMinute(5)->by($throttleKey);
|
||||||
});
|
});
|
||||||
|
|||||||
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
|
#!/usr/bin/env php
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
use Illuminate\Foundation\Application;
|
|
||||||
use Symfony\Component\Console\Input\ArgvInput;
|
|
||||||
|
|
||||||
define('LARAVEL_START', microtime(true));
|
define('LARAVEL_START', microtime(true));
|
||||||
|
|
||||||
// Register the Composer autoloader...
|
|
||||||
require __DIR__.'/vendor/autoload.php';
|
require __DIR__.'/vendor/autoload.php';
|
||||||
|
|
||||||
// Bootstrap Laravel and handle the command...
|
|
||||||
/** @var Application $app */
|
|
||||||
$app = require_once __DIR__.'/bootstrap/app.php';
|
$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);
|
exit($status);
|
||||||
|
|||||||
@@ -11,6 +11,9 @@ return Application::configure(basePath: dirname(__DIR__))
|
|||||||
commands: __DIR__.'/../routes/console.php',
|
commands: __DIR__.'/../routes/console.php',
|
||||||
health: '/up',
|
health: '/up',
|
||||||
)
|
)
|
||||||
|
->withCommands([
|
||||||
|
__DIR__.'/../app/Console/Commands',
|
||||||
|
])
|
||||||
->withMiddleware(function (Middleware $middleware): void {
|
->withMiddleware(function (Middleware $middleware): void {
|
||||||
//
|
//
|
||||||
})
|
})
|
||||||
|
|||||||
7
bootstrap/cache/packages.php
vendored
7
bootstrap/cache/packages.php
vendored
@@ -62,4 +62,11 @@
|
|||||||
0 => 'Termwind\\Laravel\\TermwindServiceProvider',
|
0 => 'Termwind\\Laravel\\TermwindServiceProvider',
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
'pestphp/pest-plugin-laravel' =>
|
||||||
|
array (
|
||||||
|
'providers' =>
|
||||||
|
array (
|
||||||
|
0 => 'Pest\\Laravel\\PestServiceProvider',
|
||||||
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
10
bootstrap/cache/services.php
vendored
10
bootstrap/cache/services.php
vendored
@@ -33,8 +33,9 @@
|
|||||||
29 => 'Carbon\\Laravel\\ServiceProvider',
|
29 => 'Carbon\\Laravel\\ServiceProvider',
|
||||||
30 => 'NunoMaduro\\Collision\\Adapters\\Laravel\\CollisionServiceProvider',
|
30 => 'NunoMaduro\\Collision\\Adapters\\Laravel\\CollisionServiceProvider',
|
||||||
31 => 'Termwind\\Laravel\\TermwindServiceProvider',
|
31 => 'Termwind\\Laravel\\TermwindServiceProvider',
|
||||||
32 => 'App\\Providers\\AppServiceProvider',
|
32 => 'Pest\\Laravel\\PestServiceProvider',
|
||||||
33 => 'App\\Providers\\FortifyServiceProvider',
|
33 => 'App\\Providers\\AppServiceProvider',
|
||||||
|
34 => 'App\\Providers\\FortifyServiceProvider',
|
||||||
),
|
),
|
||||||
'eager' =>
|
'eager' =>
|
||||||
array (
|
array (
|
||||||
@@ -54,8 +55,9 @@
|
|||||||
13 => 'Carbon\\Laravel\\ServiceProvider',
|
13 => 'Carbon\\Laravel\\ServiceProvider',
|
||||||
14 => 'NunoMaduro\\Collision\\Adapters\\Laravel\\CollisionServiceProvider',
|
14 => 'NunoMaduro\\Collision\\Adapters\\Laravel\\CollisionServiceProvider',
|
||||||
15 => 'Termwind\\Laravel\\TermwindServiceProvider',
|
15 => 'Termwind\\Laravel\\TermwindServiceProvider',
|
||||||
16 => 'App\\Providers\\AppServiceProvider',
|
16 => 'Pest\\Laravel\\PestServiceProvider',
|
||||||
17 => 'App\\Providers\\FortifyServiceProvider',
|
17 => 'App\\Providers\\AppServiceProvider',
|
||||||
|
18 => 'App\\Providers\\FortifyServiceProvider',
|
||||||
),
|
),
|
||||||
'deferred' =>
|
'deferred' =>
|
||||||
array (
|
array (
|
||||||
|
|||||||
@@ -3,14 +3,20 @@
|
|||||||
"name": "laravel/laravel",
|
"name": "laravel/laravel",
|
||||||
"type": "project",
|
"type": "project",
|
||||||
"description": "The skeleton application for the Laravel framework.",
|
"description": "The skeleton application for the Laravel framework.",
|
||||||
"keywords": ["laravel", "framework"],
|
"keywords": [
|
||||||
|
"laravel",
|
||||||
|
"framework"
|
||||||
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"require": {
|
"require": {
|
||||||
"php": "^8.2",
|
"php": "^8.4",
|
||||||
"laravel/fortify": "*",
|
"laravel/fortify": "*",
|
||||||
"laravel/framework": "^12.0",
|
"laravel/framework": "^12.0",
|
||||||
"laravel/sanctum": "*",
|
"laravel/sanctum": "*",
|
||||||
"laravel/tinker": "^2.10.1"
|
"laravel/tinker": "^2.10.1",
|
||||||
|
"s9e/text-formatter": "^2.5",
|
||||||
|
"composer-runtime-api": "^2.2",
|
||||||
|
"ext-pdo": "*"
|
||||||
},
|
},
|
||||||
"require-dev": {
|
"require-dev": {
|
||||||
"barryvdh/laravel-ide-helper": "^3.6",
|
"barryvdh/laravel-ide-helper": "^3.6",
|
||||||
@@ -20,7 +26,10 @@
|
|||||||
"laravel/sail": "^1.41",
|
"laravel/sail": "^1.41",
|
||||||
"mockery/mockery": "^1.6",
|
"mockery/mockery": "^1.6",
|
||||||
"nunomaduro/collision": "^8.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": {
|
"autoload": {
|
||||||
"psr-4": {
|
"psr-4": {
|
||||||
@@ -35,6 +44,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
"phpcs": "phpcs",
|
||||||
"setup": [
|
"setup": [
|
||||||
"composer install",
|
"composer install",
|
||||||
"@php -r \"file_exists('.env') || copy('.env.example', '.env');\"",
|
"@php -r \"file_exists('.env') || copy('.env.example', '.env');\"",
|
||||||
@@ -51,6 +61,7 @@
|
|||||||
"@php artisan config:clear --ansi",
|
"@php artisan config:clear --ansi",
|
||||||
"@php artisan test"
|
"@php artisan test"
|
||||||
],
|
],
|
||||||
|
"test:coverage": "./vendor/bin/pest --coverage",
|
||||||
"post-autoload-dump": [
|
"post-autoload-dump": [
|
||||||
"Illuminate\\Foundation\\ComposerScripts::postAutoloadDump",
|
"Illuminate\\Foundation\\ComposerScripts::postAutoloadDump",
|
||||||
"@php artisan package:discover --ansi"
|
"@php artisan package:discover --ansi"
|
||||||
@@ -85,5 +96,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"minimum-stability": "stable",
|
"minimum-stability": "stable",
|
||||||
"prefer-stable": true
|
"prefer-stable": true,
|
||||||
|
"version": "26.0.2",
|
||||||
|
"build": "56"
|
||||||
}
|
}
|
||||||
|
|||||||
2392
composer.lock
generated
2392
composer.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -112,6 +112,6 @@ return [
|
|||||||
|
|
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
'prefix' => env('CACHE_PREFIX', Str::slug((string) env('APP_NAME', 'laravel')).'-cache-'),
|
'prefix' => env('CACHE_PREFIX', Str::slug((string) env('APP_NAME', 'laravel')) . '-cache-'),
|
||||||
|
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -59,7 +59,9 @@ return [
|
|||||||
'strict' => true,
|
'strict' => true,
|
||||||
'engine' => null,
|
'engine' => null,
|
||||||
'options' => extension_loaded('pdo_mysql') ? array_filter([
|
'options' => extension_loaded('pdo_mysql') ? array_filter([
|
||||||
(PHP_VERSION_ID >= 80500 ? \Pdo\Mysql::ATTR_SSL_CA : \PDO::MYSQL_ATTR_SSL_CA) => env('MYSQL_ATTR_SSL_CA'),
|
(PHP_VERSION_ID >= 80500 ?
|
||||||
|
\Pdo\Mysql::ATTR_SSL_CA :
|
||||||
|
\PDO::MYSQL_ATTR_SSL_CA) => env('MYSQL_ATTR_SSL_CA'),
|
||||||
]) : [],
|
]) : [],
|
||||||
],
|
],
|
||||||
|
|
||||||
@@ -79,7 +81,9 @@ return [
|
|||||||
'strict' => true,
|
'strict' => true,
|
||||||
'engine' => null,
|
'engine' => null,
|
||||||
'options' => extension_loaded('pdo_mysql') ? array_filter([
|
'options' => extension_loaded('pdo_mysql') ? array_filter([
|
||||||
(PHP_VERSION_ID >= 80500 ? \Pdo\Mysql::ATTR_SSL_CA : \PDO::MYSQL_ATTR_SSL_CA) => env('MYSQL_ATTR_SSL_CA'),
|
(PHP_VERSION_ID >= 80500 ?
|
||||||
|
\Pdo\Mysql::ATTR_SSL_CA :
|
||||||
|
\PDO::MYSQL_ATTR_SSL_CA) => env('MYSQL_ATTR_SSL_CA'),
|
||||||
]) : [],
|
]) : [],
|
||||||
],
|
],
|
||||||
|
|
||||||
@@ -148,7 +152,7 @@ return [
|
|||||||
|
|
||||||
'options' => [
|
'options' => [
|
||||||
'cluster' => env('REDIS_CLUSTER', 'redis'),
|
'cluster' => env('REDIS_CLUSTER', 'redis'),
|
||||||
'prefix' => env('REDIS_PREFIX', Str::slug((string) env('APP_NAME', 'laravel')).'-database-'),
|
'prefix' => env('REDIS_PREFIX', Str::slug((string) env('APP_NAME', 'laravel')) . '-database-'),
|
||||||
'persistent' => env('REDIS_PERSISTENT', false),
|
'persistent' => env('REDIS_PERSISTENT', false),
|
||||||
],
|
],
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ return [
|
|||||||
|
|
|
|
||||||
| Here you may specify the default filesystem disk that should be used
|
| Here you may specify the default filesystem disk that should be used
|
||||||
| by the framework. The "local" disk, as well as a variety of cloud
|
| by the framework. The "local" disk, as well as a variety of cloud
|
||||||
| based disks are available to your application for file storage.
|
| based disks, are available to your application for file storage.
|
||||||
|
|
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -41,7 +41,7 @@ return [
|
|||||||
'public' => [
|
'public' => [
|
||||||
'driver' => 'local',
|
'driver' => 'local',
|
||||||
'root' => storage_path('app/public'),
|
'root' => storage_path('app/public'),
|
||||||
'url' => env('APP_URL').'/storage',
|
'url' => env('APP_URL') . '/storage',
|
||||||
'visibility' => 'public',
|
'visibility' => 'public',
|
||||||
'throw' => false,
|
'throw' => false,
|
||||||
'report' => false,
|
'report' => false,
|
||||||
|
|||||||
@@ -89,7 +89,7 @@ return [
|
|||||||
'handler_with' => [
|
'handler_with' => [
|
||||||
'host' => env('PAPERTRAIL_URL'),
|
'host' => env('PAPERTRAIL_URL'),
|
||||||
'port' => env('PAPERTRAIL_PORT'),
|
'port' => env('PAPERTRAIL_PORT'),
|
||||||
'connectionString' => 'tls://'.env('PAPERTRAIL_URL').':'.env('PAPERTRAIL_PORT'),
|
'connectionString' => 'tls://' . env('PAPERTRAIL_URL') . ':' . env('PAPERTRAIL_PORT'),
|
||||||
],
|
],
|
||||||
'processors' => [PsrLogMessageProcessor::class],
|
'processors' => [PsrLogMessageProcessor::class],
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -46,7 +46,10 @@ return [
|
|||||||
'username' => env('MAIL_USERNAME'),
|
'username' => env('MAIL_USERNAME'),
|
||||||
'password' => env('MAIL_PASSWORD'),
|
'password' => env('MAIL_PASSWORD'),
|
||||||
'timeout' => null,
|
'timeout' => null,
|
||||||
'local_domain' => env('MAIL_EHLO_DOMAIN', parse_url((string) env('APP_URL', 'http://localhost'), PHP_URL_HOST)),
|
'local_domain' => env(
|
||||||
|
'MAIL_EHLO_DOMAIN',
|
||||||
|
parse_url(url: (string) env('APP_URL', 'http://localhost'), component: PHP_URL_HOST)
|
||||||
|
),
|
||||||
],
|
],
|
||||||
|
|
||||||
'ses' => [
|
'ses' => [
|
||||||
|
|||||||
@@ -129,7 +129,7 @@ return [
|
|||||||
|
|
||||||
'cookie' => env(
|
'cookie' => env(
|
||||||
'SESSION_COOKIE',
|
'SESSION_COOKIE',
|
||||||
Str::slug((string) env('APP_NAME', 'laravel')).'-session'
|
Str::slug((string) env('APP_NAME', 'laravel')) . '-session'
|
||||||
),
|
),
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|
|||||||
@@ -23,8 +23,11 @@ class UserFactory extends Factory
|
|||||||
*/
|
*/
|
||||||
public function definition(): array
|
public function definition(): array
|
||||||
{
|
{
|
||||||
|
$name = fake()->unique()->userName();
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'name' => fake()->name(),
|
'name' => $name,
|
||||||
|
'name_canonical' => Str::lower($name),
|
||||||
'email' => fake()->unique()->safeEmail(),
|
'email' => fake()->unique()->safeEmail(),
|
||||||
'email_verified_at' => now(),
|
'email_verified_at' => now(),
|
||||||
'password' => static::$password ??= Hash::make('password'),
|
'password' => static::$password ??= Hash::make('password'),
|
||||||
|
|||||||
@@ -36,12 +36,6 @@ return new class extends Migration
|
|||||||
'created_at' => now(),
|
'created_at' => now(),
|
||||||
'updated_at' => now(),
|
'updated_at' => now(),
|
||||||
],
|
],
|
||||||
[
|
|
||||||
'key' => 'accent_color',
|
|
||||||
'value' => '#f29b3f',
|
|
||||||
'created_at' => now(),
|
|
||||||
'updated_at' => now(),
|
|
||||||
],
|
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
<?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('forums', function (Blueprint $table) {
|
||||||
|
$table->softDeletes();
|
||||||
|
$table->foreignId('deleted_by')->nullable()->constrained('users')->nullOnDelete();
|
||||||
|
$table->index(['deleted_at', 'deleted_by'], 'idx_forums_deleted_meta');
|
||||||
|
});
|
||||||
|
|
||||||
|
Schema::table('threads', function (Blueprint $table) {
|
||||||
|
$table->softDeletes();
|
||||||
|
$table->foreignId('deleted_by')->nullable()->constrained('users')->nullOnDelete();
|
||||||
|
$table->index(['deleted_at', 'deleted_by'], 'idx_threads_deleted_meta');
|
||||||
|
});
|
||||||
|
|
||||||
|
Schema::table('posts', function (Blueprint $table) {
|
||||||
|
$table->softDeletes();
|
||||||
|
$table->foreignId('deleted_by')->nullable()->constrained('users')->nullOnDelete();
|
||||||
|
$table->index(['deleted_at', 'deleted_by'], 'idx_posts_deleted_meta');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('forums', function (Blueprint $table) {
|
||||||
|
$table->dropIndex('idx_forums_deleted_meta');
|
||||||
|
$table->dropConstrainedForeignId('deleted_by');
|
||||||
|
$table->dropSoftDeletes();
|
||||||
|
});
|
||||||
|
|
||||||
|
Schema::table('threads', function (Blueprint $table) {
|
||||||
|
$table->dropIndex('idx_threads_deleted_meta');
|
||||||
|
$table->dropConstrainedForeignId('deleted_by');
|
||||||
|
$table->dropSoftDeletes();
|
||||||
|
});
|
||||||
|
|
||||||
|
Schema::table('posts', function (Blueprint $table) {
|
||||||
|
$table->dropIndex('idx_posts_deleted_meta');
|
||||||
|
$table->dropConstrainedForeignId('deleted_by');
|
||||||
|
$table->dropSoftDeletes();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
<?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
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
DB::table('forums')
|
||||||
|
->where('type', 'forum')
|
||||||
|
->whereNull('parent_id')
|
||||||
|
->update(['type' => 'category']);
|
||||||
|
|
||||||
|
$driver = Schema::getConnection()->getDriverName();
|
||||||
|
if ($driver === 'mysql') {
|
||||||
|
DB::statement('DROP TRIGGER IF EXISTS trg_forums_parent_insert');
|
||||||
|
DB::statement('DROP TRIGGER IF EXISTS trg_forums_parent_update');
|
||||||
|
DB::statement(<<<'SQL'
|
||||||
|
CREATE TRIGGER trg_forums_parent_insert
|
||||||
|
BEFORE INSERT ON forums
|
||||||
|
FOR EACH ROW
|
||||||
|
BEGIN
|
||||||
|
IF NEW.type = 'forum' AND NEW.parent_id IS NULL THEN
|
||||||
|
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'Forums must belong to a category.';
|
||||||
|
END IF;
|
||||||
|
END
|
||||||
|
SQL);
|
||||||
|
DB::statement(<<<'SQL'
|
||||||
|
CREATE TRIGGER trg_forums_parent_update
|
||||||
|
BEFORE UPDATE ON forums
|
||||||
|
FOR EACH ROW
|
||||||
|
BEGIN
|
||||||
|
IF NEW.type = 'forum' AND NEW.parent_id IS NULL THEN
|
||||||
|
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'Forums must belong to a category.';
|
||||||
|
END IF;
|
||||||
|
END
|
||||||
|
SQL);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
$driver = Schema::getConnection()->getDriverName();
|
||||||
|
if ($driver === 'mysql') {
|
||||||
|
DB::statement('DROP TRIGGER IF EXISTS trg_forums_parent_insert');
|
||||||
|
DB::statement('DROP TRIGGER IF EXISTS trg_forums_parent_update');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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('user_settings', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
|
||||||
|
$table->string('key');
|
||||||
|
$table->json('value');
|
||||||
|
$table->timestamps();
|
||||||
|
$table->unique(['user_id', 'key'], 'uniq_user_settings_user_key');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('user_settings');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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,8 +16,10 @@ class DatabaseSeeder extends Seeder
|
|||||||
{
|
{
|
||||||
$this->call([
|
$this->call([
|
||||||
RoleSeeder::class,
|
RoleSeeder::class,
|
||||||
|
RankSeeder::class,
|
||||||
UserSeeder::class,
|
UserSeeder::class,
|
||||||
ForumSeeder::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]);
|
||||||
|
}
|
||||||
|
}
|
||||||
36
database/seeders/ThreadSeeder.php
Normal file
36
database/seeders/ThreadSeeder.php
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Database\Seeders;
|
||||||
|
|
||||||
|
use App\Models\Forum;
|
||||||
|
use App\Models\Thread;
|
||||||
|
use App\Models\User;
|
||||||
|
use Faker\Factory as FakerFactory;
|
||||||
|
use Illuminate\Database\Seeder;
|
||||||
|
|
||||||
|
class ThreadSeeder extends Seeder
|
||||||
|
{
|
||||||
|
public function run(): void
|
||||||
|
{
|
||||||
|
$faker = FakerFactory::create();
|
||||||
|
$users = User::all();
|
||||||
|
$forums = Forum::where('type', 'forum')->get();
|
||||||
|
|
||||||
|
if ($users->isEmpty() || $forums->isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($forums as $forum) {
|
||||||
|
$threadCount = $faker->numberBetween(2, 8);
|
||||||
|
for ($i = 0; $i < $threadCount; $i += 1) {
|
||||||
|
$author = $users->random();
|
||||||
|
Thread::create([
|
||||||
|
'forum_id' => $forum->id,
|
||||||
|
'user_id' => $author->id,
|
||||||
|
'title' => ucfirst($faker->words($faker->numberBetween(3, 6), true)),
|
||||||
|
'body' => $faker->paragraphs($faker->numberBetween(2, 4), true),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user