Compare commits
68 Commits
version-ma
...
5eb5404061
| Author | SHA1 | Date | |
|---|---|---|---|
| 5eb5404061 | |||
| d9040f1e6c | |||
| 8270e635d6 | |||
| d724f80cad | |||
| 1f5f340ce4 | |||
| 40e111b3a6 | |||
| 506011f933 | |||
| 80a8b86a08 | |||
| c1cb3f394a | |||
| 31c8491aaf | |||
| 0ad5916504 | |||
| bac70c3927 | |||
| bf23e46e2d | |||
| 55b9a69c42 | |||
| b6ce5160f9 | |||
| d279e7f36f | |||
| a0d914ea24 | |||
| ce3b89d54e | |||
| 5cd8a1a9d6 | |||
| 6f9d9f9e7a | |||
| db7f088b36 | |||
| 54d4cd7f99 | |||
| af03c23c9f | |||
| 68dd17f895 | |||
| 8249df15df | |||
| f167e64d00 | |||
| 95ebc7778d | |||
| c67a3ec6d0 | |||
| bf278667bc | |||
| 30a06e18f0 | |||
| 0bc893dd35 | |||
| 88e4a70f88 | |||
| 160430e128 | |||
| 9c60a8944e | |||
| 64244567c0 | |||
| 7fbc566129 | |||
| c33cde6f04 | |||
| 2409feb06f | |||
| e3dcf99362 | |||
| 357f6fb755 | |||
| 2281b80980 | |||
| f23363fdcc | |||
| c1814c0d47 | |||
| 7489a3903d | |||
| b967aa912b | |||
| 67ae9517f4 | |||
| 653905d5e2 | |||
| bc893b644d | |||
| 662e00bec1 | |||
| a96913bffa | |||
| 79ac0cdca5 | |||
| fe4b7ccd7c | |||
| fc9de4c9fd | |||
| 6b6f787351 | |||
| d4fb86633b | |||
|
|
24c16ed0dd | ||
|
|
f9de433545 | ||
|
|
fd29b928d8 | ||
|
|
98094459e3 | ||
|
|
3bb2946656 | ||
|
|
bbbf8eb6c1 | ||
|
|
c8d2bd508e | ||
| eef3262a53 | |||
| fe1015bff1 | |||
|
|
8604cdf95d | ||
| f83748cc76 | |||
| 63bd166a65 | |||
|
|
bdfbe3ffd6 |
@@ -1,5 +1,3 @@
|
|||||||
# editorconfig.org
|
|
||||||
|
|
||||||
root = true
|
root = true
|
||||||
|
|
||||||
[*]
|
[*]
|
||||||
@@ -10,8 +8,11 @@ indent_style = space
|
|||||||
insert_final_newline = true
|
insert_final_newline = true
|
||||||
trim_trailing_whitespace = true
|
trim_trailing_whitespace = true
|
||||||
|
|
||||||
[{compose.yaml,compose.*.yaml}]
|
|
||||||
indent_size = 2
|
|
||||||
|
|
||||||
[*.md]
|
[*.md]
|
||||||
trim_trailing_whitespace = false
|
trim_trailing_whitespace = false
|
||||||
|
|
||||||
|
[*.{yml,yaml}]
|
||||||
|
indent_size = 2
|
||||||
|
|
||||||
|
[compose.yaml]
|
||||||
|
indent_size = 4
|
||||||
65
.env.example
Normal file
65
.env.example
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
APP_NAME=Laravel
|
||||||
|
APP_ENV=local
|
||||||
|
APP_KEY=
|
||||||
|
APP_DEBUG=true
|
||||||
|
APP_URL=http://localhost
|
||||||
|
|
||||||
|
APP_LOCALE=en
|
||||||
|
APP_FALLBACK_LOCALE=en
|
||||||
|
APP_FAKER_LOCALE=en_US
|
||||||
|
|
||||||
|
APP_MAINTENANCE_DRIVER=file
|
||||||
|
# APP_MAINTENANCE_STORE=database
|
||||||
|
|
||||||
|
# PHP_CLI_SERVER_WORKERS=4
|
||||||
|
|
||||||
|
BCRYPT_ROUNDS=12
|
||||||
|
|
||||||
|
LOG_CHANNEL=stack
|
||||||
|
LOG_STACK=single
|
||||||
|
LOG_DEPRECATIONS_CHANNEL=null
|
||||||
|
LOG_LEVEL=debug
|
||||||
|
|
||||||
|
DB_CONNECTION=sqlite
|
||||||
|
# DB_HOST=127.0.0.1
|
||||||
|
# DB_PORT=3306
|
||||||
|
# DB_DATABASE=laravel
|
||||||
|
# DB_USERNAME=root
|
||||||
|
# DB_PASSWORD=
|
||||||
|
|
||||||
|
SESSION_DRIVER=database
|
||||||
|
SESSION_LIFETIME=120
|
||||||
|
SESSION_ENCRYPT=false
|
||||||
|
SESSION_PATH=/
|
||||||
|
SESSION_DOMAIN=null
|
||||||
|
|
||||||
|
BROADCAST_CONNECTION=log
|
||||||
|
FILESYSTEM_DISK=local
|
||||||
|
QUEUE_CONNECTION=database
|
||||||
|
|
||||||
|
CACHE_STORE=database
|
||||||
|
# CACHE_PREFIX=
|
||||||
|
|
||||||
|
MEMCACHED_HOST=127.0.0.1
|
||||||
|
|
||||||
|
REDIS_CLIENT=phpredis
|
||||||
|
REDIS_HOST=127.0.0.1
|
||||||
|
REDIS_PASSWORD=null
|
||||||
|
REDIS_PORT=6379
|
||||||
|
|
||||||
|
MAIL_MAILER=log
|
||||||
|
MAIL_SCHEME=null
|
||||||
|
MAIL_HOST=127.0.0.1
|
||||||
|
MAIL_PORT=2525
|
||||||
|
MAIL_USERNAME=null
|
||||||
|
MAIL_PASSWORD=null
|
||||||
|
MAIL_FROM_ADDRESS="hello@example.com"
|
||||||
|
MAIL_FROM_NAME="${APP_NAME}"
|
||||||
|
|
||||||
|
AWS_ACCESS_KEY_ID=
|
||||||
|
AWS_SECRET_ACCESS_KEY=
|
||||||
|
AWS_DEFAULT_REGION=us-east-1
|
||||||
|
AWS_BUCKET=
|
||||||
|
AWS_USE_PATH_STYLE_ENDPOINT=false
|
||||||
|
|
||||||
|
VITE_APP_NAME="${APP_NAME}"
|
||||||
11
.gitattributes
vendored
Normal file
11
.gitattributes
vendored
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
* text=auto eol=lf
|
||||||
|
|
||||||
|
*.blade.php diff=html
|
||||||
|
*.css diff=css
|
||||||
|
*.html diff=html
|
||||||
|
*.md diff=markdown
|
||||||
|
*.php diff=php
|
||||||
|
|
||||||
|
/.github export-ignore
|
||||||
|
CHANGELOG.md export-ignore
|
||||||
|
.styleci.yml export-ignore
|
||||||
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
|
||||||
45
.gitignore
vendored
45
.gitignore
vendored
@@ -1,9 +1,36 @@
|
|||||||
.idea/
|
*.log
|
||||||
node_modules/
|
.DS_Store
|
||||||
frontend/node_modules/
|
._*
|
||||||
frontend/dist/
|
.env
|
||||||
api/var/
|
.env.backup
|
||||||
api/vendor/
|
.env.production
|
||||||
api/public/app/
|
.env.test
|
||||||
api/.env.local
|
.env.*.local
|
||||||
api/.env.*.local
|
.phpactor.json
|
||||||
|
.phpunit.result.cache
|
||||||
|
/.fleet
|
||||||
|
/.idea
|
||||||
|
/.nova
|
||||||
|
/.phpunit.cache
|
||||||
|
/.vscode
|
||||||
|
/.zed
|
||||||
|
/.phpstorm.meta.php
|
||||||
|
/_ide_helper.php
|
||||||
|
/_ide_helper_models.php
|
||||||
|
/auth.json
|
||||||
|
/node_modules
|
||||||
|
/public/build
|
||||||
|
/public/hot
|
||||||
|
/public/storage
|
||||||
|
/public/custom
|
||||||
|
/storage/app
|
||||||
|
/storage/framework
|
||||||
|
/storage/*.key
|
||||||
|
/storage/pail
|
||||||
|
/storage/framework/views/*.php
|
||||||
|
/bootstrap/cache/*.php
|
||||||
|
/custom
|
||||||
|
/vendor
|
||||||
|
Homestead.json
|
||||||
|
Homestead.yaml
|
||||||
|
Thumbs.db
|
||||||
|
|||||||
75
CHANGELOG.md
75
CHANGELOG.md
@@ -1,5 +1,79 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 2026-02-12
|
||||||
|
- Refined ACP System tab with left navigation, section-specific requirements, and CLI PHP selector.
|
||||||
|
- Added CLI PHP interpreter options (php, keyhelp-php-domain, custom) with KeyHelp guidance.
|
||||||
|
- Updated CLI update tooling and automation notes (KeyHelp PHP handling, CI runner requirements).
|
||||||
|
- Adjusted ACP layout and tab styling for better dark-mode readability and auto-sizing sidebars.
|
||||||
|
- Added Custom top-level ACP tab and preserved /custom paths during in-app updates.
|
||||||
|
|
||||||
|
## 2026-02-10
|
||||||
|
- Reshaped ACP System tab with left navigation and dedicated views (Overview, Live Update, CLI, CI/CD).
|
||||||
|
- Moved system requirements table into the CI/CD view with refresh controls.
|
||||||
|
|
||||||
|
## 2026-02-08
|
||||||
|
- Achieved 100% test coverage across the backend.
|
||||||
|
- Added comprehensive Feature and Unit tests for controllers, models, services, and console commands.
|
||||||
|
- Added extensive edge-case and error-path coverage (system update/status, versioning, attachments, forums, roles, ranks, settings, portal, etc.).
|
||||||
|
- Added `git_update.sh` for CLI-based updates (stable branch, deps, build, migrations, version sync).
|
||||||
|
|
||||||
|
## 2026-01-12
|
||||||
|
- Switched main SPA layouts to fluid containers to reduce wasted space.
|
||||||
|
- Added username-or-email login with case-insensitive unique usernames.
|
||||||
|
- Added SPA-friendly verification and password reset/update endpoints.
|
||||||
|
- Added user avatars (upload + display) and a basic profile page/API.
|
||||||
|
- Seeded a Micha test user with verified email.
|
||||||
|
- Added rank management with badge text/image options and ACP UI controls.
|
||||||
|
- Added user edit modal (name/email/rank) and rank assignment controls in ACP.
|
||||||
|
- Added ACP users search and improved sorting indicators.
|
||||||
|
- Added thread sidebar fields for posts count, registration date, and topic header.
|
||||||
|
- Linked header logo to the portal and fixed ACP breadcrumbs.
|
||||||
|
- Added profile location field with UCP editing and post sidebar display.
|
||||||
|
- Added per-thread replies and views counts, including view tracking.
|
||||||
|
- Added per-forum topics/views counts plus last-post details in board listings.
|
||||||
|
- Added portal summary API to load forums, stats, and recent posts in one request.
|
||||||
|
- Unified portal and forum thread list row styling with shared component.
|
||||||
|
|
||||||
|
## 2026-01-11
|
||||||
|
- Restyled the thread view to mimic phpBB: compact toolbar, title row, and post layout.
|
||||||
|
- Added phpBB-style post action buttons and post author info for replies.
|
||||||
|
|
||||||
|
## 2026-01-02
|
||||||
|
- Added ACP general settings for forum name, theme, accents, and logo (no reload required).
|
||||||
|
- Added admin-only upload endpoints and ACP UI for logos and favicons.
|
||||||
|
- 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.
|
||||||
@@ -21,3 +95,4 @@
|
|||||||
- Added system font stack to remove external font requests.
|
- Added system font stack to remove external font requests.
|
||||||
- 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.
|
||||||
|
|||||||
15
NOTES.md
Normal file
15
NOTES.md
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
TODO: Remove remaining IIFEs in ACP UI; prefer plain components/helpers.
|
||||||
|
Add git_update.sh script to update the forum and core.
|
||||||
|
Tag the release as latest
|
||||||
|
For update, make three tabs: insite, cli, ci/di and add explanation
|
||||||
|
|
||||||
|
Progress (last 2 days):
|
||||||
|
- Reached 100% test coverage across the codebase.
|
||||||
|
- Added extensive Feature and Unit tests for controllers, models, services, and console commands.
|
||||||
|
- Added coverage scripts and cleanup (tests for update/version flows, system update/status, attachments, forums, roles, ranks, settings, portal, etc.).
|
||||||
|
- Hardened tests with fakes/mocks to cover error paths and edge cases.
|
||||||
|
|
||||||
|
TODO: Make the PHP binary path configurable for updates if the default PHP is outdated (ACP -> System).
|
||||||
|
CI/CD: Runner must have PHP 8.4+ as the default CLI interpreter.
|
||||||
|
KeyHelp: `keyhelp-php-domain` can select the PHP version based on the domain of the script location.
|
||||||
|
KeyHelp: `keyhelp-php-domain` is a Pro feature; on non-Pro setups we must fake the command.
|
||||||
7
README.md
Normal file
7
README.md
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
# SpeedBB Forum
|
||||||
|
|
||||||
|
Placeholder README for the forum application.
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
Work in progress.
|
||||||
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=
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
123
ansible/roles/speedBB/tasks/main.yaml
Normal file
123
ansible/roles/speedBB/tasks/main.yaml
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
---
|
||||||
|
- name: Check if base_dir exists
|
||||||
|
stat:
|
||||||
|
path: "{{ prod_base_dir }}"
|
||||||
|
register: base_dir_status
|
||||||
|
|
||||||
|
- name: Fetch latest code
|
||||||
|
git:
|
||||||
|
repo: "{{ git_repo }}"
|
||||||
|
dest: "{{ prod_base_dir }}"
|
||||||
|
version: "master"
|
||||||
|
update: yes
|
||||||
|
force: true
|
||||||
|
register: git_result
|
||||||
|
|
||||||
|
- debug:
|
||||||
|
var: git_result
|
||||||
|
|
||||||
|
- name: Check if .env exists
|
||||||
|
stat:
|
||||||
|
path: "{{ prod_base_dir }}/.env"
|
||||||
|
register: env_file
|
||||||
|
|
||||||
|
- name: Ensure bootstrap cache directory exists
|
||||||
|
file:
|
||||||
|
path: "{{ prod_base_dir }}/bootstrap/cache"
|
||||||
|
state: directory
|
||||||
|
mode: "0775"
|
||||||
|
|
||||||
|
- name: Download and installs all libs and dependencies
|
||||||
|
block:
|
||||||
|
- name: Composer install
|
||||||
|
community.general.composer:
|
||||||
|
command: install
|
||||||
|
arguments: --no-dev --optimize-autoloader
|
||||||
|
working_dir: "{{ prod_base_dir }}"
|
||||||
|
php_path: /usr/bin/keyhelp-php84
|
||||||
|
rescue:
|
||||||
|
- name: Debug package discovery
|
||||||
|
shell: |
|
||||||
|
keyhelp-php84 artisan package:discover -v --ansi 2>&1 | tail -n 200
|
||||||
|
args:
|
||||||
|
chdir: "{{ prod_base_dir }}"
|
||||||
|
register: package_discover_debug
|
||||||
|
- debug:
|
||||||
|
var: package_discover_debug.stdout_lines
|
||||||
|
- fail:
|
||||||
|
msg: "Composer install failed; see package:discover output above."
|
||||||
|
|
||||||
|
- name: Install node_modules
|
||||||
|
npm:
|
||||||
|
path: "{{ prod_base_dir }}"
|
||||||
|
state: present
|
||||||
|
when: git_result.changed
|
||||||
|
|
||||||
|
- name: Build frontend
|
||||||
|
command: "npm run build"
|
||||||
|
args:
|
||||||
|
chdir: "{{ prod_base_dir }}"
|
||||||
|
|
||||||
|
- name: Clear config cache
|
||||||
|
command: "keyhelp-php84 artisan config:clear"
|
||||||
|
args:
|
||||||
|
chdir: "{{ prod_base_dir }}"
|
||||||
|
when: env_file.stat.exists
|
||||||
|
|
||||||
|
- name: Clear application cache
|
||||||
|
command: "keyhelp-php84 artisan cache:clear"
|
||||||
|
args:
|
||||||
|
chdir: "{{ prod_base_dir }}"
|
||||||
|
when: env_file.stat.exists
|
||||||
|
|
||||||
|
- name: Create database backup directory
|
||||||
|
file:
|
||||||
|
path: "{{ prod_base_dir }}/backups"
|
||||||
|
state: directory
|
||||||
|
mode: '0755'
|
||||||
|
|
||||||
|
- name: Backup database before migrations
|
||||||
|
shell: |
|
||||||
|
cd {{ prod_base_dir }}
|
||||||
|
DB_USERNAME=$(grep DB_USERNAME .env | cut -d '=' -f2)
|
||||||
|
DB_PASSWORD=$(grep DB_PASSWORD .env | cut -d '=' -f2)
|
||||||
|
DB_DATABASE=$(grep DB_DATABASE .env | cut -d '=' -f2)
|
||||||
|
BACKUP_FILE="{{ prod_base_dir }}/backups/db_backup_$(date +%Y%m%d_%H%M%S).sql"
|
||||||
|
mysqldump -u "$DB_USERNAME" -p"$DB_PASSWORD" "$DB_DATABASE" > "$BACKUP_FILE"
|
||||||
|
echo "$BACKUP_FILE"
|
||||||
|
register: backup_result
|
||||||
|
when: env_file.stat.exists
|
||||||
|
|
||||||
|
- name: Display backup location
|
||||||
|
debug:
|
||||||
|
msg: "Database backed up to: {{ backup_result.stdout }}"
|
||||||
|
when: env_file.stat.exists
|
||||||
|
|
||||||
|
- name: Run database migrations 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
|
||||||
35
api/.env
35
api/.env
@@ -1,35 +0,0 @@
|
|||||||
# In all environments, the following files are loaded if they exist,
|
|
||||||
# the latter taking precedence over the former:
|
|
||||||
#
|
|
||||||
# * .env contains default values for the environment variables needed by the app
|
|
||||||
# * .env.local uncommitted file with local overrides
|
|
||||||
# * .env.$APP_ENV committed environment-specific defaults
|
|
||||||
# * .env.$APP_ENV.local uncommitted environment-specific overrides
|
|
||||||
#
|
|
||||||
# Real environment variables win over .env files.
|
|
||||||
#
|
|
||||||
# DO NOT DEFINE PRODUCTION SECRETS IN THIS FILE NOR IN ANY OTHER COMMITTED FILES.
|
|
||||||
# https://symfony.com/doc/current/configuration/secrets.html
|
|
||||||
#
|
|
||||||
# Run "composer dump-env prod" to compile .env files for production use (requires symfony/flex >=1.2).
|
|
||||||
# https://symfony.com/doc/current/best_practices.html#use-environment-variables-for-infrastructure-configuration
|
|
||||||
|
|
||||||
###> symfony/framework-bundle ###
|
|
||||||
APP_ENV=dev
|
|
||||||
APP_SECRET=
|
|
||||||
APP_SHARE_DIR=var/share
|
|
||||||
###< symfony/framework-bundle ###
|
|
||||||
|
|
||||||
###> symfony/routing ###
|
|
||||||
# Configure how to generate URLs in non-HTTP contexts, such as CLI commands.
|
|
||||||
# See https://symfony.com/doc/current/routing.html#generating-urls-in-commands
|
|
||||||
DEFAULT_URI=http://localhost
|
|
||||||
###< symfony/routing ###
|
|
||||||
|
|
||||||
###> doctrine/doctrine-bundle ###
|
|
||||||
# Format described at https://www.doctrine-project.org/projects/doctrine-dbal/en/latest/reference/configuration.html#connecting-using-a-url
|
|
||||||
# IMPORTANT: You MUST configure your server version, either here or in config/packages/doctrine.yaml
|
|
||||||
#
|
|
||||||
# DATABASE_URL="sqlite:///%kernel.project_dir%/var/data_%kernel.environment%.db"
|
|
||||||
# DATABASE_URL="mysql://app:!ChangeMe!@127.0.0.1:3306/speedbb?serverVersion=8.0.32&charset=utf8mb4"
|
|
||||||
###< doctrine/doctrine-bundle ###
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
|
|
||||||
###> symfony/framework-bundle ###
|
|
||||||
APP_SECRET=7944fa8dd76a206ea3e7e16b9276029c
|
|
||||||
###< symfony/framework-bundle ###
|
|
||||||
14
api/.gitignore
vendored
14
api/.gitignore
vendored
@@ -1,14 +0,0 @@
|
|||||||
|
|
||||||
###> symfony/framework-bundle ###
|
|
||||||
/.env.local
|
|
||||||
/.env.local.php
|
|
||||||
/.env.*.local
|
|
||||||
/config/secrets/prod/prod.decrypt.private.php
|
|
||||||
/public/bundles/
|
|
||||||
/var/
|
|
||||||
/vendor/
|
|
||||||
###< symfony/framework-bundle ###
|
|
||||||
|
|
||||||
###> lexik/jwt-authentication-bundle ###
|
|
||||||
/config/jwt/*.pem
|
|
||||||
###< lexik/jwt-authentication-bundle ###
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
#!/usr/bin/env php
|
|
||||||
<?php
|
|
||||||
|
|
||||||
use App\Kernel;
|
|
||||||
use Symfony\Bundle\FrameworkBundle\Console\Application;
|
|
||||||
|
|
||||||
if (!is_dir(dirname(__DIR__).'/vendor')) {
|
|
||||||
throw new LogicException('Dependencies are missing. Try running "composer install".');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!is_file(dirname(__DIR__).'/vendor/autoload_runtime.php')) {
|
|
||||||
throw new LogicException('Symfony Runtime is missing. Try running "composer require symfony/runtime".');
|
|
||||||
}
|
|
||||||
|
|
||||||
require_once dirname(__DIR__).'/vendor/autoload_runtime.php';
|
|
||||||
|
|
||||||
return function (array $context) {
|
|
||||||
$kernel = new Kernel($context['APP_ENV'], (bool) $context['APP_DEBUG']);
|
|
||||||
|
|
||||||
return new Application($kernel);
|
|
||||||
};
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
|
|
||||||
services:
|
|
||||||
###> doctrine/doctrine-bundle ###
|
|
||||||
database:
|
|
||||||
ports:
|
|
||||||
- "5432"
|
|
||||||
###< doctrine/doctrine-bundle ###
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
|
|
||||||
services:
|
|
||||||
###> doctrine/doctrine-bundle ###
|
|
||||||
database:
|
|
||||||
image: postgres:${POSTGRES_VERSION:-16}-alpine
|
|
||||||
environment:
|
|
||||||
POSTGRES_DB: ${POSTGRES_DB:-app}
|
|
||||||
# You should definitely change the password in production
|
|
||||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-!ChangeMe!}
|
|
||||||
POSTGRES_USER: ${POSTGRES_USER:-app}
|
|
||||||
healthcheck:
|
|
||||||
test: ["CMD", "pg_isready", "-d", "${POSTGRES_DB:-app}", "-U", "${POSTGRES_USER:-app}"]
|
|
||||||
timeout: 5s
|
|
||||||
retries: 5
|
|
||||||
start_period: 60s
|
|
||||||
volumes:
|
|
||||||
- database_data:/var/lib/postgresql/data:rw
|
|
||||||
# You may use a bind-mounted host directory instead, so that it is harder to accidentally remove the volume and lose all your data!
|
|
||||||
# - ./docker/db/data:/var/lib/postgresql/data:rw
|
|
||||||
###< doctrine/doctrine-bundle ###
|
|
||||||
|
|
||||||
volumes:
|
|
||||||
###> doctrine/doctrine-bundle ###
|
|
||||||
database_data:
|
|
||||||
###< doctrine/doctrine-bundle ###
|
|
||||||
@@ -1,91 +0,0 @@
|
|||||||
{
|
|
||||||
"type": "project",
|
|
||||||
"version": "25.00.1",
|
|
||||||
"license": "proprietary",
|
|
||||||
"minimum-stability": "stable",
|
|
||||||
"prefer-stable": true,
|
|
||||||
"require": {
|
|
||||||
"php": ">=8.4",
|
|
||||||
"ext-ctype": "*",
|
|
||||||
"ext-iconv": "*",
|
|
||||||
"api-platform/doctrine-orm": "^4.2",
|
|
||||||
"api-platform/symfony": "^4.2",
|
|
||||||
"doctrine/doctrine-bundle": "^3.2",
|
|
||||||
"doctrine/doctrine-migrations-bundle": "^4.0",
|
|
||||||
"doctrine/orm": "^3.6",
|
|
||||||
"lexik/jwt-authentication-bundle": "^3.2",
|
|
||||||
"phpdocumentor/reflection-docblock": "^5.6",
|
|
||||||
"phpstan/phpdoc-parser": "^2.3",
|
|
||||||
"symfony/apache-pack": "*",
|
|
||||||
"symfony/asset": "8.0.*",
|
|
||||||
"symfony/console": "8.0.*",
|
|
||||||
"symfony/dotenv": "8.0.*",
|
|
||||||
"symfony/expression-language": "8.0.*",
|
|
||||||
"symfony/flex": "^2",
|
|
||||||
"symfony/framework-bundle": "8.0.*",
|
|
||||||
"symfony/property-access": "8.0.*",
|
|
||||||
"symfony/property-info": "8.0.*",
|
|
||||||
"symfony/runtime": "8.0.*",
|
|
||||||
"symfony/security-bundle": "8.0.*",
|
|
||||||
"symfony/serializer": "8.0.*",
|
|
||||||
"symfony/translation": "8.0.*",
|
|
||||||
"symfony/twig-bundle": "8.0.*",
|
|
||||||
"symfony/validator": "8.0.*",
|
|
||||||
"symfony/yaml": "8.0.*"
|
|
||||||
},
|
|
||||||
"config": {
|
|
||||||
"allow-plugins": {
|
|
||||||
"php-http/discovery": true,
|
|
||||||
"symfony/flex": true,
|
|
||||||
"symfony/runtime": true
|
|
||||||
},
|
|
||||||
"bump-after-update": true,
|
|
||||||
"sort-packages": true
|
|
||||||
},
|
|
||||||
"autoload": {
|
|
||||||
"psr-4": {
|
|
||||||
"App\\": "src/"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"autoload-dev": {
|
|
||||||
"psr-4": {
|
|
||||||
"App\\Tests\\": "tests/"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"replace": {
|
|
||||||
"symfony/polyfill-ctype": "*",
|
|
||||||
"symfony/polyfill-iconv": "*",
|
|
||||||
"symfony/polyfill-php72": "*",
|
|
||||||
"symfony/polyfill-php73": "*",
|
|
||||||
"symfony/polyfill-php74": "*",
|
|
||||||
"symfony/polyfill-php80": "*",
|
|
||||||
"symfony/polyfill-php81": "*",
|
|
||||||
"symfony/polyfill-php82": "*",
|
|
||||||
"symfony/polyfill-php83": "*",
|
|
||||||
"symfony/polyfill-php84": "*"
|
|
||||||
},
|
|
||||||
"scripts": {
|
|
||||||
"auto-scripts": {
|
|
||||||
"cache:clear": "symfony-cmd",
|
|
||||||
"assets:install %PUBLIC_DIR%": "symfony-cmd"
|
|
||||||
},
|
|
||||||
"post-install-cmd": [
|
|
||||||
"@auto-scripts"
|
|
||||||
],
|
|
||||||
"post-update-cmd": [
|
|
||||||
"@auto-scripts"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"conflict": {
|
|
||||||
"symfony/symfony": "*"
|
|
||||||
},
|
|
||||||
"extra": {
|
|
||||||
"speedbb": {
|
|
||||||
"build": 3
|
|
||||||
},
|
|
||||||
"symfony": {
|
|
||||||
"allow-contrib": false,
|
|
||||||
"require": "8.0.*"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
7418
api/composer.lock
generated
7418
api/composer.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -1,11 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
return [
|
|
||||||
Symfony\Bundle\FrameworkBundle\FrameworkBundle::class => ['all' => true],
|
|
||||||
Symfony\Bundle\TwigBundle\TwigBundle::class => ['all' => true],
|
|
||||||
Symfony\Bundle\SecurityBundle\SecurityBundle::class => ['all' => true],
|
|
||||||
Doctrine\Bundle\DoctrineBundle\DoctrineBundle::class => ['all' => true],
|
|
||||||
Doctrine\Bundle\MigrationsBundle\DoctrineMigrationsBundle::class => ['all' => true],
|
|
||||||
ApiPlatform\Symfony\Bundle\ApiPlatformBundle::class => ['all' => true],
|
|
||||||
Lexik\Bundle\JWTAuthenticationBundle\LexikJWTAuthenticationBundle::class => ['all' => true],
|
|
||||||
];
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
api_platform:
|
|
||||||
title: speedBB API
|
|
||||||
version: 1.0.0
|
|
||||||
formats:
|
|
||||||
json: ['application/json']
|
|
||||||
jsonld: ['application/ld+json']
|
|
||||||
defaults:
|
|
||||||
stateless: true
|
|
||||||
cache_headers:
|
|
||||||
vary: ['Content-Type', 'Authorization', 'Origin']
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
framework:
|
|
||||||
cache:
|
|
||||||
# Unique name of your app: used to compute stable namespaces for cache keys.
|
|
||||||
#prefix_seed: your_vendor_name/app_name
|
|
||||||
|
|
||||||
# The "app" cache stores to the filesystem by default.
|
|
||||||
# The data in this cache should persist between deploys.
|
|
||||||
# Other options include:
|
|
||||||
|
|
||||||
# Redis
|
|
||||||
#app: cache.adapter.redis
|
|
||||||
#default_redis_provider: redis://localhost
|
|
||||||
|
|
||||||
# APCu (not recommended with heavy random-write workloads as memory fragmentation can cause perf issues)
|
|
||||||
#app: cache.adapter.apcu
|
|
||||||
|
|
||||||
# Namespaced pools use the above "app" backend by default
|
|
||||||
#pools:
|
|
||||||
#my.dedicated.cache: null
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
doctrine:
|
|
||||||
dbal:
|
|
||||||
url: '%env(resolve:DATABASE_URL)%'
|
|
||||||
|
|
||||||
# IMPORTANT: You MUST configure your server version,
|
|
||||||
# either here or in the DATABASE_URL env var (see .env file)
|
|
||||||
#server_version: '16'
|
|
||||||
|
|
||||||
profiling_collect_backtrace: '%kernel.debug%'
|
|
||||||
orm:
|
|
||||||
validate_xml_mapping: true
|
|
||||||
naming_strategy: doctrine.orm.naming_strategy.underscore_number_aware
|
|
||||||
auto_mapping: true
|
|
||||||
mappings:
|
|
||||||
App:
|
|
||||||
type: attribute
|
|
||||||
is_bundle: false
|
|
||||||
dir: '%kernel.project_dir%/src/Entity'
|
|
||||||
prefix: 'App\Entity'
|
|
||||||
alias: App
|
|
||||||
controller_resolver:
|
|
||||||
auto_mapping: false
|
|
||||||
|
|
||||||
when@test:
|
|
||||||
doctrine:
|
|
||||||
dbal:
|
|
||||||
# "TEST_TOKEN" is typically set by ParaTest
|
|
||||||
dbname_suffix: '_test%env(default::TEST_TOKEN)%'
|
|
||||||
|
|
||||||
when@prod:
|
|
||||||
doctrine:
|
|
||||||
orm:
|
|
||||||
query_cache_driver:
|
|
||||||
type: pool
|
|
||||||
pool: doctrine.system_cache_pool
|
|
||||||
result_cache_driver:
|
|
||||||
type: pool
|
|
||||||
pool: doctrine.result_cache_pool
|
|
||||||
|
|
||||||
framework:
|
|
||||||
cache:
|
|
||||||
pools:
|
|
||||||
doctrine.result_cache_pool:
|
|
||||||
adapter: cache.app
|
|
||||||
doctrine.system_cache_pool:
|
|
||||||
adapter: cache.system
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
doctrine_migrations:
|
|
||||||
migrations_paths:
|
|
||||||
# namespace is arbitrary but should be different from App\Migrations
|
|
||||||
# as migrations classes should NOT be autoloaded
|
|
||||||
'DoctrineMigrations': '%kernel.project_dir%/migrations'
|
|
||||||
enable_profiler: false
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
# see https://symfony.com/doc/current/reference/configuration/framework.html
|
|
||||||
framework:
|
|
||||||
secret: '%env(APP_SECRET)%'
|
|
||||||
|
|
||||||
# Note that the session will be started ONLY if you read or write from it.
|
|
||||||
session: true
|
|
||||||
|
|
||||||
serializer:
|
|
||||||
enabled: true
|
|
||||||
|
|
||||||
#esi: true
|
|
||||||
#fragments: true
|
|
||||||
|
|
||||||
when@test:
|
|
||||||
framework:
|
|
||||||
test: true
|
|
||||||
session:
|
|
||||||
storage_factory_id: session.storage.factory.mock_file
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
lexik_jwt_authentication:
|
|
||||||
secret_key: '%env(resolve:JWT_SECRET_KEY)%'
|
|
||||||
public_key: '%env(resolve:JWT_PUBLIC_KEY)%'
|
|
||||||
pass_phrase: '%env(JWT_PASSPHRASE)%'
|
|
||||||
token_ttl: 86400
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
framework:
|
|
||||||
property_info:
|
|
||||||
with_constructor_extractor: true
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
framework:
|
|
||||||
router:
|
|
||||||
# Configure how to generate URLs in non-HTTP contexts, such as CLI commands.
|
|
||||||
# See https://symfony.com/doc/current/routing.html#generating-urls-in-commands
|
|
||||||
default_uri: '%env(DEFAULT_URI)%'
|
|
||||||
|
|
||||||
when@prod:
|
|
||||||
framework:
|
|
||||||
router:
|
|
||||||
strict_requirements: null
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
security:
|
|
||||||
# https://symfony.com/doc/current/security.html#registering-the-user-hashing-passwords
|
|
||||||
password_hashers:
|
|
||||||
Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface: 'auto'
|
|
||||||
|
|
||||||
# https://symfony.com/doc/current/security.html#loading-the-user-the-user-provider
|
|
||||||
providers:
|
|
||||||
app_user_provider:
|
|
||||||
entity:
|
|
||||||
class: App\Entity\User
|
|
||||||
property: email
|
|
||||||
|
|
||||||
firewalls:
|
|
||||||
dev:
|
|
||||||
# Ensure dev tools and static assets are always allowed
|
|
||||||
pattern: ^/(_profiler|_wdt|assets|build)/
|
|
||||||
security: false
|
|
||||||
login:
|
|
||||||
pattern: ^/api/login
|
|
||||||
stateless: true
|
|
||||||
provider: app_user_provider
|
|
||||||
json_login:
|
|
||||||
check_path: /api/login
|
|
||||||
username_path: email
|
|
||||||
password_path: password
|
|
||||||
success_handler: lexik_jwt_authentication.handler.authentication_success
|
|
||||||
failure_handler: lexik_jwt_authentication.handler.authentication_failure
|
|
||||||
main:
|
|
||||||
pattern: ^/api
|
|
||||||
lazy: true
|
|
||||||
stateless: true
|
|
||||||
provider: app_user_provider
|
|
||||||
jwt: ~
|
|
||||||
|
|
||||||
# Activate different ways to authenticate:
|
|
||||||
# https://symfony.com/doc/current/security.html#the-firewall
|
|
||||||
|
|
||||||
# https://symfony.com/doc/current/security/impersonating_user.html
|
|
||||||
# switch_user: true
|
|
||||||
|
|
||||||
# Note: Only the *first* matching rule is applied
|
|
||||||
access_control:
|
|
||||||
- { path: ^/api/login, roles: PUBLIC_ACCESS }
|
|
||||||
- { path: ^/api, roles: PUBLIC_ACCESS }
|
|
||||||
|
|
||||||
when@test:
|
|
||||||
security:
|
|
||||||
password_hashers:
|
|
||||||
# Password hashers are resource-intensive by design to ensure security.
|
|
||||||
# In tests, it's safe to reduce their cost to improve performance.
|
|
||||||
Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface:
|
|
||||||
algorithm: auto
|
|
||||||
cost: 4 # Lowest possible value for bcrypt
|
|
||||||
time_cost: 3 # Lowest possible value for argon
|
|
||||||
memory_cost: 10 # Lowest possible value for argon
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
framework:
|
|
||||||
default_locale: en
|
|
||||||
translator:
|
|
||||||
default_path: '%kernel.project_dir%/translations'
|
|
||||||
providers:
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
twig:
|
|
||||||
file_name_pattern: '*.twig'
|
|
||||||
|
|
||||||
when@test:
|
|
||||||
twig:
|
|
||||||
strict_variables: true
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
framework:
|
|
||||||
validation:
|
|
||||||
# Enables validator auto-mapping support.
|
|
||||||
# For instance, basic validation constraints will be inferred from Doctrine's metadata.
|
|
||||||
#auto_mapping:
|
|
||||||
# App\Entity\: []
|
|
||||||
|
|
||||||
when@test:
|
|
||||||
framework:
|
|
||||||
validation:
|
|
||||||
not_compromised_password: false
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
if (file_exists(dirname(__DIR__).'/var/cache/prod/App_KernelProdContainer.preload.php')) {
|
|
||||||
require dirname(__DIR__).'/var/cache/prod/App_KernelProdContainer.preload.php';
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,16 +0,0 @@
|
|||||||
# yaml-language-server: $schema=../vendor/symfony/routing/Loader/schema/routing.schema.json
|
|
||||||
|
|
||||||
# This file is the entry point to configure the routes of your app.
|
|
||||||
# Methods with the #[Route] attribute are automatically imported.
|
|
||||||
# See also https://symfony.com/doc/current/routing.html
|
|
||||||
|
|
||||||
# To list all registered routes, run the following command:
|
|
||||||
# bin/console debug:router
|
|
||||||
|
|
||||||
controllers:
|
|
||||||
resource: routing.controllers
|
|
||||||
|
|
||||||
api_login:
|
|
||||||
path: /api/login
|
|
||||||
methods: [POST]
|
|
||||||
controller: lexik_jwt_authentication.controller.authentication
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
api_platform:
|
|
||||||
resource: .
|
|
||||||
type: api_platform
|
|
||||||
prefix: /api
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
when@dev:
|
|
||||||
_errors:
|
|
||||||
resource: '@FrameworkBundle/Resources/config/routing/errors.php'
|
|
||||||
prefix: /_error
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
_security_logout:
|
|
||||||
resource: security.route_loader.logout
|
|
||||||
type: service
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
# yaml-language-server: $schema=../vendor/symfony/dependency-injection/Loader/schema/services.schema.json
|
|
||||||
|
|
||||||
# This file is the entry point to configure your own services.
|
|
||||||
# Files in the packages/ subdirectory configure your dependencies.
|
|
||||||
# See also https://symfony.com/doc/current/service_container/import.html
|
|
||||||
|
|
||||||
# Put parameters here that don't need to change on each machine where the app is deployed
|
|
||||||
# https://symfony.com/doc/current/best_practices.html#use-parameters-for-application-configuration
|
|
||||||
parameters:
|
|
||||||
|
|
||||||
services:
|
|
||||||
# default configuration for services in *this* file
|
|
||||||
_defaults:
|
|
||||||
autowire: true # Automatically injects dependencies in your services.
|
|
||||||
autoconfigure: true # Automatically registers your services as commands, event subscribers, etc.
|
|
||||||
|
|
||||||
# makes classes in src/ available to be used as services
|
|
||||||
# this creates a service per class whose id is the fully-qualified class name
|
|
||||||
App\:
|
|
||||||
resource: '../src/'
|
|
||||||
|
|
||||||
# add more service definitions when explicit configuration is needed
|
|
||||||
# please note that last definitions always *replace* previous ones
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace DoctrineMigrations;
|
|
||||||
|
|
||||||
use Doctrine\DBAL\Schema\Schema;
|
|
||||||
use Doctrine\Migrations\AbstractMigration;
|
|
||||||
|
|
||||||
final class Version20251224115331 extends AbstractMigration
|
|
||||||
{
|
|
||||||
public function getDescription(): string
|
|
||||||
{
|
|
||||||
return 'Initial schema (executed previously).';
|
|
||||||
}
|
|
||||||
|
|
||||||
public function up(Schema $schema): void
|
|
||||||
{
|
|
||||||
}
|
|
||||||
|
|
||||||
public function down(Schema $schema): void
|
|
||||||
{
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace DoctrineMigrations;
|
|
||||||
|
|
||||||
use Doctrine\DBAL\Schema\Schema;
|
|
||||||
use Doctrine\Migrations\AbstractMigration;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Auto-generated Migration: Please modify to your needs!
|
|
||||||
*/
|
|
||||||
final class Version20251224120510 extends AbstractMigration
|
|
||||||
{
|
|
||||||
public function getDescription(): string
|
|
||||||
{
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
|
|
||||||
public function up(Schema $schema): void
|
|
||||||
{
|
|
||||||
// this up() migration is auto-generated, please modify it to your needs
|
|
||||||
$this->addSql('CREATE TABLE forum (id INT AUTO_INCREMENT NOT NULL, name VARCHAR(100) NOT NULL, description LONGTEXT DEFAULT NULL, type VARCHAR(20) NOT NULL, created_at DATETIME NOT NULL, updated_at DATETIME NOT NULL, parent_id INT DEFAULT NULL, INDEX IDX_852BBECD727ACA70 (parent_id), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4');
|
|
||||||
$this->addSql('ALTER TABLE forum ADD CONSTRAINT FK_852BBECD727ACA70 FOREIGN KEY (parent_id) REFERENCES forum (id)');
|
|
||||||
$this->addSql('ALTER TABLE thread DROP FOREIGN KEY `FK_31204C8312469DE2`');
|
|
||||||
$this->addSql('DROP INDEX IDX_31204C8312469DE2 ON thread');
|
|
||||||
$this->addSql('INSERT INTO forum (id, name, description, type, created_at, updated_at) SELECT id, name, description, \'forum\', created_at, updated_at FROM category');
|
|
||||||
$this->addSql('ALTER TABLE thread CHANGE category_id forum_id INT NOT NULL');
|
|
||||||
$this->addSql('ALTER TABLE thread ADD CONSTRAINT FK_31204C8329CCBAD0 FOREIGN KEY (forum_id) REFERENCES forum (id)');
|
|
||||||
$this->addSql('CREATE INDEX IDX_31204C8329CCBAD0 ON thread (forum_id)');
|
|
||||||
$this->addSql('DROP TABLE category');
|
|
||||||
}
|
|
||||||
|
|
||||||
public function down(Schema $schema): void
|
|
||||||
{
|
|
||||||
// this down() migration is auto-generated, please modify it to your needs
|
|
||||||
$this->addSql('CREATE TABLE category (id INT AUTO_INCREMENT NOT NULL, name VARCHAR(100) CHARACTER SET utf8mb4 NOT NULL COLLATE `utf8mb4_general_ci`, description LONGTEXT CHARACTER SET utf8mb4 DEFAULT NULL COLLATE `utf8mb4_general_ci`, created_at DATETIME NOT NULL, updated_at DATETIME NOT NULL, PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_general_ci` ENGINE = InnoDB COMMENT = \'\' ');
|
|
||||||
$this->addSql('ALTER TABLE forum DROP FOREIGN KEY FK_852BBECD727ACA70');
|
|
||||||
$this->addSql('DROP TABLE forum');
|
|
||||||
$this->addSql('ALTER TABLE thread DROP FOREIGN KEY FK_31204C8329CCBAD0');
|
|
||||||
$this->addSql('DROP INDEX IDX_31204C8329CCBAD0 ON thread');
|
|
||||||
$this->addSql('ALTER TABLE thread CHANGE forum_id category_id INT NOT NULL');
|
|
||||||
$this->addSql('ALTER TABLE thread ADD CONSTRAINT `FK_31204C8312469DE2` FOREIGN KEY (category_id) REFERENCES category (id)');
|
|
||||||
$this->addSql('CREATE INDEX IDX_31204C8312469DE2 ON thread (category_id)');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace DoctrineMigrations;
|
|
||||||
|
|
||||||
use Doctrine\DBAL\Schema\Schema;
|
|
||||||
use Doctrine\Migrations\AbstractMigration;
|
|
||||||
|
|
||||||
final class Version20251224154500 extends AbstractMigration
|
|
||||||
{
|
|
||||||
public function getDescription(): string
|
|
||||||
{
|
|
||||||
return 'Add forum position for per-parent ordering.';
|
|
||||||
}
|
|
||||||
|
|
||||||
public function up(Schema $schema): void
|
|
||||||
{
|
|
||||||
$this->addSql('ALTER TABLE forum ADD position INT NOT NULL');
|
|
||||||
$this->addSql('UPDATE forum SET position = id');
|
|
||||||
}
|
|
||||||
|
|
||||||
public function down(Schema $schema): void
|
|
||||||
{
|
|
||||||
$this->addSql('ALTER TABLE forum DROP position');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace DoctrineMigrations;
|
|
||||||
|
|
||||||
use Doctrine\DBAL\Schema\Schema;
|
|
||||||
use Doctrine\Migrations\AbstractMigration;
|
|
||||||
|
|
||||||
final class Version20251224184500 extends AbstractMigration
|
|
||||||
{
|
|
||||||
public function getDescription(): string
|
|
||||||
{
|
|
||||||
return 'Add settings table with version and build metadata.';
|
|
||||||
}
|
|
||||||
|
|
||||||
public function up(Schema $schema): void
|
|
||||||
{
|
|
||||||
$this->addSql('CREATE TABLE settings (id INT AUTO_INCREMENT NOT NULL, version VARCHAR(20) NOT NULL, build INT NOT NULL, PRIMARY KEY(id))');
|
|
||||||
$this->addSql("INSERT INTO settings (id, version, build) VALUES (1, '25.00.1', 3)");
|
|
||||||
}
|
|
||||||
|
|
||||||
public function down(Schema $schema): void
|
|
||||||
{
|
|
||||||
$this->addSql('DROP TABLE settings');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace DoctrineMigrations;
|
|
||||||
|
|
||||||
use Doctrine\DBAL\Schema\Schema;
|
|
||||||
use Doctrine\Migrations\AbstractMigration;
|
|
||||||
|
|
||||||
final class Version20251224191500 extends AbstractMigration
|
|
||||||
{
|
|
||||||
public function getDescription(): string
|
|
||||||
{
|
|
||||||
return 'Convert settings table to key/value rows for version/build.';
|
|
||||||
}
|
|
||||||
|
|
||||||
public function up(Schema $schema): void
|
|
||||||
{
|
|
||||||
$this->addSql('CREATE TABLE settings_new (id INT AUTO_INCREMENT NOT NULL, `key` VARCHAR(100) NOT NULL, value LONGTEXT NOT NULL, UNIQUE INDEX UNIQ_SETTINGS_KEY (`key`), PRIMARY KEY(id))');
|
|
||||||
$this->addSql("INSERT INTO settings_new (`key`, value) SELECT 'version', version FROM settings LIMIT 1");
|
|
||||||
$this->addSql("INSERT INTO settings_new (`key`, value) SELECT 'build', CAST(build AS CHAR) FROM settings LIMIT 1");
|
|
||||||
$this->addSql('DROP TABLE settings');
|
|
||||||
$this->addSql('RENAME TABLE settings_new TO settings');
|
|
||||||
}
|
|
||||||
|
|
||||||
public function down(Schema $schema): void
|
|
||||||
{
|
|
||||||
$this->addSql('CREATE TABLE settings_old (id INT AUTO_INCREMENT NOT NULL, version VARCHAR(20) NOT NULL, build INT NOT NULL, PRIMARY KEY(id))');
|
|
||||||
$this->addSql("INSERT INTO settings_old (id, version, build) VALUES (1, (SELECT value FROM settings WHERE `key` = 'version' LIMIT 1), CAST((SELECT value FROM settings WHERE `key` = 'build' LIMIT 1) AS UNSIGNED))");
|
|
||||||
$this->addSql('DROP TABLE settings');
|
|
||||||
$this->addSql('RENAME TABLE settings_old TO settings');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace DoctrineMigrations;
|
|
||||||
|
|
||||||
use Doctrine\DBAL\Schema\Schema;
|
|
||||||
use Doctrine\Migrations\AbstractMigration;
|
|
||||||
|
|
||||||
final class Version20251224193000 extends AbstractMigration
|
|
||||||
{
|
|
||||||
public function getDescription(): string
|
|
||||||
{
|
|
||||||
return 'Add indexes for forum parent ordering and type filters.';
|
|
||||||
}
|
|
||||||
|
|
||||||
public function up(Schema $schema): void
|
|
||||||
{
|
|
||||||
$this->addSql('CREATE INDEX idx_forum_parent_position ON forum (parent_id, position)');
|
|
||||||
$this->addSql('CREATE INDEX idx_forum_type ON forum (type)');
|
|
||||||
}
|
|
||||||
|
|
||||||
public function down(Schema $schema): void
|
|
||||||
{
|
|
||||||
$this->addSql('DROP INDEX idx_forum_parent_position ON forum');
|
|
||||||
$this->addSql('DROP INDEX idx_forum_type ON forum');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
namespace DoctrineMigrations;
|
|
||||||
|
|
||||||
use Doctrine\DBAL\Schema\Schema;
|
|
||||||
use Doctrine\Migrations\AbstractMigration;
|
|
||||||
|
|
||||||
final class Version20251224194000 extends AbstractMigration
|
|
||||||
{
|
|
||||||
public function getDescription(): string
|
|
||||||
{
|
|
||||||
return 'Add accent color setting.';
|
|
||||||
}
|
|
||||||
|
|
||||||
public function up(Schema $schema): void
|
|
||||||
{
|
|
||||||
$this->addSql("INSERT IGNORE INTO settings (`key`, value) VALUES ('accent_color', '#f29b3f')");
|
|
||||||
}
|
|
||||||
|
|
||||||
public function down(Schema $schema): void
|
|
||||||
{
|
|
||||||
$this->addSql("DELETE FROM settings WHERE `key` = 'accent_color'");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
<IfModule mod_rewrite.c>
|
|
||||||
RewriteEngine On
|
|
||||||
|
|
||||||
RewriteCond %{HTTP:Authorization} .
|
|
||||||
RewriteRule ^ - [E=HTTP_AUTHORIZATION:%{HTTP:Authorization}]
|
|
||||||
RewriteRule .* - [E=HTTP_AUTHORIZATION:%{HTTP:Authorization}]
|
|
||||||
|
|
||||||
RewriteCond %{REQUEST_FILENAME} -f
|
|
||||||
RewriteRule ^ - [L]
|
|
||||||
|
|
||||||
RewriteCond %{REQUEST_FILENAME} -d
|
|
||||||
RewriteRule ^ - [L]
|
|
||||||
|
|
||||||
RewriteRule ^ index.php [L]
|
|
||||||
</IfModule>
|
|
||||||
|
|
||||||
<IfModule mod_setenvif.c>
|
|
||||||
SetEnvIf Authorization "(.*)" HTTP_AUTHORIZATION=$1
|
|
||||||
</IfModule>
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
use App\Kernel;
|
|
||||||
|
|
||||||
require_once dirname(__DIR__).'/vendor/autoload_runtime.php';
|
|
||||||
|
|
||||||
return function (array $context) {
|
|
||||||
return new Kernel($context['APP_ENV'], (bool) $context['APP_DEBUG']);
|
|
||||||
};
|
|
||||||
0
api/src/Controller/.gitignore
vendored
0
api/src/Controller/.gitignore
vendored
@@ -1,77 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Controller;
|
|
||||||
|
|
||||||
use App\Entity\Forum;
|
|
||||||
use Doctrine\ORM\EntityManagerInterface;
|
|
||||||
use Symfony\Bundle\SecurityBundle\Security;
|
|
||||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
|
||||||
use Symfony\Component\HttpFoundation\Request;
|
|
||||||
use Symfony\Component\Routing\Attribute\Route;
|
|
||||||
|
|
||||||
class ForumReorderController
|
|
||||||
{
|
|
||||||
public function __construct(
|
|
||||||
private EntityManagerInterface $entityManager,
|
|
||||||
private Security $security
|
|
||||||
) {
|
|
||||||
}
|
|
||||||
|
|
||||||
#[Route('/api/forums/reorder', name: 'api_forums_reorder', methods: ['POST'])]
|
|
||||||
public function __invoke(Request $request): JsonResponse
|
|
||||||
{
|
|
||||||
if (!$this->security->isGranted('ROLE_ADMIN')) {
|
|
||||||
return new JsonResponse(['message' => 'Forbidden'], JsonResponse::HTTP_FORBIDDEN);
|
|
||||||
}
|
|
||||||
|
|
||||||
$payload = json_decode($request->getContent(), true);
|
|
||||||
$orderedIds = $payload['orderedIds'] ?? null;
|
|
||||||
|
|
||||||
if (!is_array($orderedIds) || $orderedIds === []) {
|
|
||||||
return new JsonResponse(['message' => 'orderedIds must be a non-empty array.'], JsonResponse::HTTP_BAD_REQUEST);
|
|
||||||
}
|
|
||||||
|
|
||||||
$parentId = $payload['parentId'] ?? null;
|
|
||||||
$parent = null;
|
|
||||||
if (null !== $parentId) {
|
|
||||||
$parent = $this->entityManager->getRepository(Forum::class)->find($parentId);
|
|
||||||
if (!$parent instanceof Forum) {
|
|
||||||
return new JsonResponse(['message' => 'Parent not found.'], JsonResponse::HTTP_BAD_REQUEST);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$forums = $this->entityManager->getRepository(Forum::class)
|
|
||||||
->findBy(['id' => $orderedIds]);
|
|
||||||
|
|
||||||
if (count($forums) !== count($orderedIds)) {
|
|
||||||
return new JsonResponse(['message' => 'Some forums were not found.'], JsonResponse::HTTP_BAD_REQUEST);
|
|
||||||
}
|
|
||||||
|
|
||||||
$forumsById = [];
|
|
||||||
foreach ($forums as $forum) {
|
|
||||||
$forumsById[(string) $forum->getId()] = $forum;
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach ($orderedIds as $id) {
|
|
||||||
if (!isset($forumsById[(string) $id])) {
|
|
||||||
return new JsonResponse(['message' => 'Invalid forum list.'], JsonResponse::HTTP_BAD_REQUEST);
|
|
||||||
}
|
|
||||||
|
|
||||||
$forum = $forumsById[(string) $id];
|
|
||||||
$forumParent = $forum->getParent();
|
|
||||||
if (($parent === null && $forumParent !== null) || ($parent !== null && $forumParent?->getId() !== $parent->getId())) {
|
|
||||||
return new JsonResponse(['message' => 'Forums must share the same parent.'], JsonResponse::HTTP_BAD_REQUEST);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$position = 1;
|
|
||||||
foreach ($orderedIds as $id) {
|
|
||||||
$forumsById[(string) $id]->setPosition($position);
|
|
||||||
$position++;
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->entityManager->flush();
|
|
||||||
|
|
||||||
return new JsonResponse(['status' => 'ok']);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Controller;
|
|
||||||
|
|
||||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
|
||||||
use Symfony\Component\HttpFoundation\Response;
|
|
||||||
use Symfony\Component\Routing\Attribute\Route;
|
|
||||||
|
|
||||||
class FrontendController
|
|
||||||
{
|
|
||||||
public function __construct(
|
|
||||||
#[Autowire('%kernel.project_dir%')]
|
|
||||||
private string $projectDir
|
|
||||||
) {
|
|
||||||
}
|
|
||||||
|
|
||||||
#[Route('/', name: 'frontend_index')]
|
|
||||||
#[Route('/{path}', name: 'frontend_spa', requirements: ['path' => '^(?!api|app|_profiler|_wdt|bundles).+'])]
|
|
||||||
public function __invoke(): Response
|
|
||||||
{
|
|
||||||
$indexPath = $this->projectDir . '/public/app/index.html';
|
|
||||||
|
|
||||||
if (!is_file($indexPath)) {
|
|
||||||
return new Response(
|
|
||||||
'Frontend build not found. Run `npm run build` in the frontend folder.',
|
|
||||||
Response::HTTP_INTERNAL_SERVER_ERROR
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Response(file_get_contents($indexPath), Response::HTTP_OK, [
|
|
||||||
'Content-Type' => 'text/html; charset=UTF-8',
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Controller;
|
|
||||||
|
|
||||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
|
||||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
|
||||||
use Symfony\Component\Routing\Attribute\Route;
|
|
||||||
use Symfony\Contracts\Translation\TranslatorInterface;
|
|
||||||
|
|
||||||
class I18nController
|
|
||||||
{
|
|
||||||
public function __construct(
|
|
||||||
private TranslatorInterface $translator,
|
|
||||||
#[Autowire('%kernel.default_locale%')]
|
|
||||||
private string $defaultLocale
|
|
||||||
) {
|
|
||||||
}
|
|
||||||
|
|
||||||
#[Route(
|
|
||||||
'/api/i18n/{locale}',
|
|
||||||
name: 'api_i18n',
|
|
||||||
methods: ['GET'],
|
|
||||||
requirements: ['locale' => '[A-Za-z0-9_-]+']
|
|
||||||
)]
|
|
||||||
public function __invoke(string $locale): JsonResponse
|
|
||||||
{
|
|
||||||
$messages = $this->getMessagesForLocale($locale);
|
|
||||||
|
|
||||||
if (!$messages && $locale !== $this->defaultLocale) {
|
|
||||||
$messages = $this->getMessagesForLocale($this->defaultLocale);
|
|
||||||
}
|
|
||||||
|
|
||||||
return new JsonResponse($messages);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function getMessagesForLocale(string $locale): array
|
|
||||||
{
|
|
||||||
$catalogue = $this->translator->getCatalogue($locale);
|
|
||||||
$messages = $catalogue->all('messages');
|
|
||||||
|
|
||||||
$fallback = $catalogue->getFallbackCatalogue();
|
|
||||||
while ($fallback) {
|
|
||||||
foreach ($fallback->all('messages') as $key => $value) {
|
|
||||||
if (!array_key_exists($key, $messages)) {
|
|
||||||
$messages[$key] = $value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
$fallback = $fallback->getFallbackCatalogue();
|
|
||||||
}
|
|
||||||
|
|
||||||
ksort($messages);
|
|
||||||
|
|
||||||
return $messages;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Controller;
|
|
||||||
|
|
||||||
use App\Entity\Settings;
|
|
||||||
use Doctrine\ORM\EntityManagerInterface;
|
|
||||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
|
||||||
use Symfony\Component\Routing\Attribute\Route;
|
|
||||||
|
|
||||||
final class VersionController
|
|
||||||
{
|
|
||||||
#[Route('/api/version', name: 'api_version', methods: ['GET'])]
|
|
||||||
public function __invoke(EntityManagerInterface $entityManager): JsonResponse
|
|
||||||
{
|
|
||||||
$repository = $entityManager->getRepository(Settings::class);
|
|
||||||
$version = $repository->findOneBy(['key' => 'version']);
|
|
||||||
$build = $repository->findOneBy(['key' => 'build']);
|
|
||||||
|
|
||||||
return new JsonResponse([
|
|
||||||
'version' => $version?->getValue(),
|
|
||||||
'build' => $build ? (int) $build->getValue() : null,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
0
api/src/Entity/.gitignore
vendored
0
api/src/Entity/.gitignore
vendored
@@ -1,205 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Entity;
|
|
||||||
|
|
||||||
use ApiPlatform\Doctrine\Orm\Filter\ExistsFilter;
|
|
||||||
use ApiPlatform\Doctrine\Orm\Filter\SearchFilter;
|
|
||||||
use ApiPlatform\Metadata\ApiFilter;
|
|
||||||
use ApiPlatform\Metadata\ApiResource;
|
|
||||||
use ApiPlatform\Metadata\Delete;
|
|
||||||
use ApiPlatform\Metadata\Get;
|
|
||||||
use ApiPlatform\Metadata\GetCollection;
|
|
||||||
use ApiPlatform\Metadata\Patch;
|
|
||||||
use ApiPlatform\Metadata\Post;
|
|
||||||
use App\State\ForumPositionProcessor;
|
|
||||||
use Doctrine\Common\Collections\ArrayCollection;
|
|
||||||
use Doctrine\Common\Collections\Collection;
|
|
||||||
use Doctrine\ORM\Mapping as ORM;
|
|
||||||
//use Symfony\Component\Serializer\Annotation\Groups;
|
|
||||||
use Symfony\Component\Serializer\Attribute\Groups;
|
|
||||||
use Symfony\Component\Validator\Constraints as Assert;
|
|
||||||
|
|
||||||
#[ORM\Entity]
|
|
||||||
#[ORM\HasLifecycleCallbacks]
|
|
||||||
#[ORM\Table(indexes: [
|
|
||||||
new ORM\Index(name: 'idx_forum_parent_position', columns: ['parent_id', 'position']),
|
|
||||||
new ORM\Index(name: 'idx_forum_type', columns: ['type']),
|
|
||||||
])]
|
|
||||||
#[ApiFilter(SearchFilter::class, properties: ['parent' => 'exact', 'type' => 'exact'])]
|
|
||||||
#[ApiFilter(ExistsFilter::class, properties: ['parent'])]
|
|
||||||
#[ApiResource(
|
|
||||||
operations : [
|
|
||||||
new Get(),
|
|
||||||
new GetCollection(),
|
|
||||||
new Post(security: "is_granted('ROLE_ADMIN')", processor: ForumPositionProcessor::class),
|
|
||||||
new Patch(security: "is_granted('ROLE_ADMIN')", processor: ForumPositionProcessor::class),
|
|
||||||
new Delete(security: "is_granted('ROLE_ADMIN')")
|
|
||||||
],
|
|
||||||
normalizationContext : ['groups' => ['forum:read']],
|
|
||||||
denormalizationContext: ['groups' => ['forum:write']],
|
|
||||||
order : ['position' => 'ASC']
|
|
||||||
)]
|
|
||||||
class Forum
|
|
||||||
{
|
|
||||||
public const TYPE_CATEGORY = 'category';
|
|
||||||
public const TYPE_FORUM = 'forum';
|
|
||||||
|
|
||||||
#[ORM\Id]
|
|
||||||
#[ORM\GeneratedValue]
|
|
||||||
#[ORM\Column]
|
|
||||||
#[Groups(['forum:read', 'thread:read'])]
|
|
||||||
private ?int $id = null;
|
|
||||||
|
|
||||||
#[ORM\Column(length: 100)]
|
|
||||||
#[Assert\NotBlank]
|
|
||||||
#[Groups(['forum:read', 'forum:write', 'thread:read'])]
|
|
||||||
private ?string $name = null;
|
|
||||||
|
|
||||||
#[ORM\Column(type: 'text', nullable: true)]
|
|
||||||
#[Groups(['forum:read', 'forum:write'])]
|
|
||||||
private ?string $description = null;
|
|
||||||
|
|
||||||
#[ORM\Column(length: 20)]
|
|
||||||
#[Assert\Choice(choices: [self::TYPE_CATEGORY, self::TYPE_FORUM])]
|
|
||||||
#[Groups(['forum:read', 'forum:write'])]
|
|
||||||
private string $type = self::TYPE_CATEGORY;
|
|
||||||
|
|
||||||
#[ORM\ManyToOne(targetEntity: self::class, inversedBy: 'children')]
|
|
||||||
#[Assert\Expression(
|
|
||||||
"this.getParent() === null or this.getParent().isCategory()",
|
|
||||||
message: "Parent must be a category."
|
|
||||||
)]
|
|
||||||
#[Groups(['forum:read', 'forum:write'])]
|
|
||||||
private ?self $parent = null;
|
|
||||||
|
|
||||||
#[ORM\OneToMany(mappedBy: 'parent', targetEntity: self::class)]
|
|
||||||
#[Groups(['forum:read'])]
|
|
||||||
private Collection $children;
|
|
||||||
|
|
||||||
#[ORM\OneToMany(mappedBy: 'forum', targetEntity: Thread::class)]
|
|
||||||
#[Groups(['forum:read'])]
|
|
||||||
private Collection $threads;
|
|
||||||
|
|
||||||
#[ORM\Column]
|
|
||||||
#[Groups(['forum:read'])]
|
|
||||||
private int $position = 0;
|
|
||||||
|
|
||||||
#[ORM\Column]
|
|
||||||
#[Groups(['forum:read'])]
|
|
||||||
private ?\DateTimeImmutable $createdAt = null;
|
|
||||||
|
|
||||||
#[ORM\Column]
|
|
||||||
#[Groups(['forum:read'])]
|
|
||||||
private ?\DateTimeImmutable $updatedAt = null;
|
|
||||||
|
|
||||||
public function __construct()
|
|
||||||
{
|
|
||||||
$this->children = new ArrayCollection();
|
|
||||||
$this->threads = new ArrayCollection();
|
|
||||||
}
|
|
||||||
|
|
||||||
#[ORM\PrePersist]
|
|
||||||
public function onCreate(): void
|
|
||||||
{
|
|
||||||
$now = new \DateTimeImmutable();
|
|
||||||
$this->createdAt = $now;
|
|
||||||
$this->updatedAt = $now;
|
|
||||||
}
|
|
||||||
|
|
||||||
#[ORM\PreUpdate]
|
|
||||||
public function onUpdate(): void
|
|
||||||
{
|
|
||||||
$this->updatedAt = new \DateTimeImmutable();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getId(): ?int
|
|
||||||
{
|
|
||||||
return $this->id;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getName(): ?string
|
|
||||||
{
|
|
||||||
return $this->name;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function setName(string $name): self
|
|
||||||
{
|
|
||||||
$this->name = $name;
|
|
||||||
|
|
||||||
return $this;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getDescription(): ?string
|
|
||||||
{
|
|
||||||
return $this->description;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function setDescription(?string $description): self
|
|
||||||
{
|
|
||||||
$this->description = $description;
|
|
||||||
|
|
||||||
return $this;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getType(): string
|
|
||||||
{
|
|
||||||
return $this->type;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function setType(string $type): self
|
|
||||||
{
|
|
||||||
$this->type = $type;
|
|
||||||
|
|
||||||
return $this;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getParent(): ?self
|
|
||||||
{
|
|
||||||
return $this->parent;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function setParent(?self $parent): self
|
|
||||||
{
|
|
||||||
$this->parent = $parent;
|
|
||||||
|
|
||||||
return $this;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return Collection<int, Forum>
|
|
||||||
*/
|
|
||||||
public function getChildren(): Collection
|
|
||||||
{
|
|
||||||
return $this->children;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getPosition(): int
|
|
||||||
{
|
|
||||||
return $this->position;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function setPosition(int $position): self
|
|
||||||
{
|
|
||||||
$this->position = $position;
|
|
||||||
|
|
||||||
return $this;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return Collection<int, Thread>
|
|
||||||
*/
|
|
||||||
public function getThreads(): Collection
|
|
||||||
{
|
|
||||||
return $this->threads;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function isCategory(): bool
|
|
||||||
{
|
|
||||||
return $this->type === self::TYPE_CATEGORY;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function isForum(): bool
|
|
||||||
{
|
|
||||||
return $this->type === self::TYPE_FORUM;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,131 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Entity;
|
|
||||||
|
|
||||||
use ApiPlatform\Doctrine\Orm\Filter\SearchFilter;
|
|
||||||
use ApiPlatform\Metadata\ApiFilter;
|
|
||||||
use ApiPlatform\Metadata\ApiResource;
|
|
||||||
use ApiPlatform\Metadata\Delete;
|
|
||||||
use ApiPlatform\Metadata\Get;
|
|
||||||
use ApiPlatform\Metadata\GetCollection;
|
|
||||||
use ApiPlatform\Metadata\Patch;
|
|
||||||
use ApiPlatform\Metadata\Post as PostOperation;
|
|
||||||
use App\State\PostOwnerProcessor;
|
|
||||||
use Doctrine\ORM\Mapping as ORM;
|
|
||||||
use Symfony\Component\Serializer\Annotation\Groups;
|
|
||||||
use Symfony\Component\Validator\Constraints as Assert;
|
|
||||||
|
|
||||||
#[ORM\Entity]
|
|
||||||
#[ORM\HasLifecycleCallbacks]
|
|
||||||
#[ApiFilter(SearchFilter::class, properties: ['thread' => 'exact'])]
|
|
||||||
#[ApiResource(
|
|
||||||
normalizationContext: ['groups' => ['post:read']],
|
|
||||||
denormalizationContext: ['groups' => ['post:write']],
|
|
||||||
operations: [
|
|
||||||
new Get(),
|
|
||||||
new GetCollection(),
|
|
||||||
new PostOperation(
|
|
||||||
security: "is_granted('ROLE_USER')",
|
|
||||||
processor: PostOwnerProcessor::class
|
|
||||||
),
|
|
||||||
new Patch(security: "is_granted('ROLE_ADMIN') or object.getAuthor() == user"),
|
|
||||||
new Delete(security: "is_granted('ROLE_ADMIN') or object.getAuthor() == user")
|
|
||||||
]
|
|
||||||
)]
|
|
||||||
class Post
|
|
||||||
{
|
|
||||||
#[ORM\Id]
|
|
||||||
#[ORM\GeneratedValue]
|
|
||||||
#[ORM\Column]
|
|
||||||
#[Groups(['post:read', 'thread:read'])]
|
|
||||||
private ?int $id = null;
|
|
||||||
|
|
||||||
#[ORM\Column(type: 'text')]
|
|
||||||
#[Assert\NotBlank]
|
|
||||||
#[Groups(['post:read', 'post:write', 'thread:read'])]
|
|
||||||
private ?string $body = null;
|
|
||||||
|
|
||||||
#[ORM\ManyToOne(targetEntity: Thread::class, inversedBy: 'posts')]
|
|
||||||
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
|
|
||||||
#[Assert\NotNull]
|
|
||||||
#[Groups(['post:read', 'post:write'])]
|
|
||||||
private ?Thread $thread = null;
|
|
||||||
|
|
||||||
#[ORM\ManyToOne(targetEntity: User::class, inversedBy: 'posts')]
|
|
||||||
#[ORM\JoinColumn(nullable: true, onDelete: 'SET NULL')]
|
|
||||||
#[Groups(['post:read'])]
|
|
||||||
private ?User $author = null;
|
|
||||||
|
|
||||||
#[ORM\Column]
|
|
||||||
#[Groups(['post:read'])]
|
|
||||||
private ?\DateTimeImmutable $createdAt = null;
|
|
||||||
|
|
||||||
#[ORM\Column]
|
|
||||||
#[Groups(['post:read'])]
|
|
||||||
private ?\DateTimeImmutable $updatedAt = null;
|
|
||||||
|
|
||||||
#[ORM\PrePersist]
|
|
||||||
public function onCreate(): void
|
|
||||||
{
|
|
||||||
$now = new \DateTimeImmutable();
|
|
||||||
$this->createdAt = $now;
|
|
||||||
$this->updatedAt = $now;
|
|
||||||
}
|
|
||||||
|
|
||||||
#[ORM\PreUpdate]
|
|
||||||
public function onUpdate(): void
|
|
||||||
{
|
|
||||||
$this->updatedAt = new \DateTimeImmutable();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getId(): ?int
|
|
||||||
{
|
|
||||||
return $this->id;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getBody(): ?string
|
|
||||||
{
|
|
||||||
return $this->body;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function setBody(string $body): self
|
|
||||||
{
|
|
||||||
$this->body = $body;
|
|
||||||
|
|
||||||
return $this;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getThread(): ?Thread
|
|
||||||
{
|
|
||||||
return $this->thread;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function setThread(?Thread $thread): self
|
|
||||||
{
|
|
||||||
$this->thread = $thread;
|
|
||||||
|
|
||||||
return $this;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getAuthor(): ?User
|
|
||||||
{
|
|
||||||
return $this->author;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function setAuthor(?User $author): self
|
|
||||||
{
|
|
||||||
$this->author = $author;
|
|
||||||
|
|
||||||
return $this;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getCreatedAt(): ?\DateTimeImmutable
|
|
||||||
{
|
|
||||||
return $this->createdAt;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getUpdatedAt(): ?\DateTimeImmutable
|
|
||||||
{
|
|
||||||
return $this->updatedAt;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,69 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Entity;
|
|
||||||
|
|
||||||
use ApiPlatform\Metadata\ApiResource;
|
|
||||||
use ApiPlatform\Metadata\Get;
|
|
||||||
use ApiPlatform\Metadata\GetCollection;
|
|
||||||
use ApiPlatform\Metadata\Patch;
|
|
||||||
use ApiPlatform\Doctrine\Orm\Filter\SearchFilter;
|
|
||||||
use ApiPlatform\Metadata\ApiFilter;
|
|
||||||
use Doctrine\ORM\Mapping as ORM;
|
|
||||||
use Symfony\Component\Serializer\Attribute\Groups;
|
|
||||||
|
|
||||||
#[ORM\Entity]
|
|
||||||
#[ApiFilter(SearchFilter::class, properties: ['key' => 'exact'])]
|
|
||||||
#[ApiResource(
|
|
||||||
operations : [
|
|
||||||
new Get(),
|
|
||||||
new GetCollection(),
|
|
||||||
new Patch(security: "is_granted('ROLE_ADMIN')")
|
|
||||||
],
|
|
||||||
normalizationContext : ['groups' => ['settings:read']],
|
|
||||||
denormalizationContext: ['groups' => ['settings:write']]
|
|
||||||
)]
|
|
||||||
class Settings
|
|
||||||
{
|
|
||||||
#[ORM\Id]
|
|
||||||
#[ORM\GeneratedValue]
|
|
||||||
#[ORM\Column]
|
|
||||||
#[Groups(['settings:read'])]
|
|
||||||
private ?int $id = null;
|
|
||||||
|
|
||||||
#[ORM\Column(length: 100, unique: true)]
|
|
||||||
#[Groups(['settings:read', 'settings:write'])]
|
|
||||||
private string $key = '';
|
|
||||||
|
|
||||||
#[ORM\Column(type: 'text')]
|
|
||||||
#[Groups(['settings:read', 'settings:write'])]
|
|
||||||
private string $value = '';
|
|
||||||
|
|
||||||
public function getId(): ?int
|
|
||||||
{
|
|
||||||
return $this->id;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getKey(): string
|
|
||||||
{
|
|
||||||
return $this->key;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function setKey(string $key): self
|
|
||||||
{
|
|
||||||
$this->key = $key;
|
|
||||||
|
|
||||||
return $this;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getValue(): string
|
|
||||||
{
|
|
||||||
return $this->value;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function setValue(string $value): self
|
|
||||||
{
|
|
||||||
$this->value = $value;
|
|
||||||
|
|
||||||
return $this;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,168 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Entity;
|
|
||||||
|
|
||||||
use ApiPlatform\Doctrine\Orm\Filter\SearchFilter;
|
|
||||||
use ApiPlatform\Metadata\ApiFilter;
|
|
||||||
use ApiPlatform\Metadata\ApiResource;
|
|
||||||
use ApiPlatform\Metadata\Delete;
|
|
||||||
use ApiPlatform\Metadata\Get;
|
|
||||||
use ApiPlatform\Metadata\GetCollection;
|
|
||||||
use ApiPlatform\Metadata\Patch;
|
|
||||||
use ApiPlatform\Metadata\Post as PostOperation;
|
|
||||||
use App\State\ThreadOwnerProcessor;
|
|
||||||
use Doctrine\Common\Collections\ArrayCollection;
|
|
||||||
use Doctrine\Common\Collections\Collection;
|
|
||||||
use Doctrine\ORM\Mapping as ORM;
|
|
||||||
use Symfony\Component\Serializer\Annotation\Groups;
|
|
||||||
use Symfony\Component\Validator\Constraints as Assert;
|
|
||||||
|
|
||||||
#[ORM\Entity]
|
|
||||||
#[ORM\HasLifecycleCallbacks]
|
|
||||||
#[ApiFilter(SearchFilter::class, properties: ['forum' => 'exact'])]
|
|
||||||
#[ApiResource(
|
|
||||||
normalizationContext: ['groups' => ['thread:read']],
|
|
||||||
denormalizationContext: ['groups' => ['thread:write']],
|
|
||||||
operations: [
|
|
||||||
new Get(),
|
|
||||||
new GetCollection(),
|
|
||||||
new PostOperation(
|
|
||||||
security: "is_granted('ROLE_USER')",
|
|
||||||
processor: ThreadOwnerProcessor::class
|
|
||||||
),
|
|
||||||
new Patch(security: "is_granted('ROLE_ADMIN') or object.getAuthor() == user"),
|
|
||||||
new Delete(security: "is_granted('ROLE_ADMIN') or object.getAuthor() == user")
|
|
||||||
]
|
|
||||||
)]
|
|
||||||
class Thread
|
|
||||||
{
|
|
||||||
#[ORM\Id]
|
|
||||||
#[ORM\GeneratedValue]
|
|
||||||
#[ORM\Column]
|
|
||||||
#[Groups(['thread:read', 'forum:read', 'post:read'])]
|
|
||||||
private ?int $id = null;
|
|
||||||
|
|
||||||
#[ORM\Column(length: 200)]
|
|
||||||
#[Assert\NotBlank]
|
|
||||||
#[Groups(['thread:read', 'thread:write', 'forum:read', 'post:read'])]
|
|
||||||
private ?string $title = null;
|
|
||||||
|
|
||||||
#[ORM\Column(type: 'text')]
|
|
||||||
#[Assert\NotBlank]
|
|
||||||
#[Groups(['thread:read', 'thread:write'])]
|
|
||||||
private ?string $body = null;
|
|
||||||
|
|
||||||
#[ORM\ManyToOne(targetEntity: Forum::class, inversedBy: 'threads')]
|
|
||||||
#[ORM\JoinColumn(nullable: false)]
|
|
||||||
#[Assert\NotNull]
|
|
||||||
#[Assert\Expression("this.getForum() and this.getForum().isForum()", message: "Thread must belong to a forum.")]
|
|
||||||
#[Groups(['thread:read', 'thread:write'])]
|
|
||||||
private ?Forum $forum = null;
|
|
||||||
|
|
||||||
#[ORM\ManyToOne(targetEntity: User::class, inversedBy: 'threads')]
|
|
||||||
#[ORM\JoinColumn(nullable: true, onDelete: 'SET NULL')]
|
|
||||||
#[Groups(['thread:read'])]
|
|
||||||
private ?User $author = null;
|
|
||||||
|
|
||||||
#[ORM\Column]
|
|
||||||
#[Groups(['thread:read'])]
|
|
||||||
private ?\DateTimeImmutable $createdAt = null;
|
|
||||||
|
|
||||||
#[ORM\Column]
|
|
||||||
#[Groups(['thread:read'])]
|
|
||||||
private ?\DateTimeImmutable $updatedAt = null;
|
|
||||||
|
|
||||||
#[ORM\OneToMany(mappedBy: 'thread', targetEntity: Post::class)]
|
|
||||||
#[Groups(['thread:read'])]
|
|
||||||
private Collection $posts;
|
|
||||||
|
|
||||||
public function __construct()
|
|
||||||
{
|
|
||||||
$this->posts = new ArrayCollection();
|
|
||||||
}
|
|
||||||
|
|
||||||
#[ORM\PrePersist]
|
|
||||||
public function onCreate(): void
|
|
||||||
{
|
|
||||||
$now = new \DateTimeImmutable();
|
|
||||||
$this->createdAt = $now;
|
|
||||||
$this->updatedAt = $now;
|
|
||||||
}
|
|
||||||
|
|
||||||
#[ORM\PreUpdate]
|
|
||||||
public function onUpdate(): void
|
|
||||||
{
|
|
||||||
$this->updatedAt = new \DateTimeImmutable();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getId(): ?int
|
|
||||||
{
|
|
||||||
return $this->id;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getTitle(): ?string
|
|
||||||
{
|
|
||||||
return $this->title;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function setTitle(string $title): self
|
|
||||||
{
|
|
||||||
$this->title = $title;
|
|
||||||
|
|
||||||
return $this;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getBody(): ?string
|
|
||||||
{
|
|
||||||
return $this->body;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function setBody(string $body): self
|
|
||||||
{
|
|
||||||
$this->body = $body;
|
|
||||||
|
|
||||||
return $this;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getForum(): ?Forum
|
|
||||||
{
|
|
||||||
return $this->forum;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function setForum(?Forum $forum): self
|
|
||||||
{
|
|
||||||
$this->forum = $forum;
|
|
||||||
|
|
||||||
return $this;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getAuthor(): ?User
|
|
||||||
{
|
|
||||||
return $this->author;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function setAuthor(?User $author): self
|
|
||||||
{
|
|
||||||
$this->author = $author;
|
|
||||||
|
|
||||||
return $this;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getCreatedAt(): ?\DateTimeImmutable
|
|
||||||
{
|
|
||||||
return $this->createdAt;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getUpdatedAt(): ?\DateTimeImmutable
|
|
||||||
{
|
|
||||||
return $this->updatedAt;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return Collection<int, Post>
|
|
||||||
*/
|
|
||||||
public function getPosts(): Collection
|
|
||||||
{
|
|
||||||
return $this->posts;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,212 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Entity;
|
|
||||||
|
|
||||||
use ApiPlatform\Metadata\ApiResource;
|
|
||||||
use ApiPlatform\Metadata\Delete;
|
|
||||||
use ApiPlatform\Metadata\Get;
|
|
||||||
use ApiPlatform\Metadata\GetCollection;
|
|
||||||
use ApiPlatform\Metadata\Patch;
|
|
||||||
use ApiPlatform\Metadata\Post as PostOperation;
|
|
||||||
use App\State\UserPasswordHasherProcessor;
|
|
||||||
use Doctrine\Common\Collections\ArrayCollection;
|
|
||||||
use Doctrine\Common\Collections\Collection;
|
|
||||||
use Doctrine\ORM\Mapping as ORM;
|
|
||||||
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
|
|
||||||
use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface;
|
|
||||||
use Symfony\Component\Security\Core\User\UserInterface;
|
|
||||||
use Symfony\Component\Serializer\Annotation\Groups;
|
|
||||||
use Symfony\Component\Validator\Constraints as Assert;
|
|
||||||
|
|
||||||
#[ORM\Entity]
|
|
||||||
#[ORM\Table(name: 'users')]
|
|
||||||
#[UniqueEntity(fields: ['email'])]
|
|
||||||
#[UniqueEntity(fields: ['username'])]
|
|
||||||
#[ORM\HasLifecycleCallbacks]
|
|
||||||
#[ApiResource(
|
|
||||||
normalizationContext: ['groups' => ['user:read']],
|
|
||||||
denormalizationContext: ['groups' => ['user:write']],
|
|
||||||
operations: [
|
|
||||||
new Get(security: "is_granted('ROLE_ADMIN')"),
|
|
||||||
new GetCollection(security: "is_granted('ROLE_ADMIN')"),
|
|
||||||
new PostOperation(
|
|
||||||
security: "is_granted('PUBLIC_ACCESS')",
|
|
||||||
processor: UserPasswordHasherProcessor::class,
|
|
||||||
validationContext: ['groups' => ['Default', 'user:create']]
|
|
||||||
),
|
|
||||||
new Patch(
|
|
||||||
security: "is_granted('ROLE_ADMIN') or object == user",
|
|
||||||
processor: UserPasswordHasherProcessor::class
|
|
||||||
),
|
|
||||||
new Delete(security: "is_granted('ROLE_ADMIN')")
|
|
||||||
]
|
|
||||||
)]
|
|
||||||
class User implements UserInterface, PasswordAuthenticatedUserInterface
|
|
||||||
{
|
|
||||||
#[ORM\Id]
|
|
||||||
#[ORM\GeneratedValue]
|
|
||||||
#[ORM\Column]
|
|
||||||
#[Groups(['user:read', 'thread:read', 'post:read'])]
|
|
||||||
private ?int $id = null;
|
|
||||||
|
|
||||||
#[ORM\Column(length: 180, unique: true)]
|
|
||||||
#[Assert\NotBlank]
|
|
||||||
#[Assert\Email]
|
|
||||||
#[Groups(['user:read', 'user:write', 'thread:read', 'post:read'])]
|
|
||||||
private ?string $email = null;
|
|
||||||
|
|
||||||
#[ORM\Column(length: 50, unique: true)]
|
|
||||||
#[Assert\NotBlank]
|
|
||||||
#[Assert\Length(min: 3, max: 50)]
|
|
||||||
#[Groups(['user:read', 'user:write', 'thread:read', 'post:read'])]
|
|
||||||
private ?string $username = null;
|
|
||||||
|
|
||||||
#[ORM\Column]
|
|
||||||
private array $roles = [];
|
|
||||||
|
|
||||||
#[ORM\Column]
|
|
||||||
private ?string $password = null;
|
|
||||||
|
|
||||||
#[Assert\NotBlank(groups: ['user:create'])]
|
|
||||||
#[Assert\Length(min: 8)]
|
|
||||||
#[Groups(['user:write'])]
|
|
||||||
private ?string $plainPassword = null;
|
|
||||||
|
|
||||||
#[ORM\Column]
|
|
||||||
#[Groups(['user:read'])]
|
|
||||||
private ?\DateTimeImmutable $createdAt = null;
|
|
||||||
|
|
||||||
#[ORM\Column]
|
|
||||||
#[Groups(['user:read'])]
|
|
||||||
private ?\DateTimeImmutable $updatedAt = null;
|
|
||||||
|
|
||||||
#[ORM\OneToMany(mappedBy: 'author', targetEntity: Thread::class)]
|
|
||||||
private Collection $threads;
|
|
||||||
|
|
||||||
#[ORM\OneToMany(mappedBy: 'author', targetEntity: Post::class)]
|
|
||||||
private Collection $posts;
|
|
||||||
|
|
||||||
public function __construct()
|
|
||||||
{
|
|
||||||
$this->threads = new ArrayCollection();
|
|
||||||
$this->posts = new ArrayCollection();
|
|
||||||
}
|
|
||||||
|
|
||||||
#[ORM\PrePersist]
|
|
||||||
public function onCreate(): void
|
|
||||||
{
|
|
||||||
$now = new \DateTimeImmutable();
|
|
||||||
$this->createdAt = $now;
|
|
||||||
$this->updatedAt = $now;
|
|
||||||
}
|
|
||||||
|
|
||||||
#[ORM\PreUpdate]
|
|
||||||
public function onUpdate(): void
|
|
||||||
{
|
|
||||||
$this->updatedAt = new \DateTimeImmutable();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getId(): ?int
|
|
||||||
{
|
|
||||||
return $this->id;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getEmail(): ?string
|
|
||||||
{
|
|
||||||
return $this->email;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function setEmail(string $email): self
|
|
||||||
{
|
|
||||||
$this->email = $email;
|
|
||||||
|
|
||||||
return $this;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getUsername(): ?string
|
|
||||||
{
|
|
||||||
return $this->username;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function setUsername(string $username): self
|
|
||||||
{
|
|
||||||
$this->username = $username;
|
|
||||||
|
|
||||||
return $this;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getUserIdentifier(): string
|
|
||||||
{
|
|
||||||
return (string) $this->email;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getRoles(): array
|
|
||||||
{
|
|
||||||
$roles = $this->roles;
|
|
||||||
$roles[] = 'ROLE_USER';
|
|
||||||
|
|
||||||
return array_unique($roles);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function setRoles(array $roles): self
|
|
||||||
{
|
|
||||||
$this->roles = $roles;
|
|
||||||
|
|
||||||
return $this;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getPassword(): ?string
|
|
||||||
{
|
|
||||||
return $this->password;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function setPassword(string $password): self
|
|
||||||
{
|
|
||||||
$this->password = $password;
|
|
||||||
|
|
||||||
return $this;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getPlainPassword(): ?string
|
|
||||||
{
|
|
||||||
return $this->plainPassword;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function setPlainPassword(?string $plainPassword): self
|
|
||||||
{
|
|
||||||
$this->plainPassword = $plainPassword;
|
|
||||||
|
|
||||||
return $this;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function eraseCredentials(): void
|
|
||||||
{
|
|
||||||
$this->plainPassword = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getCreatedAt(): ?\DateTimeImmutable
|
|
||||||
{
|
|
||||||
return $this->createdAt;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getUpdatedAt(): ?\DateTimeImmutable
|
|
||||||
{
|
|
||||||
return $this->updatedAt;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return Collection<int, Thread>
|
|
||||||
*/
|
|
||||||
public function getThreads(): Collection
|
|
||||||
{
|
|
||||||
return $this->threads;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return Collection<int, Post>
|
|
||||||
*/
|
|
||||||
public function getPosts(): Collection
|
|
||||||
{
|
|
||||||
return $this->posts;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\EventSubscriber;
|
|
||||||
|
|
||||||
use App\Entity\User;
|
|
||||||
use Lexik\Bundle\JWTAuthenticationBundle\Event\JWTCreatedEvent;
|
|
||||||
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
|
|
||||||
|
|
||||||
#[AsEventListener(event: 'lexik_jwt_authentication.on_jwt_created')]
|
|
||||||
class JwtCreatedSubscriber
|
|
||||||
{
|
|
||||||
public function __invoke(JWTCreatedEvent $event): void
|
|
||||||
{
|
|
||||||
$user = $event->getUser();
|
|
||||||
if (!$user instanceof User) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$payload = $event->getData();
|
|
||||||
$payload['user_id'] = $user->getId();
|
|
||||||
$payload['username'] = $user->getEmail();
|
|
||||||
$payload['display_name'] = $user->getUsername();
|
|
||||||
$event->setData($payload);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App;
|
|
||||||
|
|
||||||
use Symfony\Bundle\FrameworkBundle\Kernel\MicroKernelTrait;
|
|
||||||
use Symfony\Component\HttpKernel\Kernel as BaseKernel;
|
|
||||||
|
|
||||||
class Kernel extends BaseKernel
|
|
||||||
{
|
|
||||||
use MicroKernelTrait;
|
|
||||||
}
|
|
||||||
0
api/src/Repository/.gitignore
vendored
0
api/src/Repository/.gitignore
vendored
@@ -1,47 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\State;
|
|
||||||
|
|
||||||
use ApiPlatform\Metadata\Operation;
|
|
||||||
use ApiPlatform\State\ProcessorInterface;
|
|
||||||
use App\Entity\Forum;
|
|
||||||
use Doctrine\ORM\EntityManagerInterface;
|
|
||||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
|
||||||
|
|
||||||
class ForumPositionProcessor implements ProcessorInterface
|
|
||||||
{
|
|
||||||
public function __construct(
|
|
||||||
private EntityManagerInterface $entityManager,
|
|
||||||
#[Autowire(service: 'api_platform.doctrine.orm.state.persist_processor')]
|
|
||||||
private ProcessorInterface $persistProcessor
|
|
||||||
) {
|
|
||||||
}
|
|
||||||
|
|
||||||
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed
|
|
||||||
{
|
|
||||||
if (!$data instanceof Forum) {
|
|
||||||
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
|
|
||||||
}
|
|
||||||
|
|
||||||
$previous = $context['previous_data'] ?? null;
|
|
||||||
$parentChanged = $previous instanceof Forum && $previous->getParent()?->getId() !== $data->getParent()?->getId();
|
|
||||||
|
|
||||||
if ($data->getPosition() === 0 || $parentChanged) {
|
|
||||||
$qb = $this->entityManager->createQueryBuilder();
|
|
||||||
$qb->select('COALESCE(MAX(f.position), 0)')
|
|
||||||
->from(Forum::class, 'f');
|
|
||||||
|
|
||||||
if ($data->getParent()) {
|
|
||||||
$qb->andWhere('f.parent = :parent')
|
|
||||||
->setParameter('parent', $data->getParent());
|
|
||||||
} else {
|
|
||||||
$qb->andWhere('f.parent IS NULL');
|
|
||||||
}
|
|
||||||
|
|
||||||
$maxPosition = (int) $qb->getQuery()->getSingleScalarResult();
|
|
||||||
$data->setPosition($maxPosition + 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\State;
|
|
||||||
|
|
||||||
use ApiPlatform\Metadata\Operation;
|
|
||||||
use ApiPlatform\State\ProcessorInterface;
|
|
||||||
use App\Entity\Post;
|
|
||||||
use App\Entity\User;
|
|
||||||
use Symfony\Bundle\SecurityBundle\Security;
|
|
||||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
|
||||||
|
|
||||||
class PostOwnerProcessor implements ProcessorInterface
|
|
||||||
{
|
|
||||||
public function __construct(
|
|
||||||
private Security $security,
|
|
||||||
#[Autowire(service: 'api_platform.doctrine.orm.state.persist_processor')]
|
|
||||||
private ProcessorInterface $persistProcessor
|
|
||||||
) {
|
|
||||||
}
|
|
||||||
|
|
||||||
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed
|
|
||||||
{
|
|
||||||
if ($data instanceof Post && null === $data->getAuthor()) {
|
|
||||||
$user = $this->security->getUser();
|
|
||||||
if ($user instanceof User) {
|
|
||||||
$data->setAuthor($user);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\State;
|
|
||||||
|
|
||||||
use ApiPlatform\Metadata\Operation;
|
|
||||||
use ApiPlatform\State\ProcessorInterface;
|
|
||||||
use App\Entity\Thread;
|
|
||||||
use App\Entity\User;
|
|
||||||
use Symfony\Bundle\SecurityBundle\Security;
|
|
||||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
|
||||||
|
|
||||||
class ThreadOwnerProcessor implements ProcessorInterface
|
|
||||||
{
|
|
||||||
public function __construct(
|
|
||||||
private Security $security,
|
|
||||||
#[Autowire(service: 'api_platform.doctrine.orm.state.persist_processor')]
|
|
||||||
private ProcessorInterface $persistProcessor
|
|
||||||
) {
|
|
||||||
}
|
|
||||||
|
|
||||||
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed
|
|
||||||
{
|
|
||||||
if ($data instanceof Thread && null === $data->getAuthor()) {
|
|
||||||
$user = $this->security->getUser();
|
|
||||||
if ($user instanceof User) {
|
|
||||||
$data->setAuthor($user);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\State;
|
|
||||||
|
|
||||||
use ApiPlatform\Metadata\Operation;
|
|
||||||
use ApiPlatform\State\ProcessorInterface;
|
|
||||||
use App\Entity\User;
|
|
||||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
|
||||||
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
|
|
||||||
|
|
||||||
class UserPasswordHasherProcessor implements ProcessorInterface
|
|
||||||
{
|
|
||||||
public function __construct(
|
|
||||||
private UserPasswordHasherInterface $passwordHasher,
|
|
||||||
#[Autowire(service: 'api_platform.doctrine.orm.state.persist_processor')]
|
|
||||||
private ProcessorInterface $persistProcessor
|
|
||||||
) {
|
|
||||||
}
|
|
||||||
|
|
||||||
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed
|
|
||||||
{
|
|
||||||
if ($data instanceof User && $data->getPlainPassword()) {
|
|
||||||
$data->setPassword(
|
|
||||||
$this->passwordHasher->hashPassword($data, $data->getPlainPassword())
|
|
||||||
);
|
|
||||||
$data->eraseCredentials();
|
|
||||||
}
|
|
||||||
|
|
||||||
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
203
api/symfony.lock
203
api/symfony.lock
@@ -1,203 +0,0 @@
|
|||||||
{
|
|
||||||
"api-platform/symfony": {
|
|
||||||
"version": "4.2",
|
|
||||||
"recipe": {
|
|
||||||
"repo": "github.com/symfony/recipes",
|
|
||||||
"branch": "main",
|
|
||||||
"version": "4.0",
|
|
||||||
"ref": "e9952e9f393c2d048f10a78f272cd35e807d972b"
|
|
||||||
},
|
|
||||||
"files": [
|
|
||||||
"config/packages/api_platform.yaml",
|
|
||||||
"config/routes/api_platform.yaml",
|
|
||||||
"src/ApiResource/.gitignore"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"doctrine/deprecations": {
|
|
||||||
"version": "1.1",
|
|
||||||
"recipe": {
|
|
||||||
"repo": "github.com/symfony/recipes",
|
|
||||||
"branch": "main",
|
|
||||||
"version": "1.0",
|
|
||||||
"ref": "87424683adc81d7dc305eefec1fced883084aab9"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"doctrine/doctrine-bundle": {
|
|
||||||
"version": "3.2",
|
|
||||||
"recipe": {
|
|
||||||
"repo": "github.com/symfony/recipes",
|
|
||||||
"branch": "main",
|
|
||||||
"version": "3.0",
|
|
||||||
"ref": "18ee08e513ba0303fd09a01fc1c934870af06ffa"
|
|
||||||
},
|
|
||||||
"files": [
|
|
||||||
"config/packages/doctrine.yaml",
|
|
||||||
"src/Entity/.gitignore",
|
|
||||||
"src/Repository/.gitignore"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"doctrine/doctrine-migrations-bundle": {
|
|
||||||
"version": "4.0",
|
|
||||||
"recipe": {
|
|
||||||
"repo": "github.com/symfony/recipes",
|
|
||||||
"branch": "main",
|
|
||||||
"version": "3.1",
|
|
||||||
"ref": "1d01ec03c6ecbd67c3375c5478c9a423ae5d6a33"
|
|
||||||
},
|
|
||||||
"files": [
|
|
||||||
"config/packages/doctrine_migrations.yaml",
|
|
||||||
"migrations/.gitignore"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"lexik/jwt-authentication-bundle": {
|
|
||||||
"version": "3.2",
|
|
||||||
"recipe": {
|
|
||||||
"repo": "github.com/symfony/recipes",
|
|
||||||
"branch": "main",
|
|
||||||
"version": "2.5",
|
|
||||||
"ref": "e9481b233a11ef7e15fe055a2b21fd3ac1aa2bb7"
|
|
||||||
},
|
|
||||||
"files": [
|
|
||||||
"config/packages/lexik_jwt_authentication.yaml"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"symfony/apache-pack": {
|
|
||||||
"version": "1.0",
|
|
||||||
"recipe": {
|
|
||||||
"repo": "github.com/symfony/recipes-contrib",
|
|
||||||
"branch": "main",
|
|
||||||
"version": "1.0",
|
|
||||||
"ref": "5d454ec6cc4c700ed3d963f3803e1d427d9669fb"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"symfony/console": {
|
|
||||||
"version": "8.0",
|
|
||||||
"recipe": {
|
|
||||||
"repo": "github.com/symfony/recipes",
|
|
||||||
"branch": "main",
|
|
||||||
"version": "5.3",
|
|
||||||
"ref": "1781ff40d8a17d87cf53f8d4cf0c8346ed2bb461"
|
|
||||||
},
|
|
||||||
"files": [
|
|
||||||
"bin/console"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"symfony/flex": {
|
|
||||||
"version": "2.10",
|
|
||||||
"recipe": {
|
|
||||||
"repo": "github.com/symfony/recipes",
|
|
||||||
"branch": "main",
|
|
||||||
"version": "2.4",
|
|
||||||
"ref": "52e9754527a15e2b79d9a610f98185a1fe46622a"
|
|
||||||
},
|
|
||||||
"files": [
|
|
||||||
".env",
|
|
||||||
".env.dev"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"symfony/framework-bundle": {
|
|
||||||
"version": "8.0",
|
|
||||||
"recipe": {
|
|
||||||
"repo": "github.com/symfony/recipes",
|
|
||||||
"branch": "main",
|
|
||||||
"version": "7.4",
|
|
||||||
"ref": "09f6e081c763a206802674ce0cb34a022f0ffc6d"
|
|
||||||
},
|
|
||||||
"files": [
|
|
||||||
"config/packages/cache.yaml",
|
|
||||||
"config/packages/framework.yaml",
|
|
||||||
"config/preload.php",
|
|
||||||
"config/routes/framework.yaml",
|
|
||||||
"config/services.yaml",
|
|
||||||
"public/index.php",
|
|
||||||
"src/Controller/.gitignore",
|
|
||||||
"src/Kernel.php",
|
|
||||||
".editorconfig"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"symfony/property-info": {
|
|
||||||
"version": "8.0",
|
|
||||||
"recipe": {
|
|
||||||
"repo": "github.com/symfony/recipes",
|
|
||||||
"branch": "main",
|
|
||||||
"version": "7.3",
|
|
||||||
"ref": "dae70df71978ae9226ae915ffd5fad817f5ca1f7"
|
|
||||||
},
|
|
||||||
"files": [
|
|
||||||
"config/packages/property_info.yaml"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"symfony/routing": {
|
|
||||||
"version": "8.0",
|
|
||||||
"recipe": {
|
|
||||||
"repo": "github.com/symfony/recipes",
|
|
||||||
"branch": "main",
|
|
||||||
"version": "7.4",
|
|
||||||
"ref": "bc94c4fd86f393f3ab3947c18b830ea343e51ded"
|
|
||||||
},
|
|
||||||
"files": [
|
|
||||||
"config/packages/routing.yaml",
|
|
||||||
"config/routes.yaml"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"symfony/security-bundle": {
|
|
||||||
"version": "8.0",
|
|
||||||
"recipe": {
|
|
||||||
"repo": "github.com/symfony/recipes",
|
|
||||||
"branch": "main",
|
|
||||||
"version": "7.4",
|
|
||||||
"ref": "c42fee7802181cdd50f61b8622715829f5d2335c"
|
|
||||||
},
|
|
||||||
"files": [
|
|
||||||
"config/packages/security.yaml",
|
|
||||||
"config/routes/security.yaml"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"symfony/translation": {
|
|
||||||
"version": "8.0",
|
|
||||||
"recipe": {
|
|
||||||
"repo": "github.com/symfony/recipes",
|
|
||||||
"branch": "main",
|
|
||||||
"version": "6.3",
|
|
||||||
"ref": "620a1b84865ceb2ba304c8f8bf2a185fbf32a843"
|
|
||||||
},
|
|
||||||
"files": [
|
|
||||||
"config/packages/translation.yaml",
|
|
||||||
"translations/.gitignore"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"symfony/twig-bundle": {
|
|
||||||
"version": "8.0",
|
|
||||||
"recipe": {
|
|
||||||
"repo": "github.com/symfony/recipes",
|
|
||||||
"branch": "main",
|
|
||||||
"version": "6.4",
|
|
||||||
"ref": "cab5fd2a13a45c266d45a7d9337e28dee6272877"
|
|
||||||
},
|
|
||||||
"files": [
|
|
||||||
"config/packages/twig.yaml",
|
|
||||||
"templates/base.html.twig"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"symfony/uid": {
|
|
||||||
"version": "8.0",
|
|
||||||
"recipe": {
|
|
||||||
"repo": "github.com/symfony/recipes",
|
|
||||||
"branch": "main",
|
|
||||||
"version": "7.0",
|
|
||||||
"ref": "0df5844274d871b37fc3816c57a768ffc60a43a5"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"symfony/validator": {
|
|
||||||
"version": "8.0",
|
|
||||||
"recipe": {
|
|
||||||
"repo": "github.com/symfony/recipes",
|
|
||||||
"branch": "main",
|
|
||||||
"version": "7.0",
|
|
||||||
"ref": "8c1c4e28d26a124b0bb273f537ca8ce443472bfd"
|
|
||||||
},
|
|
||||||
"files": [
|
|
||||||
"config/packages/validator.yaml"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html>
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<title>{% block title %}Welcome!{% endblock %}</title>
|
|
||||||
<link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 128 128%22><text y=%221.2em%22 font-size=%2296%22>⚫️</text><text y=%221.3em%22 x=%220.2em%22 font-size=%2276%22 fill=%22%23fff%22>sf</text></svg>">
|
|
||||||
{% block stylesheets %}
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block javascripts %}
|
|
||||||
{% endblock %}
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
{% block body %}{% endblock %}
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
0
api/translations/.gitignore
vendored
0
api/translations/.gitignore
vendored
@@ -1,283 +0,0 @@
|
|||||||
msgid ""
|
|
||||||
msgstr ""
|
|
||||||
"Language: de\n"
|
|
||||||
"Content-Type: text/plain; charset=UTF-8\n"
|
|
||||||
|
|
||||||
msgid "app.brand"
|
|
||||||
msgstr "speedBB"
|
|
||||||
|
|
||||||
msgid "nav.forums"
|
|
||||||
msgstr "Foren"
|
|
||||||
|
|
||||||
msgid "nav.login"
|
|
||||||
msgstr "Anmelden"
|
|
||||||
|
|
||||||
msgid "nav.register"
|
|
||||||
msgstr "Registrieren"
|
|
||||||
|
|
||||||
msgid "nav.logout"
|
|
||||||
msgstr "Abmelden"
|
|
||||||
|
|
||||||
msgid "nav.language"
|
|
||||||
msgstr "Sprache"
|
|
||||||
|
|
||||||
msgid "nav.theme"
|
|
||||||
msgstr "Design"
|
|
||||||
|
|
||||||
msgid "nav.theme_auto"
|
|
||||||
msgstr "Auto"
|
|
||||||
|
|
||||||
msgid "nav.theme_light"
|
|
||||||
msgstr "Hell"
|
|
||||||
|
|
||||||
msgid "nav.theme_dark"
|
|
||||||
msgstr "Dunkel"
|
|
||||||
|
|
||||||
msgid "home.hero_title"
|
|
||||||
msgstr "Foren"
|
|
||||||
|
|
||||||
msgid "home.hero_body"
|
|
||||||
msgstr "Entdecke Diskussionen, stelle Fragen und teile Ideen in Kategorien und Foren."
|
|
||||||
|
|
||||||
msgid "home.browse"
|
|
||||||
msgstr "Foren durchsuchen"
|
|
||||||
|
|
||||||
msgid "home.loading"
|
|
||||||
msgstr "Foren werden geladen..."
|
|
||||||
|
|
||||||
msgid "home.empty"
|
|
||||||
msgstr "Noch keine Foren vorhanden. Lege das erste Forum in der API an."
|
|
||||||
|
|
||||||
msgid "forum.threads"
|
|
||||||
msgstr "Threads"
|
|
||||||
|
|
||||||
msgid "forum.start_thread"
|
|
||||||
msgstr "Thread starten"
|
|
||||||
|
|
||||||
msgid "forum.loading"
|
|
||||||
msgstr "Forum wird geladen..."
|
|
||||||
|
|
||||||
msgid "forum.type_category"
|
|
||||||
msgstr "Kategorie"
|
|
||||||
|
|
||||||
msgid "forum.type_forum"
|
|
||||||
msgstr "Forum"
|
|
||||||
|
|
||||||
msgid "forum.no_description"
|
|
||||||
msgstr "Noch keine Beschreibung vorhanden."
|
|
||||||
|
|
||||||
msgid "forum.empty_threads"
|
|
||||||
msgstr "Noch keine Threads vorhanden. Starte unten einen."
|
|
||||||
|
|
||||||
msgid "forum.login_hint"
|
|
||||||
msgstr "Melde dich an, um einen neuen Thread zu erstellen."
|
|
||||||
|
|
||||||
msgid "forum.open"
|
|
||||||
msgstr "Forum öffnen"
|
|
||||||
|
|
||||||
msgid "forum.children"
|
|
||||||
msgstr "Unterforen"
|
|
||||||
|
|
||||||
msgid "forum.empty_children"
|
|
||||||
msgstr "Noch keine Unterforen vorhanden."
|
|
||||||
|
|
||||||
msgid "forum.only_forums"
|
|
||||||
msgstr "Threads können nur in Foren erstellt werden."
|
|
||||||
|
|
||||||
msgid "thread.replies"
|
|
||||||
msgstr "Antworten"
|
|
||||||
|
|
||||||
msgid "thread.reply"
|
|
||||||
msgstr "Antworten"
|
|
||||||
|
|
||||||
msgid "thread.loading"
|
|
||||||
msgstr "Thread wird geladen..."
|
|
||||||
|
|
||||||
msgid "thread.label"
|
|
||||||
msgstr "Thread"
|
|
||||||
|
|
||||||
msgid "thread.category"
|
|
||||||
msgstr "Forum:"
|
|
||||||
|
|
||||||
msgid "thread.back_to_category"
|
|
||||||
msgstr "Zurück zum Forum"
|
|
||||||
|
|
||||||
msgid "thread.empty"
|
|
||||||
msgstr "Sei die erste Person, die antwortet."
|
|
||||||
|
|
||||||
msgid "thread.anonymous"
|
|
||||||
msgstr "Anonym"
|
|
||||||
|
|
||||||
msgid "thread.login_hint"
|
|
||||||
msgstr "Melde dich an, um auf diesen Thread zu antworten."
|
|
||||||
|
|
||||||
msgid "thread.view"
|
|
||||||
msgstr "Thread ansehen"
|
|
||||||
|
|
||||||
msgid "auth.login_title"
|
|
||||||
msgstr "Anmelden"
|
|
||||||
|
|
||||||
msgid "auth.login_hint"
|
|
||||||
msgstr "Melde dich an, um neue Threads zu starten und zu antworten."
|
|
||||||
|
|
||||||
msgid "auth.register_title"
|
|
||||||
msgstr "Konto erstellen"
|
|
||||||
|
|
||||||
msgid "auth.register_hint"
|
|
||||||
msgstr "Registriere dich mit E-Mail und einem eindeutigen Benutzernamen."
|
|
||||||
|
|
||||||
msgid "footer.copy"
|
|
||||||
msgstr "speedBB"
|
|
||||||
|
|
||||||
msgid "form.title"
|
|
||||||
msgstr "Titel"
|
|
||||||
|
|
||||||
msgid "form.body"
|
|
||||||
msgstr "Inhalt"
|
|
||||||
|
|
||||||
msgid "form.message"
|
|
||||||
msgstr "Nachricht"
|
|
||||||
|
|
||||||
msgid "form.email"
|
|
||||||
msgstr "E-Mail"
|
|
||||||
|
|
||||||
msgid "form.username"
|
|
||||||
msgstr "Benutzername"
|
|
||||||
|
|
||||||
msgid "form.password"
|
|
||||||
msgstr "Passwort"
|
|
||||||
|
|
||||||
msgid "form.thread_title_placeholder"
|
|
||||||
msgstr "Thema"
|
|
||||||
|
|
||||||
msgid "form.thread_body_placeholder"
|
|
||||||
msgstr "Teile den Kontext und deine Frage."
|
|
||||||
|
|
||||||
msgid "form.reply_placeholder"
|
|
||||||
msgstr "Schreibe deine Antwort."
|
|
||||||
|
|
||||||
msgid "form.posting"
|
|
||||||
msgstr "Wird gesendet..."
|
|
||||||
|
|
||||||
msgid "form.create_thread"
|
|
||||||
msgstr "Thread erstellen"
|
|
||||||
|
|
||||||
msgid "form.post_reply"
|
|
||||||
msgstr "Antwort posten"
|
|
||||||
|
|
||||||
msgid "form.signing_in"
|
|
||||||
msgstr "Anmeldung läuft..."
|
|
||||||
|
|
||||||
msgid "form.sign_in"
|
|
||||||
msgstr "Anmelden"
|
|
||||||
|
|
||||||
msgid "form.registering"
|
|
||||||
msgstr "Registrierung läuft..."
|
|
||||||
|
|
||||||
msgid "form.create_account"
|
|
||||||
msgstr "Konto erstellen"
|
|
||||||
|
|
||||||
msgid "nav.acp"
|
|
||||||
msgstr "ACP"
|
|
||||||
|
|
||||||
msgid "acp.title"
|
|
||||||
msgstr "Administrationsbereich"
|
|
||||||
|
|
||||||
msgid "acp.no_access"
|
|
||||||
msgstr "Du hast keinen Zugriff auf diesen Bereich."
|
|
||||||
|
|
||||||
msgid "acp.general"
|
|
||||||
msgstr "Allgemein"
|
|
||||||
|
|
||||||
msgid "acp.general_hint"
|
|
||||||
msgstr "Globale Einstellungen und Board-Konfiguration erscheinen hier."
|
|
||||||
|
|
||||||
msgid "acp.forums"
|
|
||||||
msgstr "Foren"
|
|
||||||
|
|
||||||
msgid "acp.forums_hint"
|
|
||||||
msgstr "Kategorien und Foren in einer Baumansicht verwalten."
|
|
||||||
|
|
||||||
msgid "acp.users"
|
|
||||||
msgstr "Benutzer"
|
|
||||||
|
|
||||||
msgid "acp.users_hint"
|
|
||||||
msgstr "Werkzeuge zur Benutzerverwaltung erscheinen hier."
|
|
||||||
|
|
||||||
msgid "acp.forums_tree"
|
|
||||||
msgstr "Forenbaum"
|
|
||||||
|
|
||||||
msgid "acp.forums_empty"
|
|
||||||
msgstr "Noch keine Foren vorhanden. Lege rechts das erste an."
|
|
||||||
|
|
||||||
msgid "acp.forums_create_title"
|
|
||||||
msgstr "Forum oder Kategorie erstellen"
|
|
||||||
|
|
||||||
msgid "acp.forums_edit_title"
|
|
||||||
msgstr "Forum bearbeiten"
|
|
||||||
|
|
||||||
msgid "acp.forums_type"
|
|
||||||
msgstr "Typ"
|
|
||||||
|
|
||||||
msgid "acp.forums_parent"
|
|
||||||
msgstr "Übergeordnete Kategorie"
|
|
||||||
|
|
||||||
msgid "acp.forums_parent_root"
|
|
||||||
msgstr "Wurzel (kein Parent)"
|
|
||||||
|
|
||||||
msgid "acp.forums_confirm_delete"
|
|
||||||
msgstr "Dieses Forum löschen? Das kann nicht rückgängig gemacht werden."
|
|
||||||
|
|
||||||
msgid "acp.loading"
|
|
||||||
msgstr "Laden..."
|
|
||||||
|
|
||||||
msgid "acp.refresh"
|
|
||||||
msgstr "Aktualisieren"
|
|
||||||
|
|
||||||
msgid "acp.create"
|
|
||||||
msgstr "Erstellen"
|
|
||||||
|
|
||||||
msgid "acp.save"
|
|
||||||
msgstr "Speichern"
|
|
||||||
|
|
||||||
msgid "acp.reset"
|
|
||||||
msgstr "Zurücksetzen"
|
|
||||||
|
|
||||||
msgid "acp.edit"
|
|
||||||
msgstr "Bearbeiten"
|
|
||||||
|
|
||||||
msgid "acp.delete"
|
|
||||||
msgstr "Löschen"
|
|
||||||
|
|
||||||
msgid "form.description"
|
|
||||||
msgstr "Beschreibung"
|
|
||||||
|
|
||||||
msgid "acp.new_category"
|
|
||||||
msgstr "Neue Kategorie"
|
|
||||||
|
|
||||||
msgid "acp.new_forum"
|
|
||||||
msgstr "Neues Forum"
|
|
||||||
|
|
||||||
msgid "acp.forums_form_hint"
|
|
||||||
msgstr "Erstelle ein neues Forum oder bearbeite das ausgewählte. Kategorien können Foren und andere Kategorien enthalten."
|
|
||||||
|
|
||||||
msgid "acp.forums_name_required"
|
|
||||||
msgstr "Bitte zuerst einen Namen eingeben."
|
|
||||||
|
|
||||||
msgid "acp.drag_handle"
|
|
||||||
msgstr "Zum Sortieren ziehen"
|
|
||||||
|
|
||||||
msgid "acp.expand_all"
|
|
||||||
msgstr "Alle ausklappen"
|
|
||||||
|
|
||||||
msgid "acp.collapse_all"
|
|
||||||
msgstr "Alle einklappen"
|
|
||||||
|
|
||||||
msgid "acp.forums_form_empty_title"
|
|
||||||
msgstr "Keine Auswahl"
|
|
||||||
|
|
||||||
msgid "acp.forums_form_empty_hint"
|
|
||||||
msgstr "Wähle ein Forum zum Bearbeiten oder klicke auf Neue Kategorie / Neues Forum."
|
|
||||||
|
|
||||||
msgid "acp.cancel"
|
|
||||||
msgstr "Abbrechen"
|
|
||||||
@@ -1,283 +0,0 @@
|
|||||||
msgid ""
|
|
||||||
msgstr ""
|
|
||||||
"Language: en\n"
|
|
||||||
"Content-Type: text/plain; charset=UTF-8\n"
|
|
||||||
|
|
||||||
msgid "app.brand"
|
|
||||||
msgstr "speedBB"
|
|
||||||
|
|
||||||
msgid "nav.forums"
|
|
||||||
msgstr "Forums"
|
|
||||||
|
|
||||||
msgid "nav.login"
|
|
||||||
msgstr "Login"
|
|
||||||
|
|
||||||
msgid "nav.register"
|
|
||||||
msgstr "Register"
|
|
||||||
|
|
||||||
msgid "nav.logout"
|
|
||||||
msgstr "Logout"
|
|
||||||
|
|
||||||
msgid "nav.language"
|
|
||||||
msgstr "Language"
|
|
||||||
|
|
||||||
msgid "nav.theme"
|
|
||||||
msgstr "Theme"
|
|
||||||
|
|
||||||
msgid "nav.theme_auto"
|
|
||||||
msgstr "Auto"
|
|
||||||
|
|
||||||
msgid "nav.theme_light"
|
|
||||||
msgstr "Light"
|
|
||||||
|
|
||||||
msgid "nav.theme_dark"
|
|
||||||
msgstr "Dark"
|
|
||||||
|
|
||||||
msgid "home.hero_title"
|
|
||||||
msgstr "Forums"
|
|
||||||
|
|
||||||
msgid "home.hero_body"
|
|
||||||
msgstr "Explore conversations, ask questions, and share ideas across categories and forums."
|
|
||||||
|
|
||||||
msgid "home.browse"
|
|
||||||
msgstr "Browse forums"
|
|
||||||
|
|
||||||
msgid "home.loading"
|
|
||||||
msgstr "Loading forums..."
|
|
||||||
|
|
||||||
msgid "home.empty"
|
|
||||||
msgstr "No forums yet. Create the first one in the API."
|
|
||||||
|
|
||||||
msgid "forum.threads"
|
|
||||||
msgstr "Threads"
|
|
||||||
|
|
||||||
msgid "forum.start_thread"
|
|
||||||
msgstr "Start a thread"
|
|
||||||
|
|
||||||
msgid "forum.loading"
|
|
||||||
msgstr "Loading forum..."
|
|
||||||
|
|
||||||
msgid "forum.type_category"
|
|
||||||
msgstr "Category"
|
|
||||||
|
|
||||||
msgid "forum.type_forum"
|
|
||||||
msgstr "Forum"
|
|
||||||
|
|
||||||
msgid "forum.no_description"
|
|
||||||
msgstr "No description added yet."
|
|
||||||
|
|
||||||
msgid "forum.empty_threads"
|
|
||||||
msgstr "No threads here yet. Start one below."
|
|
||||||
|
|
||||||
msgid "forum.login_hint"
|
|
||||||
msgstr "Log in to create a new thread."
|
|
||||||
|
|
||||||
msgid "forum.open"
|
|
||||||
msgstr "Open forum"
|
|
||||||
|
|
||||||
msgid "forum.children"
|
|
||||||
msgstr "Sub-forums"
|
|
||||||
|
|
||||||
msgid "forum.empty_children"
|
|
||||||
msgstr "No sub-forums yet."
|
|
||||||
|
|
||||||
msgid "forum.only_forums"
|
|
||||||
msgstr "Threads can only be created in forums."
|
|
||||||
|
|
||||||
msgid "thread.replies"
|
|
||||||
msgstr "Replies"
|
|
||||||
|
|
||||||
msgid "thread.reply"
|
|
||||||
msgstr "Reply"
|
|
||||||
|
|
||||||
msgid "thread.loading"
|
|
||||||
msgstr "Loading thread..."
|
|
||||||
|
|
||||||
msgid "thread.label"
|
|
||||||
msgstr "Thread"
|
|
||||||
|
|
||||||
msgid "thread.category"
|
|
||||||
msgstr "Forum:"
|
|
||||||
|
|
||||||
msgid "thread.back_to_category"
|
|
||||||
msgstr "Back to forum"
|
|
||||||
|
|
||||||
msgid "thread.empty"
|
|
||||||
msgstr "Be the first to reply."
|
|
||||||
|
|
||||||
msgid "thread.anonymous"
|
|
||||||
msgstr "Anonymous"
|
|
||||||
|
|
||||||
msgid "thread.login_hint"
|
|
||||||
msgstr "Log in to reply to this thread."
|
|
||||||
|
|
||||||
msgid "thread.view"
|
|
||||||
msgstr "View thread"
|
|
||||||
|
|
||||||
msgid "auth.login_title"
|
|
||||||
msgstr "Log in"
|
|
||||||
|
|
||||||
msgid "auth.login_hint"
|
|
||||||
msgstr "Access your account to start new threads and reply."
|
|
||||||
|
|
||||||
msgid "auth.register_title"
|
|
||||||
msgstr "Create account"
|
|
||||||
|
|
||||||
msgid "auth.register_hint"
|
|
||||||
msgstr "Register with an email and a unique username."
|
|
||||||
|
|
||||||
msgid "footer.copy"
|
|
||||||
msgstr "speedBB"
|
|
||||||
|
|
||||||
msgid "form.title"
|
|
||||||
msgstr "Title"
|
|
||||||
|
|
||||||
msgid "form.body"
|
|
||||||
msgstr "Body"
|
|
||||||
|
|
||||||
msgid "form.message"
|
|
||||||
msgstr "Message"
|
|
||||||
|
|
||||||
msgid "form.email"
|
|
||||||
msgstr "Email"
|
|
||||||
|
|
||||||
msgid "form.username"
|
|
||||||
msgstr "Username"
|
|
||||||
|
|
||||||
msgid "form.password"
|
|
||||||
msgstr "Password"
|
|
||||||
|
|
||||||
msgid "form.thread_title_placeholder"
|
|
||||||
msgstr "Topic headline"
|
|
||||||
|
|
||||||
msgid "form.thread_body_placeholder"
|
|
||||||
msgstr "Share the context and your question."
|
|
||||||
|
|
||||||
msgid "form.reply_placeholder"
|
|
||||||
msgstr "Share your reply."
|
|
||||||
|
|
||||||
msgid "form.posting"
|
|
||||||
msgstr "Posting..."
|
|
||||||
|
|
||||||
msgid "form.create_thread"
|
|
||||||
msgstr "Create thread"
|
|
||||||
|
|
||||||
msgid "form.post_reply"
|
|
||||||
msgstr "Post reply"
|
|
||||||
|
|
||||||
msgid "form.signing_in"
|
|
||||||
msgstr "Signing in..."
|
|
||||||
|
|
||||||
msgid "form.sign_in"
|
|
||||||
msgstr "Sign in"
|
|
||||||
|
|
||||||
msgid "form.registering"
|
|
||||||
msgstr "Registering..."
|
|
||||||
|
|
||||||
msgid "form.create_account"
|
|
||||||
msgstr "Create account"
|
|
||||||
|
|
||||||
msgid "nav.acp"
|
|
||||||
msgstr "ACP"
|
|
||||||
|
|
||||||
msgid "acp.title"
|
|
||||||
msgstr "Admin control panel"
|
|
||||||
|
|
||||||
msgid "acp.no_access"
|
|
||||||
msgstr "You do not have access to this area."
|
|
||||||
|
|
||||||
msgid "acp.general"
|
|
||||||
msgstr "General"
|
|
||||||
|
|
||||||
msgid "acp.general_hint"
|
|
||||||
msgstr "Global settings and board configuration will appear here."
|
|
||||||
|
|
||||||
msgid "acp.forums"
|
|
||||||
msgstr "Forums"
|
|
||||||
|
|
||||||
msgid "acp.forums_hint"
|
|
||||||
msgstr "Manage categories and forums from a tree view."
|
|
||||||
|
|
||||||
msgid "acp.users"
|
|
||||||
msgstr "Users"
|
|
||||||
|
|
||||||
msgid "acp.users_hint"
|
|
||||||
msgstr "User management tools will appear here."
|
|
||||||
|
|
||||||
msgid "acp.forums_tree"
|
|
||||||
msgstr "Forum tree"
|
|
||||||
|
|
||||||
msgid "acp.forums_empty"
|
|
||||||
msgstr "No forums yet. Create the first one on the right."
|
|
||||||
|
|
||||||
msgid "acp.forums_create_title"
|
|
||||||
msgstr "Create forum or category"
|
|
||||||
|
|
||||||
msgid "acp.forums_edit_title"
|
|
||||||
msgstr "Edit forum"
|
|
||||||
|
|
||||||
msgid "acp.forums_type"
|
|
||||||
msgstr "Type"
|
|
||||||
|
|
||||||
msgid "acp.forums_parent"
|
|
||||||
msgstr "Parent category"
|
|
||||||
|
|
||||||
msgid "acp.forums_parent_root"
|
|
||||||
msgstr "Root (no parent)"
|
|
||||||
|
|
||||||
msgid "acp.forums_confirm_delete"
|
|
||||||
msgstr "Delete this forum? This cannot be undone."
|
|
||||||
|
|
||||||
msgid "acp.loading"
|
|
||||||
msgstr "Loading..."
|
|
||||||
|
|
||||||
msgid "acp.refresh"
|
|
||||||
msgstr "Refresh"
|
|
||||||
|
|
||||||
msgid "acp.create"
|
|
||||||
msgstr "Create"
|
|
||||||
|
|
||||||
msgid "acp.save"
|
|
||||||
msgstr "Save"
|
|
||||||
|
|
||||||
msgid "acp.reset"
|
|
||||||
msgstr "Reset"
|
|
||||||
|
|
||||||
msgid "acp.edit"
|
|
||||||
msgstr "Edit"
|
|
||||||
|
|
||||||
msgid "acp.delete"
|
|
||||||
msgstr "Delete"
|
|
||||||
|
|
||||||
msgid "form.description"
|
|
||||||
msgstr "Description"
|
|
||||||
|
|
||||||
msgid "acp.new_category"
|
|
||||||
msgstr "New category"
|
|
||||||
|
|
||||||
msgid "acp.new_forum"
|
|
||||||
msgstr "New forum"
|
|
||||||
|
|
||||||
msgid "acp.forums_form_hint"
|
|
||||||
msgstr "Create a new forum or edit the selected one. Categories can contain forums and other categories."
|
|
||||||
|
|
||||||
msgid "acp.forums_name_required"
|
|
||||||
msgstr "Please enter a name before saving."
|
|
||||||
|
|
||||||
msgid "acp.drag_handle"
|
|
||||||
msgstr "Drag to reorder"
|
|
||||||
|
|
||||||
msgid "acp.expand_all"
|
|
||||||
msgstr "Expand all"
|
|
||||||
|
|
||||||
msgid "acp.collapse_all"
|
|
||||||
msgstr "Collapse all"
|
|
||||||
|
|
||||||
msgid "acp.forums_form_empty_title"
|
|
||||||
msgstr "No selection"
|
|
||||||
|
|
||||||
msgid "acp.forums_form_empty_hint"
|
|
||||||
msgstr "Choose a forum to edit or click New category / New forum to create one."
|
|
||||||
|
|
||||||
msgid "acp.cancel"
|
|
||||||
msgstr "Cancel"
|
|
||||||
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];
|
||||||
|
}
|
||||||
|
}
|
||||||
50
app/Actions/Fortify/CreateNewUser.php
Normal file
50
app/Actions/Fortify/CreateNewUser.php
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Actions\Fortify;
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
use Illuminate\Support\Facades\Hash;
|
||||||
|
use Illuminate\Support\Facades\Validator;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
use Laravel\Fortify\Contracts\CreatesNewUsers;
|
||||||
|
|
||||||
|
class CreateNewUser implements CreatesNewUsers
|
||||||
|
{
|
||||||
|
use PasswordValidationRules;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate and create a newly registered user.
|
||||||
|
*
|
||||||
|
* @param array<string, string> $input
|
||||||
|
*/
|
||||||
|
public function create(array $input): User
|
||||||
|
{
|
||||||
|
$input['name_canonical'] = Str::lower(trim($input['name'] ?? ''));
|
||||||
|
|
||||||
|
Validator::make(data: $input, rules: [
|
||||||
|
'name' => ['required', 'string', 'max:255'],
|
||||||
|
'name_canonical' => [
|
||||||
|
'required',
|
||||||
|
'string',
|
||||||
|
'max:255',
|
||||||
|
Rule::unique(table: User::class, column: 'name_canonical'),
|
||||||
|
],
|
||||||
|
'email' => [
|
||||||
|
'required',
|
||||||
|
'string',
|
||||||
|
'email',
|
||||||
|
'max:255',
|
||||||
|
Rule::unique(table: User::class),
|
||||||
|
],
|
||||||
|
'password' => $this->passwordRules(),
|
||||||
|
])->validate();
|
||||||
|
|
||||||
|
return User::create(attributes: [
|
||||||
|
'name' => $input['name'],
|
||||||
|
'name_canonical' => $input['name_canonical'],
|
||||||
|
'email' => $input['email'],
|
||||||
|
'password' => Hash::make(value: $input['password']),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
19
app/Actions/Fortify/PasswordValidationRules.php
Normal file
19
app/Actions/Fortify/PasswordValidationRules.php
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Actions\Fortify;
|
||||||
|
|
||||||
|
use Illuminate\Contracts\Validation\ValidationRule;
|
||||||
|
use Illuminate\Validation\Rules\Password;
|
||||||
|
|
||||||
|
trait PasswordValidationRules
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Get the validation rules used to validate passwords.
|
||||||
|
*
|
||||||
|
* @return array<int, ValidationRule|array<mixed>|string>
|
||||||
|
*/
|
||||||
|
protected function passwordRules(): array
|
||||||
|
{
|
||||||
|
return ['required', 'string', Password::default(), 'confirmed'];
|
||||||
|
}
|
||||||
|
}
|
||||||
29
app/Actions/Fortify/ResetUserPassword.php
Normal file
29
app/Actions/Fortify/ResetUserPassword.php
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Actions\Fortify;
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Support\Facades\Hash;
|
||||||
|
use Illuminate\Support\Facades\Validator;
|
||||||
|
use Laravel\Fortify\Contracts\ResetsUserPasswords;
|
||||||
|
|
||||||
|
class ResetUserPassword implements ResetsUserPasswords
|
||||||
|
{
|
||||||
|
use PasswordValidationRules;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate and reset the user's forgotten password.
|
||||||
|
*
|
||||||
|
* @param array<string, string> $input
|
||||||
|
*/
|
||||||
|
public function reset(User $user, array $input): void
|
||||||
|
{
|
||||||
|
Validator::make($input, [
|
||||||
|
'password' => $this->passwordRules(),
|
||||||
|
])->validate();
|
||||||
|
|
||||||
|
$user->forceFill([
|
||||||
|
'password' => Hash::make($input['password']),
|
||||||
|
])->save();
|
||||||
|
}
|
||||||
|
}
|
||||||
32
app/Actions/Fortify/UpdateUserPassword.php
Normal file
32
app/Actions/Fortify/UpdateUserPassword.php
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Actions\Fortify;
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Support\Facades\Hash;
|
||||||
|
use Illuminate\Support\Facades\Validator;
|
||||||
|
use Laravel\Fortify\Contracts\UpdatesUserPasswords;
|
||||||
|
|
||||||
|
class UpdateUserPassword implements UpdatesUserPasswords
|
||||||
|
{
|
||||||
|
use PasswordValidationRules;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate and update the user's password.
|
||||||
|
*
|
||||||
|
* @param array<string, string> $input
|
||||||
|
*/
|
||||||
|
public function update(User $user, array $input): void
|
||||||
|
{
|
||||||
|
Validator::make($input, [
|
||||||
|
'current_password' => ['required', 'string', 'current_password:web'],
|
||||||
|
'password' => $this->passwordRules(),
|
||||||
|
], [
|
||||||
|
'current_password.current_password' => __('The provided password does not match your current password.'),
|
||||||
|
])->validateWithBag('updatePassword');
|
||||||
|
|
||||||
|
$user->forceFill([
|
||||||
|
'password' => Hash::make($input['password']),
|
||||||
|
])->save();
|
||||||
|
}
|
||||||
|
}
|
||||||
68
app/Actions/Fortify/UpdateUserProfileInformation.php
Normal file
68
app/Actions/Fortify/UpdateUserProfileInformation.php
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Actions\Fortify;
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Contracts\Auth\MustVerifyEmail;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
use Illuminate\Support\Facades\Validator;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
use Laravel\Fortify\Contracts\UpdatesUserProfileInformation;
|
||||||
|
|
||||||
|
class UpdateUserProfileInformation implements UpdatesUserProfileInformation
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Validate and update the given user's profile information.
|
||||||
|
*
|
||||||
|
* @param array<string, string> $input
|
||||||
|
*/
|
||||||
|
public function update(User $user, array $input): void
|
||||||
|
{
|
||||||
|
$input['name_canonical'] = Str::lower(trim($input['name'] ?? ''));
|
||||||
|
|
||||||
|
Validator::make($input, [
|
||||||
|
'name' => ['required', 'string', 'max:255'],
|
||||||
|
'name_canonical' => [
|
||||||
|
'required',
|
||||||
|
'string',
|
||||||
|
'max:255',
|
||||||
|
Rule::unique('users', 'name_canonical')->ignore($user->id),
|
||||||
|
],
|
||||||
|
|
||||||
|
'email' => [
|
||||||
|
'required',
|
||||||
|
'string',
|
||||||
|
'email',
|
||||||
|
'max:255',
|
||||||
|
Rule::unique('users')->ignore($user->id),
|
||||||
|
],
|
||||||
|
])->validateWithBag('updateProfileInformation');
|
||||||
|
|
||||||
|
if ($input['email'] !== $user->email && $user instanceof MustVerifyEmail) {
|
||||||
|
$this->updateVerifiedUser($user, $input);
|
||||||
|
} else {
|
||||||
|
$user->forceFill([
|
||||||
|
'name' => $input['name'],
|
||||||
|
'name_canonical' => $input['name_canonical'],
|
||||||
|
'email' => $input['email'],
|
||||||
|
])->save();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update the given verified user's profile information.
|
||||||
|
*
|
||||||
|
* @param array<string, string> $input
|
||||||
|
*/
|
||||||
|
protected function updateVerifiedUser(User $user, array $input): void
|
||||||
|
{
|
||||||
|
$user->forceFill([
|
||||||
|
'name' => $input['name'],
|
||||||
|
'name_canonical' => $input['name_canonical'],
|
||||||
|
'email' => $input['email'],
|
||||||
|
'email_verified_at' => null,
|
||||||
|
])->save();
|
||||||
|
|
||||||
|
$user->sendEmailVerificationNotification();
|
||||||
|
}
|
||||||
|
}
|
||||||
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,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
193
app/Http/Controllers/AuthController.php
Normal file
193
app/Http/Controllers/AuthController.php
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Actions\Fortify\CreateNewUser;
|
||||||
|
use App\Actions\Fortify\PasswordValidationRules;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Services\AuditLogger;
|
||||||
|
use Illuminate\Auth\Events\Verified;
|
||||||
|
use Illuminate\Auth\Events\PasswordReset;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Hash;
|
||||||
|
use Illuminate\Support\Facades\Password;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
use Illuminate\Validation\ValidationException;
|
||||||
|
|
||||||
|
class AuthController extends Controller
|
||||||
|
{
|
||||||
|
use PasswordValidationRules;
|
||||||
|
|
||||||
|
public function register(Request $request, CreateNewUser $creator): JsonResponse
|
||||||
|
{
|
||||||
|
$input = [
|
||||||
|
'name' => $request->input(key: 'name') ?? $request->input(key: 'username'),
|
||||||
|
'email' => $request->input(key: 'email'),
|
||||||
|
'password' => $request->input(key: 'password') ?? $request->input(key: 'plainPassword'),
|
||||||
|
'password_confirmation' => $request->input(key: 'password_confirmation')
|
||||||
|
?? $request->input(key: 'plainPassword'),
|
||||||
|
];
|
||||||
|
|
||||||
|
$user = $creator->create(input: $input);
|
||||||
|
|
||||||
|
$user->sendEmailVerificationNotification();
|
||||||
|
app(AuditLogger::class)->log($request, 'user.registered', $user, [
|
||||||
|
'email' => $user->email,
|
||||||
|
], $user);
|
||||||
|
|
||||||
|
return response()->json(data: [
|
||||||
|
'user_id' => $user->id,
|
||||||
|
'email' => $user->email,
|
||||||
|
'message' => 'Verification email sent.',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function login(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$request->merge(input: [
|
||||||
|
'login' => $request->input(key: 'login', default: $request->input(key: 'email')),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$request->validate(rules: [
|
||||||
|
'login' => ['required', 'string'],
|
||||||
|
'password' => ['required', 'string'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$login = trim(string: (string) $request->input(key: 'login'));
|
||||||
|
$loginNormalized = Str::lower(value: $login);
|
||||||
|
$userQuery = User::query();
|
||||||
|
|
||||||
|
if (filter_var(value: $login, filter: FILTER_VALIDATE_EMAIL)) {
|
||||||
|
$userQuery->whereRaw(sql: 'lower(email) = ?', bindings: [$loginNormalized]);
|
||||||
|
} else {
|
||||||
|
$userQuery->where(column: 'name_canonical', operator: $loginNormalized);
|
||||||
|
}
|
||||||
|
|
||||||
|
$user = $userQuery->first();
|
||||||
|
|
||||||
|
if (!$user || !Hash::check(value: $request->input(key: 'password'), hashedValue: $user->password)) {
|
||||||
|
throw ValidationException::withMessages(messages: [
|
||||||
|
'login' => ['Invalid credentials.'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$user->hasVerifiedEmail()) {
|
||||||
|
return response()->json(data : [
|
||||||
|
'message' => 'Email not verified.',
|
||||||
|
], status: 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
$token = $user->createToken(name: 'api')->plainTextToken;
|
||||||
|
|
||||||
|
app(AuditLogger::class)->log($request, 'user.login', $user, [
|
||||||
|
'login' => $login,
|
||||||
|
], $user);
|
||||||
|
|
||||||
|
return response()->json(data: [
|
||||||
|
'token' => $token,
|
||||||
|
'user_id' => $user->id,
|
||||||
|
'email' => $user->email,
|
||||||
|
'roles' => $user->roles()->pluck(column: 'name')->values(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function verifyEmail(Request $request, string $id, string $hash): RedirectResponse
|
||||||
|
{
|
||||||
|
$user = User::findOrFail(id: $id);
|
||||||
|
|
||||||
|
if (!hash_equals(known_string: $hash, user_string: sha1(string: $user->getEmailForVerification()))) {
|
||||||
|
abort(code: 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$user->hasVerifiedEmail()) {
|
||||||
|
$user->markEmailAsVerified();
|
||||||
|
event(new Verified(user: $user));
|
||||||
|
}
|
||||||
|
|
||||||
|
return redirect(to: '/login');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function forgotPassword(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$request->validate(rules: [
|
||||||
|
'email' => ['required', 'email'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$status = Password::sendResetLink(
|
||||||
|
$request->only(keys: 'email')
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($status !== Password::RESET_LINK_SENT) {
|
||||||
|
throw ValidationException::withMessages(messages: [
|
||||||
|
'email' => [__(key: $status)],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json(data: ['message' => __(key: $status)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function resetPassword(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$request->validate(rules: [
|
||||||
|
'token' => ['required'],
|
||||||
|
'email' => ['required', 'email'],
|
||||||
|
'password' => $this->passwordRules(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$status = Password::reset(
|
||||||
|
$request->only('email', 'password', 'password_confirmation', 'token'),
|
||||||
|
function (User $user, string $password) use ($request) {
|
||||||
|
$user->forceFill(attributes: [
|
||||||
|
'password' => Hash::make(value: $password),
|
||||||
|
'remember_token' => Str::random(length: 60),
|
||||||
|
])->save();
|
||||||
|
|
||||||
|
event(new PasswordReset(user: $user));
|
||||||
|
app(AuditLogger::class)->log($request, 'user.password_reset', $user, [], $user);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($status !== Password::PASSWORD_RESET) {
|
||||||
|
throw ValidationException::withMessages(messages: [
|
||||||
|
'email' => [__(key: $status)],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json(data: ['message' => __(key: $status)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function updatePassword(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$request->validate(rules: [
|
||||||
|
'current_password' => ['required'],
|
||||||
|
'password' => $this->passwordRules(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$user = $request->user();
|
||||||
|
|
||||||
|
if (!$user || !Hash::check(value: $request->input(key: 'current_password'), hashedValue: $user->password)) {
|
||||||
|
throw ValidationException::withMessages(messages: [
|
||||||
|
'current_password' => ['Invalid current password.'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$user->forceFill(attributes: [
|
||||||
|
'password' => Hash::make(value: $request->input(key: 'password')),
|
||||||
|
'remember_token' => Str::random(length: 60),
|
||||||
|
])->save();
|
||||||
|
|
||||||
|
app(AuditLogger::class)->log($request, 'user.password_changed', $user, [], $user);
|
||||||
|
|
||||||
|
return response()->json(data: ['message' => 'Password updated.']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function logout(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
app(AuditLogger::class)->log($request, 'user.logout', $request->user());
|
||||||
|
$request->user()?->currentAccessToken()?->delete();
|
||||||
|
|
||||||
|
return response()->json(data: null, status: 204);
|
||||||
|
}
|
||||||
|
}
|
||||||
8
app/Http/Controllers/Controller.php
Normal file
8
app/Http/Controllers/Controller.php
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
abstract class Controller
|
||||||
|
{
|
||||||
|
//
|
||||||
|
}
|
||||||
286
app/Http/Controllers/ForumController.php
Normal file
286
app/Http/Controllers/ForumController.php
Normal file
@@ -0,0 +1,286 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Models\Forum;
|
||||||
|
use App\Models\Post;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
|
class ForumController extends Controller
|
||||||
|
{
|
||||||
|
public function index(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$query = Forum::query()
|
||||||
|
->withoutTrashed()
|
||||||
|
->withCount(relations: ['threads', 'posts'])
|
||||||
|
->withSum(relation: 'threads', column: 'views_count');
|
||||||
|
|
||||||
|
$parentParam = $request->query(key: 'parent');
|
||||||
|
if (is_array(value: $parentParam) && array_key_exists('exists', $parentParam)) {
|
||||||
|
$exists = filter_var(value: $parentParam['exists'], filter: FILTER_VALIDATE_BOOLEAN, options: FILTER_NULL_ON_FAILURE);
|
||||||
|
if ($exists === false) {
|
||||||
|
$query->whereNull(columns: 'parent_id');
|
||||||
|
} elseif ($exists === true) {
|
||||||
|
$query->whereNotNull(columns: 'parent_id');
|
||||||
|
}
|
||||||
|
} elseif (is_string(value: $parentParam)) {
|
||||||
|
$parentId = $this->parseIriId(value: $parentParam);
|
||||||
|
if ($parentId !== null) {
|
||||||
|
$query->where(column: 'parent_id', operator: $parentId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($request->filled(key: 'type')) {
|
||||||
|
$query->where(column: 'type', operator: $request->query(key: 'type'));
|
||||||
|
}
|
||||||
|
|
||||||
|
$forums = $query
|
||||||
|
->orderBy(column: 'position')
|
||||||
|
->orderBy(column: 'name')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
$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
|
||||||
|
{
|
||||||
|
$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
|
||||||
|
{
|
||||||
|
$data = $request->validate([
|
||||||
|
'name' => ['required', 'string', 'max:100'],
|
||||||
|
'description' => ['nullable', 'string'],
|
||||||
|
'type' => ['required', Rule::in(['category', 'forum'])],
|
||||||
|
'parent' => ['nullable', 'string'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$parentId = $this->parseIriId($data['parent'] ?? null);
|
||||||
|
|
||||||
|
if ($data['type'] === 'forum' && !$parentId) {
|
||||||
|
return response()->json(['message' => 'Forums must belong to a category.'], 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($parentId) {
|
||||||
|
$parent = Forum::findOrFail($parentId);
|
||||||
|
if ($parent->type !== 'category') {
|
||||||
|
return response()->json(['message' => 'Parent must be a category.'], 422);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($parentId === null) {
|
||||||
|
Forum::whereNull('parent_id')->increment('position');
|
||||||
|
$position = 0;
|
||||||
|
} else {
|
||||||
|
$position = Forum::where('parent_id', $parentId)->max('position');
|
||||||
|
}
|
||||||
|
|
||||||
|
$forum = Forum::create([
|
||||||
|
'name' => $data['name'],
|
||||||
|
'description' => $data['description'] ?? null,
|
||||||
|
'type' => $data['type'],
|
||||||
|
'parent_id' => $parentId,
|
||||||
|
'position' => ($position ?? 0) + 1,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$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
|
||||||
|
{
|
||||||
|
$data = $request->validate([
|
||||||
|
'name' => ['sometimes', 'required', 'string', 'max:100'],
|
||||||
|
'description' => ['nullable', 'string'],
|
||||||
|
'type' => ['sometimes', Rule::in(['category', 'forum'])],
|
||||||
|
'parent' => ['nullable', 'string'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$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 ($parentId) {
|
||||||
|
$parent = Forum::findOrFail($parentId);
|
||||||
|
if ($parent->type !== 'category') {
|
||||||
|
return response()->json(['message' => 'Parent must be a category.'], 422);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$forum->parent_id = $parentId;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (array_key_exists('name', $data)) {
|
||||||
|
$forum->name = $data['name'];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (array_key_exists('description', $data)) {
|
||||||
|
$forum->description = $data['description'];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (array_key_exists('type', $data)) {
|
||||||
|
$forum->type = $data['type'];
|
||||||
|
}
|
||||||
|
|
||||||
|
$forum->save();
|
||||||
|
|
||||||
|
$forum->loadCount(['threads', 'posts'])
|
||||||
|
->loadSum('threads', 'views_count');
|
||||||
|
$lastPost = $this->loadLastPostForForum($forum->id);
|
||||||
|
|
||||||
|
return response()->json($this->serializeForum($forum, $lastPost));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function destroy(Request $request, Forum $forum): JsonResponse
|
||||||
|
{
|
||||||
|
$forum->deleted_by = $request->user()?->id;
|
||||||
|
$forum->save();
|
||||||
|
$forum->delete();
|
||||||
|
|
||||||
|
return response()->json(null, 204);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function reorder(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$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 => $forumId) {
|
||||||
|
Forum::where('id', $forumId)
|
||||||
|
->where('parent_id', $parentId)
|
||||||
|
->update(['position' => $index + 1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json(['status' => 'ok']);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function parseIriId(?string $value): ?int
|
||||||
|
{
|
||||||
|
if (!$value) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (preg_match('#/forums/(\d+)$#', $value, $matches)) {
|
||||||
|
return (int) $matches[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is_numeric($value)) {
|
||||||
|
return (int) $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 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 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
22
app/Http/Controllers/I18nController.php
Normal file
22
app/Http/Controllers/I18nController.php
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Support\Facades\File;
|
||||||
|
|
||||||
|
class I18nController extends Controller
|
||||||
|
{
|
||||||
|
public function __invoke(string $locale): JsonResponse
|
||||||
|
{
|
||||||
|
$path = resource_path("lang/{$locale}.json");
|
||||||
|
|
||||||
|
if (!File::exists($path)) {
|
||||||
|
return response()->json([], 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$contents = File::get($path);
|
||||||
|
|
||||||
|
return response()->json(json_decode($contents, true, 512, JSON_THROW_ON_ERROR));
|
||||||
|
}
|
||||||
|
}
|
||||||
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
272
app/Http/Controllers/PostController.php
Normal file
272
app/Http/Controllers/PostController.php
Normal file
@@ -0,0 +1,272 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Actions\BbcodeFormatter;
|
||||||
|
use App\Models\Post;
|
||||||
|
use App\Models\Thread;
|
||||||
|
use App\Models\Setting;
|
||||||
|
use App\Services\AuditLogger;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
|
||||||
|
class PostController extends Controller
|
||||||
|
{
|
||||||
|
public function index(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$query = Post::query()->withoutTrashed()->with([
|
||||||
|
'user' => fn ($query) => $query
|
||||||
|
->withCount(['posts', 'threads', 'thanksGiven', 'thanksReceived'])
|
||||||
|
->with(['rank', 'roles']),
|
||||||
|
'attachments.extension',
|
||||||
|
'attachments.group',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$threadParam = $request->query('thread');
|
||||||
|
if (is_string($threadParam)) {
|
||||||
|
$threadId = $this->parseIriId($threadParam);
|
||||||
|
if ($threadId !== null) {
|
||||||
|
$query->where('thread_id', $threadId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$posts = $query
|
||||||
|
->oldest('created_at')
|
||||||
|
->get()
|
||||||
|
->map(fn (Post $post) => $this->serializePost($post));
|
||||||
|
|
||||||
|
return response()->json($posts);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function store(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$data = $request->validate([
|
||||||
|
'body' => ['required', 'string'],
|
||||||
|
'thread' => ['required', 'string'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$threadId = $this->parseIriId($data['thread']);
|
||||||
|
$thread = Thread::findOrFail($threadId);
|
||||||
|
|
||||||
|
$post = Post::create([
|
||||||
|
'thread_id' => $thread->id,
|
||||||
|
'user_id' => $request->user()?->id,
|
||||||
|
'body' => $data['body'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
app(AuditLogger::class)->log($request, 'post.created', $post, [
|
||||||
|
'thread_id' => $thread->id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$post->loadMissing([
|
||||||
|
'user' => fn ($query) => $query
|
||||||
|
->withCount(['posts', 'threads', 'thanksGiven', 'thanksReceived'])
|
||||||
|
->with(['rank', 'roles']),
|
||||||
|
'attachments.extension',
|
||||||
|
'attachments.group',
|
||||||
|
]);
|
||||||
|
|
||||||
|
return response()->json($this->serializePost($post), 201);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function destroy(Request $request, Post $post): JsonResponse
|
||||||
|
{
|
||||||
|
$reason = $request->input('reason');
|
||||||
|
$reasonText = $request->input('reason_text');
|
||||||
|
app(AuditLogger::class)->log($request, 'post.deleted', $post, [
|
||||||
|
'thread_id' => $post->thread_id,
|
||||||
|
'reason' => $reason,
|
||||||
|
'reason_text' => $reasonText,
|
||||||
|
]);
|
||||||
|
$post->deleted_by = $request->user()?->id;
|
||||||
|
$post->save();
|
||||||
|
$post->delete();
|
||||||
|
|
||||||
|
return response()->json(null, 204);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function update(Request $request, Post $post): JsonResponse
|
||||||
|
{
|
||||||
|
$user = $request->user();
|
||||||
|
if (!$user) {
|
||||||
|
return response()->json(['message' => 'Unauthorized.'], 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
$isAdmin = $user->roles()->where('name', 'ROLE_ADMIN')->exists();
|
||||||
|
if (!$isAdmin && $post->user_id !== $user->id) {
|
||||||
|
return response()->json(['message' => 'Not authorized to edit posts.'], 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = $request->validate([
|
||||||
|
'body' => ['required', 'string'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$post->body = $data['body'];
|
||||||
|
$post->save();
|
||||||
|
$post->refresh();
|
||||||
|
|
||||||
|
app(AuditLogger::class)->log($request, 'post.edited', $post, [
|
||||||
|
'thread_id' => $post->thread_id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$post->loadMissing([
|
||||||
|
'user' => fn ($query) => $query
|
||||||
|
->withCount(['posts', 'threads', 'thanksGiven', 'thanksReceived'])
|
||||||
|
->with(['rank', 'roles']),
|
||||||
|
'attachments.extension',
|
||||||
|
'attachments.group',
|
||||||
|
]);
|
||||||
|
|
||||||
|
return response()->json($this->serializePost($post));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function parseIriId(?string $value): ?int
|
||||||
|
{
|
||||||
|
if (!$value) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (preg_match('#/threads/(\d+)$#', $value, $matches)) {
|
||||||
|
return (int) $matches[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is_numeric($value)) {
|
||||||
|
return (int) $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function serializePost(Post $post): array
|
||||||
|
{
|
||||||
|
$attachments = $post->relationLoaded('attachments') ? $post->attachments : collect();
|
||||||
|
$bodyHtml = $this->renderBody($post->body, $attachments);
|
||||||
|
return [
|
||||||
|
'id' => $post->id,
|
||||||
|
'body' => $post->body,
|
||||||
|
'body_html' => $bodyHtml,
|
||||||
|
'thread' => "/api/threads/{$post->thread_id}",
|
||||||
|
'user_id' => $post->user_id,
|
||||||
|
'user_name' => $post->user?->name,
|
||||||
|
'user_avatar_url' => $post->user?->avatar_path
|
||||||
|
? Storage::url($post->user->avatar_path)
|
||||||
|
: null,
|
||||||
|
'user_posts_count' => ($post->user?->posts_count ?? 0) + ($post->user?->threads_count ?? 0),
|
||||||
|
'user_created_at' => $post->user?->created_at?->toIso8601String(),
|
||||||
|
'user_location' => $post->user?->location,
|
||||||
|
'user_thanks_given_count' => $post->user?->thanks_given_count ?? 0,
|
||||||
|
'user_thanks_received_count' => $post->user?->thanks_received_count ?? 0,
|
||||||
|
'user_rank_name' => $post->user?->rank?->name,
|
||||||
|
'user_rank_badge_type' => $post->user?->rank?->badge_type,
|
||||||
|
'user_rank_badge_text' => $post->user?->rank?->badge_text,
|
||||||
|
'user_rank_badge_url' => $post->user?->rank?->badge_image_path
|
||||||
|
? Storage::url($post->user->rank->badge_image_path)
|
||||||
|
: null,
|
||||||
|
'user_rank_color' => $post->user?->rank?->color,
|
||||||
|
'user_group_color' => $this->resolveGroupColor($post->user),
|
||||||
|
'created_at' => $post->created_at?->toIso8601String(),
|
||||||
|
'updated_at' => $post->updated_at?->toIso8601String(),
|
||||||
|
'attachments' => $post->relationLoaded('attachments')
|
||||||
|
? $attachments
|
||||||
|
->map(fn ($attachment) => [
|
||||||
|
'id' => $attachment->id,
|
||||||
|
'group' => $attachment->group ? [
|
||||||
|
'id' => $attachment->group->id,
|
||||||
|
'name' => $attachment->group->name,
|
||||||
|
] : null,
|
||||||
|
'original_name' => $attachment->original_name,
|
||||||
|
'extension' => $attachment->extension,
|
||||||
|
'mime_type' => $attachment->mime_type,
|
||||||
|
'size_bytes' => $attachment->size_bytes,
|
||||||
|
'download_url' => "/api/attachments/{$attachment->id}/download",
|
||||||
|
'thumbnail_url' => $attachment->thumbnail_path
|
||||||
|
? "/api/attachments/{$attachment->id}/thumbnail"
|
||||||
|
: null,
|
||||||
|
'is_image' => str_starts_with((string) $attachment->mime_type, 'image/'),
|
||||||
|
'created_at' => $attachment->created_at?->toIso8601String(),
|
||||||
|
])
|
||||||
|
->values()
|
||||||
|
: [],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function renderBody(string $body, $attachments): string
|
||||||
|
{
|
||||||
|
$replaced = $this->replaceAttachmentTags($body, $attachments);
|
||||||
|
return BbcodeFormatter::format($replaced);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function replaceAttachmentTags(string $body, $attachments): string
|
||||||
|
{
|
||||||
|
if (!$attachments || count($attachments) === 0) {
|
||||||
|
return $body;
|
||||||
|
}
|
||||||
|
|
||||||
|
$map = [];
|
||||||
|
foreach ($attachments as $attachment) {
|
||||||
|
$name = strtolower($attachment->original_name ?? '');
|
||||||
|
if ($name !== '') {
|
||||||
|
$map[$name] = [
|
||||||
|
'url' => "/api/attachments/{$attachment->id}/download",
|
||||||
|
'mime' => $attachment->mime_type ?? '',
|
||||||
|
'thumb' => $attachment->thumbnail_path
|
||||||
|
? "/api/attachments/{$attachment->id}/thumbnail"
|
||||||
|
: null,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$map) {
|
||||||
|
return $body;
|
||||||
|
}
|
||||||
|
|
||||||
|
return preg_replace_callback('/\\[attachment\\](.+?)\\[\\/attachment\\]/i', function ($matches) use ($map) {
|
||||||
|
$rawName = trim($matches[1]);
|
||||||
|
$key = strtolower($rawName);
|
||||||
|
if (!array_key_exists($key, $map)) {
|
||||||
|
return $matches[0];
|
||||||
|
}
|
||||||
|
$entry = $map[$key];
|
||||||
|
$url = $entry['url'];
|
||||||
|
$mime = $entry['mime'] ?? '';
|
||||||
|
if (str_starts_with($mime, 'image/') && $this->displayImagesInline()) {
|
||||||
|
if (!empty($entry['thumb'])) {
|
||||||
|
$thumb = $entry['thumb'];
|
||||||
|
return "[url={$url}][img]{$thumb}[/img][/url]";
|
||||||
|
}
|
||||||
|
return "[img]{$url}[/img]";
|
||||||
|
}
|
||||||
|
return "[url={$url}]{$rawName}[/url]";
|
||||||
|
}, $body) ?? $body;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function displayImagesInline(): bool
|
||||||
|
{
|
||||||
|
$value = Setting::query()->where('key', 'attachments.display_images_inline')->value('value');
|
||||||
|
if ($value === null) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return in_array(strtolower($value), ['1', 'true', 'yes', 'on'], true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function resolveGroupColor(?\App\Models\User $user): ?string
|
||||||
|
{
|
||||||
|
if (!$user) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$roles = $user->roles;
|
||||||
|
if (!$roles) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($roles->sortBy('name') as $role) {
|
||||||
|
if (!empty($role->color)) {
|
||||||
|
return $role->color;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
122
app/Http/Controllers/PostThankController.php
Normal file
122
app/Http/Controllers/PostThankController.php
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Models\Post;
|
||||||
|
use App\Models\PostThank;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
class PostThankController extends Controller
|
||||||
|
{
|
||||||
|
public function store(Request $request, Post $post): JsonResponse
|
||||||
|
{
|
||||||
|
$user = $request->user();
|
||||||
|
if (!$user) {
|
||||||
|
return response()->json(['message' => 'Unauthenticated.'], 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
$thank = PostThank::firstOrCreate([
|
||||||
|
'post_id' => $post->id,
|
||||||
|
'user_id' => $user->id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'id' => $thank->id,
|
||||||
|
'post_id' => $post->id,
|
||||||
|
'user_id' => $user->id,
|
||||||
|
], 201);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function destroy(Request $request, Post $post): JsonResponse
|
||||||
|
{
|
||||||
|
$user = $request->user();
|
||||||
|
if (!$user) {
|
||||||
|
return response()->json(['message' => 'Unauthenticated.'], 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
PostThank::where('post_id', $post->id)
|
||||||
|
->where('user_id', $user->id)
|
||||||
|
->delete();
|
||||||
|
|
||||||
|
return response()->json(null, 204);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function given(User $user): JsonResponse
|
||||||
|
{
|
||||||
|
$thanks = PostThank::query()
|
||||||
|
->where('user_id', $user->id)
|
||||||
|
->with(['post.thread', 'post.user.rank', 'post.user.roles'])
|
||||||
|
->latest('created_at')
|
||||||
|
->get()
|
||||||
|
->map(fn (PostThank $thank) => $this->serializeGiven($thank));
|
||||||
|
|
||||||
|
return response()->json($thanks);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function received(User $user): JsonResponse
|
||||||
|
{
|
||||||
|
$thanks = PostThank::query()
|
||||||
|
->whereHas('post', fn ($query) => $query->where('user_id', $user->id))
|
||||||
|
->with(['post.thread', 'user.rank', 'user.roles'])
|
||||||
|
->latest('created_at')
|
||||||
|
->get()
|
||||||
|
->map(fn (PostThank $thank) => $this->serializeReceived($thank));
|
||||||
|
|
||||||
|
return response()->json($thanks);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function serializeGiven(PostThank $thank): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'id' => $thank->id,
|
||||||
|
'post_id' => $thank->post_id,
|
||||||
|
'thread_id' => $thank->post?->thread_id,
|
||||||
|
'thread_title' => $thank->post?->thread?->title,
|
||||||
|
'post_excerpt' => $thank->post?->body ? Str::limit($thank->post->body, 120) : null,
|
||||||
|
'post_author_id' => $thank->post?->user_id,
|
||||||
|
'post_author_name' => $thank->post?->user?->name,
|
||||||
|
'post_author_rank_color' => $thank->post?->user?->rank?->color,
|
||||||
|
'post_author_group_color' => $this->resolveGroupColor($thank->post?->user),
|
||||||
|
'thanked_at' => $thank->created_at?->toIso8601String(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function serializeReceived(PostThank $thank): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'id' => $thank->id,
|
||||||
|
'post_id' => $thank->post_id,
|
||||||
|
'thread_id' => $thank->post?->thread_id,
|
||||||
|
'thread_title' => $thank->post?->thread?->title,
|
||||||
|
'post_excerpt' => $thank->post?->body ? Str::limit($thank->post->body, 120) : null,
|
||||||
|
'thanker_id' => $thank->user_id,
|
||||||
|
'thanker_name' => $thank->user?->name,
|
||||||
|
'thanker_rank_color' => $thank->user?->rank?->color,
|
||||||
|
'thanker_group_color' => $this->resolveGroupColor($thank->user),
|
||||||
|
'thanked_at' => $thank->created_at?->toIso8601String(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function resolveGroupColor(?\App\Models\User $user): ?string
|
||||||
|
{
|
||||||
|
if (!$user) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$roles = $user->roles;
|
||||||
|
if (!$roles) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($roles->sortBy('name') as $role) {
|
||||||
|
if (!empty($role->color)) {
|
||||||
|
return $role->color;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
20
app/Http/Controllers/PreviewController.php
Normal file
20
app/Http/Controllers/PreviewController.php
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Actions\BbcodeFormatter;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
class PreviewController extends Controller
|
||||||
|
{
|
||||||
|
public function preview(Request $request)
|
||||||
|
{
|
||||||
|
$data = $request->validate([
|
||||||
|
'body' => ['required', 'string'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'html' => BbcodeFormatter::format($data['body']),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
167
app/Http/Controllers/RankController.php
Normal file
167
app/Http/Controllers/RankController.php
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Models\Rank;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
|
||||||
|
class RankController extends Controller
|
||||||
|
{
|
||||||
|
private function ensureAdmin(Request $request): ?JsonResponse
|
||||||
|
{
|
||||||
|
$user = $request->user();
|
||||||
|
if (!$user || !$user->roles()->where(column: 'name', operator: 'ROLE_ADMIN')->exists()) {
|
||||||
|
return response()->json(data: ['message' => 'Forbidden'], status: 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function index(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$ranks = Rank::query()
|
||||||
|
->orderBy('name')
|
||||||
|
->get()
|
||||||
|
->map(fn (Rank $rank) => [
|
||||||
|
'id' => $rank->id,
|
||||||
|
'name' => $rank->name,
|
||||||
|
'badge_type' => $rank->badge_type,
|
||||||
|
'badge_text' => $rank->badge_text,
|
||||||
|
'color' => $rank->color,
|
||||||
|
'badge_image_url' => $rank->badge_image_path
|
||||||
|
? Storage::url($rank->badge_image_path)
|
||||||
|
: null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return response()->json($ranks);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function store(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
if ($error = $this->ensureAdmin($request)) {
|
||||||
|
return $error;
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = $request->validate([
|
||||||
|
'name' => ['required', 'string', 'max:100', 'unique:ranks,name'],
|
||||||
|
'badge_type' => ['nullable', 'in:text,image,none'],
|
||||||
|
'badge_text' => ['nullable', 'string', 'max:40'],
|
||||||
|
'color' => ['nullable', 'string', 'max:20', 'regex:/^#([0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$badgeType = $data['badge_type'] ?? 'text';
|
||||||
|
$badgeText = $badgeType === 'text'
|
||||||
|
? ($data['badge_text'] ?? $data['name'])
|
||||||
|
: null;
|
||||||
|
if ($badgeType === 'none') {
|
||||||
|
$badgeText = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$rank = Rank::create([
|
||||||
|
'name' => $data['name'],
|
||||||
|
'badge_type' => $badgeType,
|
||||||
|
'badge_text' => $badgeText,
|
||||||
|
'color' => $data['color'] ?? null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'id' => $rank->id,
|
||||||
|
'name' => $rank->name,
|
||||||
|
'badge_type' => $rank->badge_type,
|
||||||
|
'badge_text' => $rank->badge_text,
|
||||||
|
'color' => $rank->color,
|
||||||
|
'badge_image_url' => null,
|
||||||
|
], 201);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function update(Request $request, Rank $rank): JsonResponse
|
||||||
|
{
|
||||||
|
if ($error = $this->ensureAdmin($request)) {
|
||||||
|
return $error;
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = $request->validate([
|
||||||
|
'name' => ['required', 'string', 'max:100', "unique:ranks,name,{$rank->id}"],
|
||||||
|
'badge_type' => ['nullable', 'in:text,image,none'],
|
||||||
|
'badge_text' => ['nullable', 'string', 'max:40'],
|
||||||
|
'color' => ['nullable', 'string', 'max:20', 'regex:/^#([0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$badgeType = $data['badge_type'] ?? $rank->badge_type ?? 'text';
|
||||||
|
$badgeText = $badgeType === 'text'
|
||||||
|
? ($data['badge_text'] ?? $rank->badge_text ?? $data['name'])
|
||||||
|
: null;
|
||||||
|
if ($badgeType === 'none') {
|
||||||
|
$badgeText = null;
|
||||||
|
}
|
||||||
|
$color = array_key_exists('color', $data) ? $data['color'] : $rank->color;
|
||||||
|
|
||||||
|
if ($badgeType !== 'image' && $rank->badge_image_path) {
|
||||||
|
Storage::disk('public')->delete($rank->badge_image_path);
|
||||||
|
$rank->badge_image_path = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$rank->update([
|
||||||
|
'name' => $data['name'],
|
||||||
|
'badge_type' => $badgeType,
|
||||||
|
'badge_text' => $badgeText,
|
||||||
|
'color' => $color,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'id' => $rank->id,
|
||||||
|
'name' => $rank->name,
|
||||||
|
'badge_type' => $rank->badge_type,
|
||||||
|
'badge_text' => $rank->badge_text,
|
||||||
|
'color' => $rank->color,
|
||||||
|
'badge_image_url' => $rank->badge_image_path
|
||||||
|
? Storage::url($rank->badge_image_path)
|
||||||
|
: null,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function destroy(Request $request, Rank $rank): JsonResponse
|
||||||
|
{
|
||||||
|
if ($error = $this->ensureAdmin($request)) {
|
||||||
|
return $error;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($rank->badge_image_path) {
|
||||||
|
Storage::disk('public')->delete($rank->badge_image_path);
|
||||||
|
}
|
||||||
|
|
||||||
|
$rank->delete();
|
||||||
|
|
||||||
|
return response()->json(null, 204);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function uploadBadgeImage(Request $request, Rank $rank): JsonResponse
|
||||||
|
{
|
||||||
|
if ($error = $this->ensureAdmin($request)) {
|
||||||
|
return $error;
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = $request->validate([
|
||||||
|
'file' => ['required', 'image', 'mimes:jpg,jpeg,png,gif,webp', 'max:2048'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
if ($rank->badge_image_path) {
|
||||||
|
Storage::disk('public')->delete($rank->badge_image_path);
|
||||||
|
}
|
||||||
|
|
||||||
|
$path = $data['file']->store('rank-badges', 'public');
|
||||||
|
$rank->badge_type = 'image';
|
||||||
|
$rank->badge_text = null;
|
||||||
|
$rank->badge_image_path = $path;
|
||||||
|
$rank->save();
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'id' => $rank->id,
|
||||||
|
'badge_type' => $rank->badge_type,
|
||||||
|
'badge_text' => $rank->badge_text,
|
||||||
|
'badge_image_url' => Storage::url($path),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
141
app/Http/Controllers/RoleController.php
Normal file
141
app/Http/Controllers/RoleController.php
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Models\Role;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
class RoleController extends Controller
|
||||||
|
{
|
||||||
|
private const CORE_ROLES = ['ROLE_ADMIN', 'ROLE_USER', 'ROLE_FOUNDER'];
|
||||||
|
|
||||||
|
private function ensureAdmin(Request $request): ?JsonResponse
|
||||||
|
{
|
||||||
|
$user = $request->user();
|
||||||
|
if (!$user || !$user->roles()->where('name', 'ROLE_ADMIN')->exists()) {
|
||||||
|
return response()->json(['message' => 'Forbidden'], 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function index(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
if ($error = $this->ensureAdmin($request)) {
|
||||||
|
return $error;
|
||||||
|
}
|
||||||
|
|
||||||
|
$roles = Role::query()
|
||||||
|
->orderBy('name')
|
||||||
|
->get()
|
||||||
|
->map(fn (Role $role) => [
|
||||||
|
'id' => $role->id,
|
||||||
|
'name' => $role->name,
|
||||||
|
'color' => $role->color,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return response()->json($roles);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function store(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
if ($error = $this->ensureAdmin($request)) {
|
||||||
|
return $error;
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = $request->validate([
|
||||||
|
'name' => ['required', 'string', 'max:100', 'unique:roles,name'],
|
||||||
|
'color' => ['nullable', 'string', 'max:20', 'regex:/^#([0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$normalizedName = $this->normalizeRoleName($data['name']);
|
||||||
|
if (Role::query()->where('name', $normalizedName)->exists()) {
|
||||||
|
return response()->json(['message' => 'Role already exists.'], 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
$role = Role::create([
|
||||||
|
'name' => $normalizedName,
|
||||||
|
'color' => $data['color'] ?? null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'id' => $role->id,
|
||||||
|
'name' => $role->name,
|
||||||
|
'color' => $role->color,
|
||||||
|
], 201);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function update(Request $request, Role $role): JsonResponse
|
||||||
|
{
|
||||||
|
if ($error = $this->ensureAdmin($request)) {
|
||||||
|
return $error;
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = $request->validate([
|
||||||
|
'name' => ['required', 'string', 'max:100', "unique:roles,name,{$role->id}"],
|
||||||
|
'color' => ['nullable', 'string', 'max:20', 'regex:/^#([0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$normalizedName = $this->normalizeRoleName($data['name']);
|
||||||
|
if (Role::query()
|
||||||
|
->where('id', '!=', $role->id)
|
||||||
|
->where('name', $normalizedName)
|
||||||
|
->exists()
|
||||||
|
) {
|
||||||
|
return response()->json(['message' => 'Role already exists.'], 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (in_array($role->name, self::CORE_ROLES, true) && $normalizedName !== $role->name) {
|
||||||
|
return response()->json(['message' => 'Core roles cannot be renamed.'], 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
$color = array_key_exists('color', $data) ? $data['color'] : $role->color;
|
||||||
|
|
||||||
|
$role->update([
|
||||||
|
'name' => $normalizedName,
|
||||||
|
'color' => $color,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'id' => $role->id,
|
||||||
|
'name' => $role->name,
|
||||||
|
'color' => $role->color,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function destroy(Request $request, Role $role): JsonResponse
|
||||||
|
{
|
||||||
|
if ($error = $this->ensureAdmin($request)) {
|
||||||
|
return $error;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (in_array($role->name, self::CORE_ROLES, true)) {
|
||||||
|
return response()->json(['message' => 'Core roles cannot be deleted.'], 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($role->users()->exists()) {
|
||||||
|
return response()->json(['message' => 'Role is assigned to users.'], 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
$role->delete();
|
||||||
|
|
||||||
|
return response()->json(null, 204);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function normalizeRoleName(string $value): string
|
||||||
|
{
|
||||||
|
$raw = strtoupper(trim($value));
|
||||||
|
$raw = preg_replace('/\s+/', '_', $raw);
|
||||||
|
$raw = preg_replace('/[^A-Z0-9_]/', '_', $raw);
|
||||||
|
$raw = preg_replace('/_+/', '_', $raw);
|
||||||
|
$raw = trim($raw, '_');
|
||||||
|
if ($raw === '') {
|
||||||
|
return 'ROLE_';
|
||||||
|
}
|
||||||
|
if (str_starts_with($raw, 'ROLE_')) {
|
||||||
|
return $raw;
|
||||||
|
}
|
||||||
|
return "ROLE_{$raw}";
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user