72 Commits

Author SHA1 Message Date
d178b8da91 make the update more verbose for testing
All checks were successful
CI/CD Pipeline / test (push) Successful in 3s
CI/CD Pipeline / deploy (push) Successful in 20s
CI/CD Pipeline / promote_stable (push) Successful in 2s
2026-02-15 23:37:48 +01:00
7ecb6378fe make the update more verbose for testing
All checks were successful
CI/CD Pipeline / test (push) Successful in 3s
CI/CD Pipeline / deploy (push) Successful in 20s
CI/CD Pipeline / promote_stable (push) Successful in 3s
2026-02-15 23:34:18 +01:00
9496078644 make the update more verbose for testing
All checks were successful
CI/CD Pipeline / test (push) Successful in 2s
CI/CD Pipeline / deploy (push) Successful in 20s
CI/CD Pipeline / promote_stable (push) Successful in 2s
2026-02-15 23:27:49 +01:00
3aab864c34 make the update more verbose for testing
All checks were successful
CI/CD Pipeline / test (push) Successful in 2s
CI/CD Pipeline / deploy (push) Successful in 19s
CI/CD Pipeline / promote_stable (push) Successful in 3s
2026-02-15 23:22:14 +01:00
5eb5404061 refactor the update
All checks were successful
CI/CD Pipeline / test (push) Successful in 3s
CI/CD Pipeline / deploy (push) Successful in 24s
CI/CD Pipeline / promote_stable (push) Successful in 2s
2026-02-15 23:13:37 +01:00
d9040f1e6c refactor the update 2026-02-15 23:11:23 +01:00
8270e635d6 fix version display
All checks were successful
CI/CD Pipeline / test (push) Successful in 3s
CI/CD Pipeline / deploy (push) Successful in 20s
CI/CD Pipeline / promote_stable (push) Successful in 2s
2026-02-14 11:45:07 +01:00
d724f80cad fix version display
All checks were successful
CI/CD Pipeline / test (push) Successful in 3s
CI/CD Pipeline / deploy (push) Successful in 25s
CI/CD Pipeline / promote_stable (push) Successful in 3s
2026-02-14 11:41:06 +01:00
1f5f340ce4 fix version updates
All checks were successful
CI/CD Pipeline / test (push) Successful in 3s
CI/CD Pipeline / deploy (push) Successful in 20s
CI/CD Pipeline / promote_stable (push) Successful in 2s
2026-02-13 18:28:18 +01:00
40e111b3a6 added version log in updater
All checks were successful
CI/CD Pipeline / test (push) Successful in 2s
CI/CD Pipeline / deploy (push) Successful in 19s
CI/CD Pipeline / promote_stable (push) Successful in 2s
2026-02-13 10:50:33 +01:00
506011f933 added cache folder
All checks were successful
CI/CD Pipeline / test (push) Successful in 3s
CI/CD Pipeline / deploy (push) Successful in 20s
CI/CD Pipeline / promote_stable (push) Successful in 2s
2026-02-13 10:19:31 +01:00
80a8b86a08 fixed bootstrap/cache
All checks were successful
CI/CD Pipeline / test (push) Successful in 2s
CI/CD Pipeline / deploy (push) Successful in 21s
CI/CD Pipeline / promote_stable (push) Successful in 2s
2026-02-13 10:15:30 +01:00
c1cb3f394a prepare for cli updates with custom php binary
Some checks failed
CI/CD Pipeline / test (push) Successful in 3s
CI/CD Pipeline / deploy (push) Failing after 12s
CI/CD Pipeline / promote_stable (push) Has been skipped
2026-02-13 10:11:34 +01:00
31c8491aaf Update composer.lock
Some checks failed
CI/CD Pipeline / test (push) Successful in 2s
CI/CD Pipeline / deploy (push) Failing after 10s
CI/CD Pipeline / promote_stable (push) Has been skipped
2026-02-13 10:08:16 +01:00
0ad5916504 Add custom paths and ACP tab
Some checks failed
CI/CD Pipeline / test (push) Successful in 12s
CI/CD Pipeline / deploy (push) Failing after 11s
CI/CD Pipeline / promote_stable (push) Has been skipped
2026-02-13 10:04:28 +01:00
bac70c3927 Empty
Some checks failed
CI/CD Pipeline / test (push) Successful in 3s
CI/CD Pipeline / deploy (push) Failing after 15s
CI/CD Pipeline / promote_stable (push) Has been skipped
2026-02-13 10:03:14 +01:00
bf23e46e2d Polish ACP system layout
Some checks failed
CI/CD Pipeline / test (push) Successful in 2s
CI/CD Pipeline / deploy (push) Failing after 11s
CI/CD Pipeline / promote_stable (push) Has been skipped
2026-02-12 20:02:28 +01:00
55b9a69c42 Refine ACP system settings
Some checks failed
CI/CD Pipeline / test (push) Successful in 4s
CI/CD Pipeline / deploy (push) Failing after 19s
CI/CD Pipeline / promote_stable (push) Has been skipped
2026-02-12 19:44:23 +01:00
b6ce5160f9 prepare for cli updates with custom php binary
Some checks failed
CI/CD Pipeline / test (push) Successful in 3s
CI/CD Pipeline / deploy (push) Failing after 10s
CI/CD Pipeline / promote_stable (push) Has been skipped
2026-02-11 19:46:33 +01:00
d279e7f36f prepare for cli updates with custom php binary
Some checks failed
CI/CD Pipeline / test (push) Successful in 2s
CI/CD Pipeline / deploy (push) Failing after 9s
CI/CD Pipeline / promote_stable (push) Has been skipped
2026-02-11 19:13:59 +01:00
a0d914ea24 Stop tracking bootstrap cache
Some checks failed
CI/CD Pipeline / test (push) Successful in 2s
CI/CD Pipeline / deploy (push) Failing after 10s
CI/CD Pipeline / promote_stable (push) Has been skipped
2026-02-11 19:13:27 +01:00
ce3b89d54e prepare for cli updates with custom php binary
All checks were successful
CI/CD Pipeline / test (push) Successful in 2s
CI/CD Pipeline / deploy (push) Successful in 20s
CI/CD Pipeline / promote_stable (push) Successful in 3s
2026-02-11 19:01:23 +01:00
5cd8a1a9d6 prepare for cli updates with custom php binary
All checks were successful
CI/CD Pipeline / test (push) Successful in 2s
CI/CD Pipeline / deploy (push) Successful in 20s
CI/CD Pipeline / promote_stable (push) Successful in 2s
2026-02-11 18:48:26 +01:00
6f9d9f9e7a prepare for cli updates with custom php binary
All checks were successful
CI/CD Pipeline / test (push) Successful in 2s
CI/CD Pipeline / deploy (push) Successful in 20s
CI/CD Pipeline / promote_stable (push) Successful in 3s
2026-02-11 18:44:01 +01:00
db7f088b36 prepare for cli updates with custom php binary
All checks were successful
CI/CD Pipeline / test (push) Successful in 3s
CI/CD Pipeline / deploy (push) Successful in 19s
CI/CD Pipeline / promote_stable (push) Successful in 3s
2026-02-11 18:36:33 +01:00
54d4cd7f99 prepare for cli updates with custom php binary
All checks were successful
CI/CD Pipeline / test (push) Successful in 2s
CI/CD Pipeline / deploy (push) Successful in 18s
CI/CD Pipeline / promote_stable (push) Successful in 2s
2026-02-11 18:31:06 +01:00
af03c23c9f Fix stable promotion fetch/merge
All checks were successful
CI/CD Pipeline / test (push) Successful in 2s
CI/CD Pipeline / deploy (push) Successful in 19s
CI/CD Pipeline / promote_stable (push) Successful in 3s
2026-02-11 17:46:56 +01:00
68dd17f895 Promote stable on successful deploy
Some checks failed
CI/CD Pipeline / test (push) Successful in 3s
CI/CD Pipeline / deploy (push) Successful in 23s
CI/CD Pipeline / promote_stable (push) Failing after 1s
2026-02-11 17:42:31 +01:00
8249df15df update stable if push to master is successful
Some checks failed
CI/CD Pipeline / test (push) Successful in 3s
CI/CD Pipeline / deploy (push) Successful in 18s
CI/CD Pipeline / promote_stable (push) Failing after 3s
2026-02-10 20:20:20 +01:00
f167e64d00 make the cli update executable
All checks were successful
CI/CD Pipeline / test (push) Successful in 2s
CI/CD Pipeline / deploy (push) Successful in 18s
2026-02-10 19:58:09 +01:00
95ebc7778d Update ACP system navigation
All checks were successful
CI/CD Pipeline / test (push) Successful in 2s
CI/CD Pipeline / deploy (push) Successful in 19s
2026-02-10 19:52:24 +01:00
c67a3ec6d0 Add pre-commit hook to verify DB
All checks were successful
CI/CD Pipeline / test (push) Successful in 3s
CI/CD Pipeline / deploy (push) Successful in 19s
2026-02-10 18:46:43 +01:00
bf278667bc Add git_update.sh and adjust update/test hooks
All checks were successful
CI/CD Pipeline / test (push) Successful in 3s
CI/CD Pipeline / deploy (push) Successful in 23s
2026-02-10 18:38:51 +01:00
30a06e18f0 Bump version
All checks were successful
CI/CD Pipeline / test (push) Successful in 2s
CI/CD Pipeline / deploy (push) Successful in 19s
2026-02-08 19:16:21 +01:00
0bc893dd35 Restore artisan script
All checks were successful
CI/CD Pipeline / test (push) Successful in 3s
CI/CD Pipeline / deploy (push) Successful in 21s
2026-02-08 19:10:56 +01:00
88e4a70f88 Add comprehensive test coverage and update notes
Some checks failed
CI/CD Pipeline / test (push) Successful in 3s
CI/CD Pipeline / deploy (push) Failing after 15s
2026-02-08 19:04:12 +01:00
160430e128 Add extensive controller and model tests
All checks were successful
CI/CD Pipeline / test (push) Successful in 10s
CI/CD Pipeline / deploy (push) Successful in 25s
2026-02-07 22:14:42 +01:00
9c60a8944e feat: system tools and admin enhancements
All checks were successful
CI/CD Pipeline / test (push) Successful in 3s
CI/CD Pipeline / deploy (push) Successful in 20s
2026-01-31 20:12:09 +01:00
64244567c0 Add attachment thumbnails and ACP refinements
All checks were successful
CI/CD Pipeline / test (push) Successful in 3s
CI/CD Pipeline / deploy (push) Successful in 24s
2026-01-31 12:02:54 +01:00
7fbc566129 re-enabled autoseeding of attachment groups
All checks were successful
CI/CD Pipeline / test (push) Successful in 7s
CI/CD Pipeline / deploy (push) Successful in 19s
2026-01-28 20:01:02 +01:00
c33cde6f04 added attchments
All checks were successful
CI/CD Pipeline / test (push) Successful in 3s
CI/CD Pipeline / deploy (push) Successful in 24s
2026-01-28 19:34:25 +01:00
2409feb06f feat: add solved threads
All checks were successful
CI/CD Pipeline / test (push) Successful in 3s
CI/CD Pipeline / deploy (push) Successful in 26s
2026-01-24 14:11:55 +01:00
e3dcf99362 feat: derive build from git commit count 2026-01-24 13:23:44 +01:00
357f6fb755 test pipeline 2026-01-23 19:26:53 +01:00
2281b80980 test pipeline 2026-01-23 19:26:53 +01:00
f23363fdcc test pipeline 2026-01-23 19:26:52 +01:00
c1814c0d47 test pipeline 2026-01-23 19:26:51 +01:00
7489a3903d test pipeline 2026-01-23 19:26:50 +01:00
b967aa912b test pipeline 2026-01-23 19:26:49 +01:00
67ae9517f4 added ansible playbook 2026-01-23 19:26:48 +01:00
653905d5e2 fixed post count 2026-01-23 19:26:43 +01:00
bc893b644d fixed post count 2026-01-23 19:26:42 +01:00
662e00bec1 fixed post count 2026-01-23 19:26:41 +01:00
a96913bffa fixed post count 2026-01-23 19:26:40 +01:00
79ac0cdca5 fix rank creation error 2026-01-23 19:26:39 +01:00
fe4b7ccd7c fix rank creation error 2026-01-23 19:26:38 +01:00
fc9de4c9fd added gitea worker 2026-01-23 19:26:37 +01:00
6b6f787351 chore: remove tracked storage artifacts 2026-01-23 19:26:36 +01:00
d4fb86633b feat: add installer, ranks/groups enhancements, and founder protections 2026-01-23 19:26:35 +01:00
Micha
24c16ed0dd Unify portal thread rows and add summary API 2026-01-16 02:44:04 +01:00
Micha
f9de433545 fixing thsu frontend views 2026-01-16 01:43:07 +01:00
Micha
fd29b928d8 Add ranks and ACP user enhancements 2026-01-14 00:15:56 +01:00
Micha
98094459e3 Tighten ACP forum actions and avatar handling 2026-01-13 00:07:25 +01:00
Micha
3bb2946656 Add avatars, profiles, and auth flows 2026-01-12 23:40:11 +01:00
Micha
bbbf8eb6c1 Show post authors and action buttons 2026-01-11 01:54:45 +01:00
Micha
c8d2bd508e Restyle thread view like phpBB 2026-01-11 01:25:16 +01:00
eef3262a53 added PSR-12 rules 2026-01-11 00:35:42 +01:00
fe1015bff1 ACP: general settings, branding, and favicon uploads 2026-01-02 20:01:22 +01:00
Micha
8604cdf95d UI: portal/header refinements, board index UX, and user settings 2026-01-01 19:54:02 +01:00
f83748cc76 Update ACP UI copy, accents, and defaults 2025-12-29 19:32:26 +01:00
63bd166a65 finished laravel migration 2025-12-29 18:19:24 +01:00
Micha
bdfbe3ffd6 Restyle home forum list 2025-12-26 16:50:27 +01:00
360 changed files with 49459 additions and 16833 deletions

View File

@@ -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
View 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
View 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

View 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
View File

@@ -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

View File

@@ -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
View File

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

7
README.md Normal file
View File

@@ -0,0 +1,7 @@
# SpeedBB Forum
Placeholder README for the forum application.
## Status
Work in progress.

4
ansible/ansible.cfg Normal file
View File

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

View File

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

8
ansible/hosts.ini Normal file
View File

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

View File

@@ -0,0 +1,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
View File

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

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

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

View File

@@ -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 ###

View File

@@ -1,4 +0,0 @@
###> symfony/framework-bundle ###
APP_SECRET=7944fa8dd76a206ea3e7e16b9276029c
###< symfony/framework-bundle ###

14
api/.gitignore vendored
View File

@@ -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 ###

View File

@@ -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);
};

View File

@@ -1,7 +0,0 @@
services:
###> doctrine/doctrine-bundle ###
database:
ports:
- "5432"
###< doctrine/doctrine-bundle ###

View File

@@ -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 ###

View File

@@ -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

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -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']

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View 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

View File

@@ -1,3 +0,0 @@
framework:
property_info:
with_constructor_extractor: true

View File

@@ -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

View File

@@ -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

View File

@@ -1,5 +0,0 @@
framework:
default_locale: en
translator:
default_path: '%kernel.project_dir%/translations'
providers:

View File

@@ -1,6 +0,0 @@
twig:
file_name_pattern: '*.twig'
when@test:
twig:
strict_variables: true

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -1,4 +0,0 @@
api_platform:
resource: .
type: api_platform
prefix: /api

View File

@@ -1,4 +0,0 @@
when@dev:
_errors:
resource: '@FrameworkBundle/Resources/config/routing/errors.php'
prefix: /_error

View File

@@ -1,3 +0,0 @@
_security_logout:
resource: security.route_loader.logout
type: service

View File

@@ -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

View File

@@ -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
{
}
}

View File

@@ -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)');
}
}

View File

@@ -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');
}
}

View File

@@ -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');
}
}

View File

@@ -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');
}
}

View File

@@ -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');
}
}

View File

@@ -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'");
}
}

View File

@@ -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>

View File

@@ -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']);
};

View File

View File

@@ -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']);
}
}

View File

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

View File

@@ -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;
}
}

View File

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

View File

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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);
}
}

View File

@@ -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;
}

View File

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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"
]
}
}

View File

@@ -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>

View File

View File

@@ -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"

View File

@@ -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"

View File

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

View File

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

View 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'];
}
}

View 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();
}
}

View 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();
}
}

View 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();
}
}

View File

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

View File

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

View File

@@ -0,0 +1,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;
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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);
}
}

View File

@@ -0,0 +1,8 @@
<?php
namespace App\Http\Controllers;
abstract class Controller
{
//
}

View 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;
}
}

View 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));
}
}

View File

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

View File

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

View File

@@ -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;
}
}

View File

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

View File

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

View File

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

View File

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

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