Compare commits
2 Commits
master
...
4c2468952c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4c2468952c | ||
|
|
073c81012b |
@@ -1,60 +0,0 @@
|
|||||||
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
|
|
||||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -4,7 +4,6 @@
|
|||||||
.env
|
.env
|
||||||
.env.backup
|
.env.backup
|
||||||
.env.production
|
.env.production
|
||||||
.env.test
|
|
||||||
.env.*.local
|
.env.*.local
|
||||||
.phpactor.json
|
.phpactor.json
|
||||||
.phpunit.result.cache
|
.phpunit.result.cache
|
||||||
@@ -22,14 +21,11 @@
|
|||||||
/public/build
|
/public/build
|
||||||
/public/hot
|
/public/hot
|
||||||
/public/storage
|
/public/storage
|
||||||
/public/custom
|
|
||||||
/storage/app
|
/storage/app
|
||||||
/storage/framework
|
/storage/framework
|
||||||
/storage/*.key
|
/storage/*.key
|
||||||
/storage/pail
|
/storage/pail
|
||||||
/storage/framework/views/*.php
|
/storage/framework/views/*.php
|
||||||
/bootstrap/cache/*.php
|
|
||||||
/custom
|
|
||||||
/vendor
|
/vendor
|
||||||
Homestead.json
|
Homestead.json
|
||||||
Homestead.yaml
|
Homestead.yaml
|
||||||
|
|||||||
17
CHANGELOG.md
17
CHANGELOG.md
@@ -1,22 +1,5 @@
|
|||||||
# 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
|
## 2026-01-12
|
||||||
- Switched main SPA layouts to fluid containers to reduce wasted space.
|
- Switched main SPA layouts to fluid containers to reduce wasted space.
|
||||||
- Added username-or-email login with case-insensitive unique usernames.
|
- Added username-or-email login with case-insensitive unique usernames.
|
||||||
|
|||||||
15
NOTES.md
15
NOTES.md
@@ -1,15 +0,0 @@
|
|||||||
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.
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
[defaults]
|
|
||||||
inventory = ./hosts.ini
|
|
||||||
set_remote_user = yes
|
|
||||||
allow_world_readable_tmpfiles=true
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
---
|
|
||||||
- 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
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
[dev]
|
|
||||||
fd20:2184:8045:4973:5054:ff:fe6c:13d1 ansible_connection=local
|
|
||||||
|
|
||||||
[prod]
|
|
||||||
support.24unix.net ansible_user=tracer ansible_become_password=
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -1,123 +0,0 @@
|
|||||||
---
|
|
||||||
- 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
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
---
|
|
||||||
git_repo: "{{ lookup('env', 'SPEEDBB_REPO') }}"
|
|
||||||
prod_base_dir: "{{ lookup('env', 'PROD_BASE_DIR') }}"
|
|
||||||
|
|
||||||
prod_become_user: "{{ vault_prod_become_user }}"
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
$ANSIBLE_VAULT;1.1;AES256
|
|
||||||
31623264303535663263613235356231623137333734626164376138656532623937316534333835
|
|
||||||
3661666237386534373466356136393566333162326562330a383833363737323637363738616666
|
|
||||||
62393164326465376634356666303861613362313430656161653531373733353530636265353738
|
|
||||||
3863633131313834390a356663373338346137373662356161643336636534626130313466343566
|
|
||||||
36653636333838633938323363646335663935646135613632356434396436326131323361366561
|
|
||||||
32633939346163356131663266346539323330613536333838616332646139313731326133646165
|
|
||||||
31343763636337306263646631353562646462323631383439353738333035623664623163303839
|
|
||||||
34343261383738396534
|
|
||||||
@@ -1,73 +0,0 @@
|
|||||||
<?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];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,93 +0,0 @@
|
|||||||
<?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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,89 +0,0 @@
|
|||||||
<?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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,97 +0,0 @@
|
|||||||
<?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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,113 +0,0 @@
|
|||||||
<?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.';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,73 +0,0 @@
|
|||||||
<?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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,351 +0,0 @@
|
|||||||
<?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(),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -1,147 +0,0 @@
|
|||||||
<?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,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,190 +0,0 @@
|
|||||||
<?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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
<?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,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -5,7 +5,6 @@ namespace App\Http\Controllers;
|
|||||||
use App\Actions\Fortify\CreateNewUser;
|
use App\Actions\Fortify\CreateNewUser;
|
||||||
use App\Actions\Fortify\PasswordValidationRules;
|
use App\Actions\Fortify\PasswordValidationRules;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Services\AuditLogger;
|
|
||||||
use Illuminate\Auth\Events\Verified;
|
use Illuminate\Auth\Events\Verified;
|
||||||
use Illuminate\Auth\Events\PasswordReset;
|
use Illuminate\Auth\Events\PasswordReset;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
@@ -23,21 +22,17 @@ class AuthController extends Controller
|
|||||||
public function register(Request $request, CreateNewUser $creator): JsonResponse
|
public function register(Request $request, CreateNewUser $creator): JsonResponse
|
||||||
{
|
{
|
||||||
$input = [
|
$input = [
|
||||||
'name' => $request->input(key: 'name') ?? $request->input(key: 'username'),
|
'name' => $request->input('name') ?? $request->input('username'),
|
||||||
'email' => $request->input(key: 'email'),
|
'email' => $request->input('email'),
|
||||||
'password' => $request->input(key: 'password') ?? $request->input(key: 'plainPassword'),
|
'password' => $request->input('password') ?? $request->input('plainPassword'),
|
||||||
'password_confirmation' => $request->input(key: 'password_confirmation')
|
'password_confirmation' => $request->input('password_confirmation') ?? $request->input('plainPassword'),
|
||||||
?? $request->input(key: 'plainPassword'),
|
|
||||||
];
|
];
|
||||||
|
|
||||||
$user = $creator->create(input: $input);
|
$user = $creator->create($input);
|
||||||
|
|
||||||
$user->sendEmailVerificationNotification();
|
$user->sendEmailVerificationNotification();
|
||||||
app(AuditLogger::class)->log($request, 'user.registered', $user, [
|
|
||||||
'email' => $user->email,
|
|
||||||
], $user);
|
|
||||||
|
|
||||||
return response()->json(data: [
|
return response()->json([
|
||||||
'user_id' => $user->id,
|
'user_id' => $user->id,
|
||||||
'email' => $user->email,
|
'email' => $user->email,
|
||||||
'message' => 'Verification email sent.',
|
'message' => 'Verification email sent.',
|
||||||
@@ -46,91 +41,87 @@ class AuthController extends Controller
|
|||||||
|
|
||||||
public function login(Request $request): JsonResponse
|
public function login(Request $request): JsonResponse
|
||||||
{
|
{
|
||||||
$request->merge(input: [
|
$request->merge([
|
||||||
'login' => $request->input(key: 'login', default: $request->input(key: 'email')),
|
'login' => $request->input('login', $request->input('email')),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$request->validate(rules: [
|
$request->validate([
|
||||||
'login' => ['required', 'string'],
|
'login' => ['required', 'string'],
|
||||||
'password' => ['required', 'string'],
|
'password' => ['required', 'string'],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$login = trim(string: (string) $request->input(key: 'login'));
|
$login = trim((string) $request->input('login'));
|
||||||
$loginNormalized = Str::lower(value: $login);
|
$loginNormalized = Str::lower($login);
|
||||||
$userQuery = User::query();
|
$userQuery = User::query();
|
||||||
|
|
||||||
if (filter_var(value: $login, filter: FILTER_VALIDATE_EMAIL)) {
|
if (filter_var($login, FILTER_VALIDATE_EMAIL)) {
|
||||||
$userQuery->whereRaw(sql: 'lower(email) = ?', bindings: [$loginNormalized]);
|
$userQuery->whereRaw('lower(email) = ?', [$loginNormalized]);
|
||||||
} else {
|
} else {
|
||||||
$userQuery->where(column: 'name_canonical', operator: $loginNormalized);
|
$userQuery->where('name_canonical', $loginNormalized);
|
||||||
}
|
}
|
||||||
|
|
||||||
$user = $userQuery->first();
|
$user = $userQuery->first();
|
||||||
|
|
||||||
if (!$user || !Hash::check(value: $request->input(key: 'password'), hashedValue: $user->password)) {
|
if (!$user || !Hash::check($request->input('password'), $user->password)) {
|
||||||
throw ValidationException::withMessages(messages: [
|
throw ValidationException::withMessages([
|
||||||
'login' => ['Invalid credentials.'],
|
'login' => ['Invalid credentials.'],
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!$user->hasVerifiedEmail()) {
|
if (!$user->hasVerifiedEmail()) {
|
||||||
return response()->json(data : [
|
return response()->json([
|
||||||
'message' => 'Email not verified.',
|
'message' => 'Email not verified.',
|
||||||
], status: 403);
|
], 403);
|
||||||
}
|
}
|
||||||
|
|
||||||
$token = $user->createToken(name: 'api')->plainTextToken;
|
$token = $user->createToken('api')->plainTextToken;
|
||||||
|
|
||||||
app(AuditLogger::class)->log($request, 'user.login', $user, [
|
return response()->json([
|
||||||
'login' => $login,
|
|
||||||
], $user);
|
|
||||||
|
|
||||||
return response()->json(data: [
|
|
||||||
'token' => $token,
|
'token' => $token,
|
||||||
'user_id' => $user->id,
|
'user_id' => $user->id,
|
||||||
'email' => $user->email,
|
'email' => $user->email,
|
||||||
'roles' => $user->roles()->pluck(column: 'name')->values(),
|
'roles' => $user->roles()->pluck('name')->values(),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function verifyEmail(Request $request, string $id, string $hash): RedirectResponse
|
public function verifyEmail(Request $request, string $id, string $hash): RedirectResponse
|
||||||
{
|
{
|
||||||
$user = User::findOrFail(id: $id);
|
$user = User::findOrFail($id);
|
||||||
|
|
||||||
if (!hash_equals(known_string: $hash, user_string: sha1(string: $user->getEmailForVerification()))) {
|
if (!hash_equals($hash, sha1($user->getEmailForVerification()))) {
|
||||||
abort(code: 403);
|
abort(403);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!$user->hasVerifiedEmail()) {
|
if (!$user->hasVerifiedEmail()) {
|
||||||
$user->markEmailAsVerified();
|
$user->markEmailAsVerified();
|
||||||
event(new Verified(user: $user));
|
event(new Verified($user));
|
||||||
}
|
}
|
||||||
|
|
||||||
return redirect(to: '/login');
|
return redirect('/login');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function forgotPassword(Request $request): JsonResponse
|
public function forgotPassword(Request $request): JsonResponse
|
||||||
{
|
{
|
||||||
$request->validate(rules: [
|
$request->validate([
|
||||||
'email' => ['required', 'email'],
|
'email' => ['required', 'email'],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$status = Password::sendResetLink(
|
$status = Password::sendResetLink(
|
||||||
$request->only(keys: 'email')
|
$request->only('email')
|
||||||
);
|
);
|
||||||
|
|
||||||
if ($status !== Password::RESET_LINK_SENT) {
|
if ($status !== Password::RESET_LINK_SENT) {
|
||||||
throw ValidationException::withMessages(messages: [
|
throw ValidationException::withMessages([
|
||||||
'email' => [__(key: $status)],
|
'email' => [__($status)],
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
return response()->json(data: ['message' => __(key: $status)]);
|
return response()->json(['message' => __($status)]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function resetPassword(Request $request): JsonResponse
|
public function resetPassword(Request $request): JsonResponse
|
||||||
{
|
{
|
||||||
$request->validate(rules: [
|
$request->validate([
|
||||||
'token' => ['required'],
|
'token' => ['required'],
|
||||||
'email' => ['required', 'email'],
|
'email' => ['required', 'email'],
|
||||||
'password' => $this->passwordRules(),
|
'password' => $this->passwordRules(),
|
||||||
@@ -138,56 +129,52 @@ class AuthController extends Controller
|
|||||||
|
|
||||||
$status = Password::reset(
|
$status = Password::reset(
|
||||||
$request->only('email', 'password', 'password_confirmation', 'token'),
|
$request->only('email', 'password', 'password_confirmation', 'token'),
|
||||||
function (User $user, string $password) use ($request) {
|
function (User $user, string $password) {
|
||||||
$user->forceFill(attributes: [
|
$user->forceFill([
|
||||||
'password' => Hash::make(value: $password),
|
'password' => Hash::make($password),
|
||||||
'remember_token' => Str::random(length: 60),
|
'remember_token' => Str::random(60),
|
||||||
])->save();
|
])->save();
|
||||||
|
|
||||||
event(new PasswordReset(user: $user));
|
event(new PasswordReset($user));
|
||||||
app(AuditLogger::class)->log($request, 'user.password_reset', $user, [], $user);
|
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
if ($status !== Password::PASSWORD_RESET) {
|
if ($status !== Password::PASSWORD_RESET) {
|
||||||
throw ValidationException::withMessages(messages: [
|
throw ValidationException::withMessages([
|
||||||
'email' => [__(key: $status)],
|
'email' => [__($status)],
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
return response()->json(data: ['message' => __(key: $status)]);
|
return response()->json(['message' => __($status)]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function updatePassword(Request $request): JsonResponse
|
public function updatePassword(Request $request): JsonResponse
|
||||||
{
|
{
|
||||||
$request->validate(rules: [
|
$request->validate([
|
||||||
'current_password' => ['required'],
|
'current_password' => ['required'],
|
||||||
'password' => $this->passwordRules(),
|
'password' => $this->passwordRules(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$user = $request->user();
|
$user = $request->user();
|
||||||
|
|
||||||
if (!$user || !Hash::check(value: $request->input(key: 'current_password'), hashedValue: $user->password)) {
|
if (!$user || !Hash::check($request->input('current_password'), $user->password)) {
|
||||||
throw ValidationException::withMessages(messages: [
|
throw ValidationException::withMessages([
|
||||||
'current_password' => ['Invalid current password.'],
|
'current_password' => ['Invalid current password.'],
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
$user->forceFill(attributes: [
|
$user->forceFill([
|
||||||
'password' => Hash::make(value: $request->input(key: 'password')),
|
'password' => Hash::make($request->input('password')),
|
||||||
'remember_token' => Str::random(length: 60),
|
'remember_token' => Str::random(60),
|
||||||
])->save();
|
])->save();
|
||||||
|
|
||||||
app(AuditLogger::class)->log($request, 'user.password_changed', $user, [], $user);
|
return response()->json(['message' => 'Password updated.']);
|
||||||
|
|
||||||
return response()->json(data: ['message' => 'Password updated.']);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function logout(Request $request): JsonResponse
|
public function logout(Request $request): JsonResponse
|
||||||
{
|
{
|
||||||
app(AuditLogger::class)->log($request, 'user.logout', $request->user());
|
|
||||||
$request->user()?->currentAccessToken()?->delete();
|
$request->user()?->currentAccessToken()?->delete();
|
||||||
|
|
||||||
return response()->json(data: null, status: 204);
|
return response()->json(null, 204);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -212,7 +212,7 @@ class ForumController extends Controller
|
|||||||
'parent' => $forum->parent_id ? "/api/forums/{$forum->parent_id}" : null,
|
'parent' => $forum->parent_id ? "/api/forums/{$forum->parent_id}" : null,
|
||||||
'position' => $forum->position,
|
'position' => $forum->position,
|
||||||
'threads_count' => $forum->threads_count ?? 0,
|
'threads_count' => $forum->threads_count ?? 0,
|
||||||
'posts_count' => ($forum->posts_count ?? 0) + ($forum->threads_count ?? 0),
|
'posts_count' => $forum->posts_count ?? 0,
|
||||||
'views_count' => (int) ($forum->threads_sum_views_count ?? 0),
|
'views_count' => (int) ($forum->threads_sum_views_count ?? 0),
|
||||||
'last_post_at' => $lastPost?->created_at?->toIso8601String(),
|
'last_post_at' => $lastPost?->created_at?->toIso8601String(),
|
||||||
'last_post_user_id' => $lastPost?->user_id,
|
'last_post_user_id' => $lastPost?->user_id,
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ class PortalController extends Controller
|
|||||||
->withoutTrashed()
|
->withoutTrashed()
|
||||||
->withCount('posts')
|
->withCount('posts')
|
||||||
->with([
|
->with([
|
||||||
'user' => fn ($query) => $query->withCount(['posts', 'threads'])->with(['rank', 'roles']),
|
'user' => fn ($query) => $query->withCount('posts')->with(['rank', 'roles']),
|
||||||
'latestPost.user.rank',
|
'latestPost.user.rank',
|
||||||
'latestPost.user.roles',
|
'latestPost.user.roles',
|
||||||
])
|
])
|
||||||
@@ -44,8 +44,7 @@ class PortalController extends Controller
|
|||||||
|
|
||||||
$stats = [
|
$stats = [
|
||||||
'threads' => Thread::query()->withoutTrashed()->count(),
|
'threads' => Thread::query()->withoutTrashed()->count(),
|
||||||
'posts' => Post::query()->withoutTrashed()->count()
|
'posts' => Post::query()->withoutTrashed()->count(),
|
||||||
+ Thread::query()->withoutTrashed()->count(),
|
|
||||||
'users' => User::query()->count(),
|
'users' => User::query()->count(),
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -81,7 +80,7 @@ class PortalController extends Controller
|
|||||||
'parent' => $forum->parent_id ? "/api/forums/{$forum->parent_id}" : null,
|
'parent' => $forum->parent_id ? "/api/forums/{$forum->parent_id}" : null,
|
||||||
'position' => $forum->position,
|
'position' => $forum->position,
|
||||||
'threads_count' => $forum->threads_count ?? 0,
|
'threads_count' => $forum->threads_count ?? 0,
|
||||||
'posts_count' => ($forum->posts_count ?? 0) + ($forum->threads_count ?? 0),
|
'posts_count' => $forum->posts_count ?? 0,
|
||||||
'views_count' => (int) ($forum->threads_sum_views_count ?? 0),
|
'views_count' => (int) ($forum->threads_sum_views_count ?? 0),
|
||||||
'last_post_at' => $lastPost?->created_at?->toIso8601String(),
|
'last_post_at' => $lastPost?->created_at?->toIso8601String(),
|
||||||
'last_post_user_id' => $lastPost?->user_id,
|
'last_post_user_id' => $lastPost?->user_id,
|
||||||
@@ -99,16 +98,15 @@ class PortalController extends Controller
|
|||||||
'id' => $thread->id,
|
'id' => $thread->id,
|
||||||
'title' => $thread->title,
|
'title' => $thread->title,
|
||||||
'body' => $thread->body,
|
'body' => $thread->body,
|
||||||
'solved' => (bool) $thread->solved,
|
|
||||||
'forum' => "/api/forums/{$thread->forum_id}",
|
'forum' => "/api/forums/{$thread->forum_id}",
|
||||||
'user_id' => $thread->user_id,
|
'user_id' => $thread->user_id,
|
||||||
'posts_count' => ($thread->posts_count ?? 0) + 1,
|
'posts_count' => $thread->posts_count ?? 0,
|
||||||
'views_count' => $thread->views_count ?? 0,
|
'views_count' => $thread->views_count ?? 0,
|
||||||
'user_name' => $thread->user?->name,
|
'user_name' => $thread->user?->name,
|
||||||
'user_avatar_url' => $thread->user?->avatar_path
|
'user_avatar_url' => $thread->user?->avatar_path
|
||||||
? Storage::url($thread->user->avatar_path)
|
? Storage::url($thread->user->avatar_path)
|
||||||
: null,
|
: null,
|
||||||
'user_posts_count' => ($thread->user?->posts_count ?? 0) + ($thread->user?->threads_count ?? 0),
|
'user_posts_count' => $thread->user?->posts_count,
|
||||||
'user_created_at' => $thread->user?->created_at?->toIso8601String(),
|
'user_created_at' => $thread->user?->created_at?->toIso8601String(),
|
||||||
'user_rank_name' => $thread->user?->rank?->name,
|
'user_rank_name' => $thread->user?->rank?->name,
|
||||||
'user_rank_badge_type' => $thread->user?->rank?->badge_type,
|
'user_rank_badge_type' => $thread->user?->rank?->badge_type,
|
||||||
|
|||||||
@@ -2,11 +2,8 @@
|
|||||||
|
|
||||||
namespace App\Http\Controllers;
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
use App\Actions\BbcodeFormatter;
|
|
||||||
use App\Models\Post;
|
use App\Models\Post;
|
||||||
use App\Models\Thread;
|
use App\Models\Thread;
|
||||||
use App\Models\Setting;
|
|
||||||
use App\Services\AuditLogger;
|
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\Storage;
|
use Illuminate\Support\Facades\Storage;
|
||||||
@@ -17,10 +14,8 @@ class PostController extends Controller
|
|||||||
{
|
{
|
||||||
$query = Post::query()->withoutTrashed()->with([
|
$query = Post::query()->withoutTrashed()->with([
|
||||||
'user' => fn ($query) => $query
|
'user' => fn ($query) => $query
|
||||||
->withCount(['posts', 'threads', 'thanksGiven', 'thanksReceived'])
|
->withCount(['posts', 'thanksGiven', 'thanksReceived'])
|
||||||
->with(['rank', 'roles']),
|
->with(['rank', 'roles']),
|
||||||
'attachments.extension',
|
|
||||||
'attachments.group',
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$threadParam = $request->query('thread');
|
$threadParam = $request->query('thread');
|
||||||
@@ -55,16 +50,10 @@ class PostController extends Controller
|
|||||||
'body' => $data['body'],
|
'body' => $data['body'],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
app(AuditLogger::class)->log($request, 'post.created', $post, [
|
|
||||||
'thread_id' => $thread->id,
|
|
||||||
]);
|
|
||||||
|
|
||||||
$post->loadMissing([
|
$post->loadMissing([
|
||||||
'user' => fn ($query) => $query
|
'user' => fn ($query) => $query
|
||||||
->withCount(['posts', 'threads', 'thanksGiven', 'thanksReceived'])
|
->withCount(['posts', 'thanksGiven', 'thanksReceived'])
|
||||||
->with(['rank', 'roles']),
|
->with(['rank', 'roles']),
|
||||||
'attachments.extension',
|
|
||||||
'attachments.group',
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return response()->json($this->serializePost($post), 201);
|
return response()->json($this->serializePost($post), 201);
|
||||||
@@ -72,13 +61,6 @@ class PostController extends Controller
|
|||||||
|
|
||||||
public function destroy(Request $request, Post $post): JsonResponse
|
public function destroy(Request $request, Post $post): JsonResponse
|
||||||
{
|
{
|
||||||
$reason = $request->input('reason');
|
|
||||||
$reasonText = $request->input('reason_text');
|
|
||||||
app(AuditLogger::class)->log($request, 'post.deleted', $post, [
|
|
||||||
'thread_id' => $post->thread_id,
|
|
||||||
'reason' => $reason,
|
|
||||||
'reason_text' => $reasonText,
|
|
||||||
]);
|
|
||||||
$post->deleted_by = $request->user()?->id;
|
$post->deleted_by = $request->user()?->id;
|
||||||
$post->save();
|
$post->save();
|
||||||
$post->delete();
|
$post->delete();
|
||||||
@@ -86,41 +68,6 @@ class PostController extends Controller
|
|||||||
return response()->json(null, 204);
|
return response()->json(null, 204);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function update(Request $request, Post $post): JsonResponse
|
|
||||||
{
|
|
||||||
$user = $request->user();
|
|
||||||
if (!$user) {
|
|
||||||
return response()->json(['message' => 'Unauthorized.'], 401);
|
|
||||||
}
|
|
||||||
|
|
||||||
$isAdmin = $user->roles()->where('name', 'ROLE_ADMIN')->exists();
|
|
||||||
if (!$isAdmin && $post->user_id !== $user->id) {
|
|
||||||
return response()->json(['message' => 'Not authorized to edit posts.'], 403);
|
|
||||||
}
|
|
||||||
|
|
||||||
$data = $request->validate([
|
|
||||||
'body' => ['required', 'string'],
|
|
||||||
]);
|
|
||||||
|
|
||||||
$post->body = $data['body'];
|
|
||||||
$post->save();
|
|
||||||
$post->refresh();
|
|
||||||
|
|
||||||
app(AuditLogger::class)->log($request, 'post.edited', $post, [
|
|
||||||
'thread_id' => $post->thread_id,
|
|
||||||
]);
|
|
||||||
|
|
||||||
$post->loadMissing([
|
|
||||||
'user' => fn ($query) => $query
|
|
||||||
->withCount(['posts', 'threads', 'thanksGiven', 'thanksReceived'])
|
|
||||||
->with(['rank', 'roles']),
|
|
||||||
'attachments.extension',
|
|
||||||
'attachments.group',
|
|
||||||
]);
|
|
||||||
|
|
||||||
return response()->json($this->serializePost($post));
|
|
||||||
}
|
|
||||||
|
|
||||||
private function parseIriId(?string $value): ?int
|
private function parseIriId(?string $value): ?int
|
||||||
{
|
{
|
||||||
if (!$value) {
|
if (!$value) {
|
||||||
@@ -140,19 +87,16 @@ class PostController extends Controller
|
|||||||
|
|
||||||
private function serializePost(Post $post): array
|
private function serializePost(Post $post): array
|
||||||
{
|
{
|
||||||
$attachments = $post->relationLoaded('attachments') ? $post->attachments : collect();
|
|
||||||
$bodyHtml = $this->renderBody($post->body, $attachments);
|
|
||||||
return [
|
return [
|
||||||
'id' => $post->id,
|
'id' => $post->id,
|
||||||
'body' => $post->body,
|
'body' => $post->body,
|
||||||
'body_html' => $bodyHtml,
|
|
||||||
'thread' => "/api/threads/{$post->thread_id}",
|
'thread' => "/api/threads/{$post->thread_id}",
|
||||||
'user_id' => $post->user_id,
|
'user_id' => $post->user_id,
|
||||||
'user_name' => $post->user?->name,
|
'user_name' => $post->user?->name,
|
||||||
'user_avatar_url' => $post->user?->avatar_path
|
'user_avatar_url' => $post->user?->avatar_path
|
||||||
? Storage::url($post->user->avatar_path)
|
? Storage::url($post->user->avatar_path)
|
||||||
: null,
|
: null,
|
||||||
'user_posts_count' => ($post->user?->posts_count ?? 0) + ($post->user?->threads_count ?? 0),
|
'user_posts_count' => $post->user?->posts_count,
|
||||||
'user_created_at' => $post->user?->created_at?->toIso8601String(),
|
'user_created_at' => $post->user?->created_at?->toIso8601String(),
|
||||||
'user_location' => $post->user?->location,
|
'user_location' => $post->user?->location,
|
||||||
'user_thanks_given_count' => $post->user?->thanks_given_count ?? 0,
|
'user_thanks_given_count' => $post->user?->thanks_given_count ?? 0,
|
||||||
@@ -167,89 +111,9 @@ class PostController extends Controller
|
|||||||
'user_group_color' => $this->resolveGroupColor($post->user),
|
'user_group_color' => $this->resolveGroupColor($post->user),
|
||||||
'created_at' => $post->created_at?->toIso8601String(),
|
'created_at' => $post->created_at?->toIso8601String(),
|
||||||
'updated_at' => $post->updated_at?->toIso8601String(),
|
'updated_at' => $post->updated_at?->toIso8601String(),
|
||||||
'attachments' => $post->relationLoaded('attachments')
|
|
||||||
? $attachments
|
|
||||||
->map(fn ($attachment) => [
|
|
||||||
'id' => $attachment->id,
|
|
||||||
'group' => $attachment->group ? [
|
|
||||||
'id' => $attachment->group->id,
|
|
||||||
'name' => $attachment->group->name,
|
|
||||||
] : null,
|
|
||||||
'original_name' => $attachment->original_name,
|
|
||||||
'extension' => $attachment->extension,
|
|
||||||
'mime_type' => $attachment->mime_type,
|
|
||||||
'size_bytes' => $attachment->size_bytes,
|
|
||||||
'download_url' => "/api/attachments/{$attachment->id}/download",
|
|
||||||
'thumbnail_url' => $attachment->thumbnail_path
|
|
||||||
? "/api/attachments/{$attachment->id}/thumbnail"
|
|
||||||
: null,
|
|
||||||
'is_image' => str_starts_with((string) $attachment->mime_type, 'image/'),
|
|
||||||
'created_at' => $attachment->created_at?->toIso8601String(),
|
|
||||||
])
|
|
||||||
->values()
|
|
||||||
: [],
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
private function renderBody(string $body, $attachments): string
|
|
||||||
{
|
|
||||||
$replaced = $this->replaceAttachmentTags($body, $attachments);
|
|
||||||
return BbcodeFormatter::format($replaced);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function replaceAttachmentTags(string $body, $attachments): string
|
|
||||||
{
|
|
||||||
if (!$attachments || count($attachments) === 0) {
|
|
||||||
return $body;
|
|
||||||
}
|
|
||||||
|
|
||||||
$map = [];
|
|
||||||
foreach ($attachments as $attachment) {
|
|
||||||
$name = strtolower($attachment->original_name ?? '');
|
|
||||||
if ($name !== '') {
|
|
||||||
$map[$name] = [
|
|
||||||
'url' => "/api/attachments/{$attachment->id}/download",
|
|
||||||
'mime' => $attachment->mime_type ?? '',
|
|
||||||
'thumb' => $attachment->thumbnail_path
|
|
||||||
? "/api/attachments/{$attachment->id}/thumbnail"
|
|
||||||
: null,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!$map) {
|
|
||||||
return $body;
|
|
||||||
}
|
|
||||||
|
|
||||||
return preg_replace_callback('/\\[attachment\\](.+?)\\[\\/attachment\\]/i', function ($matches) use ($map) {
|
|
||||||
$rawName = trim($matches[1]);
|
|
||||||
$key = strtolower($rawName);
|
|
||||||
if (!array_key_exists($key, $map)) {
|
|
||||||
return $matches[0];
|
|
||||||
}
|
|
||||||
$entry = $map[$key];
|
|
||||||
$url = $entry['url'];
|
|
||||||
$mime = $entry['mime'] ?? '';
|
|
||||||
if (str_starts_with($mime, 'image/') && $this->displayImagesInline()) {
|
|
||||||
if (!empty($entry['thumb'])) {
|
|
||||||
$thumb = $entry['thumb'];
|
|
||||||
return "[url={$url}][img]{$thumb}[/img][/url]";
|
|
||||||
}
|
|
||||||
return "[img]{$url}[/img]";
|
|
||||||
}
|
|
||||||
return "[url={$url}]{$rawName}[/url]";
|
|
||||||
}, $body) ?? $body;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function displayImagesInline(): bool
|
|
||||||
{
|
|
||||||
$value = Setting::query()->where('key', 'attachments.display_images_inline')->value('value');
|
|
||||||
if ($value === null) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return in_array(strtolower($value), ['1', 'true', 'yes', 'on'], true);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function resolveGroupColor(?\App\Models\User $user): ?string
|
private function resolveGroupColor(?\App\Models\User $user): ?string
|
||||||
{
|
{
|
||||||
if (!$user) {
|
if (!$user) {
|
||||||
|
|||||||
@@ -1,20 +0,0 @@
|
|||||||
<?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']),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -5,157 +5,16 @@ namespace App\Http\Controllers;
|
|||||||
use App\Models\Post;
|
use App\Models\Post;
|
||||||
use App\Models\Thread;
|
use App\Models\Thread;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Models\Attachment;
|
|
||||||
use App\Models\Setting;
|
|
||||||
use Illuminate\Support\Carbon;
|
|
||||||
use Illuminate\Support\Facades\DB;
|
|
||||||
use Illuminate\Support\Facades\Storage;
|
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
|
|
||||||
class StatsController extends Controller
|
class StatsController extends Controller
|
||||||
{
|
{
|
||||||
public function __invoke(): JsonResponse
|
public function __invoke(): JsonResponse
|
||||||
{
|
{
|
||||||
$threadsCount = Thread::query()->withoutTrashed()->count();
|
|
||||||
$postsCount = Post::query()->withoutTrashed()->count();
|
|
||||||
$usersCount = User::query()->count();
|
|
||||||
$attachmentsCount = Attachment::query()->withoutTrashed()->count();
|
|
||||||
$attachmentsSizeBytes = (int) Attachment::query()->withoutTrashed()->sum('size_bytes');
|
|
||||||
|
|
||||||
$boardStartedAt = $this->resolveBoardStartedAt();
|
|
||||||
$daysSinceStart = $boardStartedAt
|
|
||||||
? max(1, Carbon::parse($boardStartedAt)->diffInSeconds(now()) / 86400)
|
|
||||||
: null;
|
|
||||||
|
|
||||||
$dbSizeBytes = $this->resolveDatabaseSize();
|
|
||||||
$dbServer = $this->resolveDatabaseServer();
|
|
||||||
$avatarSizeBytes = $this->resolveAvatarDirectorySize();
|
|
||||||
$orphanAttachments = $this->resolveOrphanAttachments();
|
|
||||||
|
|
||||||
$version = Setting::query()->where('key', 'version')->value('value');
|
|
||||||
$build = Setting::query()->where('key', 'build')->value('value');
|
|
||||||
$boardVersion = $version
|
|
||||||
? ($build ? "{$version} (build {$build})" : $version)
|
|
||||||
: null;
|
|
||||||
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'threads' => $threadsCount,
|
'threads' => Thread::query()->withoutTrashed()->count(),
|
||||||
'posts' => $postsCount + $threadsCount,
|
'posts' => Post::query()->withoutTrashed()->count(),
|
||||||
'users' => $usersCount,
|
'users' => User::query()->count(),
|
||||||
'attachments' => $attachmentsCount,
|
|
||||||
'board_started_at' => $boardStartedAt,
|
|
||||||
'attachments_size_bytes' => $attachmentsSizeBytes,
|
|
||||||
'avatar_directory_size_bytes' => $avatarSizeBytes,
|
|
||||||
'database_size_bytes' => $dbSizeBytes,
|
|
||||||
'database_server' => $dbServer,
|
|
||||||
'gzip_compression' => $this->resolveGzipCompression(),
|
|
||||||
'php_version' => PHP_VERSION,
|
|
||||||
'orphan_attachments' => $orphanAttachments,
|
|
||||||
'board_version' => $boardVersion,
|
|
||||||
'posts_per_day' => $daysSinceStart ? ($postsCount + $threadsCount) / $daysSinceStart : null,
|
|
||||||
'topics_per_day' => $daysSinceStart ? $threadsCount / $daysSinceStart : null,
|
|
||||||
'users_per_day' => $daysSinceStart ? $usersCount / $daysSinceStart : null,
|
|
||||||
'attachments_per_day' => $daysSinceStart ? $attachmentsCount / $daysSinceStart : null,
|
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
private function resolveBoardStartedAt(): ?string
|
|
||||||
{
|
|
||||||
$timestamps = [
|
|
||||||
User::query()->min('created_at'),
|
|
||||||
Thread::query()->min('created_at'),
|
|
||||||
Post::query()->min('created_at'),
|
|
||||||
];
|
|
||||||
|
|
||||||
$min = null;
|
|
||||||
foreach ($timestamps as $value) {
|
|
||||||
if (!$value) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
$time = Carbon::parse($value)->timestamp;
|
|
||||||
if ($min === null || $time < $min) {
|
|
||||||
$min = $time;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return $min !== null ? Carbon::createFromTimestamp($min)->toIso8601String() : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function resolveDatabaseSize(): ?int
|
|
||||||
{
|
|
||||||
try {
|
|
||||||
$driver = DB::connection()->getDriverName();
|
|
||||||
if ($driver === 'mysql') {
|
|
||||||
$row = DB::selectOne('SELECT SUM(data_length + index_length) AS size FROM information_schema.tables WHERE table_schema = DATABASE()');
|
|
||||||
return $row && isset($row->size) ? (int) $row->size : null;
|
|
||||||
}
|
|
||||||
} catch (\Throwable) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function resolveDatabaseServer(): ?string
|
|
||||||
{
|
|
||||||
try {
|
|
||||||
$row = DB::selectOne('SELECT VERSION() AS version');
|
|
||||||
return $row && isset($row->version) ? (string) $row->version : null;
|
|
||||||
} catch (\Throwable) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private function resolveAvatarDirectorySize(): ?int
|
|
||||||
{
|
|
||||||
try {
|
|
||||||
$disk = Storage::disk('public');
|
|
||||||
$files = $disk->allFiles('avatars');
|
|
||||||
$total = 0;
|
|
||||||
foreach ($files as $file) {
|
|
||||||
$total += $disk->size($file);
|
|
||||||
}
|
|
||||||
return $total;
|
|
||||||
} catch (\Throwable) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private function resolveOrphanAttachments(): int
|
|
||||||
{
|
|
||||||
try {
|
|
||||||
return (int) DB::table('attachments')
|
|
||||||
->leftJoin('threads', 'attachments.thread_id', '=', 'threads.id')
|
|
||||||
->leftJoin('posts', 'attachments.post_id', '=', 'posts.id')
|
|
||||||
->whereNull('attachments.deleted_at')
|
|
||||||
->where(function ($query) {
|
|
||||||
$query
|
|
||||||
->whereNull('attachments.thread_id')
|
|
||||||
->whereNull('attachments.post_id')
|
|
||||||
->orWhere(function ($inner) {
|
|
||||||
$inner->whereNotNull('attachments.thread_id')
|
|
||||||
->where(function ($inner2) {
|
|
||||||
$inner2->whereNull('threads.id')
|
|
||||||
->orWhereNotNull('threads.deleted_at');
|
|
||||||
});
|
|
||||||
})
|
|
||||||
->orWhere(function ($inner) {
|
|
||||||
$inner->whereNotNull('attachments.post_id')
|
|
||||||
->where(function ($inner2) {
|
|
||||||
$inner2->whereNull('posts.id')
|
|
||||||
->orWhereNotNull('posts.deleted_at');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
})
|
|
||||||
->count();
|
|
||||||
} catch (\Throwable) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private function resolveGzipCompression(): bool
|
|
||||||
{
|
|
||||||
$value = ini_get('zlib.output_compression');
|
|
||||||
return in_array(strtolower((string) $value), ['1', 'on', 'true'], true);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,155 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Http\Controllers;
|
|
||||||
|
|
||||||
use App\Models\Setting;
|
|
||||||
use Illuminate\Http\JsonResponse;
|
|
||||||
use Illuminate\Http\Request;
|
|
||||||
use Symfony\Component\Process\Process;
|
|
||||||
|
|
||||||
class SystemStatusController extends Controller
|
|
||||||
{
|
|
||||||
public function __invoke(Request $request): JsonResponse
|
|
||||||
{
|
|
||||||
$user = $request->user();
|
|
||||||
if (!$user || !$user->roles()->where('name', 'ROLE_ADMIN')->exists()) {
|
|
||||||
return response()->json(['message' => 'Forbidden'], 403);
|
|
||||||
}
|
|
||||||
|
|
||||||
$phpDefaultPath = $this->resolveBinary('php');
|
|
||||||
$phpConfiguredPath = trim((string) Setting::where('key', 'system.php_binary')->value('value'));
|
|
||||||
$phpSelectedPath = $phpConfiguredPath ?: (PHP_BINARY ?: $phpDefaultPath);
|
|
||||||
$phpSelectedOk = (bool) $phpSelectedPath;
|
|
||||||
$phpSelectedVersion = $phpSelectedPath
|
|
||||||
? ($this->resolvePhpVersion($phpSelectedPath) ?? PHP_VERSION)
|
|
||||||
: PHP_VERSION;
|
|
||||||
$minVersions = $this->resolveMinVersions();
|
|
||||||
$composerPath = $this->resolveBinary('composer');
|
|
||||||
$nodePath = $this->resolveBinary('node');
|
|
||||||
$npmPath = $this->resolveBinary('npm');
|
|
||||||
$tarPath = $this->resolveBinary('tar');
|
|
||||||
$rsyncPath = $this->resolveBinary('rsync');
|
|
||||||
$procFunctions = [
|
|
||||||
'proc_open',
|
|
||||||
'proc_get_status',
|
|
||||||
'proc_close',
|
|
||||||
];
|
|
||||||
$disabledFunctions = array_filter(array_map('trim', explode(',', (string) ini_get('disable_functions'))));
|
|
||||||
$disabledLookup = array_fill_keys($disabledFunctions, true);
|
|
||||||
$procFunctionStatus = [];
|
|
||||||
foreach ($procFunctions as $function) {
|
|
||||||
$procFunctionStatus[$function] = function_exists($function) && !isset($disabledLookup[$function]);
|
|
||||||
}
|
|
||||||
|
|
||||||
return response()->json([
|
|
||||||
'php' => PHP_VERSION,
|
|
||||||
'php_default' => $phpDefaultPath,
|
|
||||||
'php_configured' => $phpConfiguredPath ?: null,
|
|
||||||
'php_selected_path' => $phpSelectedPath,
|
|
||||||
'php_selected_ok' => $phpSelectedOk,
|
|
||||||
'php_selected_version' => $phpSelectedVersion,
|
|
||||||
'min_versions' => $minVersions,
|
|
||||||
'composer' => $composerPath,
|
|
||||||
'composer_version' => $this->resolveBinaryVersion($composerPath, ['--version']),
|
|
||||||
'node' => $nodePath,
|
|
||||||
'node_version' => $this->resolveBinaryVersion($nodePath, ['--version']),
|
|
||||||
'npm' => $npmPath,
|
|
||||||
'npm_version' => $this->resolveBinaryVersion($npmPath, ['--version']),
|
|
||||||
'tar' => $tarPath,
|
|
||||||
'tar_version' => $this->resolveBinaryVersion($tarPath, ['--version']),
|
|
||||||
'rsync' => $rsyncPath,
|
|
||||||
'rsync_version' => $this->resolveBinaryVersion($rsyncPath, ['--version']),
|
|
||||||
'proc_functions' => $procFunctionStatus,
|
|
||||||
'storage_writable' => is_writable(storage_path()),
|
|
||||||
'updates_writable' => is_writable(storage_path('app/updates')) || @mkdir(storage_path('app/updates'), 0755, true),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function resolveBinary(string $name): ?string
|
|
||||||
{
|
|
||||||
$process = new Process(['sh', '-lc', "command -v {$name}"]);
|
|
||||||
$process->setTimeout(5);
|
|
||||||
$process->run();
|
|
||||||
|
|
||||||
if (!$process->isSuccessful()) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$output = trim($process->getOutput());
|
|
||||||
return $output !== '' ? $output : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function resolvePhpVersion(string $path): ?string
|
|
||||||
{
|
|
||||||
$process = new Process([$path, '-r', 'echo PHP_VERSION;']);
|
|
||||||
$process->setTimeout(5);
|
|
||||||
$process->run();
|
|
||||||
|
|
||||||
if (!$process->isSuccessful()) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$output = trim($process->getOutput());
|
|
||||||
return $output !== '' ? $output : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function resolveBinaryVersion(?string $path, array $args): ?string
|
|
||||||
{
|
|
||||||
if (!$path) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$process = new Process(array_merge([$path], $args));
|
|
||||||
$process->setTimeout(5);
|
|
||||||
$process->run();
|
|
||||||
|
|
||||||
if (!$process->isSuccessful()) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$output = trim($process->getOutput());
|
|
||||||
if ($output === '') {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$line = strtok($output, "\n") ?: $output;
|
|
||||||
if (preg_match('/(\\d+\\.\\d+(?:\\.\\d+)?)/', $line, $matches)) {
|
|
||||||
return $matches[1];
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function resolveMinVersions(): array
|
|
||||||
{
|
|
||||||
$composerJson = $this->readJson(base_path('composer.json'));
|
|
||||||
$packageJson = $this->readJson(base_path('package.json'));
|
|
||||||
|
|
||||||
$php = $composerJson['require']['php'] ?? null;
|
|
||||||
$node = $packageJson['engines']['node'] ?? null;
|
|
||||||
$npm = $packageJson['engines']['npm'] ?? null;
|
|
||||||
$composer = $composerJson['require']['composer-runtime-api'] ?? null;
|
|
||||||
|
|
||||||
return [
|
|
||||||
'php' => is_string($php) ? $php : null,
|
|
||||||
'node' => is_string($node) ? $node : null,
|
|
||||||
'npm' => is_string($npm) ? $npm : null,
|
|
||||||
'composer' => is_string($composer) ? $composer : null,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
private function readJson(string $path): array
|
|
||||||
{
|
|
||||||
if (!is_file($path)) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
$contents = file_get_contents($path);
|
|
||||||
if ($contents === false) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
$data = json_decode($contents, true);
|
|
||||||
return is_array($data) ? $data : [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,215 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Http\Controllers;
|
|
||||||
|
|
||||||
use App\Models\Setting;
|
|
||||||
use Illuminate\Http\JsonResponse;
|
|
||||||
use Illuminate\Http\Request;
|
|
||||||
use Illuminate\Support\Facades\File;
|
|
||||||
use Illuminate\Support\Facades\Http;
|
|
||||||
use Illuminate\Support\Str;
|
|
||||||
use Symfony\Component\Process\Process;
|
|
||||||
|
|
||||||
class SystemUpdateController extends Controller
|
|
||||||
{
|
|
||||||
public function __invoke(Request $request): JsonResponse
|
|
||||||
{
|
|
||||||
$user = $request->user();
|
|
||||||
if (!$user || !$user->roles()->where('name', 'ROLE_ADMIN')->exists()) {
|
|
||||||
return response()->json(['message' => 'Forbidden'], 403);
|
|
||||||
}
|
|
||||||
|
|
||||||
set_time_limit(0);
|
|
||||||
|
|
||||||
$owner = env('GITEA_OWNER');
|
|
||||||
$repo = env('GITEA_REPO');
|
|
||||||
$apiBase = rtrim((string) env('GITEA_API_BASE', 'https://git.24unix.net/api/v1'), '/');
|
|
||||||
$token = env('GITEA_TOKEN');
|
|
||||||
|
|
||||||
if (!$owner || !$repo) {
|
|
||||||
return response()->json(['message' => 'Missing Gitea configuration.'], 422);
|
|
||||||
}
|
|
||||||
|
|
||||||
$log = [];
|
|
||||||
$append = function (string $line) use (&$log) {
|
|
||||||
$log[] = $line;
|
|
||||||
};
|
|
||||||
|
|
||||||
try {
|
|
||||||
$client = Http::acceptJson();
|
|
||||||
if ($token) {
|
|
||||||
$client = $client->withHeaders(['Authorization' => "token {$token}"]);
|
|
||||||
}
|
|
||||||
|
|
||||||
$append('Fetching latest release...');
|
|
||||||
$response = $client->get("{$apiBase}/repos/{$owner}/{$repo}/releases/latest");
|
|
||||||
if (!$response->successful()) {
|
|
||||||
return response()->json([
|
|
||||||
'message' => "Release check failed: {$response->status()}",
|
|
||||||
'log' => $log,
|
|
||||||
], 500);
|
|
||||||
}
|
|
||||||
|
|
||||||
$tag = (string) ($response->json('tag_name') ?? '');
|
|
||||||
if ($tag === '') {
|
|
||||||
return response()->json([
|
|
||||||
'message' => 'Release tag not found.',
|
|
||||||
'log' => $log,
|
|
||||||
], 500);
|
|
||||||
}
|
|
||||||
|
|
||||||
$tarballUrl = (string) ($response->json('tarball_url') ?? '');
|
|
||||||
if ($tarballUrl === '') {
|
|
||||||
$tarballUrl = env('GITEA_TGZ_URL_TEMPLATE');
|
|
||||||
if ($tarballUrl) {
|
|
||||||
$tarballUrl = str_replace('{{TAG}}', $tag, $tarballUrl);
|
|
||||||
$tarballUrl = str_replace('{{VERSION}}', ltrim($tag, 'v'), $tarballUrl);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($tarballUrl === '') {
|
|
||||||
return response()->json([
|
|
||||||
'message' => 'No tarball URL available.',
|
|
||||||
'log' => $log,
|
|
||||||
], 500);
|
|
||||||
}
|
|
||||||
|
|
||||||
$append("Downloading {$tag}...");
|
|
||||||
$archivePath = storage_path('app/updates/' . $tag . '.tar.gz');
|
|
||||||
File::ensureDirectoryExists(dirname($archivePath));
|
|
||||||
|
|
||||||
$download = $client->withOptions(['stream' => true])->get($tarballUrl);
|
|
||||||
if (!$download->successful()) {
|
|
||||||
return response()->json([
|
|
||||||
'message' => "Download failed: {$download->status()}",
|
|
||||||
'log' => $log,
|
|
||||||
], 500);
|
|
||||||
}
|
|
||||||
File::put($archivePath, $download->body());
|
|
||||||
|
|
||||||
$extractDir = storage_path('app/updates/extract-' . Str::random(8));
|
|
||||||
File::ensureDirectoryExists($extractDir);
|
|
||||||
|
|
||||||
$append('Extracting archive...');
|
|
||||||
$tar = new Process(['tar', '-xzf', $archivePath, '-C', $extractDir]);
|
|
||||||
$tar->setTimeout(300);
|
|
||||||
$tar->run();
|
|
||||||
if (!$tar->isSuccessful()) {
|
|
||||||
return response()->json([
|
|
||||||
'message' => 'Failed to extract archive.',
|
|
||||||
'log' => array_merge($log, [$tar->getErrorOutput()]),
|
|
||||||
], 500);
|
|
||||||
}
|
|
||||||
|
|
||||||
$entries = collect(File::directories($extractDir))->values();
|
|
||||||
if ($entries->isEmpty()) {
|
|
||||||
return response()->json([
|
|
||||||
'message' => 'No extracted folder found.',
|
|
||||||
'log' => $log,
|
|
||||||
], 500);
|
|
||||||
}
|
|
||||||
$sourceDir = $entries->first();
|
|
||||||
|
|
||||||
$append('Syncing files...');
|
|
||||||
$usedRsync = false;
|
|
||||||
$rsyncPath = trim((string) shell_exec('command -v rsync'));
|
|
||||||
$protectedPaths = ['custom', 'public/custom'];
|
|
||||||
if ($rsyncPath !== '') {
|
|
||||||
$usedRsync = true;
|
|
||||||
$rsync = new Process([
|
|
||||||
'rsync',
|
|
||||||
'-a',
|
|
||||||
'--delete',
|
|
||||||
'--exclude=.env',
|
|
||||||
'--exclude=storage',
|
|
||||||
'--exclude=public/storage',
|
|
||||||
'--exclude=custom',
|
|
||||||
'--exclude=public/custom',
|
|
||||||
$sourceDir . '/',
|
|
||||||
base_path() . '/',
|
|
||||||
]);
|
|
||||||
$rsync->setTimeout(600);
|
|
||||||
$rsync->run();
|
|
||||||
if (!$rsync->isSuccessful()) {
|
|
||||||
return response()->json([
|
|
||||||
'message' => 'rsync failed.',
|
|
||||||
'log' => array_merge($log, [$rsync->getErrorOutput()]),
|
|
||||||
], 500);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
foreach ($protectedPaths as $path) {
|
|
||||||
$sourcePath = $sourceDir . DIRECTORY_SEPARATOR . $path;
|
|
||||||
if (File::exists($sourcePath)) {
|
|
||||||
File::deleteDirectory($sourcePath);
|
|
||||||
if (File::exists($sourcePath)) {
|
|
||||||
File::delete($sourcePath);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
File::copyDirectory($sourceDir, base_path());
|
|
||||||
}
|
|
||||||
|
|
||||||
$append('Installing composer dependencies...');
|
|
||||||
$composer = new Process(['composer', 'install', '--no-dev', '--optimize-autoloader'], base_path());
|
|
||||||
$composer->setTimeout(600);
|
|
||||||
$composer->run();
|
|
||||||
if (!$composer->isSuccessful()) {
|
|
||||||
return response()->json([
|
|
||||||
'message' => 'Composer install failed.',
|
|
||||||
'log' => array_merge($log, [$composer->getErrorOutput()]),
|
|
||||||
], 500);
|
|
||||||
}
|
|
||||||
|
|
||||||
$append('Installing npm dependencies...');
|
|
||||||
$npmInstall = new Process(['npm', 'install'], base_path());
|
|
||||||
$npmInstall->setTimeout(600);
|
|
||||||
$npmInstall->run();
|
|
||||||
if (!$npmInstall->isSuccessful()) {
|
|
||||||
return response()->json([
|
|
||||||
'message' => 'npm install failed.',
|
|
||||||
'log' => array_merge($log, [$npmInstall->getErrorOutput()]),
|
|
||||||
], 500);
|
|
||||||
}
|
|
||||||
|
|
||||||
$append('Building assets...');
|
|
||||||
$npmBuild = new Process(['npm', 'run', 'build'], base_path());
|
|
||||||
$npmBuild->setTimeout(900);
|
|
||||||
$npmBuild->run();
|
|
||||||
if (!$npmBuild->isSuccessful()) {
|
|
||||||
return response()->json([
|
|
||||||
'message' => 'npm run build failed.',
|
|
||||||
'log' => array_merge($log, [$npmBuild->getErrorOutput()]),
|
|
||||||
], 500);
|
|
||||||
}
|
|
||||||
|
|
||||||
$phpBinary = trim((string) Setting::where('key', 'system.php_binary')->value('value'));
|
|
||||||
if ($phpBinary === '') {
|
|
||||||
$phpBinary = env('SYSTEM_UPDATE_PHP_BINARY') ?: (PHP_BINARY ?: 'php');
|
|
||||||
}
|
|
||||||
$append("Running migrations (using {$phpBinary})...");
|
|
||||||
$migrate = new Process([$phpBinary, 'artisan', 'migrate', '--force'], base_path());
|
|
||||||
$migrate->setTimeout(600);
|
|
||||||
$migrate->run();
|
|
||||||
if (!$migrate->isSuccessful()) {
|
|
||||||
return response()->json([
|
|
||||||
'message' => 'Migrations failed.',
|
|
||||||
'log' => array_merge($log, [$migrate->getErrorOutput()]),
|
|
||||||
], 500);
|
|
||||||
}
|
|
||||||
|
|
||||||
$append('Update complete.');
|
|
||||||
|
|
||||||
return response()->json([
|
|
||||||
'message' => 'Update finished.',
|
|
||||||
'log' => $log,
|
|
||||||
'tag' => $tag,
|
|
||||||
'used_rsync' => $usedRsync,
|
|
||||||
]);
|
|
||||||
} catch (\Throwable $e) {
|
|
||||||
return response()->json([
|
|
||||||
'message' => 'Update failed.',
|
|
||||||
'log' => $log,
|
|
||||||
], 500);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -4,9 +4,6 @@ namespace App\Http\Controllers;
|
|||||||
|
|
||||||
use App\Models\Forum;
|
use App\Models\Forum;
|
||||||
use App\Models\Thread;
|
use App\Models\Thread;
|
||||||
use App\Actions\BbcodeFormatter;
|
|
||||||
use App\Models\Setting;
|
|
||||||
use App\Services\AuditLogger;
|
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
@@ -22,7 +19,7 @@ class ThreadController extends Controller
|
|||||||
->withMax('posts', 'created_at')
|
->withMax('posts', 'created_at')
|
||||||
->with([
|
->with([
|
||||||
'user' => fn ($query) => $query
|
'user' => fn ($query) => $query
|
||||||
->withCount(['posts', 'threads', 'thanksGiven', 'thanksReceived'])
|
->withCount(['posts', 'thanksGiven', 'thanksReceived'])
|
||||||
->with(['rank', 'roles']),
|
->with(['rank', 'roles']),
|
||||||
'latestPost.user.rank',
|
'latestPost.user.rank',
|
||||||
'latestPost.user.roles',
|
'latestPost.user.roles',
|
||||||
@@ -50,10 +47,8 @@ class ThreadController extends Controller
|
|||||||
$thread->refresh();
|
$thread->refresh();
|
||||||
$thread->loadMissing([
|
$thread->loadMissing([
|
||||||
'user' => fn ($query) => $query
|
'user' => fn ($query) => $query
|
||||||
->withCount(['posts', 'threads', 'thanksGiven', 'thanksReceived'])
|
->withCount(['posts', 'thanksGiven', 'thanksReceived'])
|
||||||
->with(['rank', 'roles']),
|
->with(['rank', 'roles']),
|
||||||
'attachments.extension',
|
|
||||||
'attachments.group',
|
|
||||||
'latestPost.user.rank',
|
'latestPost.user.rank',
|
||||||
'latestPost.user.roles',
|
'latestPost.user.roles',
|
||||||
])->loadCount('posts');
|
])->loadCount('posts');
|
||||||
@@ -82,17 +77,10 @@ class ThreadController extends Controller
|
|||||||
'body' => $data['body'],
|
'body' => $data['body'],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
app(AuditLogger::class)->log($request, 'thread.created', $thread, [
|
|
||||||
'forum_id' => $forum->id,
|
|
||||||
'title' => $thread->title,
|
|
||||||
]);
|
|
||||||
|
|
||||||
$thread->loadMissing([
|
$thread->loadMissing([
|
||||||
'user' => fn ($query) => $query
|
'user' => fn ($query) => $query
|
||||||
->withCount(['posts', 'threads', 'thanksGiven', 'thanksReceived'])
|
->withCount(['posts', 'thanksGiven', 'thanksReceived'])
|
||||||
->with(['rank', 'roles']),
|
->with(['rank', 'roles']),
|
||||||
'attachments.extension',
|
|
||||||
'attachments.group',
|
|
||||||
'latestPost.user.rank',
|
'latestPost.user.rank',
|
||||||
'latestPost.user.roles',
|
'latestPost.user.roles',
|
||||||
])->loadCount('posts');
|
])->loadCount('posts');
|
||||||
@@ -102,14 +90,6 @@ class ThreadController extends Controller
|
|||||||
|
|
||||||
public function destroy(Request $request, Thread $thread): JsonResponse
|
public function destroy(Request $request, Thread $thread): JsonResponse
|
||||||
{
|
{
|
||||||
$reason = $request->input('reason');
|
|
||||||
$reasonText = $request->input('reason_text');
|
|
||||||
app(AuditLogger::class)->log($request, 'thread.deleted', $thread, [
|
|
||||||
'forum_id' => $thread->forum_id,
|
|
||||||
'title' => $thread->title,
|
|
||||||
'reason' => $reason,
|
|
||||||
'reason_text' => $reasonText,
|
|
||||||
]);
|
|
||||||
$thread->deleted_by = $request->user()?->id;
|
$thread->deleted_by = $request->user()?->id;
|
||||||
$thread->save();
|
$thread->save();
|
||||||
$thread->delete();
|
$thread->delete();
|
||||||
@@ -117,86 +97,6 @@ class ThreadController extends Controller
|
|||||||
return response()->json(null, 204);
|
return response()->json(null, 204);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function update(Request $request, Thread $thread): JsonResponse
|
|
||||||
{
|
|
||||||
$user = $request->user();
|
|
||||||
if (!$user) {
|
|
||||||
return response()->json(['message' => 'Unauthorized.'], 401);
|
|
||||||
}
|
|
||||||
|
|
||||||
$isAdmin = $user->roles()->where('name', 'ROLE_ADMIN')->exists();
|
|
||||||
if (!$isAdmin && $thread->user_id !== $user->id) {
|
|
||||||
return response()->json(['message' => 'Not authorized to edit threads.'], 403);
|
|
||||||
}
|
|
||||||
|
|
||||||
$data = $request->validate([
|
|
||||||
'title' => ['sometimes', 'required', 'string'],
|
|
||||||
'body' => ['sometimes', 'required', 'string'],
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (array_key_exists('title', $data)) {
|
|
||||||
$thread->title = $data['title'];
|
|
||||||
}
|
|
||||||
if (array_key_exists('body', $data)) {
|
|
||||||
$thread->body = $data['body'];
|
|
||||||
}
|
|
||||||
|
|
||||||
$thread->save();
|
|
||||||
$thread->refresh();
|
|
||||||
|
|
||||||
app(AuditLogger::class)->log($request, 'thread.edited', $thread, [
|
|
||||||
'forum_id' => $thread->forum_id,
|
|
||||||
'title' => $thread->title,
|
|
||||||
]);
|
|
||||||
|
|
||||||
$thread->loadMissing([
|
|
||||||
'user' => fn ($query) => $query
|
|
||||||
->withCount(['posts', 'threads', 'thanksGiven', 'thanksReceived'])
|
|
||||||
->with(['rank', 'roles']),
|
|
||||||
'attachments.extension',
|
|
||||||
'attachments.group',
|
|
||||||
'latestPost.user.rank',
|
|
||||||
'latestPost.user.roles',
|
|
||||||
])->loadCount('posts');
|
|
||||||
|
|
||||||
return response()->json($this->serializeThread($thread));
|
|
||||||
}
|
|
||||||
|
|
||||||
public function updateSolved(Request $request, Thread $thread): JsonResponse
|
|
||||||
{
|
|
||||||
$user = $request->user();
|
|
||||||
if (!$user) {
|
|
||||||
return response()->json(['message' => 'Unauthorized.'], 401);
|
|
||||||
}
|
|
||||||
|
|
||||||
$isAdmin = $user->roles()->where('name', 'ROLE_ADMIN')->exists();
|
|
||||||
if (!$isAdmin && $thread->user_id !== $user->id) {
|
|
||||||
return response()->json(['message' => 'Not authorized to update solved status.'], 403);
|
|
||||||
}
|
|
||||||
|
|
||||||
$data = $request->validate([
|
|
||||||
'solved' => ['required', 'boolean'],
|
|
||||||
]);
|
|
||||||
|
|
||||||
$thread->solved = $data['solved'];
|
|
||||||
$thread->save();
|
|
||||||
app(AuditLogger::class)->log($request, 'thread.solved_updated', $thread, [
|
|
||||||
'solved' => $thread->solved,
|
|
||||||
]);
|
|
||||||
$thread->refresh();
|
|
||||||
$thread->loadMissing([
|
|
||||||
'user' => fn ($query) => $query
|
|
||||||
->withCount(['posts', 'threads', 'thanksGiven', 'thanksReceived'])
|
|
||||||
->with(['rank', 'roles']),
|
|
||||||
'attachments.extension',
|
|
||||||
'attachments.group',
|
|
||||||
'latestPost.user.rank',
|
|
||||||
'latestPost.user.roles',
|
|
||||||
])->loadCount('posts');
|
|
||||||
|
|
||||||
return response()->json($this->serializeThread($thread));
|
|
||||||
}
|
|
||||||
|
|
||||||
private function parseIriId(?string $value): ?int
|
private function parseIriId(?string $value): ?int
|
||||||
{
|
{
|
||||||
if (!$value) {
|
if (!$value) {
|
||||||
@@ -216,23 +116,19 @@ class ThreadController extends Controller
|
|||||||
|
|
||||||
private function serializeThread(Thread $thread): array
|
private function serializeThread(Thread $thread): array
|
||||||
{
|
{
|
||||||
$attachments = $thread->relationLoaded('attachments') ? $thread->attachments : collect();
|
|
||||||
$bodyHtml = $this->renderBody($thread->body, $attachments);
|
|
||||||
return [
|
return [
|
||||||
'id' => $thread->id,
|
'id' => $thread->id,
|
||||||
'title' => $thread->title,
|
'title' => $thread->title,
|
||||||
'body' => $thread->body,
|
'body' => $thread->body,
|
||||||
'body_html' => $bodyHtml,
|
|
||||||
'solved' => (bool) $thread->solved,
|
|
||||||
'forum' => "/api/forums/{$thread->forum_id}",
|
'forum' => "/api/forums/{$thread->forum_id}",
|
||||||
'user_id' => $thread->user_id,
|
'user_id' => $thread->user_id,
|
||||||
'posts_count' => ($thread->posts_count ?? 0) + 1,
|
'posts_count' => $thread->posts_count ?? 0,
|
||||||
'views_count' => $thread->views_count ?? 0,
|
'views_count' => $thread->views_count ?? 0,
|
||||||
'user_name' => $thread->user?->name,
|
'user_name' => $thread->user?->name,
|
||||||
'user_avatar_url' => $thread->user?->avatar_path
|
'user_avatar_url' => $thread->user?->avatar_path
|
||||||
? Storage::url($thread->user->avatar_path)
|
? Storage::url($thread->user->avatar_path)
|
||||||
: null,
|
: null,
|
||||||
'user_posts_count' => ($thread->user?->posts_count ?? 0) + ($thread->user?->threads_count ?? 0),
|
'user_posts_count' => $thread->user?->posts_count,
|
||||||
'user_created_at' => $thread->user?->created_at?->toIso8601String(),
|
'user_created_at' => $thread->user?->created_at?->toIso8601String(),
|
||||||
'user_location' => $thread->user?->location,
|
'user_location' => $thread->user?->location,
|
||||||
'user_thanks_given_count' => $thread->user?->thanks_given_count ?? 0,
|
'user_thanks_given_count' => $thread->user?->thanks_given_count ?? 0,
|
||||||
@@ -257,89 +153,9 @@ class ThreadController extends Controller
|
|||||||
?? $this->resolveGroupColor($thread->user),
|
?? $this->resolveGroupColor($thread->user),
|
||||||
'created_at' => $thread->created_at?->toIso8601String(),
|
'created_at' => $thread->created_at?->toIso8601String(),
|
||||||
'updated_at' => $thread->updated_at?->toIso8601String(),
|
'updated_at' => $thread->updated_at?->toIso8601String(),
|
||||||
'attachments' => $thread->relationLoaded('attachments')
|
|
||||||
? $attachments
|
|
||||||
->map(fn ($attachment) => [
|
|
||||||
'id' => $attachment->id,
|
|
||||||
'group' => $attachment->group ? [
|
|
||||||
'id' => $attachment->group->id,
|
|
||||||
'name' => $attachment->group->name,
|
|
||||||
] : null,
|
|
||||||
'original_name' => $attachment->original_name,
|
|
||||||
'extension' => $attachment->extension,
|
|
||||||
'mime_type' => $attachment->mime_type,
|
|
||||||
'size_bytes' => $attachment->size_bytes,
|
|
||||||
'download_url' => "/api/attachments/{$attachment->id}/download",
|
|
||||||
'thumbnail_url' => $attachment->thumbnail_path
|
|
||||||
? "/api/attachments/{$attachment->id}/thumbnail"
|
|
||||||
: null,
|
|
||||||
'is_image' => str_starts_with((string) $attachment->mime_type, 'image/'),
|
|
||||||
'created_at' => $attachment->created_at?->toIso8601String(),
|
|
||||||
])
|
|
||||||
->values()
|
|
||||||
: [],
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
private function renderBody(string $body, $attachments): string
|
|
||||||
{
|
|
||||||
$replaced = $this->replaceAttachmentTags($body, $attachments);
|
|
||||||
return BbcodeFormatter::format($replaced);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function replaceAttachmentTags(string $body, $attachments): string
|
|
||||||
{
|
|
||||||
if (!$attachments || count($attachments) === 0) {
|
|
||||||
return $body;
|
|
||||||
}
|
|
||||||
|
|
||||||
$map = [];
|
|
||||||
foreach ($attachments as $attachment) {
|
|
||||||
$name = strtolower($attachment->original_name ?? '');
|
|
||||||
if ($name !== '') {
|
|
||||||
$map[$name] = [
|
|
||||||
'url' => "/api/attachments/{$attachment->id}/download",
|
|
||||||
'mime' => $attachment->mime_type ?? '',
|
|
||||||
'thumb' => $attachment->thumbnail_path
|
|
||||||
? "/api/attachments/{$attachment->id}/thumbnail"
|
|
||||||
: null,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!$map) {
|
|
||||||
return $body;
|
|
||||||
}
|
|
||||||
|
|
||||||
return preg_replace_callback('/\\[attachment\\](.+?)\\[\\/attachment\\]/i', function ($matches) use ($map) {
|
|
||||||
$rawName = trim($matches[1]);
|
|
||||||
$key = strtolower($rawName);
|
|
||||||
if (!array_key_exists($key, $map)) {
|
|
||||||
return $matches[0];
|
|
||||||
}
|
|
||||||
$entry = $map[$key];
|
|
||||||
$url = $entry['url'];
|
|
||||||
$mime = $entry['mime'] ?? '';
|
|
||||||
if (str_starts_with($mime, 'image/') && $this->displayImagesInline()) {
|
|
||||||
if (!empty($entry['thumb'])) {
|
|
||||||
$thumb = $entry['thumb'];
|
|
||||||
return "[url={$url}][img]{$thumb}[/img][/url]";
|
|
||||||
}
|
|
||||||
return "[img]{$url}[/img]";
|
|
||||||
}
|
|
||||||
return "[url={$url}]{$rawName}[/url]";
|
|
||||||
}, $body) ?? $body;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function displayImagesInline(): bool
|
|
||||||
{
|
|
||||||
$value = Setting::query()->where('key', 'attachments.display_images_inline')->value('value');
|
|
||||||
if ($value === null) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return in_array(strtolower($value), ['1', 'true', 'yes', 'on'], true);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function resolveGroupColor(?\App\Models\User $user): ?string
|
private function resolveGroupColor(?\App\Models\User $user): ?string
|
||||||
{
|
{
|
||||||
if (!$user) {
|
if (!$user) {
|
||||||
|
|||||||
@@ -1,72 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Http\Controllers;
|
|
||||||
|
|
||||||
use App\Models\Setting;
|
|
||||||
use Illuminate\Http\JsonResponse;
|
|
||||||
use Illuminate\Support\Facades\Http;
|
|
||||||
|
|
||||||
class VersionCheckController extends Controller
|
|
||||||
{
|
|
||||||
public function __invoke(): JsonResponse
|
|
||||||
{
|
|
||||||
$current = Setting::query()->where('key', 'version')->value('value');
|
|
||||||
$build = Setting::query()->where('key', 'build')->value('value');
|
|
||||||
|
|
||||||
$owner = env('GITEA_OWNER');
|
|
||||||
$repo = env('GITEA_REPO');
|
|
||||||
$apiBase = rtrim((string) env('GITEA_API_BASE', 'https://git.24unix.net/api/v1'), '/');
|
|
||||||
$token = env('GITEA_TOKEN');
|
|
||||||
|
|
||||||
if (!$owner || !$repo) {
|
|
||||||
return response()->json([
|
|
||||||
'current_version' => $current,
|
|
||||||
'current_build' => $build !== null ? (int) $build : null,
|
|
||||||
'latest_tag' => null,
|
|
||||||
'latest_version' => null,
|
|
||||||
'is_latest' => null,
|
|
||||||
'error' => 'Missing GITEA_OWNER/GITEA_REPO configuration.',
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
$client = Http::acceptJson();
|
|
||||||
if ($token) {
|
|
||||||
$client = $client->withHeaders(['Authorization' => "token {$token}"]);
|
|
||||||
}
|
|
||||||
|
|
||||||
$response = $client->get("{$apiBase}/repos/{$owner}/{$repo}/releases/latest");
|
|
||||||
if (!$response->successful()) {
|
|
||||||
return response()->json([
|
|
||||||
'current_version' => $current,
|
|
||||||
'current_build' => $build !== null ? (int) $build : null,
|
|
||||||
'latest_tag' => null,
|
|
||||||
'latest_version' => null,
|
|
||||||
'is_latest' => null,
|
|
||||||
'error' => "Release check failed: {$response->status()}",
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
$tag = (string) ($response->json('tag_name') ?? '');
|
|
||||||
$latestVersion = ltrim($tag, 'v');
|
|
||||||
$isLatest = $current && $latestVersion ? $current === $latestVersion : null;
|
|
||||||
|
|
||||||
return response()->json([
|
|
||||||
'current_version' => $current,
|
|
||||||
'current_build' => $build !== null ? (int) $build : null,
|
|
||||||
'latest_tag' => $tag ?: null,
|
|
||||||
'latest_version' => $latestVersion ?: null,
|
|
||||||
'is_latest' => $isLatest,
|
|
||||||
]);
|
|
||||||
} catch (\Throwable $e) {
|
|
||||||
return response()->json([
|
|
||||||
'current_version' => $current,
|
|
||||||
'current_build' => $build !== null ? (int) $build : null,
|
|
||||||
'latest_tag' => null,
|
|
||||||
'latest_version' => null,
|
|
||||||
'is_latest' => null,
|
|
||||||
'error' => 'Version check failed.',
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,74 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Models;
|
|
||||||
|
|
||||||
use Illuminate\Database\Eloquent\Model;
|
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
|
||||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @property int $id
|
|
||||||
* @property int|null $thread_id
|
|
||||||
* @property int|null $post_id
|
|
||||||
* @property int|null $attachment_extension_id
|
|
||||||
* @property int|null $attachment_group_id
|
|
||||||
* @property int|null $user_id
|
|
||||||
* @property string $disk
|
|
||||||
* @property string $path
|
|
||||||
* @property string $original_name
|
|
||||||
* @property string|null $extension
|
|
||||||
* @property string $mime_type
|
|
||||||
* @property int $size_bytes
|
|
||||||
* @mixin \Eloquent
|
|
||||||
*/
|
|
||||||
class Attachment extends Model
|
|
||||||
{
|
|
||||||
use SoftDeletes;
|
|
||||||
|
|
||||||
protected $fillable = [
|
|
||||||
'thread_id',
|
|
||||||
'post_id',
|
|
||||||
'attachment_extension_id',
|
|
||||||
'attachment_group_id',
|
|
||||||
'user_id',
|
|
||||||
'disk',
|
|
||||||
'path',
|
|
||||||
'thumbnail_path',
|
|
||||||
'thumbnail_mime_type',
|
|
||||||
'thumbnail_size_bytes',
|
|
||||||
'original_name',
|
|
||||||
'extension',
|
|
||||||
'mime_type',
|
|
||||||
'size_bytes',
|
|
||||||
];
|
|
||||||
|
|
||||||
protected $casts = [
|
|
||||||
'size_bytes' => 'int',
|
|
||||||
'thumbnail_size_bytes' => 'int',
|
|
||||||
];
|
|
||||||
|
|
||||||
public function thread(): BelongsTo
|
|
||||||
{
|
|
||||||
return $this->belongsTo(Thread::class);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function post(): BelongsTo
|
|
||||||
{
|
|
||||||
return $this->belongsTo(Post::class);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function extension(): BelongsTo
|
|
||||||
{
|
|
||||||
return $this->belongsTo(AttachmentExtension::class, 'attachment_extension_id');
|
|
||||||
}
|
|
||||||
|
|
||||||
public function group(): BelongsTo
|
|
||||||
{
|
|
||||||
return $this->belongsTo(AttachmentGroup::class, 'attachment_group_id');
|
|
||||||
}
|
|
||||||
|
|
||||||
public function user(): BelongsTo
|
|
||||||
{
|
|
||||||
return $this->belongsTo(User::class);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Models;
|
|
||||||
|
|
||||||
use Illuminate\Database\Eloquent\Model;
|
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @property int $id
|
|
||||||
* @property string $extension
|
|
||||||
* @property int|null $attachment_group_id
|
|
||||||
* @property array|null $allowed_mimes
|
|
||||||
* @mixin \Eloquent
|
|
||||||
*/
|
|
||||||
class AttachmentExtension extends Model
|
|
||||||
{
|
|
||||||
protected $fillable = [
|
|
||||||
'extension',
|
|
||||||
'attachment_group_id',
|
|
||||||
'allowed_mimes',
|
|
||||||
];
|
|
||||||
|
|
||||||
protected $casts = [
|
|
||||||
'allowed_mimes' => 'array',
|
|
||||||
];
|
|
||||||
|
|
||||||
public function group(): BelongsTo
|
|
||||||
{
|
|
||||||
return $this->belongsTo(AttachmentGroup::class, 'attachment_group_id');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Models;
|
|
||||||
|
|
||||||
use Illuminate\Database\Eloquent\Model;
|
|
||||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @property int $id
|
|
||||||
* @property string $name
|
|
||||||
* @property int|null $parent_id
|
|
||||||
* @property int|null $position
|
|
||||||
* @property int $max_size_kb
|
|
||||||
* @property bool $is_active
|
|
||||||
* @mixin \Eloquent
|
|
||||||
*/
|
|
||||||
class AttachmentGroup extends Model
|
|
||||||
{
|
|
||||||
protected $fillable = [
|
|
||||||
'name',
|
|
||||||
'parent_id',
|
|
||||||
'position',
|
|
||||||
'max_size_kb',
|
|
||||||
'is_active',
|
|
||||||
];
|
|
||||||
|
|
||||||
protected $casts = [
|
|
||||||
'is_active' => 'bool',
|
|
||||||
];
|
|
||||||
|
|
||||||
public function extensions(): HasMany
|
|
||||||
{
|
|
||||||
return $this->hasMany(AttachmentExtension::class, 'attachment_group_id');
|
|
||||||
}
|
|
||||||
|
|
||||||
public function parent(): BelongsTo
|
|
||||||
{
|
|
||||||
return $this->belongsTo(self::class, 'parent_id');
|
|
||||||
}
|
|
||||||
|
|
||||||
public function children(): HasMany
|
|
||||||
{
|
|
||||||
return $this->hasMany(self::class, 'parent_id');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Models;
|
|
||||||
|
|
||||||
use Illuminate\Database\Eloquent\Model;
|
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @property int $id
|
|
||||||
* @property int|null $user_id
|
|
||||||
* @property string $action
|
|
||||||
* @property string|null $subject_type
|
|
||||||
* @property int|null $subject_id
|
|
||||||
* @property array|null $metadata
|
|
||||||
* @property string|null $ip_address
|
|
||||||
* @property string|null $user_agent
|
|
||||||
* @property \Illuminate\Support\Carbon|null $created_at
|
|
||||||
* @property \Illuminate\Support\Carbon|null $updated_at
|
|
||||||
* @mixin \Eloquent
|
|
||||||
*/
|
|
||||||
class AuditLog extends Model
|
|
||||||
{
|
|
||||||
protected $fillable = [
|
|
||||||
'user_id',
|
|
||||||
'action',
|
|
||||||
'subject_type',
|
|
||||||
'subject_id',
|
|
||||||
'metadata',
|
|
||||||
'ip_address',
|
|
||||||
'user_agent',
|
|
||||||
];
|
|
||||||
|
|
||||||
protected $casts = [
|
|
||||||
'metadata' => 'array',
|
|
||||||
];
|
|
||||||
|
|
||||||
public function user(): BelongsTo
|
|
||||||
{
|
|
||||||
return $this->belongsTo(User::class);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -14,7 +14,6 @@ use Illuminate\Database\Eloquent\SoftDeletes;
|
|||||||
* @property string $body
|
* @property string $body
|
||||||
* @property \Illuminate\Support\Carbon|null $created_at
|
* @property \Illuminate\Support\Carbon|null $created_at
|
||||||
* @property \Illuminate\Support\Carbon|null $updated_at
|
* @property \Illuminate\Support\Carbon|null $updated_at
|
||||||
* @property-read \Illuminate\Database\Eloquent\Collection<int, \App\Models\Attachment> $attachments
|
|
||||||
* @property-read \App\Models\Thread $thread
|
* @property-read \App\Models\Thread $thread
|
||||||
* @property-read \App\Models\User|null $user
|
* @property-read \App\Models\User|null $user
|
||||||
* @method static \Illuminate\Database\Eloquent\Builder<static>|Post newModelQuery()
|
* @method static \Illuminate\Database\Eloquent\Builder<static>|Post newModelQuery()
|
||||||
@@ -52,9 +51,4 @@ class Post extends Model
|
|||||||
{
|
{
|
||||||
return $this->hasMany(PostThank::class);
|
return $this->hasMany(PostThank::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function attachments(): HasMany
|
|
||||||
{
|
|
||||||
return $this->hasMany(Attachment::class);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,11 +14,9 @@ use Illuminate\Database\Eloquent\SoftDeletes;
|
|||||||
* @property int|null $user_id
|
* @property int|null $user_id
|
||||||
* @property string $title
|
* @property string $title
|
||||||
* @property string $body
|
* @property string $body
|
||||||
* @property bool $solved
|
|
||||||
* @property \Illuminate\Support\Carbon|null $created_at
|
* @property \Illuminate\Support\Carbon|null $created_at
|
||||||
* @property \Illuminate\Support\Carbon|null $updated_at
|
* @property \Illuminate\Support\Carbon|null $updated_at
|
||||||
* @property-read \App\Models\Forum $forum
|
* @property-read \App\Models\Forum $forum
|
||||||
* @property-read \Illuminate\Database\Eloquent\Collection<int, \App\Models\Attachment> $attachments
|
|
||||||
* @property-read \Illuminate\Database\Eloquent\Collection<int, \App\Models\Post> $posts
|
* @property-read \Illuminate\Database\Eloquent\Collection<int, \App\Models\Post> $posts
|
||||||
* @property-read int|null $posts_count
|
* @property-read int|null $posts_count
|
||||||
* @property-read \App\Models\User|null $user
|
* @property-read \App\Models\User|null $user
|
||||||
@@ -43,11 +41,6 @@ class Thread extends Model
|
|||||||
'user_id',
|
'user_id',
|
||||||
'title',
|
'title',
|
||||||
'body',
|
'body',
|
||||||
'solved',
|
|
||||||
];
|
|
||||||
|
|
||||||
protected $casts = [
|
|
||||||
'solved' => 'bool',
|
|
||||||
];
|
];
|
||||||
|
|
||||||
public function forum(): BelongsTo
|
public function forum(): BelongsTo
|
||||||
@@ -65,11 +58,6 @@ class Thread extends Model
|
|||||||
return $this->hasMany(Post::class);
|
return $this->hasMany(Post::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function attachments(): HasMany
|
|
||||||
{
|
|
||||||
return $this->hasMany(Attachment::class);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function latestPost(): HasOne
|
public function latestPost(): HasOne
|
||||||
{
|
{
|
||||||
return $this->hasOne(Post::class)->latestOfMany();
|
return $this->hasOne(Post::class)->latestOfMany();
|
||||||
|
|||||||
@@ -107,11 +107,6 @@ class User extends Authenticatable implements MustVerifyEmail
|
|||||||
return $this->hasMany(Post::class);
|
return $this->hasMany(Post::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function threads(): HasMany
|
|
||||||
{
|
|
||||||
return $this->hasMany(Thread::class);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function thanksGiven(): HasMany
|
public function thanksGiven(): HasMany
|
||||||
{
|
{
|
||||||
return $this->hasMany(PostThank::class);
|
return $this->hasMany(PostThank::class);
|
||||||
|
|||||||
@@ -1,208 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Services;
|
|
||||||
|
|
||||||
use App\Models\Attachment;
|
|
||||||
use App\Models\Setting;
|
|
||||||
use Illuminate\Http\UploadedFile;
|
|
||||||
use Illuminate\Support\Facades\Storage;
|
|
||||||
use Illuminate\Support\Str;
|
|
||||||
|
|
||||||
class AttachmentThumbnailService
|
|
||||||
{
|
|
||||||
public function createForUpload(UploadedFile $file, string $scopeFolder, string $disk = 'local'): ?array
|
|
||||||
{
|
|
||||||
$mime = $file->getMimeType() ?? '';
|
|
||||||
if (!str_starts_with($mime, 'image/')) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$sourcePath = $file->getPathname();
|
|
||||||
$extension = strtolower((string) $file->getClientOriginalExtension());
|
|
||||||
|
|
||||||
return $this->createThumbnail($sourcePath, $mime, $extension, $scopeFolder, $disk);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function createForAttachment(Attachment $attachment, bool $force = false): ?array
|
|
||||||
{
|
|
||||||
if (!$force && $attachment->thumbnail_path) {
|
|
||||||
$thumbDisk = Storage::disk($attachment->disk);
|
|
||||||
if ($thumbDisk->exists($attachment->thumbnail_path)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$mime = $attachment->mime_type ?? '';
|
|
||||||
if (!str_starts_with($mime, 'image/')) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$disk = Storage::disk($attachment->disk);
|
|
||||||
if (!$disk->exists($attachment->path)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$sourcePath = $disk->path($attachment->path);
|
|
||||||
$scopeFolder = $this->resolveScopeFolder($attachment);
|
|
||||||
$extension = strtolower((string) ($attachment->extension ?? ''));
|
|
||||||
|
|
||||||
return $this->createThumbnail($sourcePath, $mime, $extension, $scopeFolder, $attachment->disk);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function resolveScopeFolder(Attachment $attachment): string
|
|
||||||
{
|
|
||||||
if ($attachment->thread_id) {
|
|
||||||
return "threads/{$attachment->thread_id}";
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($attachment->post_id) {
|
|
||||||
return "posts/{$attachment->post_id}";
|
|
||||||
}
|
|
||||||
|
|
||||||
return 'misc';
|
|
||||||
}
|
|
||||||
|
|
||||||
private function createThumbnail(
|
|
||||||
string $sourcePath,
|
|
||||||
string $mime,
|
|
||||||
string $extension,
|
|
||||||
string $scopeFolder,
|
|
||||||
string $diskName
|
|
||||||
): ?array {
|
|
||||||
if (!$this->settingBool('attachments.create_thumbnails', true)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$maxWidth = $this->settingInt('attachments.thumbnail_max_width', 300);
|
|
||||||
$maxHeight = $this->settingInt('attachments.thumbnail_max_height', 300);
|
|
||||||
if ($maxWidth <= 0 || $maxHeight <= 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$info = @getimagesize($sourcePath);
|
|
||||||
if (!$info) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
[$width, $height] = $info;
|
|
||||||
if ($width <= 0 || $height <= 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($width <= $maxWidth && $height <= $maxHeight) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$ratio = min($maxWidth / $width, $maxHeight / $height);
|
|
||||||
$targetWidth = max(1, (int) round($width * $ratio));
|
|
||||||
$targetHeight = max(1, (int) round($height * $ratio));
|
|
||||||
|
|
||||||
$sourceImage = $this->createImageFromFile($sourcePath, $mime);
|
|
||||||
if (!$sourceImage) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$thumbImage = imagecreatetruecolor($targetWidth, $targetHeight);
|
|
||||||
if (!$thumbImage) {
|
|
||||||
imagedestroy($sourceImage);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (in_array($mime, ['image/png', 'image/gif'], true)) {
|
|
||||||
imagecolortransparent($thumbImage, imagecolorallocatealpha($thumbImage, 0, 0, 0, 127));
|
|
||||||
imagealphablending($thumbImage, false);
|
|
||||||
imagesavealpha($thumbImage, true);
|
|
||||||
}
|
|
||||||
|
|
||||||
imagecopyresampled(
|
|
||||||
$thumbImage,
|
|
||||||
$sourceImage,
|
|
||||||
0,
|
|
||||||
0,
|
|
||||||
0,
|
|
||||||
0,
|
|
||||||
$targetWidth,
|
|
||||||
$targetHeight,
|
|
||||||
$width,
|
|
||||||
$height
|
|
||||||
);
|
|
||||||
|
|
||||||
$quality = $this->settingInt('attachments.thumbnail_quality', 85);
|
|
||||||
$thumbBinary = $this->renderImageBinary($thumbImage, $mime, $quality);
|
|
||||||
|
|
||||||
imagedestroy($sourceImage);
|
|
||||||
imagedestroy($thumbImage);
|
|
||||||
|
|
||||||
if ($thumbBinary === null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$filename = Str::uuid()->toString();
|
|
||||||
if ($extension !== '') {
|
|
||||||
$filename .= ".{$extension}";
|
|
||||||
}
|
|
||||||
|
|
||||||
$thumbPath = "attachments/{$scopeFolder}/thumbs/{$filename}";
|
|
||||||
Storage::disk($diskName)->put($thumbPath, $thumbBinary);
|
|
||||||
|
|
||||||
return [
|
|
||||||
'path' => $thumbPath,
|
|
||||||
'mime' => $mime,
|
|
||||||
'size' => strlen($thumbBinary),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
private function createImageFromFile(string $path, string $mime)
|
|
||||||
{
|
|
||||||
return match ($mime) {
|
|
||||||
'image/jpeg', 'image/jpg' => @imagecreatefromjpeg($path),
|
|
||||||
'image/png' => @imagecreatefrompng($path),
|
|
||||||
'image/gif' => @imagecreatefromgif($path),
|
|
||||||
'image/webp' => function_exists('imagecreatefromwebp') ? @imagecreatefromwebp($path) : null,
|
|
||||||
default => null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private function renderImageBinary($image, string $mime, int $quality): ?string
|
|
||||||
{
|
|
||||||
ob_start();
|
|
||||||
$success = false;
|
|
||||||
|
|
||||||
if (in_array($mime, ['image/jpeg', 'image/jpg'], true)) {
|
|
||||||
$success = imagejpeg($image, null, max(10, min(95, $quality)));
|
|
||||||
} elseif ($mime === 'image/png') {
|
|
||||||
$compression = (int) round(9 - (max(10, min(95, $quality)) / 100) * 9);
|
|
||||||
$success = imagepng($image, null, $compression);
|
|
||||||
} elseif ($mime === 'image/gif') {
|
|
||||||
$success = imagegif($image);
|
|
||||||
} elseif ($mime === 'image/webp' && function_exists('imagewebp')) {
|
|
||||||
$success = imagewebp($image, null, max(10, min(95, $quality)));
|
|
||||||
}
|
|
||||||
|
|
||||||
$data = ob_get_clean();
|
|
||||||
|
|
||||||
if (!$success) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $data !== false ? $data : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function settingBool(string $key, bool $default): bool
|
|
||||||
{
|
|
||||||
$value = Setting::query()->where('key', $key)->value('value');
|
|
||||||
if ($value === null) {
|
|
||||||
return $default;
|
|
||||||
}
|
|
||||||
return in_array(strtolower($value), ['1', 'true', 'yes', 'on'], true);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function settingInt(string $key, int $default): int
|
|
||||||
{
|
|
||||||
$value = Setting::query()->where('key', $key)->value('value');
|
|
||||||
if ($value === null) {
|
|
||||||
return $default;
|
|
||||||
}
|
|
||||||
return (int) $value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Services;
|
|
||||||
|
|
||||||
use App\Models\AuditLog;
|
|
||||||
use Illuminate\Database\Eloquent\Model;
|
|
||||||
use Illuminate\Http\Request;
|
|
||||||
|
|
||||||
class AuditLogger
|
|
||||||
{
|
|
||||||
public function log(
|
|
||||||
Request $request,
|
|
||||||
string $action,
|
|
||||||
?Model $subject = null,
|
|
||||||
array $metadata = [],
|
|
||||||
?Model $actor = null
|
|
||||||
): ?AuditLog {
|
|
||||||
try {
|
|
||||||
$actorUser = $actor ?? $request->user();
|
|
||||||
|
|
||||||
return AuditLog::create([
|
|
||||||
'user_id' => $actorUser?->id,
|
|
||||||
'action' => $action,
|
|
||||||
'subject_type' => $subject ? get_class($subject) : null,
|
|
||||||
'subject_id' => $subject?->getKey(),
|
|
||||||
'metadata' => $metadata ?: null,
|
|
||||||
'ip_address' => $request->ip(),
|
|
||||||
'user_agent' => $request->userAgent(),
|
|
||||||
]);
|
|
||||||
} catch (\Throwable) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
17
artisan
17
artisan
@@ -1,19 +1,18 @@
|
|||||||
#!/usr/bin/env php
|
#!/usr/bin/env php
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
define('LARAVEL_START', microtime(true));
|
use Illuminate\Foundation\Application;
|
||||||
|
use Symfony\Component\Console\Input\ArgvInput;
|
||||||
|
|
||||||
|
define(constant_name: 'LARAVEL_START', value: microtime(as_float: true));
|
||||||
|
|
||||||
|
// Register the Composer autoloader...
|
||||||
require __DIR__.'/vendor/autoload.php';
|
require __DIR__.'/vendor/autoload.php';
|
||||||
|
|
||||||
|
// Bootstrap Laravel and handle the command...
|
||||||
|
/** @var Application $app */
|
||||||
$app = require_once __DIR__.'/bootstrap/app.php';
|
$app = require_once __DIR__.'/bootstrap/app.php';
|
||||||
|
|
||||||
$kernel = $app->make(Illuminate\Contracts\Console\Kernel::class);
|
$status = $app->handleCommand(input: new ArgvInput);
|
||||||
|
|
||||||
$status = $kernel->handle(
|
|
||||||
$input = new Symfony\Component\Console\Input\ArgvInput,
|
|
||||||
new Symfony\Component\Console\Output\ConsoleOutput
|
|
||||||
);
|
|
||||||
|
|
||||||
$kernel->terminate($input, $status);
|
|
||||||
|
|
||||||
exit($status);
|
exit($status);
|
||||||
|
|||||||
@@ -11,9 +11,6 @@ return Application::configure(basePath: dirname(__DIR__))
|
|||||||
commands: __DIR__.'/../routes/console.php',
|
commands: __DIR__.'/../routes/console.php',
|
||||||
health: '/up',
|
health: '/up',
|
||||||
)
|
)
|
||||||
->withCommands([
|
|
||||||
__DIR__.'/../app/Console/Commands',
|
|
||||||
])
|
|
||||||
->withMiddleware(function (Middleware $middleware): void {
|
->withMiddleware(function (Middleware $middleware): void {
|
||||||
//
|
//
|
||||||
})
|
})
|
||||||
|
|||||||
0
bootstrap/cache/.gitkeep
vendored
0
bootstrap/cache/.gitkeep
vendored
65
bootstrap/cache/packages.php
vendored
Normal file
65
bootstrap/cache/packages.php
vendored
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
<?php return array (
|
||||||
|
'barryvdh/laravel-ide-helper' =>
|
||||||
|
array (
|
||||||
|
'providers' =>
|
||||||
|
array (
|
||||||
|
0 => 'Barryvdh\\LaravelIdeHelper\\IdeHelperServiceProvider',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
'laravel/fortify' =>
|
||||||
|
array (
|
||||||
|
'providers' =>
|
||||||
|
array (
|
||||||
|
0 => 'Laravel\\Fortify\\FortifyServiceProvider',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
'laravel/pail' =>
|
||||||
|
array (
|
||||||
|
'providers' =>
|
||||||
|
array (
|
||||||
|
0 => 'Laravel\\Pail\\PailServiceProvider',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
'laravel/sail' =>
|
||||||
|
array (
|
||||||
|
'providers' =>
|
||||||
|
array (
|
||||||
|
0 => 'Laravel\\Sail\\SailServiceProvider',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
'laravel/sanctum' =>
|
||||||
|
array (
|
||||||
|
'providers' =>
|
||||||
|
array (
|
||||||
|
0 => 'Laravel\\Sanctum\\SanctumServiceProvider',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
'laravel/tinker' =>
|
||||||
|
array (
|
||||||
|
'providers' =>
|
||||||
|
array (
|
||||||
|
0 => 'Laravel\\Tinker\\TinkerServiceProvider',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
'nesbot/carbon' =>
|
||||||
|
array (
|
||||||
|
'providers' =>
|
||||||
|
array (
|
||||||
|
0 => 'Carbon\\Laravel\\ServiceProvider',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
'nunomaduro/collision' =>
|
||||||
|
array (
|
||||||
|
'providers' =>
|
||||||
|
array (
|
||||||
|
0 => 'NunoMaduro\\Collision\\Adapters\\Laravel\\CollisionServiceProvider',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
'nunomaduro/termwind' =>
|
||||||
|
array (
|
||||||
|
'providers' =>
|
||||||
|
array (
|
||||||
|
0 => 'Termwind\\Laravel\\TermwindServiceProvider',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
275
bootstrap/cache/services.php
vendored
Normal file
275
bootstrap/cache/services.php
vendored
Normal file
@@ -0,0 +1,275 @@
|
|||||||
|
<?php return array (
|
||||||
|
'providers' =>
|
||||||
|
array (
|
||||||
|
0 => 'Illuminate\\Auth\\AuthServiceProvider',
|
||||||
|
1 => 'Illuminate\\Broadcasting\\BroadcastServiceProvider',
|
||||||
|
2 => 'Illuminate\\Bus\\BusServiceProvider',
|
||||||
|
3 => 'Illuminate\\Cache\\CacheServiceProvider',
|
||||||
|
4 => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
|
||||||
|
5 => 'Illuminate\\Concurrency\\ConcurrencyServiceProvider',
|
||||||
|
6 => 'Illuminate\\Cookie\\CookieServiceProvider',
|
||||||
|
7 => 'Illuminate\\Database\\DatabaseServiceProvider',
|
||||||
|
8 => 'Illuminate\\Encryption\\EncryptionServiceProvider',
|
||||||
|
9 => 'Illuminate\\Filesystem\\FilesystemServiceProvider',
|
||||||
|
10 => 'Illuminate\\Foundation\\Providers\\FoundationServiceProvider',
|
||||||
|
11 => 'Illuminate\\Hashing\\HashServiceProvider',
|
||||||
|
12 => 'Illuminate\\Mail\\MailServiceProvider',
|
||||||
|
13 => 'Illuminate\\Notifications\\NotificationServiceProvider',
|
||||||
|
14 => 'Illuminate\\Pagination\\PaginationServiceProvider',
|
||||||
|
15 => 'Illuminate\\Auth\\Passwords\\PasswordResetServiceProvider',
|
||||||
|
16 => 'Illuminate\\Pipeline\\PipelineServiceProvider',
|
||||||
|
17 => 'Illuminate\\Queue\\QueueServiceProvider',
|
||||||
|
18 => 'Illuminate\\Redis\\RedisServiceProvider',
|
||||||
|
19 => 'Illuminate\\Session\\SessionServiceProvider',
|
||||||
|
20 => 'Illuminate\\Translation\\TranslationServiceProvider',
|
||||||
|
21 => 'Illuminate\\Validation\\ValidationServiceProvider',
|
||||||
|
22 => 'Illuminate\\View\\ViewServiceProvider',
|
||||||
|
23 => 'Barryvdh\\LaravelIdeHelper\\IdeHelperServiceProvider',
|
||||||
|
24 => 'Laravel\\Fortify\\FortifyServiceProvider',
|
||||||
|
25 => 'Laravel\\Pail\\PailServiceProvider',
|
||||||
|
26 => 'Laravel\\Sail\\SailServiceProvider',
|
||||||
|
27 => 'Laravel\\Sanctum\\SanctumServiceProvider',
|
||||||
|
28 => 'Laravel\\Tinker\\TinkerServiceProvider',
|
||||||
|
29 => 'Carbon\\Laravel\\ServiceProvider',
|
||||||
|
30 => 'NunoMaduro\\Collision\\Adapters\\Laravel\\CollisionServiceProvider',
|
||||||
|
31 => 'Termwind\\Laravel\\TermwindServiceProvider',
|
||||||
|
32 => 'App\\Providers\\AppServiceProvider',
|
||||||
|
33 => 'App\\Providers\\FortifyServiceProvider',
|
||||||
|
),
|
||||||
|
'eager' =>
|
||||||
|
array (
|
||||||
|
0 => 'Illuminate\\Auth\\AuthServiceProvider',
|
||||||
|
1 => 'Illuminate\\Cookie\\CookieServiceProvider',
|
||||||
|
2 => 'Illuminate\\Database\\DatabaseServiceProvider',
|
||||||
|
3 => 'Illuminate\\Encryption\\EncryptionServiceProvider',
|
||||||
|
4 => 'Illuminate\\Filesystem\\FilesystemServiceProvider',
|
||||||
|
5 => 'Illuminate\\Foundation\\Providers\\FoundationServiceProvider',
|
||||||
|
6 => 'Illuminate\\Notifications\\NotificationServiceProvider',
|
||||||
|
7 => 'Illuminate\\Pagination\\PaginationServiceProvider',
|
||||||
|
8 => 'Illuminate\\Session\\SessionServiceProvider',
|
||||||
|
9 => 'Illuminate\\View\\ViewServiceProvider',
|
||||||
|
10 => 'Laravel\\Fortify\\FortifyServiceProvider',
|
||||||
|
11 => 'Laravel\\Pail\\PailServiceProvider',
|
||||||
|
12 => 'Laravel\\Sanctum\\SanctumServiceProvider',
|
||||||
|
13 => 'Carbon\\Laravel\\ServiceProvider',
|
||||||
|
14 => 'NunoMaduro\\Collision\\Adapters\\Laravel\\CollisionServiceProvider',
|
||||||
|
15 => 'Termwind\\Laravel\\TermwindServiceProvider',
|
||||||
|
16 => 'App\\Providers\\AppServiceProvider',
|
||||||
|
17 => 'App\\Providers\\FortifyServiceProvider',
|
||||||
|
),
|
||||||
|
'deferred' =>
|
||||||
|
array (
|
||||||
|
'Illuminate\\Broadcasting\\BroadcastManager' => 'Illuminate\\Broadcasting\\BroadcastServiceProvider',
|
||||||
|
'Illuminate\\Contracts\\Broadcasting\\Factory' => 'Illuminate\\Broadcasting\\BroadcastServiceProvider',
|
||||||
|
'Illuminate\\Contracts\\Broadcasting\\Broadcaster' => 'Illuminate\\Broadcasting\\BroadcastServiceProvider',
|
||||||
|
'Illuminate\\Bus\\Dispatcher' => 'Illuminate\\Bus\\BusServiceProvider',
|
||||||
|
'Illuminate\\Contracts\\Bus\\Dispatcher' => 'Illuminate\\Bus\\BusServiceProvider',
|
||||||
|
'Illuminate\\Contracts\\Bus\\QueueingDispatcher' => 'Illuminate\\Bus\\BusServiceProvider',
|
||||||
|
'Illuminate\\Bus\\BatchRepository' => 'Illuminate\\Bus\\BusServiceProvider',
|
||||||
|
'Illuminate\\Bus\\DatabaseBatchRepository' => 'Illuminate\\Bus\\BusServiceProvider',
|
||||||
|
'cache' => 'Illuminate\\Cache\\CacheServiceProvider',
|
||||||
|
'cache.store' => 'Illuminate\\Cache\\CacheServiceProvider',
|
||||||
|
'cache.psr6' => 'Illuminate\\Cache\\CacheServiceProvider',
|
||||||
|
'memcached.connector' => 'Illuminate\\Cache\\CacheServiceProvider',
|
||||||
|
'Illuminate\\Cache\\RateLimiter' => 'Illuminate\\Cache\\CacheServiceProvider',
|
||||||
|
'Illuminate\\Foundation\\Console\\AboutCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
|
||||||
|
'Illuminate\\Cache\\Console\\ClearCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
|
||||||
|
'Illuminate\\Cache\\Console\\ForgetCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
|
||||||
|
'Illuminate\\Foundation\\Console\\ClearCompiledCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
|
||||||
|
'Illuminate\\Auth\\Console\\ClearResetsCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
|
||||||
|
'Illuminate\\Foundation\\Console\\ConfigCacheCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
|
||||||
|
'Illuminate\\Foundation\\Console\\ConfigClearCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
|
||||||
|
'Illuminate\\Foundation\\Console\\ConfigShowCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
|
||||||
|
'Illuminate\\Database\\Console\\DbCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
|
||||||
|
'Illuminate\\Database\\Console\\MonitorCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
|
||||||
|
'Illuminate\\Database\\Console\\PruneCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
|
||||||
|
'Illuminate\\Database\\Console\\ShowCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
|
||||||
|
'Illuminate\\Database\\Console\\TableCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
|
||||||
|
'Illuminate\\Database\\Console\\WipeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
|
||||||
|
'Illuminate\\Foundation\\Console\\DownCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
|
||||||
|
'Illuminate\\Foundation\\Console\\EnvironmentCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
|
||||||
|
'Illuminate\\Foundation\\Console\\EnvironmentDecryptCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
|
||||||
|
'Illuminate\\Foundation\\Console\\EnvironmentEncryptCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
|
||||||
|
'Illuminate\\Foundation\\Console\\EventCacheCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
|
||||||
|
'Illuminate\\Foundation\\Console\\EventClearCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
|
||||||
|
'Illuminate\\Foundation\\Console\\EventListCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
|
||||||
|
'Illuminate\\Concurrency\\Console\\InvokeSerializedClosureCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
|
||||||
|
'Illuminate\\Foundation\\Console\\KeyGenerateCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
|
||||||
|
'Illuminate\\Foundation\\Console\\OptimizeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
|
||||||
|
'Illuminate\\Foundation\\Console\\OptimizeClearCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
|
||||||
|
'Illuminate\\Foundation\\Console\\PackageDiscoverCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
|
||||||
|
'Illuminate\\Cache\\Console\\PruneStaleTagsCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
|
||||||
|
'Illuminate\\Queue\\Console\\ClearCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
|
||||||
|
'Illuminate\\Queue\\Console\\ListFailedCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
|
||||||
|
'Illuminate\\Queue\\Console\\FlushFailedCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
|
||||||
|
'Illuminate\\Queue\\Console\\ForgetFailedCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
|
||||||
|
'Illuminate\\Queue\\Console\\ListenCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
|
||||||
|
'Illuminate\\Queue\\Console\\MonitorCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
|
||||||
|
'Illuminate\\Queue\\Console\\PauseCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
|
||||||
|
'Illuminate\\Queue\\Console\\PruneBatchesCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
|
||||||
|
'Illuminate\\Queue\\Console\\PruneFailedJobsCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
|
||||||
|
'Illuminate\\Queue\\Console\\RestartCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
|
||||||
|
'Illuminate\\Queue\\Console\\ResumeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
|
||||||
|
'Illuminate\\Queue\\Console\\RetryCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
|
||||||
|
'Illuminate\\Queue\\Console\\RetryBatchCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
|
||||||
|
'Illuminate\\Queue\\Console\\WorkCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
|
||||||
|
'Illuminate\\Foundation\\Console\\ReloadCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
|
||||||
|
'Illuminate\\Foundation\\Console\\RouteCacheCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
|
||||||
|
'Illuminate\\Foundation\\Console\\RouteClearCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
|
||||||
|
'Illuminate\\Foundation\\Console\\RouteListCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
|
||||||
|
'Illuminate\\Database\\Console\\DumpCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
|
||||||
|
'Illuminate\\Database\\Console\\Seeds\\SeedCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
|
||||||
|
'Illuminate\\Console\\Scheduling\\ScheduleFinishCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
|
||||||
|
'Illuminate\\Console\\Scheduling\\ScheduleListCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
|
||||||
|
'Illuminate\\Console\\Scheduling\\ScheduleRunCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
|
||||||
|
'Illuminate\\Console\\Scheduling\\ScheduleClearCacheCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
|
||||||
|
'Illuminate\\Console\\Scheduling\\ScheduleTestCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
|
||||||
|
'Illuminate\\Console\\Scheduling\\ScheduleWorkCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
|
||||||
|
'Illuminate\\Console\\Scheduling\\ScheduleInterruptCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
|
||||||
|
'Illuminate\\Database\\Console\\ShowModelCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
|
||||||
|
'Illuminate\\Foundation\\Console\\StorageLinkCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
|
||||||
|
'Illuminate\\Foundation\\Console\\StorageUnlinkCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
|
||||||
|
'Illuminate\\Foundation\\Console\\UpCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
|
||||||
|
'Illuminate\\Foundation\\Console\\ViewCacheCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
|
||||||
|
'Illuminate\\Foundation\\Console\\ViewClearCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
|
||||||
|
'Illuminate\\Foundation\\Console\\ApiInstallCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
|
||||||
|
'Illuminate\\Foundation\\Console\\BroadcastingInstallCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
|
||||||
|
'Illuminate\\Cache\\Console\\CacheTableCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
|
||||||
|
'Illuminate\\Foundation\\Console\\CastMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
|
||||||
|
'Illuminate\\Foundation\\Console\\ChannelListCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
|
||||||
|
'Illuminate\\Foundation\\Console\\ChannelMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
|
||||||
|
'Illuminate\\Foundation\\Console\\ClassMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
|
||||||
|
'Illuminate\\Foundation\\Console\\ComponentMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
|
||||||
|
'Illuminate\\Foundation\\Console\\ConfigMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
|
||||||
|
'Illuminate\\Foundation\\Console\\ConfigPublishCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
|
||||||
|
'Illuminate\\Foundation\\Console\\ConsoleMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
|
||||||
|
'Illuminate\\Routing\\Console\\ControllerMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
|
||||||
|
'Illuminate\\Foundation\\Console\\DocsCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
|
||||||
|
'Illuminate\\Foundation\\Console\\EnumMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
|
||||||
|
'Illuminate\\Foundation\\Console\\EventGenerateCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
|
||||||
|
'Illuminate\\Foundation\\Console\\EventMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
|
||||||
|
'Illuminate\\Foundation\\Console\\ExceptionMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
|
||||||
|
'Illuminate\\Database\\Console\\Factories\\FactoryMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
|
||||||
|
'Illuminate\\Foundation\\Console\\InterfaceMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
|
||||||
|
'Illuminate\\Foundation\\Console\\JobMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
|
||||||
|
'Illuminate\\Foundation\\Console\\JobMiddlewareMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
|
||||||
|
'Illuminate\\Foundation\\Console\\LangPublishCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
|
||||||
|
'Illuminate\\Foundation\\Console\\ListenerMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
|
||||||
|
'Illuminate\\Foundation\\Console\\MailMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
|
||||||
|
'Illuminate\\Routing\\Console\\MiddlewareMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
|
||||||
|
'Illuminate\\Foundation\\Console\\ModelMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
|
||||||
|
'Illuminate\\Foundation\\Console\\NotificationMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
|
||||||
|
'Illuminate\\Notifications\\Console\\NotificationTableCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
|
||||||
|
'Illuminate\\Foundation\\Console\\ObserverMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
|
||||||
|
'Illuminate\\Foundation\\Console\\PolicyMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
|
||||||
|
'Illuminate\\Foundation\\Console\\ProviderMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
|
||||||
|
'Illuminate\\Queue\\Console\\FailedTableCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
|
||||||
|
'Illuminate\\Queue\\Console\\TableCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
|
||||||
|
'Illuminate\\Queue\\Console\\BatchesTableCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
|
||||||
|
'Illuminate\\Foundation\\Console\\RequestMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
|
||||||
|
'Illuminate\\Foundation\\Console\\ResourceMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
|
||||||
|
'Illuminate\\Foundation\\Console\\RuleMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
|
||||||
|
'Illuminate\\Foundation\\Console\\ScopeMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
|
||||||
|
'Illuminate\\Database\\Console\\Seeds\\SeederMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
|
||||||
|
'Illuminate\\Session\\Console\\SessionTableCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
|
||||||
|
'Illuminate\\Foundation\\Console\\ServeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
|
||||||
|
'Illuminate\\Foundation\\Console\\StubPublishCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
|
||||||
|
'Illuminate\\Foundation\\Console\\TestMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
|
||||||
|
'Illuminate\\Foundation\\Console\\TraitMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
|
||||||
|
'Illuminate\\Foundation\\Console\\VendorPublishCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
|
||||||
|
'Illuminate\\Foundation\\Console\\ViewMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
|
||||||
|
'migrator' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
|
||||||
|
'migration.repository' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
|
||||||
|
'migration.creator' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
|
||||||
|
'Illuminate\\Database\\Migrations\\Migrator' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
|
||||||
|
'Illuminate\\Database\\Console\\Migrations\\MigrateCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
|
||||||
|
'Illuminate\\Database\\Console\\Migrations\\FreshCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
|
||||||
|
'Illuminate\\Database\\Console\\Migrations\\InstallCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
|
||||||
|
'Illuminate\\Database\\Console\\Migrations\\RefreshCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
|
||||||
|
'Illuminate\\Database\\Console\\Migrations\\ResetCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
|
||||||
|
'Illuminate\\Database\\Console\\Migrations\\RollbackCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
|
||||||
|
'Illuminate\\Database\\Console\\Migrations\\StatusCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
|
||||||
|
'Illuminate\\Database\\Console\\Migrations\\MigrateMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
|
||||||
|
'composer' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
|
||||||
|
'Illuminate\\Concurrency\\ConcurrencyManager' => 'Illuminate\\Concurrency\\ConcurrencyServiceProvider',
|
||||||
|
'hash' => 'Illuminate\\Hashing\\HashServiceProvider',
|
||||||
|
'hash.driver' => 'Illuminate\\Hashing\\HashServiceProvider',
|
||||||
|
'mail.manager' => 'Illuminate\\Mail\\MailServiceProvider',
|
||||||
|
'mailer' => 'Illuminate\\Mail\\MailServiceProvider',
|
||||||
|
'Illuminate\\Mail\\Markdown' => 'Illuminate\\Mail\\MailServiceProvider',
|
||||||
|
'auth.password' => 'Illuminate\\Auth\\Passwords\\PasswordResetServiceProvider',
|
||||||
|
'auth.password.broker' => 'Illuminate\\Auth\\Passwords\\PasswordResetServiceProvider',
|
||||||
|
'Illuminate\\Contracts\\Pipeline\\Hub' => 'Illuminate\\Pipeline\\PipelineServiceProvider',
|
||||||
|
'pipeline' => 'Illuminate\\Pipeline\\PipelineServiceProvider',
|
||||||
|
'queue' => 'Illuminate\\Queue\\QueueServiceProvider',
|
||||||
|
'queue.connection' => 'Illuminate\\Queue\\QueueServiceProvider',
|
||||||
|
'queue.failer' => 'Illuminate\\Queue\\QueueServiceProvider',
|
||||||
|
'queue.listener' => 'Illuminate\\Queue\\QueueServiceProvider',
|
||||||
|
'queue.worker' => 'Illuminate\\Queue\\QueueServiceProvider',
|
||||||
|
'redis' => 'Illuminate\\Redis\\RedisServiceProvider',
|
||||||
|
'redis.connection' => 'Illuminate\\Redis\\RedisServiceProvider',
|
||||||
|
'translator' => 'Illuminate\\Translation\\TranslationServiceProvider',
|
||||||
|
'translation.loader' => 'Illuminate\\Translation\\TranslationServiceProvider',
|
||||||
|
'validator' => 'Illuminate\\Validation\\ValidationServiceProvider',
|
||||||
|
'validation.presence' => 'Illuminate\\Validation\\ValidationServiceProvider',
|
||||||
|
'Illuminate\\Contracts\\Validation\\UncompromisedVerifier' => 'Illuminate\\Validation\\ValidationServiceProvider',
|
||||||
|
'Barryvdh\\LaravelIdeHelper\\Console\\GeneratorCommand' => 'Barryvdh\\LaravelIdeHelper\\IdeHelperServiceProvider',
|
||||||
|
'Barryvdh\\LaravelIdeHelper\\Console\\ModelsCommand' => 'Barryvdh\\LaravelIdeHelper\\IdeHelperServiceProvider',
|
||||||
|
'Barryvdh\\LaravelIdeHelper\\Console\\MetaCommand' => 'Barryvdh\\LaravelIdeHelper\\IdeHelperServiceProvider',
|
||||||
|
'Barryvdh\\LaravelIdeHelper\\Console\\EloquentCommand' => 'Barryvdh\\LaravelIdeHelper\\IdeHelperServiceProvider',
|
||||||
|
'Laravel\\Sail\\Console\\InstallCommand' => 'Laravel\\Sail\\SailServiceProvider',
|
||||||
|
'Laravel\\Sail\\Console\\PublishCommand' => 'Laravel\\Sail\\SailServiceProvider',
|
||||||
|
'command.tinker' => 'Laravel\\Tinker\\TinkerServiceProvider',
|
||||||
|
),
|
||||||
|
'when' =>
|
||||||
|
array (
|
||||||
|
'Illuminate\\Broadcasting\\BroadcastServiceProvider' =>
|
||||||
|
array (
|
||||||
|
),
|
||||||
|
'Illuminate\\Bus\\BusServiceProvider' =>
|
||||||
|
array (
|
||||||
|
),
|
||||||
|
'Illuminate\\Cache\\CacheServiceProvider' =>
|
||||||
|
array (
|
||||||
|
),
|
||||||
|
'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider' =>
|
||||||
|
array (
|
||||||
|
),
|
||||||
|
'Illuminate\\Concurrency\\ConcurrencyServiceProvider' =>
|
||||||
|
array (
|
||||||
|
),
|
||||||
|
'Illuminate\\Hashing\\HashServiceProvider' =>
|
||||||
|
array (
|
||||||
|
),
|
||||||
|
'Illuminate\\Mail\\MailServiceProvider' =>
|
||||||
|
array (
|
||||||
|
),
|
||||||
|
'Illuminate\\Auth\\Passwords\\PasswordResetServiceProvider' =>
|
||||||
|
array (
|
||||||
|
),
|
||||||
|
'Illuminate\\Pipeline\\PipelineServiceProvider' =>
|
||||||
|
array (
|
||||||
|
),
|
||||||
|
'Illuminate\\Queue\\QueueServiceProvider' =>
|
||||||
|
array (
|
||||||
|
),
|
||||||
|
'Illuminate\\Redis\\RedisServiceProvider' =>
|
||||||
|
array (
|
||||||
|
),
|
||||||
|
'Illuminate\\Translation\\TranslationServiceProvider' =>
|
||||||
|
array (
|
||||||
|
),
|
||||||
|
'Illuminate\\Validation\\ValidationServiceProvider' =>
|
||||||
|
array (
|
||||||
|
),
|
||||||
|
'Barryvdh\\LaravelIdeHelper\\IdeHelperServiceProvider' =>
|
||||||
|
array (
|
||||||
|
),
|
||||||
|
'Laravel\\Sail\\SailServiceProvider' =>
|
||||||
|
array (
|
||||||
|
),
|
||||||
|
'Laravel\\Tinker\\TinkerServiceProvider' =>
|
||||||
|
array (
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
@@ -3,10 +3,7 @@
|
|||||||
"name": "laravel/laravel",
|
"name": "laravel/laravel",
|
||||||
"type": "project",
|
"type": "project",
|
||||||
"description": "The skeleton application for the Laravel framework.",
|
"description": "The skeleton application for the Laravel framework.",
|
||||||
"keywords": [
|
"keywords": ["laravel", "framework"],
|
||||||
"laravel",
|
|
||||||
"framework"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"require": {
|
"require": {
|
||||||
"php": "^8.4",
|
"php": "^8.4",
|
||||||
@@ -14,8 +11,6 @@
|
|||||||
"laravel/framework": "^12.0",
|
"laravel/framework": "^12.0",
|
||||||
"laravel/sanctum": "*",
|
"laravel/sanctum": "*",
|
||||||
"laravel/tinker": "^2.10.1",
|
"laravel/tinker": "^2.10.1",
|
||||||
"s9e/text-formatter": "^2.5",
|
|
||||||
"composer-runtime-api": "^2.2",
|
|
||||||
"ext-pdo": "*"
|
"ext-pdo": "*"
|
||||||
},
|
},
|
||||||
"require-dev": {
|
"require-dev": {
|
||||||
@@ -26,9 +21,7 @@
|
|||||||
"laravel/sail": "^1.41",
|
"laravel/sail": "^1.41",
|
||||||
"mockery/mockery": "^1.6",
|
"mockery/mockery": "^1.6",
|
||||||
"nunomaduro/collision": "^8.6",
|
"nunomaduro/collision": "^8.6",
|
||||||
"pestphp/pest": "^4.0",
|
"phpunit/phpunit": "^11.5.3",
|
||||||
"pestphp/pest-plugin-laravel": "^4.0",
|
|
||||||
"phpunit/phpunit": "^12.3",
|
|
||||||
"squizlabs/php_codesniffer": "^4.0"
|
"squizlabs/php_codesniffer": "^4.0"
|
||||||
},
|
},
|
||||||
"autoload": {
|
"autoload": {
|
||||||
@@ -61,7 +54,6 @@
|
|||||||
"@php artisan config:clear --ansi",
|
"@php artisan config:clear --ansi",
|
||||||
"@php artisan test"
|
"@php artisan test"
|
||||||
],
|
],
|
||||||
"test:coverage": "./vendor/bin/pest --coverage",
|
|
||||||
"post-autoload-dump": [
|
"post-autoload-dump": [
|
||||||
"Illuminate\\Foundation\\ComposerScripts::postAutoloadDump",
|
"Illuminate\\Foundation\\ComposerScripts::postAutoloadDump",
|
||||||
"@php artisan package:discover --ansi"
|
"@php artisan package:discover --ansi"
|
||||||
@@ -96,7 +88,5 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"minimum-stability": "stable",
|
"minimum-stability": "stable",
|
||||||
"prefer-stable": true,
|
"prefer-stable": true
|
||||||
"version": "26.0.2",
|
|
||||||
"build": "72"
|
|
||||||
}
|
}
|
||||||
|
|||||||
2307
composer.lock
generated
2307
composer.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -1,28 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
use Illuminate\Database\Migrations\Migration;
|
|
||||||
use Illuminate\Database\Schema\Blueprint;
|
|
||||||
use Illuminate\Support\Facades\Schema;
|
|
||||||
|
|
||||||
return new class extends Migration
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* Run the migrations.
|
|
||||||
*/
|
|
||||||
public function up(): void
|
|
||||||
{
|
|
||||||
Schema::table('threads', function (Blueprint $table) {
|
|
||||||
$table->boolean('solved')->default(false)->after('body');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Reverse the migrations.
|
|
||||||
*/
|
|
||||||
public function down(): void
|
|
||||||
{
|
|
||||||
Schema::table('threads', function (Blueprint $table) {
|
|
||||||
$table->dropColumn('solved');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
use Illuminate\Database\Migrations\Migration;
|
|
||||||
use Illuminate\Database\Schema\Blueprint;
|
|
||||||
use Illuminate\Support\Facades\Schema;
|
|
||||||
|
|
||||||
return new class extends Migration
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* Run the migrations.
|
|
||||||
*/
|
|
||||||
public function up(): void
|
|
||||||
{
|
|
||||||
Schema::create('attachments', function (Blueprint $table) {
|
|
||||||
$table->id();
|
|
||||||
$table->foreignId('thread_id')->nullable()->constrained('threads')->nullOnDelete();
|
|
||||||
$table->foreignId('post_id')->nullable()->constrained('posts')->nullOnDelete();
|
|
||||||
$table->foreignId('user_id')->nullable()->constrained('users')->nullOnDelete();
|
|
||||||
$table->string('disk', 50)->default('local');
|
|
||||||
$table->string('path');
|
|
||||||
$table->string('original_name');
|
|
||||||
$table->string('extension', 30)->nullable();
|
|
||||||
$table->string('mime_type', 150);
|
|
||||||
$table->unsignedBigInteger('size_bytes');
|
|
||||||
$table->timestamps();
|
|
||||||
$table->softDeletes();
|
|
||||||
|
|
||||||
$table->index('thread_id', 'idx_attachments_thread_id');
|
|
||||||
$table->index('post_id', 'idx_attachments_post_id');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Reverse the migrations.
|
|
||||||
*/
|
|
||||||
public function down(): void
|
|
||||||
{
|
|
||||||
Schema::dropIfExists('attachments');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
use Illuminate\Database\Migrations\Migration;
|
|
||||||
use Illuminate\Database\Schema\Blueprint;
|
|
||||||
use Illuminate\Support\Facades\DB;
|
|
||||||
use Illuminate\Support\Facades\Schema;
|
|
||||||
|
|
||||||
return new class extends Migration
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* Run the migrations.
|
|
||||||
*/
|
|
||||||
public function up(): void
|
|
||||||
{
|
|
||||||
Schema::create('attachment_groups', function (Blueprint $table) {
|
|
||||||
$table->id();
|
|
||||||
$table->string('name', 150);
|
|
||||||
$table->unsignedInteger('max_size_kb')->default(25600);
|
|
||||||
$table->boolean('is_active')->default(true);
|
|
||||||
$table->timestamps();
|
|
||||||
});
|
|
||||||
|
|
||||||
if (Schema::hasTable('attachment_types')) {
|
|
||||||
$types = DB::table('attachment_types')->orderBy('id')->get();
|
|
||||||
foreach ($types as $type) {
|
|
||||||
DB::table('attachment_groups')->insert([
|
|
||||||
'name' => $type->label ?? $type->key ?? 'General',
|
|
||||||
'max_size_kb' => $type->max_size_kb ?? 25600,
|
|
||||||
'is_active' => $type->is_active ?? true,
|
|
||||||
'created_at' => now(),
|
|
||||||
'updated_at' => now(),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (DB::table('attachment_groups')->count() === 0) {
|
|
||||||
DB::table('attachment_groups')->insert([
|
|
||||||
'name' => 'General',
|
|
||||||
'max_size_kb' => 25600,
|
|
||||||
'is_active' => true,
|
|
||||||
'created_at' => now(),
|
|
||||||
'updated_at' => now(),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Reverse the migrations.
|
|
||||||
*/
|
|
||||||
public function down(): void
|
|
||||||
{
|
|
||||||
Schema::dropIfExists('attachment_groups');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -1,65 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
use Illuminate\Database\Migrations\Migration;
|
|
||||||
use Illuminate\Database\Schema\Blueprint;
|
|
||||||
use Illuminate\Support\Facades\DB;
|
|
||||||
use Illuminate\Support\Facades\Schema;
|
|
||||||
|
|
||||||
return new class extends Migration
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* Run the migrations.
|
|
||||||
*/
|
|
||||||
public function up(): void
|
|
||||||
{
|
|
||||||
Schema::create('attachment_extensions', function (Blueprint $table) {
|
|
||||||
$table->id();
|
|
||||||
$table->string('extension', 30)->unique();
|
|
||||||
$table->foreignId('attachment_group_id')->nullable()->constrained('attachment_groups')->nullOnDelete();
|
|
||||||
$table->json('allowed_mimes')->nullable();
|
|
||||||
$table->timestamps();
|
|
||||||
});
|
|
||||||
|
|
||||||
if (Schema::hasTable('attachment_types') && Schema::hasTable('attachment_groups')) {
|
|
||||||
$groups = DB::table('attachment_groups')->orderBy('id')->get()->values();
|
|
||||||
$types = DB::table('attachment_types')->orderBy('id')->get()->values();
|
|
||||||
|
|
||||||
foreach ($types as $index => $type) {
|
|
||||||
$group = $groups[$index] ?? null;
|
|
||||||
if (!$group) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
$extensions = [];
|
|
||||||
if (!empty($type->allowed_extensions)) {
|
|
||||||
$decoded = json_decode($type->allowed_extensions, true);
|
|
||||||
if (is_array($decoded)) {
|
|
||||||
$extensions = $decoded;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
foreach ($extensions as $ext) {
|
|
||||||
$ext = strtolower(trim((string) $ext));
|
|
||||||
if ($ext === '') {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
DB::table('attachment_extensions')->updateOrInsert(
|
|
||||||
['extension' => $ext],
|
|
||||||
[
|
|
||||||
'attachment_group_id' => $group->id,
|
|
||||||
'allowed_mimes' => $type->allowed_mimes,
|
|
||||||
'created_at' => now(),
|
|
||||||
'updated_at' => now(),
|
|
||||||
]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Reverse the migrations.
|
|
||||||
*/
|
|
||||||
public function down(): void
|
|
||||||
{
|
|
||||||
Schema::dropIfExists('attachment_extensions');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
use Illuminate\Database\Migrations\Migration;
|
|
||||||
use Illuminate\Database\Schema\Blueprint;
|
|
||||||
use Illuminate\Support\Facades\DB;
|
|
||||||
use Illuminate\Support\Facades\Schema;
|
|
||||||
|
|
||||||
return new class extends Migration
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* Run the migrations.
|
|
||||||
*/
|
|
||||||
public function up(): void
|
|
||||||
{
|
|
||||||
Schema::table('attachments', function (Blueprint $table) {
|
|
||||||
$table->foreignId('attachment_extension_id')->nullable()->constrained('attachment_extensions')->nullOnDelete();
|
|
||||||
$table->foreignId('attachment_group_id')->nullable()->constrained('attachment_groups')->nullOnDelete();
|
|
||||||
$table->index('attachment_extension_id', 'idx_attachments_extension_id');
|
|
||||||
$table->index('attachment_group_id', 'idx_attachments_group_id');
|
|
||||||
});
|
|
||||||
|
|
||||||
if (Schema::hasTable('attachment_extensions')) {
|
|
||||||
$extensions = DB::table('attachment_extensions')->get()->keyBy('extension');
|
|
||||||
$attachments = DB::table('attachments')->select('id', 'extension')->get();
|
|
||||||
foreach ($attachments as $attachment) {
|
|
||||||
$ext = strtolower(trim((string) $attachment->extension));
|
|
||||||
if ($ext === '' || !$extensions->has($ext)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
$extRow = $extensions->get($ext);
|
|
||||||
DB::table('attachments')
|
|
||||||
->where('id', $attachment->id)
|
|
||||||
->update([
|
|
||||||
'attachment_extension_id' => $extRow->id,
|
|
||||||
'attachment_group_id' => $extRow->attachment_group_id,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Reverse the migrations.
|
|
||||||
*/
|
|
||||||
public function down(): void
|
|
||||||
{
|
|
||||||
Schema::table('attachments', function (Blueprint $table) {
|
|
||||||
$table->dropIndex('idx_attachments_extension_id');
|
|
||||||
$table->dropIndex('idx_attachments_group_id');
|
|
||||||
$table->dropConstrainedForeignId('attachment_extension_id');
|
|
||||||
$table->dropConstrainedForeignId('attachment_group_id');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
use Illuminate\Database\Migrations\Migration;
|
|
||||||
use Illuminate\Database\Schema\Blueprint;
|
|
||||||
use Illuminate\Support\Facades\Schema;
|
|
||||||
|
|
||||||
return new class extends Migration
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* Run the migrations.
|
|
||||||
*/
|
|
||||||
public function up(): void
|
|
||||||
{
|
|
||||||
if (Schema::hasColumn('attachments', 'attachment_type_id')) {
|
|
||||||
Schema::table('attachments', function (Blueprint $table) {
|
|
||||||
$table->dropForeign(['attachment_type_id']);
|
|
||||||
$table->dropIndex('idx_attachments_type_id');
|
|
||||||
$table->dropColumn('attachment_type_id');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Reverse the migrations.
|
|
||||||
*/
|
|
||||||
public function down(): void
|
|
||||||
{
|
|
||||||
if (!Schema::hasColumn('attachments', 'attachment_type_id')) {
|
|
||||||
Schema::table('attachments', function (Blueprint $table) {
|
|
||||||
$table->foreignId('attachment_type_id')->constrained('attachment_types');
|
|
||||||
$table->index('attachment_type_id', 'idx_attachments_type_id');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
use Illuminate\Database\Migrations\Migration;
|
|
||||||
use Illuminate\Support\Facades\Schema;
|
|
||||||
|
|
||||||
return new class extends Migration
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* Run the migrations.
|
|
||||||
*/
|
|
||||||
public function up(): void
|
|
||||||
{
|
|
||||||
Schema::dropIfExists('attachment_types');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Reverse the migrations.
|
|
||||||
*/
|
|
||||||
public function down(): void
|
|
||||||
{
|
|
||||||
// Intentionally left empty. attachment_types is deprecated.
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -1,77 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
use Illuminate\Database\Migrations\Migration;
|
|
||||||
use Illuminate\Database\Schema\Blueprint;
|
|
||||||
use Illuminate\Support\Facades\DB;
|
|
||||||
use Illuminate\Support\Facades\Schema;
|
|
||||||
|
|
||||||
return new class extends Migration
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* Run the migrations.
|
|
||||||
*/
|
|
||||||
public function up(): void
|
|
||||||
{
|
|
||||||
if (Schema::hasColumn('attachment_groups', 'category')) {
|
|
||||||
Schema::table('attachment_groups', function (Blueprint $table) {
|
|
||||||
$table->dropColumn('category');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Schema::hasColumn('attachment_groups', 'allowed_mimes')) {
|
|
||||||
if (Schema::hasTable('attachment_extensions')) {
|
|
||||||
if (!Schema::hasColumn('attachment_extensions', 'allowed_mimes')) {
|
|
||||||
Schema::table('attachment_extensions', function (Blueprint $table) {
|
|
||||||
$table->json('allowed_mimes')->nullable();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
$groups = DB::table('attachment_groups')
|
|
||||||
->select('id', 'allowed_mimes')
|
|
||||||
->get()
|
|
||||||
->keyBy('id');
|
|
||||||
|
|
||||||
$extensions = DB::table('attachment_extensions')
|
|
||||||
->select('id', 'attachment_group_id', 'allowed_mimes')
|
|
||||||
->get();
|
|
||||||
|
|
||||||
foreach ($extensions as $extension) {
|
|
||||||
if (!empty($extension->allowed_mimes)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
$group = $groups->get($extension->attachment_group_id);
|
|
||||||
if (!$group || empty($group->allowed_mimes)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
DB::table('attachment_extensions')
|
|
||||||
->where('id', $extension->id)
|
|
||||||
->update([
|
|
||||||
'allowed_mimes' => $group->allowed_mimes,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Schema::table('attachment_groups', function (Blueprint $table) {
|
|
||||||
$table->dropColumn('allowed_mimes');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Reverse the migrations.
|
|
||||||
*/
|
|
||||||
public function down(): void
|
|
||||||
{
|
|
||||||
if (!Schema::hasColumn('attachment_groups', 'category')) {
|
|
||||||
Schema::table('attachment_groups', function (Blueprint $table) {
|
|
||||||
$table->string('category', 50)->default('other');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!Schema::hasColumn('attachment_groups', 'allowed_mimes')) {
|
|
||||||
Schema::table('attachment_groups', function (Blueprint $table) {
|
|
||||||
$table->json('allowed_mimes')->nullable();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
use Illuminate\Database\Migrations\Migration;
|
|
||||||
use Illuminate\Database\Schema\Blueprint;
|
|
||||||
use Illuminate\Support\Facades\DB;
|
|
||||||
use Illuminate\Support\Facades\Schema;
|
|
||||||
|
|
||||||
return new class extends Migration
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* Run the migrations.
|
|
||||||
*/
|
|
||||||
public function up(): void
|
|
||||||
{
|
|
||||||
Schema::table('attachment_groups', function (Blueprint $table) {
|
|
||||||
$table->foreignId('parent_id')->nullable()->constrained('attachment_groups')->nullOnDelete();
|
|
||||||
$table->unsignedInteger('position')->default(1);
|
|
||||||
$table->index(['parent_id', 'position'], 'idx_attachment_groups_parent_position');
|
|
||||||
});
|
|
||||||
|
|
||||||
$groups = DB::table('attachment_groups')->orderBy('id')->get();
|
|
||||||
$position = 1;
|
|
||||||
foreach ($groups as $group) {
|
|
||||||
DB::table('attachment_groups')
|
|
||||||
->where('id', $group->id)
|
|
||||||
->update(['position' => $position++]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Reverse the migrations.
|
|
||||||
*/
|
|
||||||
public function down(): void
|
|
||||||
{
|
|
||||||
Schema::table('attachment_groups', function (Blueprint $table) {
|
|
||||||
$table->dropIndex('idx_attachment_groups_parent_position');
|
|
||||||
$table->dropConstrainedForeignId('parent_id');
|
|
||||||
$table->dropColumn('position');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
use Illuminate\Database\Migrations\Migration;
|
|
||||||
use Illuminate\Database\Schema\Blueprint;
|
|
||||||
use Illuminate\Support\Facades\Schema;
|
|
||||||
|
|
||||||
return new class extends Migration
|
|
||||||
{
|
|
||||||
public function up(): void
|
|
||||||
{
|
|
||||||
Schema::table('attachments', function (Blueprint $table) {
|
|
||||||
$table->string('thumbnail_path')->nullable()->after('path');
|
|
||||||
$table->string('thumbnail_mime_type', 150)->nullable()->after('thumbnail_path');
|
|
||||||
$table->unsignedBigInteger('thumbnail_size_bytes')->nullable()->after('thumbnail_mime_type');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
public function down(): void
|
|
||||||
{
|
|
||||||
Schema::table('attachments', function (Blueprint $table) {
|
|
||||||
$table->dropColumn(['thumbnail_path', 'thumbnail_mime_type', 'thumbnail_size_bytes']);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
use Illuminate\Database\Migrations\Migration;
|
|
||||||
use Illuminate\Database\Schema\Blueprint;
|
|
||||||
use Illuminate\Support\Facades\Schema;
|
|
||||||
|
|
||||||
return new class extends Migration {
|
|
||||||
public function up(): void
|
|
||||||
{
|
|
||||||
Schema::create('audit_logs', function (Blueprint $table) {
|
|
||||||
$table->id();
|
|
||||||
$table->foreignId('user_id')->nullable()->constrained()->nullOnDelete();
|
|
||||||
$table->string('action');
|
|
||||||
$table->string('subject_type')->nullable();
|
|
||||||
$table->unsignedBigInteger('subject_id')->nullable();
|
|
||||||
$table->json('metadata')->nullable();
|
|
||||||
$table->string('ip_address', 45)->nullable();
|
|
||||||
$table->string('user_agent', 255)->nullable();
|
|
||||||
$table->timestamps();
|
|
||||||
|
|
||||||
$table->index(['action', 'created_at']);
|
|
||||||
$table->index(['user_id', 'created_at']);
|
|
||||||
$table->index(['subject_type', 'subject_id']);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
public function down(): void
|
|
||||||
{
|
|
||||||
Schema::dropIfExists('audit_logs');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
199
git_update.sh
199
git_update.sh
@@ -1,199 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# shellcheck disable=SC2016
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
||||||
cd "$ROOT_DIR"
|
|
||||||
|
|
||||||
git restore -q bootstrap/cache/packages.php bootstrap/cache/services.php 2>/dev/null || true
|
|
||||||
DIRTY="$(git status --porcelain)"
|
|
||||||
DIRTY_FILTERED="$(echo "$DIRTY" | grep -vE '^( M|M ) (bootstrap/cache/(packages|services)\.php|package-lock\.json)$' || true)"
|
|
||||||
if [[ -n "$DIRTY_FILTERED" ]]; then
|
|
||||||
echo "Working tree is dirty. Please commit or stash changes before updating."
|
|
||||||
echo "$DIRTY_FILTERED"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
if echo "$DIRTY" | grep -qE 'package-lock\.json'; then
|
|
||||||
echo "Warning: package-lock.json is modified. Continuing anyway."
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "Fetching latest refs..."
|
|
||||||
git fetch --prune --tags
|
|
||||||
|
|
||||||
echo "Checking out stable branch..."
|
|
||||||
git checkout stable
|
|
||||||
|
|
||||||
echo "Pulling latest stable..."
|
|
||||||
git pull --ff-only
|
|
||||||
|
|
||||||
resolve_php_bin() {
|
|
||||||
if [[ -n "${PHP_BIN:-}" ]]; then
|
|
||||||
echo "$PHP_BIN"
|
|
||||||
return
|
|
||||||
fi
|
|
||||||
if command -v keyhelp-php84 >/dev/null 2>&1; then
|
|
||||||
echo "keyhelp-php84"
|
|
||||||
return
|
|
||||||
fi
|
|
||||||
if command -v php >/dev/null 2>&1; then
|
|
||||||
echo "php"
|
|
||||||
return
|
|
||||||
fi
|
|
||||||
echo "php"
|
|
||||||
}
|
|
||||||
|
|
||||||
resolve_configured_php_bin() {
|
|
||||||
local configured="${1:-}"
|
|
||||||
local current="${2:-php}"
|
|
||||||
local trimmed="$configured"
|
|
||||||
trimmed="${trimmed#"${trimmed%%[![:space:]]*}"}"
|
|
||||||
trimmed="${trimmed%"${trimmed##*[![:space:]]}"}"
|
|
||||||
|
|
||||||
if [[ -z "$trimmed" ]]; then
|
|
||||||
echo "$current"
|
|
||||||
return
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [[ "$trimmed" == "keyhelp-php-domain" ]]; then
|
|
||||||
if command -v keyhelp-php-domain >/dev/null 2>&1; then
|
|
||||||
echo "keyhelp-php-domain"
|
|
||||||
return
|
|
||||||
fi
|
|
||||||
if [[ -x "/usr/bin/keyhelp-php-domain" ]]; then
|
|
||||||
echo "/usr/bin/keyhelp-php-domain"
|
|
||||||
return
|
|
||||||
fi
|
|
||||||
if [[ -x "/usr/local/bin/keyhelp-php-domain" ]]; then
|
|
||||||
echo "/usr/local/bin/keyhelp-php-domain"
|
|
||||||
return
|
|
||||||
fi
|
|
||||||
echo "Configured PHP binary 'keyhelp-php-domain' was not found." >&2
|
|
||||||
echo "Set ACP -> System -> CLI to a working custom binary (e.g. keyhelp-php84)." >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
if command -v "$trimmed" >/dev/null 2>&1; then
|
|
||||||
echo "$trimmed"
|
|
||||||
return
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [[ "$trimmed" == */* && -x "$trimmed" ]]; then
|
|
||||||
echo "$trimmed"
|
|
||||||
return
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "Configured PHP binary '$trimmed' is not executable/resolvable." >&2
|
|
||||||
echo "Set ACP -> System -> CLI to a valid command or absolute executable path." >&2
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
|
|
||||||
read_setting_php_bin() {
|
|
||||||
if [[ ! -f artisan ]]; then
|
|
||||||
echo ""
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
echo "Running with PHP binary: $PHP_BIN -r <read system.php_binary>" >&2
|
|
||||||
"$PHP_BIN" -r '
|
|
||||||
require "vendor/autoload.php";
|
|
||||||
$app = require "bootstrap/app.php";
|
|
||||||
$app->make(Illuminate\Contracts\Console\Kernel::class)->bootstrap();
|
|
||||||
$value = (string) \App\Models\Setting::where("key", "system.php_binary")->value("value");
|
|
||||||
echo trim($value);
|
|
||||||
'
|
|
||||||
}
|
|
||||||
|
|
||||||
PHP_BIN="$(resolve_php_bin)"
|
|
||||||
echo "Resolved PHP binary: $PHP_BIN"
|
|
||||||
if command -v "$PHP_BIN" >/dev/null 2>&1; then
|
|
||||||
echo "PHP version ($PHP_BIN): $($PHP_BIN -v | head -n 1)"
|
|
||||||
else
|
|
||||||
echo "PHP binary '$PHP_BIN' not found in PATH."
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "Installing PHP dependencies..."
|
|
||||||
COMPOSER_BIN="$(command -v composer || true)"
|
|
||||||
if [[ -z "$COMPOSER_BIN" ]]; then
|
|
||||||
echo "Composer not found in PATH."
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
echo "Running with PHP binary: $PHP_BIN $COMPOSER_BIN install --no-dev --optimize-autoloader"
|
|
||||||
"$PHP_BIN" "$COMPOSER_BIN" install --no-dev --optimize-autoloader
|
|
||||||
|
|
||||||
if ! CONFIGURED_PHP="$(read_setting_php_bin)"; then
|
|
||||||
echo "Failed to read configured PHP binary from settings." >&2
|
|
||||||
echo "Aborting to avoid running update with the wrong PHP binary." >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
echo "Configured PHP binary from settings: ${CONFIGURED_PHP:-<empty>}"
|
|
||||||
PHP_BIN="$(resolve_configured_php_bin "$CONFIGURED_PHP" "$PHP_BIN")"
|
|
||||||
|
|
||||||
echo "Final PHP binary: $PHP_BIN"
|
|
||||||
if command -v "$PHP_BIN" >/dev/null 2>&1; then
|
|
||||||
echo "Final PHP version ($PHP_BIN): $($PHP_BIN -v | head -n 1)"
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "Installing JS dependencies..."
|
|
||||||
npm install
|
|
||||||
|
|
||||||
echo "Building assets..."
|
|
||||||
npm run build
|
|
||||||
|
|
||||||
echo "Running migrations..."
|
|
||||||
echo "Running with PHP binary: $PHP_BIN artisan migrate --force"
|
|
||||||
"$PHP_BIN" artisan migrate --force
|
|
||||||
|
|
||||||
echo "Syncing version/build to settings..."
|
|
||||||
echo "Running with PHP binary: $PHP_BIN -r <read composer.json version>"
|
|
||||||
VERSION="$("$PHP_BIN" -r '$c=json_decode(file_get_contents("composer.json"), true); echo $c["version"] ?? "";')"
|
|
||||||
echo "Running with PHP binary: $PHP_BIN -r <read composer.json build>"
|
|
||||||
BUILD="$("$PHP_BIN" -r '$c=json_decode(file_get_contents("composer.json"), true); echo $c["build"] ?? "";')"
|
|
||||||
echo "Computed from composer.json: VERSION=$VERSION, BUILD=$BUILD"
|
|
||||||
|
|
||||||
if [[ -n "$VERSION" || -n "$BUILD" ]]; then
|
|
||||||
echo "Updating settings version/build (VERSION=$VERSION, BUILD=$BUILD)..."
|
|
||||||
echo "Running with PHP binary: $PHP_BIN -r <write settings version/build>"
|
|
||||||
SPEEDBB_VERSION="$VERSION" SPEEDBB_BUILD="$BUILD" "$PHP_BIN" -r '
|
|
||||||
require "vendor/autoload.php";
|
|
||||||
$app = require "bootstrap/app.php";
|
|
||||||
$app->make(Illuminate\Contracts\Console\Kernel::class)->bootstrap();
|
|
||||||
$version = getenv("SPEEDBB_VERSION");
|
|
||||||
$build = getenv("SPEEDBB_BUILD");
|
|
||||||
if ($version !== false && $version !== "") {
|
|
||||||
\Illuminate\Support\Facades\DB::table("settings")->upsert(
|
|
||||||
[[
|
|
||||||
"key" => "version",
|
|
||||||
"value" => $version,
|
|
||||||
"created_at" => now(),
|
|
||||||
"updated_at" => now(),
|
|
||||||
]],
|
|
||||||
["key"],
|
|
||||||
["value", "updated_at"]
|
|
||||||
);
|
|
||||||
echo "Upserted version setting.\n";
|
|
||||||
}
|
|
||||||
if ($build !== false && $build !== "") {
|
|
||||||
\Illuminate\Support\Facades\DB::table("settings")->upsert(
|
|
||||||
[[
|
|
||||||
"key" => "build",
|
|
||||||
"value" => $build,
|
|
||||||
"created_at" => now(),
|
|
||||||
"updated_at" => now(),
|
|
||||||
]],
|
|
||||||
["key"],
|
|
||||||
["value", "updated_at"]
|
|
||||||
);
|
|
||||||
echo "Upserted build setting.\n";
|
|
||||||
}
|
|
||||||
' \
|
|
||||||
&& echo "Running with PHP binary: $PHP_BIN -r <verify settings version/build>" \
|
|
||||||
&& "$PHP_BIN" -r '
|
|
||||||
require "vendor/autoload.php";
|
|
||||||
$app = require "bootstrap/app.php";
|
|
||||||
$app->make(Illuminate\Contracts\Console\Kernel::class)->bootstrap();
|
|
||||||
$version = \App\Models\Setting::where("key", "version")->value("value");
|
|
||||||
$build = \App\Models\Setting::where("key", "build")->value("value");
|
|
||||||
echo "Settings now: version={$version}, build={$build}\n";
|
|
||||||
'
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "Update complete."
|
|
||||||
298
package-lock.json
generated
298
package-lock.json
generated
@@ -2072,6 +2072,18 @@
|
|||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/detect-libc": {
|
||||||
|
"version": "2.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
||||||
|
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"peer": true,
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/dom-helpers": {
|
"node_modules/dom-helpers": {
|
||||||
"version": "5.2.1",
|
"version": "5.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz",
|
||||||
@@ -2876,6 +2888,18 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/jiti": {
|
||||||
|
"version": "2.6.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz",
|
||||||
|
"integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"peer": true,
|
||||||
|
"bin": {
|
||||||
|
"jiti": "lib/jiti-cli.mjs"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/js-tokens": {
|
"node_modules/js-tokens": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
||||||
@@ -2986,6 +3010,280 @@
|
|||||||
"node": ">= 0.8.0"
|
"node": ">= 0.8.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/lightningcss": {
|
||||||
|
"version": "1.30.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.2.tgz",
|
||||||
|
"integrity": "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MPL-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"peer": true,
|
||||||
|
"dependencies": {
|
||||||
|
"detect-libc": "^2.0.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 12.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/parcel"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"lightningcss-android-arm64": "1.30.2",
|
||||||
|
"lightningcss-darwin-arm64": "1.30.2",
|
||||||
|
"lightningcss-darwin-x64": "1.30.2",
|
||||||
|
"lightningcss-freebsd-x64": "1.30.2",
|
||||||
|
"lightningcss-linux-arm-gnueabihf": "1.30.2",
|
||||||
|
"lightningcss-linux-arm64-gnu": "1.30.2",
|
||||||
|
"lightningcss-linux-arm64-musl": "1.30.2",
|
||||||
|
"lightningcss-linux-x64-gnu": "1.30.2",
|
||||||
|
"lightningcss-linux-x64-musl": "1.30.2",
|
||||||
|
"lightningcss-win32-arm64-msvc": "1.30.2",
|
||||||
|
"lightningcss-win32-x64-msvc": "1.30.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/lightningcss-android-arm64": {
|
||||||
|
"version": "1.30.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.30.2.tgz",
|
||||||
|
"integrity": "sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MPL-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"android"
|
||||||
|
],
|
||||||
|
"peer": true,
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 12.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/parcel"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/lightningcss-darwin-arm64": {
|
||||||
|
"version": "1.30.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.30.2.tgz",
|
||||||
|
"integrity": "sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MPL-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"peer": true,
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 12.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/parcel"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/lightningcss-darwin-x64": {
|
||||||
|
"version": "1.30.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.30.2.tgz",
|
||||||
|
"integrity": "sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MPL-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"peer": true,
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 12.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/parcel"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/lightningcss-freebsd-x64": {
|
||||||
|
"version": "1.30.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.30.2.tgz",
|
||||||
|
"integrity": "sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MPL-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"freebsd"
|
||||||
|
],
|
||||||
|
"peer": true,
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 12.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/parcel"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/lightningcss-linux-arm-gnueabihf": {
|
||||||
|
"version": "1.30.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.30.2.tgz",
|
||||||
|
"integrity": "sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MPL-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"peer": true,
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 12.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/parcel"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/lightningcss-linux-arm64-gnu": {
|
||||||
|
"version": "1.30.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.30.2.tgz",
|
||||||
|
"integrity": "sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MPL-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"peer": true,
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 12.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/parcel"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/lightningcss-linux-arm64-musl": {
|
||||||
|
"version": "1.30.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.30.2.tgz",
|
||||||
|
"integrity": "sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MPL-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"peer": true,
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 12.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/parcel"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/lightningcss-linux-x64-gnu": {
|
||||||
|
"version": "1.30.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.30.2.tgz",
|
||||||
|
"integrity": "sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MPL-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"peer": true,
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 12.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/parcel"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/lightningcss-linux-x64-musl": {
|
||||||
|
"version": "1.30.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.30.2.tgz",
|
||||||
|
"integrity": "sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MPL-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"peer": true,
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 12.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/parcel"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/lightningcss-win32-arm64-msvc": {
|
||||||
|
"version": "1.30.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.30.2.tgz",
|
||||||
|
"integrity": "sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MPL-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"peer": true,
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 12.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/parcel"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/lightningcss-win32-x64-msvc": {
|
||||||
|
"version": "1.30.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.30.2.tgz",
|
||||||
|
"integrity": "sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MPL-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"peer": true,
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 12.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/parcel"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/locate-path": {
|
"node_modules/locate-path": {
|
||||||
"version": "6.0.0",
|
"version": "6.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
|
||||||
|
|||||||
@@ -2,14 +2,10 @@
|
|||||||
"$schema": "https://www.schemastore.org/package.json",
|
"$schema": "https://www.schemastore.org/package.json",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"engines": {
|
|
||||||
"node": ">=20",
|
|
||||||
"npm": ">=10"
|
|
||||||
},
|
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
|
"build:watch": "vite build --watch",
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"watch": "vite build --watch",
|
|
||||||
"lint": "eslint ."
|
"lint": "eslint ."
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
@@ -18,11 +18,13 @@
|
|||||||
</include>
|
</include>
|
||||||
</source>
|
</source>
|
||||||
<php>
|
<php>
|
||||||
<env name="APP_ENV" value="test"/>
|
<env name="APP_ENV" value="testing"/>
|
||||||
<env name="APP_MAINTENANCE_DRIVER" value="file"/>
|
<env name="APP_MAINTENANCE_DRIVER" value="file"/>
|
||||||
<env name="BCRYPT_ROUNDS" value="4"/>
|
<env name="BCRYPT_ROUNDS" value="4"/>
|
||||||
<env name="BROADCAST_CONNECTION" value="null"/>
|
<env name="BROADCAST_CONNECTION" value="null"/>
|
||||||
<env name="CACHE_STORE" value="array"/>
|
<env name="CACHE_STORE" value="array"/>
|
||||||
|
<env name="DB_CONNECTION" value="sqlite"/>
|
||||||
|
<env name="DB_DATABASE" value=":memory:"/>
|
||||||
<env name="MAIL_MAILER" value="array"/>
|
<env name="MAIL_MAILER" value="array"/>
|
||||||
<env name="QUEUE_CONNECTION" value="sync"/>
|
<env name="QUEUE_CONNECTION" value="sync"/>
|
||||||
<env name="SESSION_DRIVER" value="array"/>
|
<env name="SESSION_DRIVER" value="array"/>
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import ForumView from './pages/ForumView'
|
|||||||
import ThreadView from './pages/ThreadView'
|
import ThreadView from './pages/ThreadView'
|
||||||
import Login from './pages/Login'
|
import Login from './pages/Login'
|
||||||
import Register from './pages/Register'
|
import Register from './pages/Register'
|
||||||
import { Acp } from './pages/Acp'
|
import Acp from './pages/Acp'
|
||||||
import BoardIndex from './pages/BoardIndex'
|
import BoardIndex from './pages/BoardIndex'
|
||||||
import Ucp from './pages/Ucp'
|
import Ucp from './pages/Ucp'
|
||||||
import Profile from './pages/Profile'
|
import Profile from './pages/Profile'
|
||||||
|
|||||||
@@ -62,12 +62,6 @@ export async function registerUser({ email, username, plainPassword }) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function logoutUser() {
|
|
||||||
return apiFetch('/logout', {
|
|
||||||
method: 'POST',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function listRootForums() {
|
export async function listRootForums() {
|
||||||
return getCollection('/forums?parent[exists]=false')
|
return getCollection('/forums?parent[exists]=false')
|
||||||
}
|
}
|
||||||
@@ -115,20 +109,6 @@ export async function fetchVersion() {
|
|||||||
return apiFetch('/version')
|
return apiFetch('/version')
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchVersionCheck() {
|
|
||||||
return apiFetch('/version/check')
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function runSystemUpdate() {
|
|
||||||
return apiFetch('/system/update', {
|
|
||||||
method: 'POST',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function fetchSystemStatus() {
|
|
||||||
return apiFetch('/system/status')
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function fetchStats() {
|
export async function fetchStats() {
|
||||||
return apiFetch('/stats')
|
return apiFetch('/stats')
|
||||||
}
|
}
|
||||||
@@ -137,13 +117,6 @@ export async function fetchPortalSummary() {
|
|||||||
return apiFetch('/portal/summary')
|
return apiFetch('/portal/summary')
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function previewBbcode(body) {
|
|
||||||
return apiFetch('/preview', {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({ body }),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function fetchSetting(key) {
|
export async function fetchSetting(key) {
|
||||||
// TODO: Prefer fetchSettings() when multiple settings are needed.
|
// TODO: Prefer fetchSettings() when multiple settings are needed.
|
||||||
const cacheBust = Date.now()
|
const cacheBust = Date.now()
|
||||||
@@ -273,147 +246,14 @@ export async function getThread(id) {
|
|||||||
return apiFetch(`/threads/${id}`)
|
return apiFetch(`/threads/${id}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteThread(id, payload = null) {
|
|
||||||
return apiFetch(`/threads/${id}`, {
|
|
||||||
method: 'DELETE',
|
|
||||||
...(payload ? { body: JSON.stringify(payload) } : {}),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function updateThread(id, payload) {
|
|
||||||
return apiFetch(`/threads/${id}`, {
|
|
||||||
method: 'PATCH',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/merge-patch+json',
|
|
||||||
},
|
|
||||||
body: JSON.stringify(payload),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function updateThreadSolved(threadId, solved) {
|
|
||||||
return apiFetch(`/threads/${threadId}/solved`, {
|
|
||||||
method: 'PATCH',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/merge-patch+json',
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ solved }),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function listAttachmentsByThread(threadId) {
|
|
||||||
return getCollection(`/attachments?thread=/api/threads/${threadId}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function listAttachmentsByPost(postId) {
|
|
||||||
return getCollection(`/attachments?post=/api/posts/${postId}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function uploadAttachment({ threadId, postId, file }) {
|
|
||||||
const body = new FormData()
|
|
||||||
if (threadId) body.append('thread', `/api/threads/${threadId}`)
|
|
||||||
if (postId) body.append('post', `/api/posts/${postId}`)
|
|
||||||
body.append('file', file)
|
|
||||||
return apiFetch('/attachments', {
|
|
||||||
method: 'POST',
|
|
||||||
body,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function deleteAttachment(id) {
|
|
||||||
return apiFetch(`/attachments/${id}`, {
|
|
||||||
method: 'DELETE',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function listAttachmentGroups() {
|
|
||||||
return getCollection('/attachment-groups')
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function createAttachmentGroup(payload) {
|
|
||||||
return apiFetch('/attachment-groups', {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify(payload),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function updateAttachmentGroup(id, payload) {
|
|
||||||
return apiFetch(`/attachment-groups/${id}`, {
|
|
||||||
method: 'PATCH',
|
|
||||||
body: JSON.stringify(payload),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function deleteAttachmentGroup(id) {
|
|
||||||
return apiFetch(`/attachment-groups/${id}`, {
|
|
||||||
method: 'DELETE',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function reorderAttachmentGroups(parentId, orderedIds) {
|
|
||||||
return apiFetch('/attachment-groups/reorder', {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({ parentId, orderedIds }),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function listAttachmentExtensions() {
|
|
||||||
return getCollection('/attachment-extensions')
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function listAttachmentExtensionsPublic() {
|
|
||||||
return getCollection('/attachment-extensions/public')
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function createAttachmentExtension(payload) {
|
|
||||||
return apiFetch('/attachment-extensions', {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify(payload),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function updateAttachmentExtension(id, payload) {
|
|
||||||
return apiFetch(`/attachment-extensions/${id}`, {
|
|
||||||
method: 'PATCH',
|
|
||||||
body: JSON.stringify(payload),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function deleteAttachmentExtension(id) {
|
|
||||||
return apiFetch(`/attachment-extensions/${id}`, {
|
|
||||||
method: 'DELETE',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function listPostsByThread(threadId) {
|
export async function listPostsByThread(threadId) {
|
||||||
return getCollection(`/posts?thread=/api/threads/${threadId}`)
|
return getCollection(`/posts?thread=/api/threads/${threadId}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updatePost(id, payload) {
|
|
||||||
return apiFetch(`/posts/${id}`, {
|
|
||||||
method: 'PATCH',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/merge-patch+json',
|
|
||||||
},
|
|
||||||
body: JSON.stringify(payload),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function deletePost(id, payload = null) {
|
|
||||||
return apiFetch(`/posts/${id}`, {
|
|
||||||
method: 'DELETE',
|
|
||||||
...(payload ? { body: JSON.stringify(payload) } : {}),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function listUsers() {
|
export async function listUsers() {
|
||||||
return getCollection('/users')
|
return getCollection('/users')
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function listAuditLogs(limit = 200) {
|
|
||||||
const query = Number.isFinite(limit) ? `?limit=${limit}` : ''
|
|
||||||
return getCollection(`/audit-logs${query}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function listRanks() {
|
export async function listRanks() {
|
||||||
return getCollection('/ranks')
|
return getCollection('/ranks')
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,8 +28,6 @@ export default function PortalTopicRow({ thread, forumName, forumId, showForum =
|
|||||||
return `${day}.${month}.${year} ${hours}:${minutes}:${seconds}`
|
return `${day}.${month}.${year} ${hours}:${minutes}:${seconds}`
|
||||||
}
|
}
|
||||||
|
|
||||||
const repliesCount = Math.max((thread.posts_count ?? 0) - 1, 0)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bb-portal-topic-row">
|
<div className="bb-portal-topic-row">
|
||||||
<div className="bb-portal-topic-main">
|
<div className="bb-portal-topic-main">
|
||||||
@@ -39,9 +37,6 @@ export default function PortalTopicRow({ thread, forumName, forumId, showForum =
|
|||||||
<div>
|
<div>
|
||||||
<Link to={`/thread/${thread.id}`} className="bb-portal-topic-title">
|
<Link to={`/thread/${thread.id}`} className="bb-portal-topic-title">
|
||||||
{thread.title}
|
{thread.title}
|
||||||
{thread.solved && (
|
|
||||||
<span className="bb-thread-solved-badge">{t('thread.solved')}</span>
|
|
||||||
)}
|
|
||||||
</Link>
|
</Link>
|
||||||
<div className="bb-portal-topic-meta">
|
<div className="bb-portal-topic-meta">
|
||||||
<div className="bb-portal-topic-meta-line">
|
<div className="bb-portal-topic-meta-line">
|
||||||
@@ -77,7 +72,7 @@ export default function PortalTopicRow({ thread, forumName, forumId, showForum =
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="bb-portal-topic-cell">{repliesCount}</div>
|
<div className="bb-portal-topic-cell">{thread.posts_count ?? 0}</div>
|
||||||
<div className="bb-portal-topic-cell">{thread.views_count ?? 0}</div>
|
<div className="bb-portal-topic-cell">{thread.views_count ?? 0}</div>
|
||||||
<div className="bb-portal-topic-cell bb-portal-topic-cell--last">
|
<div className="bb-portal-topic-cell bb-portal-topic-cell--last">
|
||||||
<div className="bb-portal-last">
|
<div className="bb-portal-last">
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { createContext, useContext, useMemo, useState, useEffect } from 'react'
|
import { createContext, useContext, useMemo, useState, useEffect } from 'react'
|
||||||
import { login as apiLogin, logoutUser } from '../api/client'
|
import { login as apiLogin } from '../api/client'
|
||||||
|
|
||||||
const AuthContext = createContext(null)
|
const AuthContext = createContext(null)
|
||||||
|
|
||||||
@@ -46,12 +46,7 @@ export function AuthProvider({ children }) {
|
|||||||
setToken(data.token)
|
setToken(data.token)
|
||||||
setEmail(data.email || loginInput)
|
setEmail(data.email || loginInput)
|
||||||
},
|
},
|
||||||
async logout() {
|
logout() {
|
||||||
try {
|
|
||||||
await logoutUser()
|
|
||||||
} catch {
|
|
||||||
// Ignore logout failures; client state is cleared regardless.
|
|
||||||
}
|
|
||||||
localStorage.removeItem('speedbb_token')
|
localStorage.removeItem('speedbb_token')
|
||||||
localStorage.removeItem('speedbb_email')
|
localStorage.removeItem('speedbb_email')
|
||||||
localStorage.removeItem('speedbb_user_id')
|
localStorage.removeItem('speedbb_user_id')
|
||||||
|
|||||||
@@ -105,40 +105,6 @@ a {
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 1.6rem;
|
font-size: 1.6rem;
|
||||||
color: var(--bb-accent, #f29b3f);
|
color: var(--bb-accent, #f29b3f);
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.5rem;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bb-thread-solved-badge {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.25rem;
|
|
||||||
padding: 0.18rem 0.5rem;
|
|
||||||
border-radius: 999px;
|
|
||||||
background: var(--bb-accent, #f29b3f);
|
|
||||||
color: #0b0f17;
|
|
||||||
font-size: 0.75rem;
|
|
||||||
font-weight: 700;
|
|
||||||
letter-spacing: 0.04em;
|
|
||||||
text-transform: uppercase;
|
|
||||||
margin-left: 0.45rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bb-thread-solved-toggle {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.4rem;
|
|
||||||
color: var(--bb-accent, #f29b3f);
|
|
||||||
border-color: var(--bb-accent, #f29b3f);
|
|
||||||
}
|
|
||||||
|
|
||||||
.bb-thread-solved-toggle:hover,
|
|
||||||
.bb-thread-solved-toggle:focus {
|
|
||||||
background: var(--bb-accent, #f29b3f);
|
|
||||||
border-color: var(--bb-accent, #f29b3f);
|
|
||||||
color: #0b0f17;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.bb-thread-meta {
|
.bb-thread-meta {
|
||||||
@@ -170,355 +136,6 @@ a {
|
|||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.bb-thread-attachments {
|
|
||||||
border: 1px solid var(--bb-border);
|
|
||||||
border-radius: 12px;
|
|
||||||
padding: 0.8rem 1rem;
|
|
||||||
background: #141822;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bb-thread-attachments-header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 0.75rem;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--bb-ink);
|
|
||||||
margin-bottom: 0.6rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bb-thread-attachments-actions {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.6rem;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bb-thread-attachments-actions input[type='file'] {
|
|
||||||
max-width: 280px;
|
|
||||||
color: var(--bb-ink-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.bb-thread-modal.modal-dialog {
|
|
||||||
max-width: 95vw !important;
|
|
||||||
width: 95vw !important;
|
|
||||||
height: 95vh;
|
|
||||||
margin: 2.5vh auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bb-thread-modal.modal-dialog .modal-content {
|
|
||||||
height: 95vh;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bb-thread-modal.modal-dialog .modal-body {
|
|
||||||
overflow-y: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bb-lightbox-modal {
|
|
||||||
max-width: min(96vw, 1200px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.bb-lightbox-modal .modal-content {
|
|
||||||
background: rgba(12, 16, 24, 0.98);
|
|
||||||
}
|
|
||||||
|
|
||||||
.bb-lightbox-body {
|
|
||||||
position: relative;
|
|
||||||
min-height: 200px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bb-lightbox-controls {
|
|
||||||
position: absolute;
|
|
||||||
inset: 0;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
padding: 0 0.75rem;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bb-lightbox-btn {
|
|
||||||
pointer-events: auto;
|
|
||||||
border-radius: 999px;
|
|
||||||
width: 42px;
|
|
||||||
height: 42px;
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bb-attachment-list {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.35rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bb-post-content .bb-attachment-list {
|
|
||||||
margin-top: 0.4rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bb-attachment-item {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.5rem;
|
|
||||||
color: var(--bb-ink);
|
|
||||||
text-decoration: none;
|
|
||||||
padding: 0.35rem 0.5rem;
|
|
||||||
border-radius: 8px;
|
|
||||||
background: rgba(255, 255, 255, 0.04);
|
|
||||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
|
||||||
}
|
|
||||||
|
|
||||||
.bb-attachment-item .bi-paperclip {
|
|
||||||
color: var(--bb-accent, #f29b3f);
|
|
||||||
}
|
|
||||||
|
|
||||||
.bb-attachment-item:hover {
|
|
||||||
border-color: var(--bb-accent, #f29b3f);
|
|
||||||
color: var(--bb-accent, #f29b3f);
|
|
||||||
}
|
|
||||||
|
|
||||||
.bb-attachment-name {
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bb-attachment-meta {
|
|
||||||
color: var(--bb-ink-muted);
|
|
||||||
font-size: 0.8rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bb-attachment-panel {
|
|
||||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
|
||||||
border-radius: 12px;
|
|
||||||
background: rgba(18, 23, 33, 0.9);
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bb-attachment-tabs {
|
|
||||||
display: flex;
|
|
||||||
gap: 0.4rem;
|
|
||||||
padding: 0.6rem 0.8rem 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bb-attachment-tab {
|
|
||||||
border: none;
|
|
||||||
background: #1a202b;
|
|
||||||
color: var(--bb-ink-muted);
|
|
||||||
padding: 0.35rem 0.75rem;
|
|
||||||
border-radius: 8px 8px 0 0;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bb-attachment-tab.is-active {
|
|
||||||
color: var(--bb-accent, #f29b3f);
|
|
||||||
background: #202735;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bb-attachment-body {
|
|
||||||
padding: 0.8rem;
|
|
||||||
border-top: 1px solid rgba(255, 255, 255, 0.06);
|
|
||||||
}
|
|
||||||
|
|
||||||
.bb-attachment-actions {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.75rem;
|
|
||||||
margin-bottom: 0.8rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bb-attachment-drop {
|
|
||||||
border: 2px dashed color-mix(in srgb, var(--bb-accent, #f29b3f) 65%, transparent);
|
|
||||||
border-radius: 12px;
|
|
||||||
padding: 0.85rem 1rem;
|
|
||||||
background: rgba(18, 23, 33, 0.6);
|
|
||||||
color: var(--bb-ink-muted);
|
|
||||||
text-align: center;
|
|
||||||
margin-bottom: 0.8rem;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: border-color 0.2s ease, background 0.2s ease, color 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bb-attachment-drop.is-dragover {
|
|
||||||
border-color: var(--bb-accent, #f29b3f);
|
|
||||||
background: rgba(242, 155, 63, 0.12);
|
|
||||||
color: var(--bb-ink);
|
|
||||||
}
|
|
||||||
|
|
||||||
.bb-attachment-drop-link {
|
|
||||||
border: 0;
|
|
||||||
padding: 0;
|
|
||||||
background: transparent;
|
|
||||||
color: var(--bb-accent, #f29b3f);
|
|
||||||
font-weight: 600;
|
|
||||||
text-decoration: underline;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bb-attachment-drop-link:hover {
|
|
||||||
color: color-mix(in srgb, var(--bb-accent, #f29b3f) 80%, #fff);
|
|
||||||
}
|
|
||||||
|
|
||||||
.bb-attachment-input {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bb-attachment-table {
|
|
||||||
border-radius: 10px;
|
|
||||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
|
||||||
overflow: hidden;
|
|
||||||
margin-bottom: 0;
|
|
||||||
color: var(--bb-ink);
|
|
||||||
}
|
|
||||||
|
|
||||||
.bb-attachment-table thead th {
|
|
||||||
font-size: 0.7rem;
|
|
||||||
text-transform: none;
|
|
||||||
letter-spacing: 0.02em;
|
|
||||||
color: var(--bb-ink-muted);
|
|
||||||
background: rgba(255, 255, 255, 0.03);
|
|
||||||
text-align: left;
|
|
||||||
border-bottom: 0;
|
|
||||||
padding: 0.6rem 0.8rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bb-attachment-table tbody td {
|
|
||||||
border-top: 1px solid rgba(255, 255, 255, 0.06);
|
|
||||||
padding: 0.6rem 0.8rem;
|
|
||||||
vertical-align: middle;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bb-attachment-table thead th:nth-child(3),
|
|
||||||
.bb-attachment-table tbody td:nth-child(3) {
|
|
||||||
width: 90px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bb-attachment-table thead th:nth-child(4),
|
|
||||||
.bb-attachment-table tbody td:nth-child(4) {
|
|
||||||
width: 80px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bb-attachment-table thead th:nth-child(5),
|
|
||||||
.bb-attachment-table tbody td:nth-child(5) {
|
|
||||||
width: 1%;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bb-attachment-name {
|
|
||||||
color: var(--bb-accent, #f29b3f);
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bb-attachment-size {
|
|
||||||
color: var(--bb-ink-muted);
|
|
||||||
font-size: 0.85rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bb-attachment-status {
|
|
||||||
color: #8bd98b;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bb-attachment-comment {
|
|
||||||
background: #202734;
|
|
||||||
border-color: rgba(255, 255, 255, 0.08);
|
|
||||||
color: var(--bb-ink);
|
|
||||||
font-size: 0.85rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bb-attachment-row-actions {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0;
|
|
||||||
justify-content: flex-end;
|
|
||||||
background: var(--bb-accent, #f29b3f);
|
|
||||||
border-radius: 10px;
|
|
||||||
padding: 0.2rem;
|
|
||||||
width: fit-content;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bb-attachment-action {
|
|
||||||
border: 0;
|
|
||||||
background: transparent;
|
|
||||||
color: #0e121b;
|
|
||||||
width: 36px;
|
|
||||||
height: 32px;
|
|
||||||
border-radius: 8px;
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
font-size: 0.95rem;
|
|
||||||
transition: transform 0.15s ease, border-color 0.15s ease, background 0.15s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bb-attachment-action:hover {
|
|
||||||
transform: translateY(-1px);
|
|
||||||
background: color-mix(in srgb, #fff 18%, transparent);
|
|
||||||
color: #0e121b;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bb-attachment-remove {
|
|
||||||
border: none;
|
|
||||||
background: rgba(255, 255, 255, 0.06);
|
|
||||||
color: var(--bb-ink-muted);
|
|
||||||
width: 32px;
|
|
||||||
height: 28px;
|
|
||||||
border-radius: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bb-attachment-remove:hover {
|
|
||||||
color: #f07f7f;
|
|
||||||
background: rgba(240, 127, 127, 0.12);
|
|
||||||
}
|
|
||||||
|
|
||||||
.bb-attachment-empty {
|
|
||||||
padding: 0.8rem;
|
|
||||||
color: var(--bb-ink-muted);
|
|
||||||
font-size: 0.85rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bb-attachment-options {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.6rem;
|
|
||||||
padding: 0.2rem 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bb-attachment-options .form-check-label {
|
|
||||||
color: var(--bb-ink);
|
|
||||||
}
|
|
||||||
|
|
||||||
.bb-attachment-options .form-check-input {
|
|
||||||
background-color: #1a202b;
|
|
||||||
border-color: rgba(255, 255, 255, 0.2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.bb-attachment-options .form-check-input:checked {
|
|
||||||
background-color: var(--bb-accent, #f29b3f);
|
|
||||||
border-color: var(--bb-accent, #f29b3f);
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-control:focus,
|
|
||||||
.form-select:focus,
|
|
||||||
.form-check-input:focus {
|
|
||||||
border-color: var(--bb-accent, #f29b3f);
|
|
||||||
box-shadow: 0 0 0 0.2rem color-mix(in srgb, var(--bb-accent, #f29b3f) 30%, transparent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.tr-header {
|
|
||||||
border-bottom: 3px solid var(--bb-accent, #f29b3f);
|
|
||||||
}
|
|
||||||
|
|
||||||
.tr-header th {
|
|
||||||
border-bottom: 3px solid var(--bb-accent, #f29b3f);
|
|
||||||
}
|
|
||||||
|
|
||||||
.rdt_TableHeadRow {
|
|
||||||
border-bottom: 3px solid var(--bb-accent, #f29b3f);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
.bb-thread-actions {
|
.bb-thread-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -700,9 +317,6 @@ a {
|
|||||||
|
|
||||||
.bb-post-content {
|
.bb-post-content {
|
||||||
padding: 1rem 1.35rem 1.2rem;
|
padding: 1rem 1.35rem 1.2rem;
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.8rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.bb-post-header {
|
.bb-post-header {
|
||||||
@@ -764,40 +378,21 @@ a {
|
|||||||
|
|
||||||
.bb-post-content {
|
.bb-post-content {
|
||||||
position: relative;
|
position: relative;
|
||||||
|
padding-bottom: 3.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.bb-post-body {
|
.bb-post-body {
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
color: var(--bb-ink);
|
color: var(--bb-ink);
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
flex: 1 1 auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bb-post-body blockquote {
|
|
||||||
margin: 1rem 0;
|
|
||||||
padding: 0.75rem 1rem;
|
|
||||||
border-left: 3px solid var(--bb-accent, #f29b3f);
|
|
||||||
background: rgba(255, 255, 255, 0.04);
|
|
||||||
color: var(--bb-ink-muted);
|
|
||||||
border-radius: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bb-post-body blockquote > cite {
|
|
||||||
display: block;
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--bb-ink);
|
|
||||||
margin-bottom: 0.35rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bb-post-body blockquote > div {
|
|
||||||
color: var(--bb-ink);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.bb-post-footer {
|
.bb-post-footer {
|
||||||
|
position: absolute;
|
||||||
|
right: 1rem;
|
||||||
|
bottom: 1rem;
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
margin-top: auto;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.bb-thread-reply {
|
.bb-thread-reply {
|
||||||
@@ -862,8 +457,8 @@ a {
|
|||||||
}
|
}
|
||||||
|
|
||||||
[data-bs-theme="dark"] {
|
[data-bs-theme="dark"] {
|
||||||
--bb-ink: #aaaeb4;
|
--bb-ink: #e6e8eb;
|
||||||
--bb-ink-muted: #6b7483;
|
--bb-ink-muted: #9aa4b2;
|
||||||
--bb-border: #2a2f3a;
|
--bb-border: #2a2f3a;
|
||||||
--bb-page-bg: radial-gradient(circle at 10% 20%, #141823 0%, #10131a 45%, #0b0e14 100%);
|
--bb-page-bg: radial-gradient(circle at 10% 20%, #141823 0%, #10131a 45%, #0b0e14 100%);
|
||||||
}
|
}
|
||||||
@@ -922,18 +517,10 @@ a {
|
|||||||
|
|
||||||
.nav-tabs .nav-link {
|
.nav-tabs .nav-link {
|
||||||
color: var(--bb-accent, #f29b3f);
|
color: var(--bb-accent, #f29b3f);
|
||||||
border: 1px solid var(--bb-border);
|
|
||||||
border-bottom-color: transparent;
|
|
||||||
border-radius: 10px 10px 0 0;
|
|
||||||
margin-right: 0.35rem;
|
|
||||||
background: transparent;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-tabs .nav-link.active {
|
.nav-tabs .nav-link.active {
|
||||||
color: inherit;
|
color: inherit;
|
||||||
background: rgba(255, 255, 255, 0.04);
|
|
||||||
border-color: var(--bb-border);
|
|
||||||
border-bottom-color: transparent;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.bb-version {
|
.bb-version {
|
||||||
@@ -2184,14 +1771,6 @@ a {
|
|||||||
color: #0e121b;
|
color: #0e121b;
|
||||||
}
|
}
|
||||||
|
|
||||||
.bb-accent-button:disabled,
|
|
||||||
.bb-accent-button.disabled {
|
|
||||||
background: var(--bb-accent, #f29b3f);
|
|
||||||
border-color: var(--bb-accent, #f29b3f);
|
|
||||||
color: #0e121b;
|
|
||||||
opacity: 0.6;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-content .modal-header {
|
.modal-content .modal-header {
|
||||||
background: #0f1218;
|
background: #0f1218;
|
||||||
color: #e6e8eb;
|
color: #e6e8eb;
|
||||||
@@ -2238,219 +1817,9 @@ a {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.bb-acp {
|
.bb-acp {
|
||||||
max-width: 100%;
|
max-width: 1880px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.bb-acp-sidebar {
|
|
||||||
position: sticky;
|
|
||||||
top: 1rem;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bb-acp-sidebar-section {
|
|
||||||
background: rgba(16, 20, 30, 0.7);
|
|
||||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
|
||||||
border-radius: 10px;
|
|
||||||
padding: 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bb-acp-sidebar-title {
|
|
||||||
font-size: 0.75rem;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.08em;
|
|
||||||
color: var(--bb-ink-muted);
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bb-acp-sidebar .list-group-item {
|
|
||||||
background: transparent;
|
|
||||||
color: var(--bb-ink);
|
|
||||||
border: 0;
|
|
||||||
padding: 0.35rem 0.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bb-acp-sidebar .list-group-item.is-active,
|
|
||||||
.bb-acp-sidebar .list-group-item:hover {
|
|
||||||
color: var(--bb-accent, #f29b3f);
|
|
||||||
}
|
|
||||||
|
|
||||||
.bb-acp-panel {
|
|
||||||
background: rgba(18, 23, 33, 0.8);
|
|
||||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
|
||||||
border-radius: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bb-acp-panel-header {
|
|
||||||
padding: 0.9rem 1rem;
|
|
||||||
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
|
|
||||||
}
|
|
||||||
|
|
||||||
.bb-acp-panel-body {
|
|
||||||
padding: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bb-acp-stats-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
|
||||||
gap: 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bb-acp-stats-table {
|
|
||||||
width: 100%;
|
|
||||||
border-radius: 10px;
|
|
||||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
|
||||||
background: rgba(12, 16, 24, 0.6);
|
|
||||||
overflow: hidden;
|
|
||||||
border-collapse: collapse;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bb-acp-stats-table th {
|
|
||||||
text-align: left;
|
|
||||||
padding: 0.55rem 0.75rem;
|
|
||||||
font-size: 0.72rem;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.08em;
|
|
||||||
color: var(--bb-ink-muted);
|
|
||||||
background: rgba(255, 255, 255, 0.04);
|
|
||||||
}
|
|
||||||
|
|
||||||
.bb-acp-stats-table td {
|
|
||||||
padding: 0.6rem 0.75rem;
|
|
||||||
border-top: 1px solid rgba(255, 255, 255, 0.06);
|
|
||||||
color: var(--bb-ink);
|
|
||||||
}
|
|
||||||
|
|
||||||
.bb-acp-stats-table tbody tr:nth-child(even) {
|
|
||||||
background: rgba(255, 255, 255, 0.03);
|
|
||||||
}
|
|
||||||
|
|
||||||
.bb-acp-stats-value {
|
|
||||||
text-align: right;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bb-acp-version-inline {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.75rem;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bb-acp-version-link {
|
|
||||||
color: var(--bb-accent, #f29b3f);
|
|
||||||
text-decoration: none;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bb-acp-version-link:hover {
|
|
||||||
text-decoration: underline;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bb-acp-version-meta {
|
|
||||||
color: var(--bb-ink-muted);
|
|
||||||
font-size: 0.85rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bb-acp-update-log {
|
|
||||||
max-height: 240px;
|
|
||||||
overflow: auto;
|
|
||||||
background: rgba(12, 16, 24, 0.7);
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 0.75rem;
|
|
||||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
|
||||||
color: var(--bb-ink);
|
|
||||||
font-size: 0.85rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bb-status-icon {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
font-size: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bb-status-icon.is-ok {
|
|
||||||
color: #22c55e;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bb-status-icon.is-bad {
|
|
||||||
color: #ef4444;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bb-status-icon.is-warn {
|
|
||||||
color: #f59e0b;
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-bs-theme="light"] .bb-acp-stats-table {
|
|
||||||
background: #ffffff;
|
|
||||||
border-color: rgba(14, 18, 27, 0.12);
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-bs-theme="light"] .bb-acp-stats-table th {
|
|
||||||
background: rgba(14, 18, 27, 0.04);
|
|
||||||
color: #5b6678;
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-bs-theme="light"] .bb-acp-stats-table td {
|
|
||||||
border-top-color: rgba(14, 18, 27, 0.06);
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-bs-theme="light"] .bb-acp-stats-table tbody tr:nth-child(even) {
|
|
||||||
background: rgba(14, 18, 27, 0.02);
|
|
||||||
}
|
|
||||||
|
|
||||||
.bb-acp-admin-log__table {
|
|
||||||
width: 100%;
|
|
||||||
border-collapse: collapse;
|
|
||||||
border-radius: 10px;
|
|
||||||
overflow: hidden;
|
|
||||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
|
||||||
background: rgba(12, 16, 24, 0.6);
|
|
||||||
}
|
|
||||||
|
|
||||||
.bb-acp-admin-log__table th {
|
|
||||||
text-align: left;
|
|
||||||
padding: 0.55rem 0.75rem;
|
|
||||||
font-size: 0.72rem;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.08em;
|
|
||||||
color: var(--bb-ink-muted);
|
|
||||||
background: rgba(255, 255, 255, 0.04);
|
|
||||||
}
|
|
||||||
|
|
||||||
.bb-acp-admin-log__table td {
|
|
||||||
padding: 0.6rem 0.75rem;
|
|
||||||
border-top: 1px solid rgba(255, 255, 255, 0.06);
|
|
||||||
color: var(--bb-ink);
|
|
||||||
}
|
|
||||||
|
|
||||||
.bb-acp-admin-log__table tbody tr:nth-child(even) {
|
|
||||||
background: rgba(255, 255, 255, 0.03);
|
|
||||||
}
|
|
||||||
|
|
||||||
.bb-acp-admin-log__table tfoot td {
|
|
||||||
text-align: right;
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-bs-theme="light"] .bb-acp-admin-log__table {
|
|
||||||
background: #ffffff;
|
|
||||||
border-color: rgba(14, 18, 27, 0.12);
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-bs-theme="light"] .bb-acp-admin-log__table th {
|
|
||||||
background: rgba(14, 18, 27, 0.04);
|
|
||||||
color: #5b6678;
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-bs-theme="light"] .bb-acp-admin-log__table td {
|
|
||||||
border-top-color: rgba(14, 18, 27, 0.06);
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-bs-theme="light"] .bb-acp-admin-log__table tbody tr:nth-child(even) {
|
|
||||||
background: rgba(14, 18, 27, 0.02);
|
|
||||||
}
|
|
||||||
.bb-icon {
|
.bb-icon {
|
||||||
width: 44px;
|
width: 44px;
|
||||||
height: 44px;
|
height: 44px;
|
||||||
@@ -2518,104 +1887,6 @@ a {
|
|||||||
gap: 0.6rem;
|
gap: 0.6rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.bb-attachment-type-main {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bb-attachment-type-title {
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--bb-ink);
|
|
||||||
}
|
|
||||||
|
|
||||||
.bb-attachment-type-meta {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 0.4rem;
|
|
||||||
color: var(--bb-ink-muted);
|
|
||||||
font-size: 0.85rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bb-attachment-type-rules {
|
|
||||||
margin-top: 0.2rem;
|
|
||||||
color: var(--bb-ink-muted);
|
|
||||||
font-size: 0.8rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bb-attachment-admin {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bb-attachment-extension-form {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: minmax(160px, 1fr) minmax(200px, 1.2fr) minmax(160px, 1fr) auto;
|
|
||||||
gap: 0.75rem;
|
|
||||||
align-items: center;
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bb-attachment-extension-table {
|
|
||||||
border-radius: 12px;
|
|
||||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
|
||||||
overflow: hidden;
|
|
||||||
background: rgba(18, 23, 33, 0.8);
|
|
||||||
}
|
|
||||||
|
|
||||||
.bb-attachment-extension-table table {
|
|
||||||
margin-bottom: 0;
|
|
||||||
color: var(--bb-ink);
|
|
||||||
}
|
|
||||||
|
|
||||||
.bb-attachment-extension-table thead th {
|
|
||||||
font-size: 0.8rem;
|
|
||||||
color: var(--bb-ink-muted);
|
|
||||||
background: rgba(15, 19, 27, 0.7);
|
|
||||||
border-bottom: 0;
|
|
||||||
padding: 0.55rem 0.8rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bb-attachment-extension-table tbody td {
|
|
||||||
border-top: 1px solid rgba(255, 255, 255, 0.08);
|
|
||||||
padding: 0.55rem 0.8rem;
|
|
||||||
vertical-align: middle;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bb-attachment-extension-name {
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--bb-ink);
|
|
||||||
}
|
|
||||||
|
|
||||||
.bb-attachment-extension-actions {
|
|
||||||
display: flex;
|
|
||||||
justify-content: flex-end;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bb-attachment-extension-meta {
|
|
||||||
color: var(--bb-ink-muted);
|
|
||||||
font-size: 0.9rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bb-attachment-tree-toggle {
|
|
||||||
cursor: pointer;
|
|
||||||
flex: 1 1 auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bb-attachment-tree-toggle:focus-visible {
|
|
||||||
outline: 2px solid color-mix(in srgb, var(--bb-accent, #f29b3f) 70%, #000);
|
|
||||||
outline-offset: 2px;
|
|
||||||
border-radius: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 900px) {
|
|
||||||
.bb-attachment-extension-form {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.bb-rank-main img {
|
.bb-rank-main img {
|
||||||
height: 22px;
|
height: 22px;
|
||||||
width: auto;
|
width: auto;
|
||||||
@@ -2809,10 +2080,6 @@ a {
|
|||||||
max-width: 320px;
|
max-width: 320px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.bb-audit-limit {
|
|
||||||
max-width: 120px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bb-sort-label {
|
.bb-sort-label {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -2883,7 +2150,6 @@ a {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
.bb-collapse-toggle {
|
.bb-collapse-toggle {
|
||||||
width: 20px;
|
width: 20px;
|
||||||
height: 20px;
|
height: 20px;
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -96,48 +96,7 @@ export default function BoardIndex() {
|
|||||||
nodes.forEach((node) => sortNodes(node.children))
|
nodes.forEach((node) => sortNodes(node.children))
|
||||||
}
|
}
|
||||||
|
|
||||||
const aggregateNodes = (node) => {
|
|
||||||
if (!node.children?.length) {
|
|
||||||
return {
|
|
||||||
threads: node.threads_count ?? 0,
|
|
||||||
views: node.views_count ?? 0,
|
|
||||||
posts: node.posts_count ?? 0,
|
|
||||||
last: node.last_post_at ? { at: node.last_post_at, node } : null,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let threads = node.threads_count ?? 0
|
|
||||||
let views = node.views_count ?? 0
|
|
||||||
let posts = node.posts_count ?? 0
|
|
||||||
let last = node.last_post_at ? { at: node.last_post_at, node } : null
|
|
||||||
|
|
||||||
node.children.forEach((child) => {
|
|
||||||
const agg = aggregateNodes(child)
|
|
||||||
threads += agg.threads
|
|
||||||
views += agg.views
|
|
||||||
posts += agg.posts
|
|
||||||
if (agg.last && (!last || agg.last.at > last.at)) {
|
|
||||||
last = agg.last
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
node.threads_count = threads
|
|
||||||
node.views_count = views
|
|
||||||
node.posts_count = posts
|
|
||||||
if (last) {
|
|
||||||
const source = last.node
|
|
||||||
node.last_post_at = source.last_post_at
|
|
||||||
node.last_post_user_id = source.last_post_user_id
|
|
||||||
node.last_post_user_name = source.last_post_user_name
|
|
||||||
node.last_post_user_rank_color = source.last_post_user_rank_color
|
|
||||||
node.last_post_user_group_color = source.last_post_user_group_color
|
|
||||||
}
|
|
||||||
|
|
||||||
return { threads, views, posts, last }
|
|
||||||
}
|
|
||||||
|
|
||||||
sortNodes(roots)
|
sortNodes(roots)
|
||||||
roots.forEach((root) => aggregateNodes(root))
|
|
||||||
|
|
||||||
return roots
|
return roots
|
||||||
}, [forums])
|
}, [forums])
|
||||||
|
|||||||
@@ -1,15 +1,7 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { Button, Badge, Card, Col, Container, Form, Modal, Row } from 'react-bootstrap'
|
import { Button, Badge, Card, Col, Container, Form, Modal, Row } from 'react-bootstrap'
|
||||||
import { Link, useParams } from 'react-router-dom'
|
import { Link, useParams } from 'react-router-dom'
|
||||||
import {
|
import { createThread, getForum, listForumsByParent, listThreadsByForum } from '../api/client'
|
||||||
createThread,
|
|
||||||
getForum,
|
|
||||||
listAllForums,
|
|
||||||
listThreadsByForum,
|
|
||||||
uploadAttachment,
|
|
||||||
listAttachmentExtensionsPublic,
|
|
||||||
previewBbcode,
|
|
||||||
} from '../api/client'
|
|
||||||
import PortalTopicRow from '../components/PortalTopicRow'
|
import PortalTopicRow from '../components/PortalTopicRow'
|
||||||
import { useAuth } from '../context/AuthContext'
|
import { useAuth } from '../context/AuthContext'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
@@ -26,24 +18,6 @@ export default function ForumView() {
|
|||||||
const [title, setTitle] = useState('')
|
const [title, setTitle] = useState('')
|
||||||
const [body, setBody] = useState('')
|
const [body, setBody] = useState('')
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
const [threadFiles, setThreadFiles] = useState([])
|
|
||||||
const [uploading, setUploading] = useState(false)
|
|
||||||
const [allowedAttachmentExtensions, setAllowedAttachmentExtensions] = useState([])
|
|
||||||
const [attachmentValidationError, setAttachmentValidationError] = useState('')
|
|
||||||
const [threadDropActive, setThreadDropActive] = useState(false)
|
|
||||||
const [showPreview, setShowPreview] = useState(false)
|
|
||||||
const [previewHtml, setPreviewHtml] = useState('')
|
|
||||||
const [previewLoading, setPreviewLoading] = useState(false)
|
|
||||||
const [previewUrls, setPreviewUrls] = useState([])
|
|
||||||
const [attachmentTab, setAttachmentTab] = useState('options')
|
|
||||||
const [attachmentOptions, setAttachmentOptions] = useState({
|
|
||||||
disableBbcode: false,
|
|
||||||
disableSmilies: false,
|
|
||||||
disableAutoUrls: false,
|
|
||||||
attachSignature: true,
|
|
||||||
notifyReplies: false,
|
|
||||||
lockTopic: false,
|
|
||||||
})
|
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
|
|
||||||
const renderChildRows = (nodes) =>
|
const renderChildRows = (nodes) =>
|
||||||
@@ -97,77 +71,6 @@ export default function ForumView() {
|
|||||||
</div>
|
</div>
|
||||||
))
|
))
|
||||||
|
|
||||||
const getParentId = (node) => {
|
|
||||||
if (!node.parent) return null
|
|
||||||
if (typeof node.parent === 'string') {
|
|
||||||
return node.parent.split('/').pop()
|
|
||||||
}
|
|
||||||
return node.parent.id ?? null
|
|
||||||
}
|
|
||||||
|
|
||||||
const buildForumTree = (allForums) => {
|
|
||||||
const map = new Map()
|
|
||||||
const roots = []
|
|
||||||
|
|
||||||
allForums.forEach((item) => {
|
|
||||||
map.set(String(item.id), { ...item, children: [] })
|
|
||||||
})
|
|
||||||
|
|
||||||
allForums.forEach((item) => {
|
|
||||||
const parentId = getParentId(item)
|
|
||||||
const node = map.get(String(item.id))
|
|
||||||
if (parentId && map.has(String(parentId))) {
|
|
||||||
map.get(String(parentId)).children.push(node)
|
|
||||||
} else {
|
|
||||||
roots.push(node)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const aggregateNodes = (node) => {
|
|
||||||
if (!node.children?.length) {
|
|
||||||
return {
|
|
||||||
threads: node.threads_count ?? 0,
|
|
||||||
views: node.views_count ?? 0,
|
|
||||||
posts: node.posts_count ?? 0,
|
|
||||||
last: node.last_post_at ? { at: node.last_post_at, node } : null,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let threads = node.threads_count ?? 0
|
|
||||||
let views = node.views_count ?? 0
|
|
||||||
let posts = node.posts_count ?? 0
|
|
||||||
let last = node.last_post_at ? { at: node.last_post_at, node } : null
|
|
||||||
|
|
||||||
node.children.forEach((child) => {
|
|
||||||
const agg = aggregateNodes(child)
|
|
||||||
threads += agg.threads
|
|
||||||
views += agg.views
|
|
||||||
posts += agg.posts
|
|
||||||
if (agg.last && (!last || agg.last.at > last.at)) {
|
|
||||||
last = agg.last
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
node.threads_count = threads
|
|
||||||
node.views_count = views
|
|
||||||
node.posts_count = posts
|
|
||||||
if (last) {
|
|
||||||
const source = last.node
|
|
||||||
node.last_post_at = source.last_post_at
|
|
||||||
node.last_post_user_id = source.last_post_user_id
|
|
||||||
node.last_post_user_name = source.last_post_user_name
|
|
||||||
node.last_post_user_rank_color = source.last_post_user_rank_color
|
|
||||||
node.last_post_user_group_color = source.last_post_user_group_color
|
|
||||||
}
|
|
||||||
|
|
||||||
return { threads, views, posts, last }
|
|
||||||
}
|
|
||||||
|
|
||||||
roots.forEach((root) => aggregateNodes(root))
|
|
||||||
|
|
||||||
return map
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let active = true
|
let active = true
|
||||||
|
|
||||||
@@ -178,11 +81,9 @@ export default function ForumView() {
|
|||||||
const forumData = await getForum(id)
|
const forumData = await getForum(id)
|
||||||
if (!active) return
|
if (!active) return
|
||||||
setForum(forumData)
|
setForum(forumData)
|
||||||
const allForums = await listAllForums()
|
const childData = await listForumsByParent(id)
|
||||||
if (!active) return
|
if (!active) return
|
||||||
const treeMap = buildForumTree(allForums)
|
setChildren(childData)
|
||||||
const currentNode = treeMap.get(String(forumData.id))
|
|
||||||
setChildren(currentNode?.children ?? [])
|
|
||||||
if (forumData.type === 'forum') {
|
if (forumData.type === 'forum') {
|
||||||
const threadData = await listThreadsByForum(id)
|
const threadData = await listThreadsByForum(id)
|
||||||
if (!active) return
|
if (!active) return
|
||||||
@@ -204,388 +105,24 @@ export default function ForumView() {
|
|||||||
}
|
}
|
||||||
}, [id])
|
}, [id])
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
listAttachmentExtensionsPublic()
|
|
||||||
.then((data) => {
|
|
||||||
if (Array.isArray(data)) {
|
|
||||||
setAllowedAttachmentExtensions(data.map((item) => String(item).toLowerCase()))
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch(() => {})
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const handleSubmit = async (event) => {
|
const handleSubmit = async (event) => {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
setSaving(true)
|
setSaving(true)
|
||||||
setError('')
|
setError('')
|
||||||
try {
|
try {
|
||||||
const created = await createThread({ title, body, forumId: id })
|
await createThread({ title, body, forumId: id })
|
||||||
if (threadFiles.length > 0 && created?.id) {
|
|
||||||
setUploading(true)
|
|
||||||
for (const entry of threadFiles) {
|
|
||||||
await uploadAttachment({ threadId: created.id, file: entry.file })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
setTitle('')
|
setTitle('')
|
||||||
setBody('')
|
setBody('')
|
||||||
setThreadFiles([])
|
|
||||||
const updated = await listThreadsByForum(id)
|
const updated = await listThreadsByForum(id)
|
||||||
setThreads(updated)
|
setThreads(updated)
|
||||||
setShowModal(false)
|
setShowModal(false)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err.message)
|
setError(err.message)
|
||||||
} finally {
|
} finally {
|
||||||
setUploading(false)
|
|
||||||
setSaving(false)
|
setSaving(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const formatBytes = (bytes) => {
|
|
||||||
if (!bytes && bytes !== 0) return ''
|
|
||||||
if (bytes < 1024) return `${bytes} B`
|
|
||||||
const kb = bytes / 1024
|
|
||||||
if (kb < 1024) return `${kb.toFixed(1)} KB`
|
|
||||||
const mb = kb / 1024
|
|
||||||
return `${mb.toFixed(1)} MB`
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleInlineInsert = (entry) => {
|
|
||||||
const marker = `[attachment]${entry.file.name}[/attachment]`
|
|
||||||
setBody((prev) => (prev ? `${prev}\n${marker}` : marker))
|
|
||||||
}
|
|
||||||
|
|
||||||
const clearPreviewUrls = () => {
|
|
||||||
previewUrls.forEach((url) => URL.revokeObjectURL(url))
|
|
||||||
setPreviewUrls([])
|
|
||||||
}
|
|
||||||
|
|
||||||
const buildPreviewBody = (rawBody, entries) => {
|
|
||||||
if (!entries || entries.length === 0) {
|
|
||||||
return { body: rawBody, urls: [] }
|
|
||||||
}
|
|
||||||
|
|
||||||
const urls = []
|
|
||||||
const map = new Map()
|
|
||||||
entries.forEach((entry) => {
|
|
||||||
const file = entry.file
|
|
||||||
if (!file) return
|
|
||||||
const url = URL.createObjectURL(file)
|
|
||||||
urls.push(url)
|
|
||||||
map.set(String(file.name || '').toLowerCase(), { url, mime: file.type || '' })
|
|
||||||
})
|
|
||||||
|
|
||||||
const replaced = rawBody.replace(/\[attachment\](.+?)\[\/attachment\]/gi, (match, name) => {
|
|
||||||
const key = String(name || '').trim().toLowerCase()
|
|
||||||
if (!map.has(key)) return match
|
|
||||||
const { url, mime } = map.get(key)
|
|
||||||
if (mime.startsWith('image/')) {
|
|
||||||
return `[img]${url}[/img]`
|
|
||||||
}
|
|
||||||
return `[url=${url}]${name}[/url]`
|
|
||||||
})
|
|
||||||
|
|
||||||
return { body: replaced, urls }
|
|
||||||
}
|
|
||||||
|
|
||||||
const handlePreview = async () => {
|
|
||||||
setPreviewLoading(true)
|
|
||||||
setError('')
|
|
||||||
try {
|
|
||||||
clearPreviewUrls()
|
|
||||||
const { body: previewBody, urls } = buildPreviewBody(body || '', threadFiles)
|
|
||||||
const result = await previewBbcode(previewBody || '')
|
|
||||||
setPreviewHtml(result?.html || '')
|
|
||||||
setShowPreview(true)
|
|
||||||
setPreviewUrls(urls)
|
|
||||||
} catch (err) {
|
|
||||||
setError(err.message)
|
|
||||||
} finally {
|
|
||||||
setPreviewLoading(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const applyThreadFiles = (files) => {
|
|
||||||
const fileList = Array.from(files || [])
|
|
||||||
const allowed = allowedAttachmentExtensions
|
|
||||||
const rejected = []
|
|
||||||
const accepted = fileList.filter((file) => {
|
|
||||||
const ext = file.name.split('.').pop()?.toLowerCase() || ''
|
|
||||||
if (!ext || (allowed.length > 0 && !allowed.includes(ext))) {
|
|
||||||
rejected.push(file.name)
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
})
|
|
||||||
|
|
||||||
if (rejected.length > 0) {
|
|
||||||
setAttachmentValidationError(
|
|
||||||
t('attachment.invalid_extensions', { names: rejected.join(', ') })
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
setAttachmentValidationError('')
|
|
||||||
}
|
|
||||||
|
|
||||||
setThreadFiles(
|
|
||||||
accepted.map((file) => ({
|
|
||||||
id: `${file.name}-${file.lastModified}`,
|
|
||||||
file,
|
|
||||||
comment: '',
|
|
||||||
}))
|
|
||||||
)
|
|
||||||
setAttachmentTab('attachments')
|
|
||||||
}
|
|
||||||
|
|
||||||
const appendThreadFiles = (files) => {
|
|
||||||
const fileList = Array.from(files || [])
|
|
||||||
const allowed = allowedAttachmentExtensions
|
|
||||||
const rejected = []
|
|
||||||
const accepted = fileList.filter((file) => {
|
|
||||||
const ext = file.name.split('.').pop()?.toLowerCase() || ''
|
|
||||||
if (!ext || (allowed.length > 0 && !allowed.includes(ext))) {
|
|
||||||
rejected.push(file.name)
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
})
|
|
||||||
|
|
||||||
if (rejected.length > 0) {
|
|
||||||
setAttachmentValidationError(
|
|
||||||
t('attachment.invalid_extensions', { names: rejected.join(', ') })
|
|
||||||
)
|
|
||||||
} else if (accepted.length > 0) {
|
|
||||||
setAttachmentValidationError('')
|
|
||||||
}
|
|
||||||
|
|
||||||
if (accepted.length === 0) return
|
|
||||||
setThreadFiles((prev) => [
|
|
||||||
...prev,
|
|
||||||
...accepted.map((file) => ({
|
|
||||||
id: `${file.name}-${file.lastModified}`,
|
|
||||||
file,
|
|
||||||
comment: '',
|
|
||||||
})),
|
|
||||||
])
|
|
||||||
setAttachmentTab('attachments')
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleThreadPaste = (event) => {
|
|
||||||
const items = Array.from(event.clipboardData?.items || [])
|
|
||||||
if (items.length === 0) return
|
|
||||||
const imageItems = items.filter((item) => item.type?.startsWith('image/'))
|
|
||||||
if (imageItems.length === 0) return
|
|
||||||
event.preventDefault()
|
|
||||||
const files = imageItems
|
|
||||||
.map((item) => item.getAsFile())
|
|
||||||
.filter(Boolean)
|
|
||||||
.map((file) => {
|
|
||||||
const ext = file.type?.split('/')[1] || 'png'
|
|
||||||
const name = `pasted-${Date.now()}-${Math.floor(Math.random() * 1000)}.${ext}`
|
|
||||||
return new File([file], name, { type: file.type })
|
|
||||||
})
|
|
||||||
appendThreadFiles(files)
|
|
||||||
if (files.length > 0) {
|
|
||||||
const marker = `[attachment]${files[0].name}[/attachment]`
|
|
||||||
setBody((prev) => (prev ? `${prev}\n${marker}` : marker))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const renderAttachmentFooter = () => (
|
|
||||||
<div className="bb-attachment-panel">
|
|
||||||
<div className="bb-attachment-tabs">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={`bb-attachment-tab ${attachmentTab === 'options' ? 'is-active' : ''}`}
|
|
||||||
onClick={() => setAttachmentTab('options')}
|
|
||||||
>
|
|
||||||
{t('attachment.tab_options')}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={`bb-attachment-tab ${attachmentTab === 'attachments' ? 'is-active' : ''}`}
|
|
||||||
onClick={() => setAttachmentTab('attachments')}
|
|
||||||
>
|
|
||||||
{t('attachment.tab_attachments')}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div className="bb-attachment-body">
|
|
||||||
{attachmentTab === 'options' && (
|
|
||||||
<div className="bb-attachment-options">
|
|
||||||
<Form.Check
|
|
||||||
type="checkbox"
|
|
||||||
id="bb-option-disable-bbcode"
|
|
||||||
label={t('attachment.option_disable_bbcode')}
|
|
||||||
checked={attachmentOptions.disableBbcode}
|
|
||||||
onChange={(event) =>
|
|
||||||
setAttachmentOptions((prev) => ({
|
|
||||||
...prev,
|
|
||||||
disableBbcode: event.target.checked,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Form.Check
|
|
||||||
type="checkbox"
|
|
||||||
id="bb-option-disable-smilies"
|
|
||||||
label={t('attachment.option_disable_smilies')}
|
|
||||||
checked={attachmentOptions.disableSmilies}
|
|
||||||
onChange={(event) =>
|
|
||||||
setAttachmentOptions((prev) => ({
|
|
||||||
...prev,
|
|
||||||
disableSmilies: event.target.checked,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Form.Check
|
|
||||||
type="checkbox"
|
|
||||||
id="bb-option-disable-auto-urls"
|
|
||||||
label={t('attachment.option_disable_auto_urls')}
|
|
||||||
checked={attachmentOptions.disableAutoUrls}
|
|
||||||
onChange={(event) =>
|
|
||||||
setAttachmentOptions((prev) => ({
|
|
||||||
...prev,
|
|
||||||
disableAutoUrls: event.target.checked,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Form.Check
|
|
||||||
type="checkbox"
|
|
||||||
id="bb-option-attach-signature"
|
|
||||||
label={t('attachment.option_attach_signature')}
|
|
||||||
checked={attachmentOptions.attachSignature}
|
|
||||||
onChange={(event) =>
|
|
||||||
setAttachmentOptions((prev) => ({
|
|
||||||
...prev,
|
|
||||||
attachSignature: event.target.checked,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Form.Check
|
|
||||||
type="checkbox"
|
|
||||||
id="bb-option-notify-replies"
|
|
||||||
label={t('attachment.option_notify_replies')}
|
|
||||||
checked={attachmentOptions.notifyReplies}
|
|
||||||
onChange={(event) =>
|
|
||||||
setAttachmentOptions((prev) => ({
|
|
||||||
...prev,
|
|
||||||
notifyReplies: event.target.checked,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Form.Check
|
|
||||||
type="checkbox"
|
|
||||||
id="bb-option-lock-topic"
|
|
||||||
label={t('attachment.option_lock_topic')}
|
|
||||||
checked={attachmentOptions.lockTopic}
|
|
||||||
onChange={(event) =>
|
|
||||||
setAttachmentOptions((prev) => ({
|
|
||||||
...prev,
|
|
||||||
lockTopic: event.target.checked,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{attachmentTab === 'attachments' && (
|
|
||||||
<>
|
|
||||||
<p className="bb-muted mb-2">
|
|
||||||
{t('attachment.hint')}
|
|
||||||
</p>
|
|
||||||
<p className="bb-muted mb-3">
|
|
||||||
{t('attachment.max_size', { size: '25 MB' })}
|
|
||||||
</p>
|
|
||||||
<div className="bb-attachment-actions">
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
size="sm"
|
|
||||||
variant="outline-secondary"
|
|
||||||
onClick={() => document.getElementById('bb-thread-attachment-input')?.click()}
|
|
||||||
>
|
|
||||||
{t('attachment.add_files')}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
{attachmentValidationError && (
|
|
||||||
<p className="text-danger mb-2">{attachmentValidationError}</p>
|
|
||||||
)}
|
|
||||||
<table className="table bb-attachment-table">
|
|
||||||
<thead className="tr-header">
|
|
||||||
<tr>
|
|
||||||
<th scope="col" className="text-start">{t('attachment.filename')}</th>
|
|
||||||
<th scope="col" className="text-start">{t('attachment.file_comment')}</th>
|
|
||||||
<th scope="col" className="text-start">{t('attachment.size')}</th>
|
|
||||||
<th scope="col" className="text-start">{t('attachment.status')}</th>
|
|
||||||
<th scope="col" className="text-start">{t('attachment.actions')}</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{threadFiles.length === 0 && (
|
|
||||||
<tr>
|
|
||||||
<td colSpan={5} className="bb-attachment-empty">
|
|
||||||
{t('attachment.empty')}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
)}
|
|
||||||
{threadFiles.map((entry) => (
|
|
||||||
<tr key={entry.id} className="bb-attachment-row">
|
|
||||||
<td className="bb-attachment-name text-start" style={{ color: 'var(--bb-accent)' }}>
|
|
||||||
{entry.file.name}
|
|
||||||
</td>
|
|
||||||
<td className="bb-attachment-cell-comment">
|
|
||||||
<Form.Control
|
|
||||||
className="bb-attachment-comment"
|
|
||||||
value={entry.comment}
|
|
||||||
onChange={(event) =>
|
|
||||||
setThreadFiles((prev) =>
|
|
||||||
prev.map((item) =>
|
|
||||||
item.id === entry.id
|
|
||||||
? { ...item, comment: event.target.value }
|
|
||||||
: item
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
placeholder={t('attachment.file_comment_placeholder')}
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
<td className="bb-attachment-size text-start" style={{ color: 'var(--bb-accent)' }}>
|
|
||||||
{formatBytes(entry.file.size)}
|
|
||||||
</td>
|
|
||||||
<td className="bb-attachment-status text-center">
|
|
||||||
<i className="bi bi-check-circle-fill text-success" aria-hidden="true" />
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<div className="bb-attachment-row-actions">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="bb-attachment-action"
|
|
||||||
onClick={() => handleInlineInsert(entry)}
|
|
||||||
title={t('attachment.place_inline')}
|
|
||||||
aria-label={t('attachment.place_inline')}
|
|
||||||
>
|
|
||||||
<i className="bi bi-paperclip" aria-hidden="true" />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="bb-attachment-action"
|
|
||||||
onClick={() =>
|
|
||||||
setThreadFiles((prev) =>
|
|
||||||
prev.filter((item) => item.id !== entry.id)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
title={t('attachment.delete_file')}
|
|
||||||
aria-label={t('attachment.delete_file')}
|
|
||||||
>
|
|
||||||
<i className="bi bi-trash" aria-hidden="true" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Container fluid className="py-5 bb-shell-container">
|
<Container fluid className="py-5 bb-shell-container">
|
||||||
{loading && <p className="bb-muted">{t('forum.loading')}</p>}
|
{loading && <p className="bb-muted">{t('forum.loading')}</p>}
|
||||||
@@ -648,7 +185,7 @@ export default function ForumView() {
|
|||||||
</div>
|
</div>
|
||||||
{!token && <p className="bb-muted mb-3">{t('forum.login_hint')}</p>}
|
{!token && <p className="bb-muted mb-3">{t('forum.login_hint')}</p>}
|
||||||
<div className="bb-portal-topic-table">
|
<div className="bb-portal-topic-table">
|
||||||
<div className="bb-portal-topic-header tr-header">
|
<div className="bb-portal-topic-header">
|
||||||
<span>{t('portal.topic')}</span>
|
<span>{t('portal.topic')}</span>
|
||||||
<span>{t('thread.replies')}</span>
|
<span>{t('thread.replies')}</span>
|
||||||
<span>{t('thread.views')}</span>
|
<span>{t('thread.views')}</span>
|
||||||
@@ -674,19 +211,13 @@ export default function ForumView() {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{forum?.type === 'forum' && (
|
{forum?.type === 'forum' && (
|
||||||
<Modal
|
<Modal show={showModal} onHide={() => setShowModal(false)} centered size="lg">
|
||||||
show={showModal}
|
|
||||||
onHide={() => setShowModal(false)}
|
|
||||||
centered
|
|
||||||
size="lg"
|
|
||||||
dialogClassName="bb-thread-modal"
|
|
||||||
>
|
|
||||||
<Modal.Header closeButton>
|
<Modal.Header closeButton>
|
||||||
<Modal.Title>{t('forum.start_thread')}</Modal.Title>
|
<Modal.Title>{t('forum.start_thread')}</Modal.Title>
|
||||||
</Modal.Header>
|
</Modal.Header>
|
||||||
<Modal.Body className="d-flex flex-column p-0">
|
<Modal.Body>
|
||||||
{!token && <p className="bb-muted mb-3">{t('forum.login_hint')}</p>}
|
{!token && <p className="bb-muted mb-3">{t('forum.login_hint')}</p>}
|
||||||
<Form onSubmit={handleSubmit} className="d-flex flex-column flex-grow-1 px-3 pb-3 pt-2">
|
<Form onSubmit={handleSubmit}>
|
||||||
<Form.Group className="mb-3">
|
<Form.Group className="mb-3">
|
||||||
<Form.Label>{t('form.title')}</Form.Label>
|
<Form.Label>{t('form.title')}</Form.Label>
|
||||||
<Form.Control
|
<Form.Control
|
||||||
@@ -698,113 +229,30 @@ export default function ForumView() {
|
|||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</Form.Group>
|
</Form.Group>
|
||||||
<Form.Group className="mb-3 d-flex flex-column flex-grow-1">
|
<Form.Group className="mb-3">
|
||||||
<Form.Label>{t('form.body')}</Form.Label>
|
<Form.Label>{t('form.body')}</Form.Label>
|
||||||
<Form.Control
|
<Form.Control
|
||||||
as="textarea"
|
as="textarea"
|
||||||
rows={6}
|
rows={6}
|
||||||
className="flex-grow-1"
|
|
||||||
placeholder={t('form.thread_body_placeholder')}
|
placeholder={t('form.thread_body_placeholder')}
|
||||||
value={body}
|
value={body}
|
||||||
onChange={(event) => setBody(event.target.value)}
|
onChange={(event) => setBody(event.target.value)}
|
||||||
onPaste={handleThreadPaste}
|
|
||||||
disabled={!token || saving}
|
disabled={!token || saving}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</Form.Group>
|
</Form.Group>
|
||||||
<Form.Control
|
<div className="d-flex gap-2 justify-content-between">
|
||||||
id="bb-thread-attachment-input"
|
|
||||||
type="file"
|
|
||||||
multiple
|
|
||||||
className="bb-attachment-input"
|
|
||||||
disabled={!token || saving || uploading}
|
|
||||||
onChange={(event) => {
|
|
||||||
applyThreadFiles(event.target.files)
|
|
||||||
event.target.value = ''
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
className={`bb-attachment-drop ${threadDropActive ? 'is-dragover' : ''}`}
|
|
||||||
role="button"
|
|
||||||
tabIndex={0}
|
|
||||||
onClick={() => document.getElementById('bb-thread-attachment-input')?.click()}
|
|
||||||
onKeyDown={(event) => {
|
|
||||||
if (event.key === 'Enter' || event.key === ' ') {
|
|
||||||
event.preventDefault()
|
|
||||||
document.getElementById('bb-thread-attachment-input')?.click()
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onDragOver={(event) => {
|
|
||||||
event.preventDefault()
|
|
||||||
setThreadDropActive(true)
|
|
||||||
}}
|
|
||||||
onDragLeave={() => setThreadDropActive(false)}
|
|
||||||
onDrop={(event) => {
|
|
||||||
event.preventDefault()
|
|
||||||
setThreadDropActive(false)
|
|
||||||
applyThreadFiles(event.dataTransfer.files)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span>
|
|
||||||
{t('attachment.drop_hint')}{' '}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="bb-attachment-drop-link"
|
|
||||||
onClick={(event) => {
|
|
||||||
event.stopPropagation()
|
|
||||||
document.getElementById('bb-thread-attachment-input')?.click()
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{t('attachment.drop_browse')}
|
|
||||||
</button>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{renderAttachmentFooter()}
|
|
||||||
<Modal.Footer className="d-flex gap-2 justify-content-between mt-auto pt-2 px-0 border-0 mb-0 pb-0">
|
|
||||||
<Button type="button" variant="outline-secondary" onClick={() => setShowModal(false)}>
|
<Button type="button" variant="outline-secondary" onClick={() => setShowModal(false)}>
|
||||||
{t('acp.cancel')}
|
{t('acp.cancel')}
|
||||||
</Button>
|
</Button>
|
||||||
<div className="d-flex gap-2">
|
<Button type="submit" className="bb-accent-button" disabled={!token || saving}>
|
||||||
<Button
|
{saving ? t('form.posting') : t('form.create_thread')}
|
||||||
type="button"
|
</Button>
|
||||||
variant="outline-secondary"
|
</div>
|
||||||
onClick={handlePreview}
|
|
||||||
disabled={!token || saving || uploading || previewLoading}
|
|
||||||
>
|
|
||||||
{t('form.preview')}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
className="bb-accent-button"
|
|
||||||
disabled={!token || saving || uploading}
|
|
||||||
>
|
|
||||||
{saving || uploading ? t('form.posting') : t('form.create_thread')}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</Modal.Footer>
|
|
||||||
</Form>
|
</Form>
|
||||||
</Modal.Body>
|
</Modal.Body>
|
||||||
</Modal>
|
</Modal>
|
||||||
)}
|
)}
|
||||||
<Modal
|
|
||||||
show={showPreview}
|
|
||||||
onHide={() => {
|
|
||||||
setShowPreview(false)
|
|
||||||
clearPreviewUrls()
|
|
||||||
}}
|
|
||||||
centered
|
|
||||||
size="lg"
|
|
||||||
>
|
|
||||||
<Modal.Header closeButton>
|
|
||||||
<Modal.Title>{t('form.preview')}</Modal.Title>
|
|
||||||
</Modal.Header>
|
|
||||||
<Modal.Body>
|
|
||||||
<div
|
|
||||||
className="bb-post-body"
|
|
||||||
dangerouslySetInnerHTML={{ __html: previewHtml || '' }}
|
|
||||||
/>
|
|
||||||
</Modal.Body>
|
|
||||||
</Modal>
|
|
||||||
</Container>
|
</Container>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -91,48 +91,7 @@ export default function Home() {
|
|||||||
nodes.forEach((node) => sortNodes(node.children))
|
nodes.forEach((node) => sortNodes(node.children))
|
||||||
}
|
}
|
||||||
|
|
||||||
const aggregateNodes = (node) => {
|
|
||||||
if (!node.children?.length) {
|
|
||||||
return {
|
|
||||||
threads: node.threads_count ?? 0,
|
|
||||||
views: node.views_count ?? 0,
|
|
||||||
posts: node.posts_count ?? 0,
|
|
||||||
last: node.last_post_at ? { at: node.last_post_at, node } : null,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let threads = node.threads_count ?? 0
|
|
||||||
let views = node.views_count ?? 0
|
|
||||||
let posts = node.posts_count ?? 0
|
|
||||||
let last = node.last_post_at ? { at: node.last_post_at, node } : null
|
|
||||||
|
|
||||||
node.children.forEach((child) => {
|
|
||||||
const agg = aggregateNodes(child)
|
|
||||||
threads += agg.threads
|
|
||||||
views += agg.views
|
|
||||||
posts += agg.posts
|
|
||||||
if (agg.last && (!last || agg.last.at > last.at)) {
|
|
||||||
last = agg.last
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
node.threads_count = threads
|
|
||||||
node.views_count = views
|
|
||||||
node.posts_count = posts
|
|
||||||
if (last) {
|
|
||||||
const source = last.node
|
|
||||||
node.last_post_at = source.last_post_at
|
|
||||||
node.last_post_user_id = source.last_post_user_id
|
|
||||||
node.last_post_user_name = source.last_post_user_name
|
|
||||||
node.last_post_user_rank_color = source.last_post_user_rank_color
|
|
||||||
node.last_post_user_group_color = source.last_post_user_group_color
|
|
||||||
}
|
|
||||||
|
|
||||||
return { threads, views, posts, last }
|
|
||||||
}
|
|
||||||
|
|
||||||
sortNodes(roots)
|
sortNodes(roots)
|
||||||
roots.forEach((root) => aggregateNodes(root))
|
|
||||||
|
|
||||||
return roots
|
return roots
|
||||||
}, [forums])
|
}, [forums])
|
||||||
@@ -235,7 +194,7 @@ export default function Home() {
|
|||||||
)}
|
)}
|
||||||
{!loadingThreads && recentThreads.length > 0 && (
|
{!loadingThreads && recentThreads.length > 0 && (
|
||||||
<div className="bb-portal-topic-table">
|
<div className="bb-portal-topic-table">
|
||||||
<div className="bb-portal-topic-header tr-header">
|
<div className="bb-portal-topic-header">
|
||||||
<span>{t('portal.topic')}</span>
|
<span>{t('portal.topic')}</span>
|
||||||
<span>{t('thread.replies')}</span>
|
<span>{t('thread.replies')}</span>
|
||||||
<span>{t('thread.views')}</span>
|
<span>{t('thread.views')}</span>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -46,21 +46,10 @@
|
|||||||
"acp.add_forum": "Forum hinzufügen",
|
"acp.add_forum": "Forum hinzufügen",
|
||||||
"acp.ranks": "Ränge",
|
"acp.ranks": "Ränge",
|
||||||
"acp.groups": "Gruppen",
|
"acp.groups": "Gruppen",
|
||||||
"acp.attachments": "Anh\u00e4nge",
|
|
||||||
"acp.forums_parent_root": "Wurzel (kein Parent)",
|
"acp.forums_parent_root": "Wurzel (kein Parent)",
|
||||||
"acp.forums_tree": "Forenbaum",
|
"acp.forums_tree": "Forenbaum",
|
||||||
"acp.forums_type": "Typ",
|
"acp.forums_type": "Typ",
|
||||||
"acp.general": "Allgemein",
|
"acp.general": "Allgemein",
|
||||||
"acp.quick_access": "Schnellzugriff",
|
|
||||||
"acp.board_configuration": "Board-Konfiguration",
|
|
||||||
"acp.client_communication": "Client-Kommunikation",
|
|
||||||
"acp.server_configuration": "Server-Konfiguration",
|
|
||||||
"acp.authentication": "Authentifizierung",
|
|
||||||
"acp.email_settings": "E-Mail-Einstellungen",
|
|
||||||
"acp.security_settings": "Sicherheitseinstellungen",
|
|
||||||
"acp.search_settings": "Sucheinstellungen",
|
|
||||||
"acp.welcome_title": "Willkommen bei speedBB",
|
|
||||||
"acp.general_settings": "Allgemeine Einstellungen",
|
|
||||||
"acp.general_hint": "Globale Einstellungen und Board-Konfiguration erscheinen hier.",
|
"acp.general_hint": "Globale Einstellungen und Board-Konfiguration erscheinen hier.",
|
||||||
"acp.loading": "Laden...",
|
"acp.loading": "Laden...",
|
||||||
"acp.new_category": "Neue Kategorie",
|
"acp.new_category": "Neue Kategorie",
|
||||||
@@ -69,14 +58,8 @@
|
|||||||
"acp.refresh": "Aktualisieren",
|
"acp.refresh": "Aktualisieren",
|
||||||
"acp.reset": "Zurücksetzen",
|
"acp.reset": "Zurücksetzen",
|
||||||
"acp.save": "Speichern",
|
"acp.save": "Speichern",
|
||||||
"acp.statistics": "Statistik",
|
|
||||||
"acp.title": "Administrationsbereich",
|
"acp.title": "Administrationsbereich",
|
||||||
"acp.users": "Benutzer",
|
"acp.users": "Benutzer",
|
||||||
"acp.audit_logs": "Audit-Log",
|
|
||||||
"acp.system": "System",
|
|
||||||
"acp.admin_log_title": "Administrator-Aktionen",
|
|
||||||
"acp.admin_log_hint": "Uebersicht der letzten Administrator-Aktionen. Vollstaendiges Log unten.",
|
|
||||||
"acp.view_admin_log": "Administrator-Log anzeigen",
|
|
||||||
"auth.login_hint": "Melde dich an, um neue Threads zu starten und zu antworten.",
|
"auth.login_hint": "Melde dich an, um neue Threads zu starten und zu antworten.",
|
||||||
"auth.login_title": "Anmelden",
|
"auth.login_title": "Anmelden",
|
||||||
"auth.login_identifier": "E-Mail oder Benutzername",
|
"auth.login_identifier": "E-Mail oder Benutzername",
|
||||||
@@ -94,9 +77,6 @@
|
|||||||
"form.password": "Passwort",
|
"form.password": "Passwort",
|
||||||
"form.post_reply": "Antwort posten",
|
"form.post_reply": "Antwort posten",
|
||||||
"form.posting": "Wird gesendet...",
|
"form.posting": "Wird gesendet...",
|
||||||
"form.preview": "Vorschau",
|
|
||||||
"form.upload": "Hochladen",
|
|
||||||
"form.uploading": "Wird hochgeladen...",
|
|
||||||
"form.registering": "Registrierung läuft...",
|
"form.registering": "Registrierung läuft...",
|
||||||
"form.reply_placeholder": "Schreibe deine Antwort.",
|
"form.reply_placeholder": "Schreibe deine Antwort.",
|
||||||
"form.sign_in": "Anmelden",
|
"form.sign_in": "Anmelden",
|
||||||
@@ -106,18 +86,6 @@
|
|||||||
"form.thread_title_placeholder": "Thema",
|
"form.thread_title_placeholder": "Thema",
|
||||||
"form.title": "Titel",
|
"form.title": "Titel",
|
||||||
"form.username": "Benutzername",
|
"form.username": "Benutzername",
|
||||||
"thread.title": "Titel",
|
|
||||||
"thread.edit": "Bearbeiten",
|
|
||||||
"thread.delete": "Löschen",
|
|
||||||
"thread.quote": "Zitieren",
|
|
||||||
"thread.delete_confirm": "Diesen Thread löschen?",
|
|
||||||
"thread.delete_post_confirm": "Diesen Beitrag löschen?",
|
|
||||||
"thread.delete_reason": "Loeschgrund",
|
|
||||||
"thread.delete_reason_obsolete": "Obsolet",
|
|
||||||
"thread.delete_reason_double": "Doppelpost",
|
|
||||||
"thread.delete_reason_other": "Andere",
|
|
||||||
"thread.delete_reason_other_label": "Begruendung",
|
|
||||||
"thread.delete_reason_other_placeholder": "Kurze Begruendung...",
|
|
||||||
"forum.children": "Unterforen",
|
"forum.children": "Unterforen",
|
||||||
"forum.empty_children": "Noch keine Unterforen vorhanden.",
|
"forum.empty_children": "Noch keine Unterforen vorhanden.",
|
||||||
"forum.empty_threads": "Noch keine Threads vorhanden. Starte unten einen.",
|
"forum.empty_threads": "Noch keine Threads vorhanden. Starte unten einen.",
|
||||||
@@ -136,70 +104,11 @@
|
|||||||
"user.email": "E-Mail",
|
"user.email": "E-Mail",
|
||||||
"user.rank": "Rang",
|
"user.rank": "Rang",
|
||||||
"user.rank_unassigned": "Nicht zugewiesen",
|
"user.rank_unassigned": "Nicht zugewiesen",
|
||||||
"audit.user": "Benutzer",
|
|
||||||
"audit.action": "Aktion",
|
|
||||||
"audit.subject": "Objekt",
|
|
||||||
"audit.ip": "IP",
|
|
||||||
"audit.created_at": "Zeitpunkt",
|
|
||||||
"audit.search": "Audit-Logs durchsuchen...",
|
|
||||||
"audit.empty": "Noch keine Audit-Einträge.",
|
|
||||||
"admin_log.username": "Benutzername",
|
|
||||||
"admin_log.user_ip": "Benutzer-IP",
|
|
||||||
"admin_log.time": "Zeit",
|
|
||||||
"admin_log.action": "Aktion",
|
|
||||||
"admin_log.empty": "Noch keine Administrator-Aktionen.",
|
|
||||||
"stats.statistic": "Statistik",
|
|
||||||
"stats.value": "Wert",
|
|
||||||
"stats.board_started": "Board gestartet",
|
|
||||||
"stats.avatar_directory_size": "Avatar-Verzeichnisgröße",
|
|
||||||
"stats.database_size": "Datenbankgröße",
|
|
||||||
"stats.attachments_size": "Groesse der Anhänge",
|
|
||||||
"stats.database_server": "Datenbankserver",
|
|
||||||
"stats.gzip_compression": "GZip-Kompression",
|
|
||||||
"stats.php_version": "PHP-Version",
|
|
||||||
"stats.orphan_attachments": "Verwaiste Anhänge",
|
|
||||||
"stats.board_version": "Board-Version",
|
|
||||||
"stats.posts": "Anzahl der Beiträge",
|
|
||||||
"stats.posts_per_day": "Beiträge pro Tag",
|
|
||||||
"stats.topics": "Anzahl der Themen",
|
|
||||||
"stats.topics_per_day": "Themen pro Tag",
|
|
||||||
"stats.users": "Anzahl der Benutzer",
|
|
||||||
"stats.users_per_day": "Benutzer pro Tag",
|
|
||||||
"stats.attachments": "Anzahl der Anhänge",
|
|
||||||
"stats.attachments_per_day": "Anhänge pro Tag",
|
|
||||||
"stats.on": "An",
|
|
||||||
"stats.off": "Aus",
|
|
||||||
"version.recheck": "Version prüfen",
|
|
||||||
"version.checking": "Prüfe…",
|
|
||||||
"version.up_to_date": "Aktuell",
|
|
||||||
"version.update_available": "Update verfügbar (v{{version}})",
|
|
||||||
"version.update_available_short": "Update verfügbar",
|
|
||||||
"version.unknown": "Version unbekannt",
|
|
||||||
"version.update_now": "Jetzt aktualisieren",
|
|
||||||
"version.update_title": "System aktualisieren",
|
|
||||||
"version.update_hint": "Letzte Version herunterladen, installieren, Migrationen ausführen und Assets neu bauen.",
|
|
||||||
"version.updating": "Aktualisiere…",
|
|
||||||
"system.requirements": "Systemanforderungen",
|
|
||||||
"system.check": "Prüfung",
|
|
||||||
"system.path": "Pfad",
|
|
||||||
"system.min_version": "Mindestversion",
|
|
||||||
"system.current_version": "Aktuelle Version",
|
|
||||||
"system.status": "Status",
|
|
||||||
"system.recheck": "Neu prüfen",
|
|
||||||
"system.none": "Keine",
|
|
||||||
"system.not_found": "Nicht gefunden",
|
|
||||||
"system.storage_writable": "Storage beschreibbar",
|
|
||||||
"system.updates_writable": "Updates beschreibbar",
|
|
||||||
"system.ok": "OK",
|
|
||||||
"system.not_ok": "Nicht OK",
|
|
||||||
"lightbox.prev": "Vorheriges Bild",
|
|
||||||
"lightbox.next": "Nächstes Bild",
|
|
||||||
"user.edit_title": "Benutzer bearbeiten",
|
"user.edit_title": "Benutzer bearbeiten",
|
||||||
"user.search": "Benutzer suchen...",
|
"user.search": "Benutzer suchen...",
|
||||||
"rank.name": "Rangname",
|
"rank.name": "Rangname",
|
||||||
"rank.name_placeholder": "z. B. Operator",
|
"rank.name_placeholder": "z. B. Operator",
|
||||||
"rank.create": "Rang erstellen",
|
"rank.create": "Rang erstellen",
|
||||||
"rank.create_title": "Rang erstellen",
|
|
||||||
"rank.edit_title": "Rang bearbeiten",
|
"rank.edit_title": "Rang bearbeiten",
|
||||||
"rank.badge_type": "Badge-Typ",
|
"rank.badge_type": "Badge-Typ",
|
||||||
"rank.badge_text": "Text-Badge",
|
"rank.badge_text": "Text-Badge",
|
||||||
@@ -302,69 +211,6 @@
|
|||||||
"ucp.accent_override": "Akzentfarbe überschreiben",
|
"ucp.accent_override": "Akzentfarbe überschreiben",
|
||||||
"ucp.accent_override_hint": "Wähle eine eigene Akzentfarbe für die Oberfläche.",
|
"ucp.accent_override_hint": "Wähle eine eigene Akzentfarbe für die Oberfläche.",
|
||||||
"ucp.custom_color": "Eigene Farbe",
|
"ucp.custom_color": "Eigene Farbe",
|
||||||
"attachment.groups_title": "Anhanggruppen",
|
|
||||||
"attachment.settings_title": "Anhang-Einstellungen",
|
|
||||||
"attachment.display_images_inline": "Bilder inline anzeigen",
|
|
||||||
"attachment.create_thumbnails": "Vorschaubilder erstellen",
|
|
||||||
"attachment.thumbnail_max_width": "Maximale Vorschaubreite (px)",
|
|
||||||
"attachment.thumbnail_max_height": "Maximale Vorschaubildhöhe (px)",
|
|
||||||
"attachment.thumbnail_quality": "Vorschaubild-Qualität (JPEG/WebP)",
|
|
||||||
"attachment.group_create": "Neue Anhanggruppe",
|
|
||||||
"attachment.group_create_title": "Anhanggruppe erstellen",
|
|
||||||
"attachment.group_edit_title": "Anhanggruppe bearbeiten",
|
|
||||||
"attachment.group_empty": "Noch keine Anhanggruppen.",
|
|
||||||
"attachment.seed_defaults": "Standard-Anhangset erstellen",
|
|
||||||
"attachment.seed_in_progress": "Standardwerte werden erstellt...",
|
|
||||||
"attachment.seed_hint": "Fügt eine Media- und Files-Struktur mit üblichen Endungen hinzu.",
|
|
||||||
"attachment.group_extensions": "{{count}} Endungen",
|
|
||||||
"attachment.group_delete_confirm": "Diese Anhanggruppe l\u00f6schen?",
|
|
||||||
"attachment.group_name": "Name",
|
|
||||||
"attachment.group_parent": "\u00dcbergeordnete Gruppe",
|
|
||||||
"attachment.group_parent_none": "Keine",
|
|
||||||
"attachment.group_max_size": "Max. Gr\u00f6\u00dfe (KB)",
|
|
||||||
"attachment.group_max_size_hint": "Standard 25600 KB (25 MB).",
|
|
||||||
"attachment.group_active": "Aktiv",
|
|
||||||
"attachment.group_add_child": "Untergruppe hinzuf\u00fcgen",
|
|
||||||
"attachment.group_auto_nest": "Standardgruppen automatisch verschachteln",
|
|
||||||
"attachment.group_auto_nest_hint": "Erstellt Media- und Files-Eltern und ordnet die Standardgruppen darunter ein.",
|
|
||||||
"attachment.extensions_title": "Dateiendungen verwalten",
|
|
||||||
"attachment.extension_placeholder": "Endung hinzuf\u00fcgen (z. B. pdf)",
|
|
||||||
"attachment.extension_mimes_placeholder": "Erlaubte MIME-Typen (kommagetrennt)",
|
|
||||||
"attachment.extension_unassigned": "Nicht zugewiesen",
|
|
||||||
"attachment.extension_add": "Endung hinzuf\u00fcgen",
|
|
||||||
"attachment.extension_edit": "Endung bearbeiten",
|
|
||||||
"attachment.extension_add_button": "Endung hinzuf\u00fcgen",
|
|
||||||
"attachment.extension_empty": "Noch keine Endungen.",
|
|
||||||
"attachment.extension": "Endung",
|
|
||||||
"attachment.extension_group": "Endungsgruppe",
|
|
||||||
"attachment.extension_delete_confirm": "Diese Endung l\u00f6schen?",
|
|
||||||
"attachment.actions": "Aktionen",
|
|
||||||
"attachment.allowed_mimes": "MIME-Typen:",
|
|
||||||
"attachment.active": "Aktiv",
|
|
||||||
"attachment.inactive": "Inaktiv",
|
|
||||||
"attachment.tab_options": "Optionen",
|
|
||||||
"attachment.tab_attachments": "Anh\u00e4nge",
|
|
||||||
"attachment.hint": "Wenn du Dateien anh\u00e4ngen m\u00f6chtest, f\u00fcge sie unten hinzu.",
|
|
||||||
"attachment.max_size": "Maximale Dateigr\u00f6\u00dfe pro Anhang: {{size}}.",
|
|
||||||
"attachment.add_files": "Dateien hinzuf\u00fcgen",
|
|
||||||
"attachment.drop_hint": "Sie k\u00f6nnen Dateien zum Hochladen hier hineinziehen oder",
|
|
||||||
"attachment.drop_browse": "Durchsuchen",
|
|
||||||
"attachment.filename": "Dateiname",
|
|
||||||
"attachment.size": "Gr\u00f6\u00dfe",
|
|
||||||
"attachment.status": "Status",
|
|
||||||
"attachment.empty": "Noch keine Dateien hinzugef\u00fcgt.",
|
|
||||||
"attachment.remove": "Datei entfernen",
|
|
||||||
"attachment.file_comment": "Dateikommentar",
|
|
||||||
"attachment.file_comment_placeholder": "Kommentar (optional)",
|
|
||||||
"attachment.place_inline": "Inline platzieren",
|
|
||||||
"attachment.delete_file": "Datei l\u00f6schen",
|
|
||||||
"attachment.option_disable_bbcode": "BBCode deaktivieren",
|
|
||||||
"attachment.option_disable_smilies": "Smileys deaktivieren",
|
|
||||||
"attachment.option_disable_auto_urls": "URLs nicht automatisch verlinken",
|
|
||||||
"attachment.option_attach_signature": "Signatur anh\u00e4ngen (über die UCP \u00e4nderbar)",
|
|
||||||
"attachment.option_notify_replies": "Bei Antworten benachrichtigen",
|
|
||||||
"attachment.option_lock_topic": "Thema sperren",
|
|
||||||
"attachment.invalid_extensions": "Nicht erlaubt: {{names}}.",
|
|
||||||
"thread.anonymous": "Anonym",
|
"thread.anonymous": "Anonym",
|
||||||
"thread.back_to_category": "Zurück zum Forum",
|
"thread.back_to_category": "Zurück zum Forum",
|
||||||
"thread.category": "Forum:",
|
"thread.category": "Forum:",
|
||||||
@@ -380,11 +226,6 @@
|
|||||||
"thread.reply_prefix": "Aw:",
|
"thread.reply_prefix": "Aw:",
|
||||||
"thread.registered": "Registriert",
|
"thread.registered": "Registriert",
|
||||||
"thread.replies": "Antworten",
|
"thread.replies": "Antworten",
|
||||||
"thread.solved": "Gel\u00f6st",
|
|
||||||
"thread.mark_solved": "Als gel\u00f6st markieren",
|
|
||||||
"thread.mark_unsolved": "Als ungel\u00f6st markieren",
|
|
||||||
"thread.attachments": "Anh\u00e4nge",
|
|
||||||
"thread.attach_files": "Dateien anh\u00e4ngen",
|
|
||||||
"thread.views": "Zugriffe",
|
"thread.views": "Zugriffe",
|
||||||
"thread.last_post": "Letzter Beitrag",
|
"thread.last_post": "Letzter Beitrag",
|
||||||
"thread.by": "von",
|
"thread.by": "von",
|
||||||
|
|||||||
@@ -46,21 +46,10 @@
|
|||||||
"acp.add_forum": "Add forum",
|
"acp.add_forum": "Add forum",
|
||||||
"acp.ranks": "Ranks",
|
"acp.ranks": "Ranks",
|
||||||
"acp.groups": "Groups",
|
"acp.groups": "Groups",
|
||||||
"acp.attachments": "Attachments",
|
|
||||||
"acp.forums_parent_root": "Root (no parent)",
|
"acp.forums_parent_root": "Root (no parent)",
|
||||||
"acp.forums_tree": "Forum tree",
|
"acp.forums_tree": "Forum tree",
|
||||||
"acp.forums_type": "Type",
|
"acp.forums_type": "Type",
|
||||||
"acp.general": "General",
|
"acp.general": "General",
|
||||||
"acp.quick_access": "Quick access",
|
|
||||||
"acp.board_configuration": "Board configuration",
|
|
||||||
"acp.client_communication": "Client communication",
|
|
||||||
"acp.server_configuration": "Server configuration",
|
|
||||||
"acp.authentication": "Authentication",
|
|
||||||
"acp.email_settings": "Email settings",
|
|
||||||
"acp.security_settings": "Security settings",
|
|
||||||
"acp.search_settings": "Search settings",
|
|
||||||
"acp.welcome_title": "Welcome to speedBB",
|
|
||||||
"acp.general_settings": "General settings",
|
|
||||||
"acp.general_hint": "Global settings and board configuration will appear here.",
|
"acp.general_hint": "Global settings and board configuration will appear here.",
|
||||||
"acp.loading": "Loading...",
|
"acp.loading": "Loading...",
|
||||||
"acp.new_category": "New category",
|
"acp.new_category": "New category",
|
||||||
@@ -69,14 +58,8 @@
|
|||||||
"acp.refresh": "Refresh",
|
"acp.refresh": "Refresh",
|
||||||
"acp.reset": "Reset",
|
"acp.reset": "Reset",
|
||||||
"acp.save": "Save",
|
"acp.save": "Save",
|
||||||
"acp.statistics": "Statistics",
|
|
||||||
"acp.title": "Admin control panel",
|
"acp.title": "Admin control panel",
|
||||||
"acp.users": "Users",
|
"acp.users": "Users",
|
||||||
"acp.audit_logs": "Audit log",
|
|
||||||
"acp.system": "System",
|
|
||||||
"acp.admin_log_title": "Logged administrator actions",
|
|
||||||
"acp.admin_log_hint": "Overview of the latest administrator actions. Full log is available below.",
|
|
||||||
"acp.view_admin_log": "View administrator log",
|
|
||||||
"auth.login_hint": "Access your account to start new threads and reply.",
|
"auth.login_hint": "Access your account to start new threads and reply.",
|
||||||
"auth.login_title": "Log in",
|
"auth.login_title": "Log in",
|
||||||
"auth.login_identifier": "Email or username",
|
"auth.login_identifier": "Email or username",
|
||||||
@@ -94,9 +77,6 @@
|
|||||||
"form.password": "Password",
|
"form.password": "Password",
|
||||||
"form.post_reply": "Post reply",
|
"form.post_reply": "Post reply",
|
||||||
"form.posting": "Posting...",
|
"form.posting": "Posting...",
|
||||||
"form.preview": "Preview",
|
|
||||||
"form.upload": "Upload",
|
|
||||||
"form.uploading": "Uploading...",
|
|
||||||
"form.registering": "Registering...",
|
"form.registering": "Registering...",
|
||||||
"form.reply_placeholder": "Share your reply.",
|
"form.reply_placeholder": "Share your reply.",
|
||||||
"form.sign_in": "Sign in",
|
"form.sign_in": "Sign in",
|
||||||
@@ -106,18 +86,6 @@
|
|||||||
"form.thread_title_placeholder": "Topic headline",
|
"form.thread_title_placeholder": "Topic headline",
|
||||||
"form.title": "Title",
|
"form.title": "Title",
|
||||||
"form.username": "Username",
|
"form.username": "Username",
|
||||||
"thread.title": "Title",
|
|
||||||
"thread.edit": "Edit",
|
|
||||||
"thread.delete": "Delete",
|
|
||||||
"thread.quote": "Quote",
|
|
||||||
"thread.delete_confirm": "Delete this thread?",
|
|
||||||
"thread.delete_post_confirm": "Delete this post?",
|
|
||||||
"thread.delete_reason": "Delete reason",
|
|
||||||
"thread.delete_reason_obsolete": "Obsolete",
|
|
||||||
"thread.delete_reason_double": "Double post",
|
|
||||||
"thread.delete_reason_other": "Other",
|
|
||||||
"thread.delete_reason_other_label": "Reason details",
|
|
||||||
"thread.delete_reason_other_placeholder": "Add a short reason...",
|
|
||||||
"forum.children": "Sub-forums",
|
"forum.children": "Sub-forums",
|
||||||
"forum.empty_children": "No sub-forums yet.",
|
"forum.empty_children": "No sub-forums yet.",
|
||||||
"forum.empty_threads": "No threads here yet. Start one below.",
|
"forum.empty_threads": "No threads here yet. Start one below.",
|
||||||
@@ -138,68 +106,9 @@
|
|||||||
"user.rank_unassigned": "Unassigned",
|
"user.rank_unassigned": "Unassigned",
|
||||||
"user.edit_title": "Edit user",
|
"user.edit_title": "Edit user",
|
||||||
"user.search": "Search users...",
|
"user.search": "Search users...",
|
||||||
"stats.statistic": "Statistic",
|
|
||||||
"stats.value": "Value",
|
|
||||||
"stats.board_started": "Board started",
|
|
||||||
"stats.avatar_directory_size": "Avatar directory size",
|
|
||||||
"stats.database_size": "Database size",
|
|
||||||
"stats.attachments_size": "Size of posted attachments",
|
|
||||||
"stats.database_server": "Database server",
|
|
||||||
"stats.gzip_compression": "GZip compression",
|
|
||||||
"stats.php_version": "PHP version",
|
|
||||||
"stats.orphan_attachments": "Orphan attachments",
|
|
||||||
"stats.board_version": "Board version",
|
|
||||||
"stats.posts": "Number of posts",
|
|
||||||
"stats.posts_per_day": "Posts per day",
|
|
||||||
"stats.topics": "Number of topics",
|
|
||||||
"stats.topics_per_day": "Topics per day",
|
|
||||||
"stats.users": "Number of users",
|
|
||||||
"stats.users_per_day": "Users per day",
|
|
||||||
"stats.attachments": "Number of attachments",
|
|
||||||
"stats.attachments_per_day": "Attachments per day",
|
|
||||||
"stats.on": "On",
|
|
||||||
"stats.off": "Off",
|
|
||||||
"version.recheck": "Re-check version",
|
|
||||||
"version.checking": "Checking…",
|
|
||||||
"version.up_to_date": "Up to date",
|
|
||||||
"version.update_available": "Update available (v{{version}})",
|
|
||||||
"version.update_available_short": "Update available",
|
|
||||||
"version.unknown": "Version unknown",
|
|
||||||
"version.update_now": "Update now",
|
|
||||||
"version.update_title": "Update system",
|
|
||||||
"version.update_hint": "Download and install the latest release, then run migrations and rebuild assets.",
|
|
||||||
"version.updating": "Updating…",
|
|
||||||
"system.requirements": "System requirements",
|
|
||||||
"system.check": "Check",
|
|
||||||
"system.path": "Path",
|
|
||||||
"system.min_version": "Min version",
|
|
||||||
"system.current_version": "Current version",
|
|
||||||
"system.status": "Status",
|
|
||||||
"system.recheck": "Re-check",
|
|
||||||
"system.none": "None",
|
|
||||||
"system.not_found": "Not found",
|
|
||||||
"system.storage_writable": "Storage writable",
|
|
||||||
"system.updates_writable": "Updates writable",
|
|
||||||
"system.ok": "OK",
|
|
||||||
"system.not_ok": "Not OK",
|
|
||||||
"lightbox.prev": "Previous image",
|
|
||||||
"lightbox.next": "Next image",
|
|
||||||
"audit.user": "User",
|
|
||||||
"audit.action": "Action",
|
|
||||||
"audit.subject": "Subject",
|
|
||||||
"audit.ip": "IP",
|
|
||||||
"audit.created_at": "When",
|
|
||||||
"audit.search": "Search audit logs...",
|
|
||||||
"audit.empty": "No audit events yet.",
|
|
||||||
"admin_log.username": "Username",
|
|
||||||
"admin_log.user_ip": "User IP",
|
|
||||||
"admin_log.time": "Time",
|
|
||||||
"admin_log.action": "Action",
|
|
||||||
"admin_log.empty": "No administrator actions logged yet.",
|
|
||||||
"rank.name": "Rank name",
|
"rank.name": "Rank name",
|
||||||
"rank.name_placeholder": "e.g. Operator",
|
"rank.name_placeholder": "e.g. Operator",
|
||||||
"rank.create": "Create rank",
|
"rank.create": "Create rank",
|
||||||
"rank.create_title": "Create rank",
|
|
||||||
"rank.edit_title": "Edit rank",
|
"rank.edit_title": "Edit rank",
|
||||||
"rank.badge_type": "Badge type",
|
"rank.badge_type": "Badge type",
|
||||||
"rank.badge_text": "Text badge",
|
"rank.badge_text": "Text badge",
|
||||||
@@ -302,69 +211,6 @@
|
|||||||
"ucp.accent_override": "Accent color override",
|
"ucp.accent_override": "Accent color override",
|
||||||
"ucp.accent_override_hint": "Choose a custom accent color for your UI.",
|
"ucp.accent_override_hint": "Choose a custom accent color for your UI.",
|
||||||
"ucp.custom_color": "Custom color",
|
"ucp.custom_color": "Custom color",
|
||||||
"attachment.groups_title": "Attachment groups",
|
|
||||||
"attachment.settings_title": "Attachment settings",
|
|
||||||
"attachment.display_images_inline": "Display images inline",
|
|
||||||
"attachment.create_thumbnails": "Create thumbnails",
|
|
||||||
"attachment.thumbnail_max_width": "Maximum thumbnail width (px)",
|
|
||||||
"attachment.thumbnail_max_height": "Maximum thumbnail height (px)",
|
|
||||||
"attachment.thumbnail_quality": "Thumbnail quality (JPEG/WebP)",
|
|
||||||
"attachment.group_create": "New attachment group",
|
|
||||||
"attachment.group_create_title": "Create attachment group",
|
|
||||||
"attachment.group_edit_title": "Edit attachment group",
|
|
||||||
"attachment.group_empty": "No attachment groups yet.",
|
|
||||||
"attachment.seed_defaults": "Create default attachment set",
|
|
||||||
"attachment.seed_in_progress": "Creating defaults...",
|
|
||||||
"attachment.seed_hint": "Adds a Media and Files hierarchy with common extensions.",
|
|
||||||
"attachment.group_extensions": "{{count}} extensions",
|
|
||||||
"attachment.group_delete_confirm": "Delete this attachment group?",
|
|
||||||
"attachment.group_name": "Name",
|
|
||||||
"attachment.group_parent": "Parent group",
|
|
||||||
"attachment.group_parent_none": "No parent",
|
|
||||||
"attachment.group_max_size": "Max size (KB)",
|
|
||||||
"attachment.group_max_size_hint": "Default 25600 KB (25 MB).",
|
|
||||||
"attachment.group_active": "Active",
|
|
||||||
"attachment.group_add_child": "Add child group",
|
|
||||||
"attachment.group_auto_nest": "Auto-nest default groups",
|
|
||||||
"attachment.group_auto_nest_hint": "Creates Media and Files parents and nests the default groups underneath.",
|
|
||||||
"attachment.extensions_title": "Manage attachment extensions",
|
|
||||||
"attachment.extension_placeholder": "Add extension (e.g. pdf)",
|
|
||||||
"attachment.extension_mimes_placeholder": "Allowed MIME types (comma-separated)",
|
|
||||||
"attachment.extension_unassigned": "Not assigned",
|
|
||||||
"attachment.extension_add": "Add extension",
|
|
||||||
"attachment.extension_edit": "Edit extension",
|
|
||||||
"attachment.extension_add_button": "Add extension",
|
|
||||||
"attachment.extension_empty": "No extensions yet.",
|
|
||||||
"attachment.extension": "Extension",
|
|
||||||
"attachment.extension_group": "Extension group",
|
|
||||||
"attachment.extension_delete_confirm": "Delete this extension?",
|
|
||||||
"attachment.actions": "Actions",
|
|
||||||
"attachment.allowed_mimes": "MIME types:",
|
|
||||||
"attachment.active": "Active",
|
|
||||||
"attachment.inactive": "Inactive",
|
|
||||||
"attachment.tab_options": "Options",
|
|
||||||
"attachment.tab_attachments": "Attachments",
|
|
||||||
"attachment.hint": "If you wish to attach one or more files enter the details below.",
|
|
||||||
"attachment.max_size": "Maximum filesize per attachment: {{size}}.",
|
|
||||||
"attachment.add_files": "Add files",
|
|
||||||
"attachment.drop_hint": "Drag files here to upload or",
|
|
||||||
"attachment.drop_browse": "Browse",
|
|
||||||
"attachment.filename": "Filename",
|
|
||||||
"attachment.size": "Size",
|
|
||||||
"attachment.status": "Status",
|
|
||||||
"attachment.empty": "No files added yet.",
|
|
||||||
"attachment.remove": "Remove file",
|
|
||||||
"attachment.file_comment": "File comment",
|
|
||||||
"attachment.file_comment_placeholder": "Comment (optional)",
|
|
||||||
"attachment.place_inline": "Place inline",
|
|
||||||
"attachment.delete_file": "Delete file",
|
|
||||||
"attachment.option_disable_bbcode": "Disable BBCode",
|
|
||||||
"attachment.option_disable_smilies": "Disable smilies",
|
|
||||||
"attachment.option_disable_auto_urls": "Do not automatically parse URLs",
|
|
||||||
"attachment.option_attach_signature": "Attach a signature (signatures can be altered via the UCP)",
|
|
||||||
"attachment.option_notify_replies": "Notify me when a reply is posted",
|
|
||||||
"attachment.option_lock_topic": "Lock topic",
|
|
||||||
"attachment.invalid_extensions": "Not allowed: {{names}}.",
|
|
||||||
"thread.anonymous": "Anonymous",
|
"thread.anonymous": "Anonymous",
|
||||||
"thread.back_to_category": "Back to forum",
|
"thread.back_to_category": "Back to forum",
|
||||||
"thread.category": "Forum:",
|
"thread.category": "Forum:",
|
||||||
@@ -380,11 +226,6 @@
|
|||||||
"thread.reply_prefix": "Re:",
|
"thread.reply_prefix": "Re:",
|
||||||
"thread.registered": "Registered",
|
"thread.registered": "Registered",
|
||||||
"thread.replies": "Replies",
|
"thread.replies": "Replies",
|
||||||
"thread.solved": "Solved",
|
|
||||||
"thread.mark_solved": "Mark solved",
|
|
||||||
"thread.mark_unsolved": "Mark unsolved",
|
|
||||||
"thread.attachments": "Attachments",
|
|
||||||
"thread.attach_files": "Attach files",
|
|
||||||
"thread.views": "Views",
|
"thread.views": "Views",
|
||||||
"thread.last_post": "Last post",
|
"thread.last_post": "Last post",
|
||||||
"thread.by": "by",
|
"thread.by": "by",
|
||||||
|
|||||||
@@ -1,16 +1,11 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
use App\Http\Controllers\AttachmentController;
|
|
||||||
use App\Http\Controllers\AttachmentExtensionController;
|
|
||||||
use App\Http\Controllers\AttachmentGroupController;
|
|
||||||
use App\Http\Controllers\AuditLogController;
|
|
||||||
use App\Http\Controllers\AuthController;
|
use App\Http\Controllers\AuthController;
|
||||||
use App\Http\Controllers\ForumController;
|
use App\Http\Controllers\ForumController;
|
||||||
use App\Http\Controllers\I18nController;
|
use App\Http\Controllers\I18nController;
|
||||||
use App\Http\Controllers\PortalController;
|
use App\Http\Controllers\PortalController;
|
||||||
use App\Http\Controllers\PostController;
|
use App\Http\Controllers\PostController;
|
||||||
use App\Http\Controllers\PostThankController;
|
use App\Http\Controllers\PostThankController;
|
||||||
use App\Http\Controllers\PreviewController;
|
|
||||||
use App\Http\Controllers\SettingController;
|
use App\Http\Controllers\SettingController;
|
||||||
use App\Http\Controllers\StatsController;
|
use App\Http\Controllers\StatsController;
|
||||||
use App\Http\Controllers\ThreadController;
|
use App\Http\Controllers\ThreadController;
|
||||||
@@ -18,9 +13,6 @@ use App\Http\Controllers\UploadController;
|
|||||||
use App\Http\Controllers\UserSettingController;
|
use App\Http\Controllers\UserSettingController;
|
||||||
use App\Http\Controllers\UserController;
|
use App\Http\Controllers\UserController;
|
||||||
use App\Http\Controllers\VersionController;
|
use App\Http\Controllers\VersionController;
|
||||||
use App\Http\Controllers\VersionCheckController;
|
|
||||||
use App\Http\Controllers\SystemUpdateController;
|
|
||||||
use App\Http\Controllers\SystemStatusController;
|
|
||||||
use App\Http\Controllers\RankController;
|
use App\Http\Controllers\RankController;
|
||||||
use App\Http\Controllers\RoleController;
|
use App\Http\Controllers\RoleController;
|
||||||
use Illuminate\Support\Facades\Route;
|
use Illuminate\Support\Facades\Route;
|
||||||
@@ -36,15 +28,11 @@ Route::post('/logout', [AuthController::class, 'logout'])->middleware('auth:sanc
|
|||||||
Route::post('/user/password', [AuthController::class, 'updatePassword'])->middleware('auth:sanctum');
|
Route::post('/user/password', [AuthController::class, 'updatePassword'])->middleware('auth:sanctum');
|
||||||
|
|
||||||
Route::get('/version', VersionController::class);
|
Route::get('/version', VersionController::class);
|
||||||
Route::get('/version/check', VersionCheckController::class);
|
|
||||||
Route::post('/system/update', SystemUpdateController::class)->middleware('auth:sanctum');
|
|
||||||
Route::get('/system/status', SystemStatusController::class)->middleware('auth:sanctum');
|
|
||||||
Route::get('/portal/summary', PortalController::class);
|
Route::get('/portal/summary', PortalController::class);
|
||||||
Route::get('/stats', StatsController::class);
|
Route::get('/stats', StatsController::class);
|
||||||
Route::get('/settings', [SettingController::class, 'index']);
|
Route::get('/settings', [SettingController::class, 'index']);
|
||||||
Route::post('/settings', [SettingController::class, 'store'])->middleware('auth:sanctum');
|
Route::post('/settings', [SettingController::class, 'store'])->middleware('auth:sanctum');
|
||||||
Route::post('/settings/bulk', [SettingController::class, 'bulkStore'])->middleware('auth:sanctum');
|
Route::post('/settings/bulk', [SettingController::class, 'bulkStore'])->middleware('auth:sanctum');
|
||||||
Route::get('/audit-logs', [AuditLogController::class, 'index'])->middleware('auth:sanctum');
|
|
||||||
Route::get('/user-settings', [UserSettingController::class, 'index'])->middleware('auth:sanctum');
|
Route::get('/user-settings', [UserSettingController::class, 'index'])->middleware('auth:sanctum');
|
||||||
Route::post('/user-settings', [UserSettingController::class, 'store'])->middleware('auth:sanctum');
|
Route::post('/user-settings', [UserSettingController::class, 'store'])->middleware('auth:sanctum');
|
||||||
Route::post('/uploads/logo', [UploadController::class, 'storeLogo'])->middleware('auth:sanctum');
|
Route::post('/uploads/logo', [UploadController::class, 'storeLogo'])->middleware('auth:sanctum');
|
||||||
@@ -69,25 +57,6 @@ Route::patch('/ranks/{rank}', [RankController::class, 'update'])->middleware('au
|
|||||||
Route::delete('/ranks/{rank}', [RankController::class, 'destroy'])->middleware('auth:sanctum');
|
Route::delete('/ranks/{rank}', [RankController::class, 'destroy'])->middleware('auth:sanctum');
|
||||||
Route::post('/ranks/{rank}/badge-image', [RankController::class, 'uploadBadgeImage'])->middleware('auth:sanctum');
|
Route::post('/ranks/{rank}/badge-image', [RankController::class, 'uploadBadgeImage'])->middleware('auth:sanctum');
|
||||||
|
|
||||||
Route::get('/attachment-groups', [AttachmentGroupController::class, 'index'])->middleware('auth:sanctum');
|
|
||||||
Route::post('/attachment-groups', [AttachmentGroupController::class, 'store'])->middleware('auth:sanctum');
|
|
||||||
Route::patch('/attachment-groups/{attachmentGroup}', [AttachmentGroupController::class, 'update'])->middleware('auth:sanctum');
|
|
||||||
Route::delete('/attachment-groups/{attachmentGroup}', [AttachmentGroupController::class, 'destroy'])->middleware('auth:sanctum');
|
|
||||||
Route::post('/attachment-groups/reorder', [AttachmentGroupController::class, 'reorder'])->middleware('auth:sanctum');
|
|
||||||
|
|
||||||
Route::get('/attachment-extensions', [AttachmentExtensionController::class, 'index'])->middleware('auth:sanctum');
|
|
||||||
Route::get('/attachment-extensions/public', [AttachmentExtensionController::class, 'publicIndex']);
|
|
||||||
Route::post('/attachment-extensions', [AttachmentExtensionController::class, 'store'])->middleware('auth:sanctum');
|
|
||||||
Route::patch('/attachment-extensions/{attachmentExtension}', [AttachmentExtensionController::class, 'update'])->middleware('auth:sanctum');
|
|
||||||
Route::delete('/attachment-extensions/{attachmentExtension}', [AttachmentExtensionController::class, 'destroy'])->middleware('auth:sanctum');
|
|
||||||
|
|
||||||
Route::get('/attachments', [AttachmentController::class, 'index']);
|
|
||||||
Route::post('/attachments', [AttachmentController::class, 'store'])->middleware('auth:sanctum');
|
|
||||||
Route::get('/attachments/{attachment}', [AttachmentController::class, 'show']);
|
|
||||||
Route::get('/attachments/{attachment}/download', [AttachmentController::class, 'download']);
|
|
||||||
Route::get('/attachments/{attachment}/thumbnail', [AttachmentController::class, 'thumbnail']);
|
|
||||||
Route::delete('/attachments/{attachment}', [AttachmentController::class, 'destroy'])->middleware('auth:sanctum');
|
|
||||||
|
|
||||||
Route::get('/forums', [ForumController::class, 'index']);
|
Route::get('/forums', [ForumController::class, 'index']);
|
||||||
Route::get('/forums/{forum}', [ForumController::class, 'show']);
|
Route::get('/forums/{forum}', [ForumController::class, 'show']);
|
||||||
Route::post('/forums', [ForumController::class, 'store'])->middleware('auth:sanctum');
|
Route::post('/forums', [ForumController::class, 'store'])->middleware('auth:sanctum');
|
||||||
@@ -98,14 +67,10 @@ Route::delete('/forums/{forum}', [ForumController::class, 'destroy'])->middlewar
|
|||||||
Route::get('/threads', [ThreadController::class, 'index']);
|
Route::get('/threads', [ThreadController::class, 'index']);
|
||||||
Route::get('/threads/{thread}', [ThreadController::class, 'show']);
|
Route::get('/threads/{thread}', [ThreadController::class, 'show']);
|
||||||
Route::post('/threads', [ThreadController::class, 'store'])->middleware('auth:sanctum');
|
Route::post('/threads', [ThreadController::class, 'store'])->middleware('auth:sanctum');
|
||||||
Route::patch('/threads/{thread}', [ThreadController::class, 'update'])->middleware('auth:sanctum');
|
|
||||||
Route::patch('/threads/{thread}/solved', [ThreadController::class, 'updateSolved'])->middleware('auth:sanctum');
|
|
||||||
Route::delete('/threads/{thread}', [ThreadController::class, 'destroy'])->middleware('auth:sanctum');
|
Route::delete('/threads/{thread}', [ThreadController::class, 'destroy'])->middleware('auth:sanctum');
|
||||||
|
|
||||||
Route::get('/posts', [PostController::class, 'index']);
|
Route::get('/posts', [PostController::class, 'index']);
|
||||||
Route::post('/posts', [PostController::class, 'store'])->middleware('auth:sanctum');
|
Route::post('/posts', [PostController::class, 'store'])->middleware('auth:sanctum');
|
||||||
Route::post('/posts/{post}/thanks', [PostThankController::class, 'store'])->middleware('auth:sanctum');
|
Route::post('/posts/{post}/thanks', [PostThankController::class, 'store'])->middleware('auth:sanctum');
|
||||||
Route::delete('/posts/{post}/thanks', [PostThankController::class, 'destroy'])->middleware('auth:sanctum');
|
Route::delete('/posts/{post}/thanks', [PostThankController::class, 'destroy'])->middleware('auth:sanctum');
|
||||||
Route::patch('/posts/{post}', [PostController::class, 'update'])->middleware('auth:sanctum');
|
|
||||||
Route::delete('/posts/{post}', [PostController::class, 'destroy'])->middleware('auth:sanctum');
|
Route::delete('/posts/{post}', [PostController::class, 'destroy'])->middleware('auth:sanctum');
|
||||||
Route::post('/preview', [PreviewController::class, 'preview'])->middleware('auth:sanctum');
|
|
||||||
|
|||||||
@@ -1,71 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
|
||||||
OUTPUT_DIR="${OUTPUT_DIR:-"$ROOT_DIR/dist"}"
|
|
||||||
ALLOW_DIRTY="${ALLOW_DIRTY:-0}"
|
|
||||||
|
|
||||||
if [[ "$ALLOW_DIRTY" != "1" ]]; then
|
|
||||||
if [[ -n "$(git -C "$ROOT_DIR" status --porcelain)" ]]; then
|
|
||||||
echo "Working tree is dirty. Set ALLOW_DIRTY=1 to override." >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
VERSION="$(php -r 'echo json_decode(file_get_contents("composer.json"), true)["version"] ?? "0.0.0";')"
|
|
||||||
if [[ -z "$VERSION" ]]; then
|
|
||||||
echo "Could not determine version from composer.json" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
BUILD_DIR="$(mktemp -d)"
|
|
||||||
cleanup() {
|
|
||||||
rm -rf "$BUILD_DIR"
|
|
||||||
}
|
|
||||||
trap cleanup EXIT
|
|
||||||
|
|
||||||
exclude_args=(
|
|
||||||
--exclude ".git"
|
|
||||||
--exclude "node_modules"
|
|
||||||
--exclude "vendor"
|
|
||||||
--exclude "storage"
|
|
||||||
--exclude "dist"
|
|
||||||
--exclude "tests"
|
|
||||||
--exclude ".env"
|
|
||||||
--exclude ".env.test"
|
|
||||||
--exclude "public/build"
|
|
||||||
)
|
|
||||||
|
|
||||||
if command -v rsync >/dev/null 2>&1; then
|
|
||||||
rsync -a "${exclude_args[@]}" "$ROOT_DIR/" "$BUILD_DIR/"
|
|
||||||
else
|
|
||||||
tar -C "$ROOT_DIR" -cf - \
|
|
||||||
--exclude=".git" \
|
|
||||||
--exclude="node_modules" \
|
|
||||||
--exclude="vendor" \
|
|
||||||
--exclude="storage" \
|
|
||||||
--exclude="dist" \
|
|
||||||
--exclude="tests" \
|
|
||||||
--exclude=".env" \
|
|
||||||
--exclude=".env.test" \
|
|
||||||
--exclude="public/build" \
|
|
||||||
. | tar -C "$BUILD_DIR" -xf -
|
|
||||||
fi
|
|
||||||
|
|
||||||
pushd "$BUILD_DIR" >/dev/null
|
|
||||||
composer install --no-dev --optimize-autoloader
|
|
||||||
npm install
|
|
||||||
npm run build
|
|
||||||
rm -rf node_modules
|
|
||||||
popd >/dev/null
|
|
||||||
|
|
||||||
mkdir -p "$OUTPUT_DIR"
|
|
||||||
FULL_TAR="$OUTPUT_DIR/speedbb-full-v${VERSION}.tar.gz"
|
|
||||||
SRC_TAR="$OUTPUT_DIR/speedbb-src-v${VERSION}.tar.gz"
|
|
||||||
|
|
||||||
tar -C "$BUILD_DIR" -czf "$FULL_TAR" --exclude="tests" .
|
|
||||||
tar -C "$BUILD_DIR" -czf "$SRC_TAR" --exclude="vendor" --exclude="public/build" --exclude="tests" .
|
|
||||||
|
|
||||||
echo "Built:"
|
|
||||||
echo " $FULL_TAR"
|
|
||||||
echo " $SRC_TAR"
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
# Keep commits possible when local DB is offline.
|
|
||||||
if ! php artisan version:fetch >/dev/null 2>&1; then
|
|
||||||
echo "pre-commit: skipped 'php artisan version:fetch' (database unreachable)." >&2
|
|
||||||
echo "pre-commit: start MySQL and run it manually when needed." >&2
|
|
||||||
fi
|
|
||||||
@@ -1,284 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
use App\Models\Attachment;
|
|
||||||
use App\Models\AttachmentExtension;
|
|
||||||
use App\Models\AttachmentGroup;
|
|
||||||
use App\Models\Forum;
|
|
||||||
use App\Models\Post;
|
|
||||||
use App\Models\Thread;
|
|
||||||
use App\Models\User;
|
|
||||||
use Illuminate\Http\UploadedFile;
|
|
||||||
use Illuminate\Support\Facades\Storage;
|
|
||||||
use Laravel\Sanctum\Sanctum;
|
|
||||||
|
|
||||||
function makeThreadForAttachments(?User $owner = null): Thread
|
|
||||||
{
|
|
||||||
$category = Forum::create([
|
|
||||||
'name' => 'Category',
|
|
||||||
'description' => null,
|
|
||||||
'type' => 'category',
|
|
||||||
'parent_id' => null,
|
|
||||||
'position' => 1,
|
|
||||||
]);
|
|
||||||
|
|
||||||
$forum = Forum::create([
|
|
||||||
'name' => 'Forum',
|
|
||||||
'description' => null,
|
|
||||||
'type' => 'forum',
|
|
||||||
'parent_id' => $category->id,
|
|
||||||
'position' => 1,
|
|
||||||
]);
|
|
||||||
|
|
||||||
return Thread::create([
|
|
||||||
'forum_id' => $forum->id,
|
|
||||||
'user_id' => $owner?->id,
|
|
||||||
'title' => 'Attachment Thread',
|
|
||||||
'body' => 'Thread Body',
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
function makeAttachmentConfig(string $extension = 'pdf', array $mimes = ['application/pdf']): AttachmentExtension
|
|
||||||
{
|
|
||||||
$group = AttachmentGroup::create([
|
|
||||||
'name' => 'Docs',
|
|
||||||
'max_size_kb' => 25600,
|
|
||||||
'is_active' => true,
|
|
||||||
]);
|
|
||||||
|
|
||||||
return AttachmentExtension::create([
|
|
||||||
'extension' => $extension,
|
|
||||||
'attachment_group_id' => $group->id,
|
|
||||||
'allowed_mimes' => $mimes,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
it('requires authentication to upload attachments', function (): void {
|
|
||||||
Storage::fake('local');
|
|
||||||
makeAttachmentConfig();
|
|
||||||
|
|
||||||
$response = $this->postJson('/api/attachments', [
|
|
||||||
'thread' => '/api/threads/1',
|
|
||||||
'file' => UploadedFile::fake()->create('doc.pdf', 10, 'application/pdf'),
|
|
||||||
]);
|
|
||||||
|
|
||||||
$response->assertStatus(401);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects uploads without thread or post', function (): void {
|
|
||||||
Storage::fake('local');
|
|
||||||
makeAttachmentConfig();
|
|
||||||
$user = User::factory()->create();
|
|
||||||
Sanctum::actingAs($user);
|
|
||||||
|
|
||||||
$response = $this->postJson('/api/attachments', [
|
|
||||||
'file' => UploadedFile::fake()->create('doc.pdf', 10, 'application/pdf'),
|
|
||||||
]);
|
|
||||||
|
|
||||||
$response->assertStatus(422);
|
|
||||||
$response->assertJsonFragment(['message' => 'Provide either thread or post.']);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects uploads with both thread and post', function (): void {
|
|
||||||
Storage::fake('local');
|
|
||||||
makeAttachmentConfig();
|
|
||||||
$user = User::factory()->create();
|
|
||||||
$thread = makeThreadForAttachments($user);
|
|
||||||
$post = Post::create([
|
|
||||||
'thread_id' => $thread->id,
|
|
||||||
'user_id' => $user->id,
|
|
||||||
'body' => 'Post',
|
|
||||||
]);
|
|
||||||
|
|
||||||
Sanctum::actingAs($user);
|
|
||||||
$response = $this->postJson('/api/attachments', [
|
|
||||||
'thread' => "/api/threads/{$thread->id}",
|
|
||||||
'post' => "/api/posts/{$post->id}",
|
|
||||||
'file' => UploadedFile::fake()->create('doc.pdf', 10, 'application/pdf'),
|
|
||||||
]);
|
|
||||||
|
|
||||||
$response->assertStatus(422);
|
|
||||||
$response->assertJsonFragment(['message' => 'Provide either thread or post.']);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('forbids uploads when user is not owner', function (): void {
|
|
||||||
Storage::fake('local');
|
|
||||||
makeAttachmentConfig();
|
|
||||||
|
|
||||||
$owner = User::factory()->create();
|
|
||||||
$other = User::factory()->create();
|
|
||||||
$thread = makeThreadForAttachments($owner);
|
|
||||||
|
|
||||||
Sanctum::actingAs($other);
|
|
||||||
$response = $this->postJson('/api/attachments', [
|
|
||||||
'thread' => "/api/threads/{$thread->id}",
|
|
||||||
'file' => UploadedFile::fake()->create('doc.pdf', 10, 'application/pdf'),
|
|
||||||
]);
|
|
||||||
|
|
||||||
$response->assertStatus(403);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('stores attachment for a thread', function (): void {
|
|
||||||
Storage::fake('local');
|
|
||||||
makeAttachmentConfig();
|
|
||||||
|
|
||||||
$user = User::factory()->create();
|
|
||||||
$thread = makeThreadForAttachments($user);
|
|
||||||
|
|
||||||
Sanctum::actingAs($user);
|
|
||||||
$response = $this->postJson('/api/attachments', [
|
|
||||||
'thread' => "/api/threads/{$thread->id}",
|
|
||||||
'file' => UploadedFile::fake()->create('doc.pdf', 10, 'application/pdf'),
|
|
||||||
]);
|
|
||||||
|
|
||||||
$response->assertStatus(201);
|
|
||||||
$attachmentId = $response->json('id');
|
|
||||||
$attachment = Attachment::findOrFail($attachmentId);
|
|
||||||
|
|
||||||
$this->assertDatabaseHas('attachments', [
|
|
||||||
'id' => $attachment->id,
|
|
||||||
'thread_id' => $thread->id,
|
|
||||||
'user_id' => $user->id,
|
|
||||||
'extension' => 'pdf',
|
|
||||||
]);
|
|
||||||
|
|
||||||
Storage::disk('local')->assertExists($attachment->path);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('filters attachments by thread', function (): void {
|
|
||||||
Storage::fake('local');
|
|
||||||
makeAttachmentConfig();
|
|
||||||
|
|
||||||
$user = User::factory()->create();
|
|
||||||
$threadA = makeThreadForAttachments($user);
|
|
||||||
$threadB = makeThreadForAttachments($user);
|
|
||||||
|
|
||||||
Sanctum::actingAs($user);
|
|
||||||
|
|
||||||
$response = $this->postJson('/api/attachments', [
|
|
||||||
'thread' => "/api/threads/{$threadA->id}",
|
|
||||||
'file' => UploadedFile::fake()->create('doc.pdf', 10, 'application/pdf'),
|
|
||||||
]);
|
|
||||||
$response->assertStatus(201);
|
|
||||||
$attachmentId = $response->json('id');
|
|
||||||
|
|
||||||
$this->postJson('/api/attachments', [
|
|
||||||
'thread' => "/api/threads/{$threadB->id}",
|
|
||||||
'file' => UploadedFile::fake()->create('doc.pdf', 10, 'application/pdf'),
|
|
||||||
])->assertStatus(201);
|
|
||||||
|
|
||||||
$response = $this->getJson("/api/attachments?thread=/api/threads/{$threadA->id}");
|
|
||||||
|
|
||||||
$response->assertOk();
|
|
||||||
$response->assertJsonCount(1);
|
|
||||||
$response->assertJsonFragment(['id' => $attachmentId]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns 404 when parent thread is deleted', function (): void {
|
|
||||||
Storage::fake('local');
|
|
||||||
makeAttachmentConfig();
|
|
||||||
|
|
||||||
$user = User::factory()->create();
|
|
||||||
$thread = makeThreadForAttachments($user);
|
|
||||||
|
|
||||||
Sanctum::actingAs($user);
|
|
||||||
$response = $this->postJson('/api/attachments', [
|
|
||||||
'thread' => "/api/threads/{$thread->id}",
|
|
||||||
'file' => UploadedFile::fake()->create('doc.pdf', 10, 'application/pdf'),
|
|
||||||
]);
|
|
||||||
|
|
||||||
$response->assertStatus(201);
|
|
||||||
$attachmentId = $response->json('id');
|
|
||||||
|
|
||||||
$thread->delete();
|
|
||||||
|
|
||||||
$this->getJson("/api/attachments/{$attachmentId}")
|
|
||||||
->assertStatus(404);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('downloads attachment file when available', function (): void {
|
|
||||||
Storage::fake('local');
|
|
||||||
makeAttachmentConfig();
|
|
||||||
|
|
||||||
$user = User::factory()->create();
|
|
||||||
$thread = makeThreadForAttachments($user);
|
|
||||||
|
|
||||||
$attachment = Attachment::create([
|
|
||||||
'thread_id' => $thread->id,
|
|
||||||
'post_id' => null,
|
|
||||||
'attachment_extension_id' => null,
|
|
||||||
'attachment_group_id' => null,
|
|
||||||
'user_id' => $user->id,
|
|
||||||
'disk' => 'local',
|
|
||||||
'path' => 'attachments/threads/'.$thread->id.'/file.pdf',
|
|
||||||
'original_name' => 'file.pdf',
|
|
||||||
'extension' => 'pdf',
|
|
||||||
'mime_type' => 'application/pdf',
|
|
||||||
'size_bytes' => 4,
|
|
||||||
]);
|
|
||||||
|
|
||||||
Storage::disk('local')->put($attachment->path, 'data');
|
|
||||||
|
|
||||||
$response = $this->get("/api/attachments/{$attachment->id}/download");
|
|
||||||
$response->assertOk();
|
|
||||||
$response->assertHeader('content-type', 'application/pdf');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('serves attachment thumbnail when present', function (): void {
|
|
||||||
Storage::fake('local');
|
|
||||||
makeAttachmentConfig();
|
|
||||||
|
|
||||||
$user = User::factory()->create();
|
|
||||||
$thread = makeThreadForAttachments($user);
|
|
||||||
|
|
||||||
$attachment = Attachment::create([
|
|
||||||
'thread_id' => $thread->id,
|
|
||||||
'post_id' => null,
|
|
||||||
'attachment_extension_id' => null,
|
|
||||||
'attachment_group_id' => null,
|
|
||||||
'user_id' => $user->id,
|
|
||||||
'disk' => 'local',
|
|
||||||
'path' => 'attachments/threads/'.$thread->id.'/file.pdf',
|
|
||||||
'thumbnail_path' => 'attachments/threads/'.$thread->id.'/thumbs/thumb.jpg',
|
|
||||||
'thumbnail_mime_type' => 'image/jpeg',
|
|
||||||
'thumbnail_size_bytes' => 4,
|
|
||||||
'original_name' => 'file.pdf',
|
|
||||||
'extension' => 'pdf',
|
|
||||||
'mime_type' => 'application/pdf',
|
|
||||||
'size_bytes' => 4,
|
|
||||||
]);
|
|
||||||
|
|
||||||
Storage::disk('local')->put($attachment->path, 'data');
|
|
||||||
Storage::disk('local')->put($attachment->thumbnail_path, 'thumb');
|
|
||||||
|
|
||||||
$response = $this->get("/api/attachments/{$attachment->id}/thumbnail");
|
|
||||||
$response->assertOk();
|
|
||||||
$response->assertHeader('content-type', 'image/jpeg');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('soft deletes attachments when owner requests', function (): void {
|
|
||||||
Storage::fake('local');
|
|
||||||
makeAttachmentConfig();
|
|
||||||
|
|
||||||
$user = User::factory()->create();
|
|
||||||
$thread = makeThreadForAttachments($user);
|
|
||||||
|
|
||||||
$attachment = Attachment::create([
|
|
||||||
'thread_id' => $thread->id,
|
|
||||||
'post_id' => null,
|
|
||||||
'attachment_extension_id' => null,
|
|
||||||
'attachment_group_id' => null,
|
|
||||||
'user_id' => $user->id,
|
|
||||||
'disk' => 'local',
|
|
||||||
'path' => 'attachments/threads/'.$thread->id.'/file.pdf',
|
|
||||||
'original_name' => 'file.pdf',
|
|
||||||
'extension' => 'pdf',
|
|
||||||
'mime_type' => 'application/pdf',
|
|
||||||
'size_bytes' => 4,
|
|
||||||
]);
|
|
||||||
|
|
||||||
Sanctum::actingAs($user);
|
|
||||||
$response = $this->deleteJson("/api/attachments/{$attachment->id}");
|
|
||||||
|
|
||||||
$response->assertStatus(204);
|
|
||||||
$this->assertSoftDeleted('attachments', ['id' => $attachment->id]);
|
|
||||||
});
|
|
||||||
@@ -1,114 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
use App\Models\Attachment;
|
|
||||||
use App\Models\AttachmentExtension;
|
|
||||||
use App\Models\AttachmentGroup;
|
|
||||||
use App\Models\Role;
|
|
||||||
use App\Models\User;
|
|
||||||
use Laravel\Sanctum\Sanctum;
|
|
||||||
|
|
||||||
function makeAdminForAttachmentExtensions(): User
|
|
||||||
{
|
|
||||||
$admin = User::factory()->create();
|
|
||||||
$role = Role::firstOrCreate(['name' => 'ROLE_ADMIN'], ['color' => '#111111']);
|
|
||||||
$admin->roles()->attach($role);
|
|
||||||
|
|
||||||
return $admin;
|
|
||||||
}
|
|
||||||
|
|
||||||
it('lists extensions for admins', function (): void {
|
|
||||||
$admin = makeAdminForAttachmentExtensions();
|
|
||||||
$group = AttachmentGroup::create(['name' => 'General', 'max_size_kb' => 100, 'is_active' => true]);
|
|
||||||
AttachmentExtension::create([
|
|
||||||
'extension' => 'pdf',
|
|
||||||
'attachment_group_id' => $group->id,
|
|
||||||
]);
|
|
||||||
|
|
||||||
Sanctum::actingAs($admin);
|
|
||||||
$response = $this->getJson('/api/attachment-extensions');
|
|
||||||
|
|
||||||
$response->assertOk();
|
|
||||||
$response->assertJsonFragment(['extension' => 'pdf']);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('lists public extensions for active groups', function (): void {
|
|
||||||
$active = AttachmentGroup::create(['name' => 'Active', 'max_size_kb' => 100, 'is_active' => true]);
|
|
||||||
$inactive = AttachmentGroup::create(['name' => 'Inactive', 'max_size_kb' => 100, 'is_active' => false]);
|
|
||||||
|
|
||||||
AttachmentExtension::create([
|
|
||||||
'extension' => 'png',
|
|
||||||
'attachment_group_id' => $active->id,
|
|
||||||
]);
|
|
||||||
|
|
||||||
AttachmentExtension::create([
|
|
||||||
'extension' => 'exe',
|
|
||||||
'attachment_group_id' => $inactive->id,
|
|
||||||
]);
|
|
||||||
|
|
||||||
$response = $this->getJson('/api/attachment-extensions/public');
|
|
||||||
|
|
||||||
$response->assertOk();
|
|
||||||
$response->assertJsonFragment(['png']);
|
|
||||||
$response->assertJsonMissing(['exe']);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('creates extensions as admin and normalizes extension', function (): void {
|
|
||||||
$admin = makeAdminForAttachmentExtensions();
|
|
||||||
$group = AttachmentGroup::create(['name' => 'Docs', 'max_size_kb' => 100, 'is_active' => true]);
|
|
||||||
|
|
||||||
Sanctum::actingAs($admin);
|
|
||||||
$response = $this->postJson('/api/attachment-extensions', [
|
|
||||||
'extension' => '.PDF',
|
|
||||||
'attachment_group_id' => $group->id,
|
|
||||||
'allowed_mimes' => ['application/pdf'],
|
|
||||||
]);
|
|
||||||
|
|
||||||
$response->assertStatus(201);
|
|
||||||
$response->assertJsonFragment(['extension' => 'pdf']);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('updates extensions as admin', function (): void {
|
|
||||||
$admin = makeAdminForAttachmentExtensions();
|
|
||||||
$group = AttachmentGroup::create(['name' => 'Images', 'max_size_kb' => 100, 'is_active' => true]);
|
|
||||||
$ext = AttachmentExtension::create([
|
|
||||||
'extension' => 'png',
|
|
||||||
'attachment_group_id' => null,
|
|
||||||
]);
|
|
||||||
|
|
||||||
Sanctum::actingAs($admin);
|
|
||||||
$response = $this->patchJson("/api/attachment-extensions/{$ext->id}", [
|
|
||||||
'attachment_group_id' => $group->id,
|
|
||||||
'allowed_mimes' => ['image/png'],
|
|
||||||
]);
|
|
||||||
|
|
||||||
$response->assertOk();
|
|
||||||
$response->assertJsonFragment(['attachment_group_id' => $group->id]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('prevents deleting extensions in use', function (): void {
|
|
||||||
$admin = makeAdminForAttachmentExtensions();
|
|
||||||
$ext = AttachmentExtension::create([
|
|
||||||
'extension' => 'pdf',
|
|
||||||
'attachment_group_id' => null,
|
|
||||||
]);
|
|
||||||
|
|
||||||
Attachment::create([
|
|
||||||
'thread_id' => null,
|
|
||||||
'post_id' => null,
|
|
||||||
'attachment_extension_id' => $ext->id,
|
|
||||||
'attachment_group_id' => null,
|
|
||||||
'user_id' => null,
|
|
||||||
'disk' => 'local',
|
|
||||||
'path' => 'attachments/misc/file.pdf',
|
|
||||||
'original_name' => 'file.pdf',
|
|
||||||
'extension' => 'pdf',
|
|
||||||
'mime_type' => 'application/pdf',
|
|
||||||
'size_bytes' => 10,
|
|
||||||
]);
|
|
||||||
|
|
||||||
Sanctum::actingAs($admin);
|
|
||||||
$response = $this->deleteJson("/api/attachment-extensions/{$ext->id}");
|
|
||||||
|
|
||||||
$response->assertStatus(422);
|
|
||||||
$response->assertJsonFragment(['message' => 'Extension is in use.']);
|
|
||||||
});
|
|
||||||
@@ -1,136 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
use App\Models\Attachment;
|
|
||||||
use App\Models\AttachmentExtension;
|
|
||||||
use App\Models\AttachmentGroup;
|
|
||||||
use App\Models\Role;
|
|
||||||
use App\Models\User;
|
|
||||||
use Laravel\Sanctum\Sanctum;
|
|
||||||
|
|
||||||
function makeAdminForAttachmentGroups(): User
|
|
||||||
{
|
|
||||||
$admin = User::factory()->create();
|
|
||||||
$role = Role::firstOrCreate(['name' => 'ROLE_ADMIN'], ['color' => '#111111']);
|
|
||||||
$admin->roles()->attach($role);
|
|
||||||
|
|
||||||
return $admin;
|
|
||||||
}
|
|
||||||
|
|
||||||
it('lists attachment groups for admins', function (): void {
|
|
||||||
$admin = makeAdminForAttachmentGroups();
|
|
||||||
AttachmentGroup::create(['name' => 'General', 'max_size_kb' => 10, 'is_active' => true]);
|
|
||||||
|
|
||||||
Sanctum::actingAs($admin);
|
|
||||||
$response = $this->getJson('/api/attachment-groups');
|
|
||||||
|
|
||||||
$response->assertOk();
|
|
||||||
$response->assertJsonFragment(['name' => 'General']);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('creates attachment groups as admin', function (): void {
|
|
||||||
$admin = makeAdminForAttachmentGroups();
|
|
||||||
Sanctum::actingAs($admin);
|
|
||||||
|
|
||||||
$response = $this->postJson('/api/attachment-groups', [
|
|
||||||
'name' => 'Images',
|
|
||||||
'parent_id' => null,
|
|
||||||
'max_size_kb' => 1024,
|
|
||||||
'is_active' => true,
|
|
||||||
]);
|
|
||||||
|
|
||||||
$response->assertStatus(201);
|
|
||||||
$response->assertJsonFragment(['name' => 'Images']);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('updates attachment groups as admin', function (): void {
|
|
||||||
$admin = makeAdminForAttachmentGroups();
|
|
||||||
$group = AttachmentGroup::create([
|
|
||||||
'name' => 'Docs',
|
|
||||||
'parent_id' => null,
|
|
||||||
'position' => 1,
|
|
||||||
'max_size_kb' => 100,
|
|
||||||
'is_active' => true,
|
|
||||||
]);
|
|
||||||
|
|
||||||
Sanctum::actingAs($admin);
|
|
||||||
$response = $this->patchJson("/api/attachment-groups/{$group->id}", [
|
|
||||||
'name' => 'Docs Updated',
|
|
||||||
'parent_id' => null,
|
|
||||||
'max_size_kb' => 200,
|
|
||||||
'is_active' => false,
|
|
||||||
]);
|
|
||||||
|
|
||||||
$response->assertOk();
|
|
||||||
$response->assertJsonFragment(['name' => 'Docs Updated', 'is_active' => false]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('prevents deleting groups with extensions or attachments', function (): void {
|
|
||||||
$admin = makeAdminForAttachmentGroups();
|
|
||||||
$group = AttachmentGroup::create([
|
|
||||||
'name' => 'Protected',
|
|
||||||
'max_size_kb' => 100,
|
|
||||||
'is_active' => true,
|
|
||||||
]);
|
|
||||||
|
|
||||||
AttachmentExtension::create([
|
|
||||||
'extension' => 'pdf',
|
|
||||||
'attachment_group_id' => $group->id,
|
|
||||||
]);
|
|
||||||
|
|
||||||
Sanctum::actingAs($admin);
|
|
||||||
$response = $this->deleteJson("/api/attachment-groups/{$group->id}");
|
|
||||||
|
|
||||||
$response->assertStatus(422);
|
|
||||||
$response->assertJsonFragment(['message' => 'Attachment group has extensions.']);
|
|
||||||
|
|
||||||
$group2 = AttachmentGroup::create([
|
|
||||||
'name' => 'InUse',
|
|
||||||
'max_size_kb' => 100,
|
|
||||||
'is_active' => true,
|
|
||||||
]);
|
|
||||||
Attachment::create([
|
|
||||||
'thread_id' => null,
|
|
||||||
'post_id' => null,
|
|
||||||
'attachment_extension_id' => null,
|
|
||||||
'attachment_group_id' => $group2->id,
|
|
||||||
'user_id' => null,
|
|
||||||
'disk' => 'local',
|
|
||||||
'path' => 'attachments/misc/file.pdf',
|
|
||||||
'original_name' => 'file.pdf',
|
|
||||||
'extension' => 'pdf',
|
|
||||||
'mime_type' => 'application/pdf',
|
|
||||||
'size_bytes' => 10,
|
|
||||||
]);
|
|
||||||
|
|
||||||
$response = $this->deleteJson("/api/attachment-groups/{$group2->id}");
|
|
||||||
$response->assertStatus(422);
|
|
||||||
$response->assertJsonFragment(['message' => 'Attachment group is in use.']);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('reorders attachment groups', function (): void {
|
|
||||||
$admin = makeAdminForAttachmentGroups();
|
|
||||||
$first = AttachmentGroup::create([
|
|
||||||
'name' => 'First',
|
|
||||||
'position' => 1,
|
|
||||||
'max_size_kb' => 100,
|
|
||||||
'is_active' => true,
|
|
||||||
]);
|
|
||||||
$second = AttachmentGroup::create([
|
|
||||||
'name' => 'Second',
|
|
||||||
'position' => 2,
|
|
||||||
'max_size_kb' => 100,
|
|
||||||
'is_active' => true,
|
|
||||||
]);
|
|
||||||
|
|
||||||
Sanctum::actingAs($admin);
|
|
||||||
$response = $this->postJson('/api/attachment-groups/reorder', [
|
|
||||||
'parentId' => null,
|
|
||||||
'orderedIds' => [$second->id, $first->id],
|
|
||||||
]);
|
|
||||||
|
|
||||||
$response->assertOk();
|
|
||||||
$this->assertDatabaseHas('attachment_groups', [
|
|
||||||
'id' => $second->id,
|
|
||||||
'position' => 1,
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
use App\Models\AuditLog;
|
|
||||||
use App\Models\Role;
|
|
||||||
use App\Models\User;
|
|
||||||
use Laravel\Sanctum\Sanctum;
|
|
||||||
|
|
||||||
it('requires authentication to list audit logs', function (): void {
|
|
||||||
$response = $this->getJson('/api/audit-logs');
|
|
||||||
|
|
||||||
$response->assertStatus(401);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('forbids non-admin audit log access', function (): void {
|
|
||||||
$user = User::factory()->create();
|
|
||||||
Sanctum::actingAs($user);
|
|
||||||
|
|
||||||
$response = $this->getJson('/api/audit-logs');
|
|
||||||
|
|
||||||
$response->assertStatus(403);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('lists audit logs for admins', function (): void {
|
|
||||||
$admin = User::factory()->create();
|
|
||||||
$role = Role::firstOrCreate(['name' => 'ROLE_ADMIN'], ['color' => '#111111']);
|
|
||||||
$admin->roles()->attach($role);
|
|
||||||
|
|
||||||
$log = AuditLog::create([
|
|
||||||
'user_id' => $admin->id,
|
|
||||||
'action' => 'test.action',
|
|
||||||
'subject_type' => null,
|
|
||||||
'subject_id' => null,
|
|
||||||
'metadata' => ['foo' => 'bar'],
|
|
||||||
'ip_address' => '127.0.0.1',
|
|
||||||
'user_agent' => 'test',
|
|
||||||
]);
|
|
||||||
|
|
||||||
Sanctum::actingAs($admin);
|
|
||||||
$response = $this->getJson('/api/audit-logs');
|
|
||||||
|
|
||||||
$response->assertOk();
|
|
||||||
$response->assertJsonFragment([
|
|
||||||
'id' => $log->id,
|
|
||||||
'action' => 'test.action',
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
@@ -1,222 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
use App\Models\User;
|
|
||||||
use Illuminate\Auth\Notifications\VerifyEmail;
|
|
||||||
use Illuminate\Support\Facades\Hash;
|
|
||||||
use Illuminate\Support\Facades\Notification;
|
|
||||||
use Illuminate\Support\Facades\Password;
|
|
||||||
use Illuminate\Support\Facades\URL;
|
|
||||||
use Laravel\Sanctum\Sanctum;
|
|
||||||
|
|
||||||
it('registers a user with username and plainPassword', function (): void {
|
|
||||||
Notification::fake();
|
|
||||||
|
|
||||||
$response = $this->postJson('/api/register', [
|
|
||||||
'username' => 'NewUser',
|
|
||||||
'email' => 'newuser@example.com',
|
|
||||||
'plainPassword' => 'Password123!',
|
|
||||||
]);
|
|
||||||
|
|
||||||
$response->assertOk();
|
|
||||||
$response->assertJsonStructure(['user_id', 'email', 'message']);
|
|
||||||
|
|
||||||
$this->assertDatabaseHas('users', [
|
|
||||||
'email' => 'newuser@example.com',
|
|
||||||
'name' => 'NewUser',
|
|
||||||
'name_canonical' => 'newuser',
|
|
||||||
]);
|
|
||||||
|
|
||||||
$user = User::where('email', 'newuser@example.com')->firstOrFail();
|
|
||||||
Notification::assertSentTo($user, VerifyEmail::class);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects invalid login credentials', function (): void {
|
|
||||||
$user = User::factory()->create([
|
|
||||||
'password' => Hash::make('Password123!'),
|
|
||||||
]);
|
|
||||||
|
|
||||||
$response = $this->postJson('/api/login', [
|
|
||||||
'login' => $user->email,
|
|
||||||
'password' => 'wrong-password',
|
|
||||||
]);
|
|
||||||
|
|
||||||
$response->assertStatus(422);
|
|
||||||
$response->assertJsonValidationErrors(['login']);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('blocks login for unverified email', function (): void {
|
|
||||||
$user = User::factory()->unverified()->create([
|
|
||||||
'password' => Hash::make('Password123!'),
|
|
||||||
]);
|
|
||||||
|
|
||||||
$response = $this->postJson('/api/login', [
|
|
||||||
'login' => $user->email,
|
|
||||||
'password' => 'Password123!',
|
|
||||||
]);
|
|
||||||
|
|
||||||
$response->assertStatus(403);
|
|
||||||
$response->assertJsonFragment(['message' => 'Email not verified.']);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('logs in with username', function (): void {
|
|
||||||
$user = User::factory()->create([
|
|
||||||
'name' => 'TestUser',
|
|
||||||
'name_canonical' => 'testuser',
|
|
||||||
'password' => Hash::make('Password123!'),
|
|
||||||
]);
|
|
||||||
|
|
||||||
$response = $this->postJson('/api/login', [
|
|
||||||
'login' => 'TestUser',
|
|
||||||
'password' => 'Password123!',
|
|
||||||
]);
|
|
||||||
|
|
||||||
$response->assertOk();
|
|
||||||
$response->assertJsonStructure(['token', 'user_id', 'email', 'roles']);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('validates forgot password requests', function (): void {
|
|
||||||
$response = $this->postJson('/api/forgot-password', []);
|
|
||||||
|
|
||||||
$response->assertStatus(422);
|
|
||||||
$response->assertJsonValidationErrors(['email']);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('sends a reset link for valid email', function (): void {
|
|
||||||
$user = User::factory()->create([
|
|
||||||
'email' => 'reset@example.com',
|
|
||||||
]);
|
|
||||||
|
|
||||||
$response = $this->postJson('/api/forgot-password', [
|
|
||||||
'email' => $user->email,
|
|
||||||
]);
|
|
||||||
|
|
||||||
$response->assertOk();
|
|
||||||
$response->assertJsonStructure(['message']);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns validation error when reset link cannot be sent', function (): void {
|
|
||||||
Password::shouldReceive('sendResetLink')
|
|
||||||
->once()
|
|
||||||
->andReturn(Password::INVALID_USER);
|
|
||||||
|
|
||||||
$response = $this->postJson('/api/forgot-password', [
|
|
||||||
'email' => 'missing@example.com',
|
|
||||||
]);
|
|
||||||
|
|
||||||
$response->assertStatus(422);
|
|
||||||
$response->assertJsonValidationErrors(['email']);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('resets a password with a valid token', function (): void {
|
|
||||||
$user = User::factory()->create([
|
|
||||||
'email' => 'reset2@example.com',
|
|
||||||
'password' => Hash::make('OldPassword123!'),
|
|
||||||
]);
|
|
||||||
|
|
||||||
$token = Password::createToken($user);
|
|
||||||
|
|
||||||
$response = $this->postJson('/api/reset-password', [
|
|
||||||
'email' => $user->email,
|
|
||||||
'password' => 'NewPassword123!',
|
|
||||||
'password_confirmation' => 'NewPassword123!',
|
|
||||||
'token' => $token,
|
|
||||||
]);
|
|
||||||
|
|
||||||
$response->assertOk();
|
|
||||||
$response->assertJsonStructure(['message']);
|
|
||||||
|
|
||||||
$user->refresh();
|
|
||||||
expect(Hash::check('NewPassword123!', $user->password))->toBeTrue();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns validation error when reset fails', function (): void {
|
|
||||||
Password::shouldReceive('reset')
|
|
||||||
->once()
|
|
||||||
->andReturn(Password::INVALID_TOKEN);
|
|
||||||
|
|
||||||
$response = $this->postJson('/api/reset-password', [
|
|
||||||
'email' => 'resetfail@example.com',
|
|
||||||
'password' => 'NewPassword123!',
|
|
||||||
'password_confirmation' => 'NewPassword123!',
|
|
||||||
'token' => 'bad-token',
|
|
||||||
]);
|
|
||||||
|
|
||||||
$response->assertStatus(422);
|
|
||||||
$response->assertJsonValidationErrors(['email']);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('verifies email and redirects to login', function (): void {
|
|
||||||
$user = User::factory()->unverified()->create();
|
|
||||||
|
|
||||||
$hash = sha1($user->getEmailForVerification());
|
|
||||||
|
|
||||||
$url = URL::signedRoute('verification.verify', [
|
|
||||||
'id' => $user->id,
|
|
||||||
'hash' => $hash,
|
|
||||||
]);
|
|
||||||
|
|
||||||
$response = $this->get($url);
|
|
||||||
|
|
||||||
$response->assertRedirect('/login');
|
|
||||||
$user->refresh();
|
|
||||||
expect($user->hasVerifiedEmail())->toBeTrue();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects invalid email verification hash', function (): void {
|
|
||||||
$user = User::factory()->unverified()->create();
|
|
||||||
|
|
||||||
$url = URL::signedRoute('verification.verify', [
|
|
||||||
'id' => $user->id,
|
|
||||||
'hash' => sha1('wrong'),
|
|
||||||
]);
|
|
||||||
|
|
||||||
$response = $this->get($url);
|
|
||||||
|
|
||||||
$response->assertStatus(403);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('updates password for authenticated users', function (): void {
|
|
||||||
$user = User::factory()->create([
|
|
||||||
'password' => Hash::make('OldPass123!'),
|
|
||||||
]);
|
|
||||||
|
|
||||||
Sanctum::actingAs($user);
|
|
||||||
|
|
||||||
$response = $this->postJson('/api/user/password', [
|
|
||||||
'current_password' => 'OldPass123!',
|
|
||||||
'password' => 'NewPass123!',
|
|
||||||
'password_confirmation' => 'NewPass123!',
|
|
||||||
]);
|
|
||||||
|
|
||||||
$response->assertOk();
|
|
||||||
$response->assertJsonFragment(['message' => 'Password updated.']);
|
|
||||||
|
|
||||||
$user->refresh();
|
|
||||||
expect(Hash::check('NewPass123!', $user->password))->toBeTrue();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects password update with wrong current password', function (): void {
|
|
||||||
$user = User::factory()->create([
|
|
||||||
'password' => Hash::make('OldPass123!'),
|
|
||||||
]);
|
|
||||||
|
|
||||||
Sanctum::actingAs($user);
|
|
||||||
|
|
||||||
$response = $this->postJson('/api/user/password', [
|
|
||||||
'current_password' => 'WrongPass123!',
|
|
||||||
'password' => 'NewPass123!',
|
|
||||||
'password_confirmation' => 'NewPass123!',
|
|
||||||
]);
|
|
||||||
|
|
||||||
$response->assertStatus(422);
|
|
||||||
$response->assertJsonValidationErrors(['current_password']);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('logs out authenticated users', function (): void {
|
|
||||||
$user = User::factory()->create();
|
|
||||||
Sanctum::actingAs($user);
|
|
||||||
|
|
||||||
$response = $this->postJson('/api/logout');
|
|
||||||
|
|
||||||
$response->assertStatus(204);
|
|
||||||
});
|
|
||||||
@@ -1,473 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
use App\Models\Forum;
|
|
||||||
use App\Models\Post;
|
|
||||||
use App\Models\Role;
|
|
||||||
use App\Models\Thread;
|
|
||||||
use App\Models\User;
|
|
||||||
use Laravel\Sanctum\Sanctum;
|
|
||||||
|
|
||||||
it('can filter forums by parent exists', function (): void {
|
|
||||||
$category = Forum::create([
|
|
||||||
'name' => 'Category 1',
|
|
||||||
'description' => null,
|
|
||||||
'type' => 'category',
|
|
||||||
'parent_id' => null,
|
|
||||||
'position' => 1,
|
|
||||||
]);
|
|
||||||
|
|
||||||
$forum = Forum::create([
|
|
||||||
'name' => 'Forum A',
|
|
||||||
'description' => null,
|
|
||||||
'type' => 'forum',
|
|
||||||
'parent_id' => $category->id,
|
|
||||||
'position' => 1,
|
|
||||||
]);
|
|
||||||
|
|
||||||
$response = $this->getJson('/api/forums?parent[exists]=false');
|
|
||||||
$response->assertOk();
|
|
||||||
$response->assertJsonCount(1);
|
|
||||||
$response->assertJsonFragment(['id' => $category->id]);
|
|
||||||
|
|
||||||
$response = $this->getJson('/api/forums?parent[exists]=true');
|
|
||||||
$response->assertOk();
|
|
||||||
$response->assertJsonCount(1);
|
|
||||||
$response->assertJsonFragment(['id' => $forum->id]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('filters forums by parent id and type', function (): void {
|
|
||||||
$category = Forum::create([
|
|
||||||
'name' => 'Category 2',
|
|
||||||
'description' => null,
|
|
||||||
'type' => 'category',
|
|
||||||
'parent_id' => null,
|
|
||||||
'position' => 1,
|
|
||||||
]);
|
|
||||||
|
|
||||||
$forum = Forum::create([
|
|
||||||
'name' => 'Forum B',
|
|
||||||
'description' => null,
|
|
||||||
'type' => 'forum',
|
|
||||||
'parent_id' => $category->id,
|
|
||||||
'position' => 1,
|
|
||||||
]);
|
|
||||||
|
|
||||||
$response = $this->getJson("/api/forums?parent=/api/forums/{$category->id}");
|
|
||||||
$response->assertOk();
|
|
||||||
$response->assertJsonCount(1);
|
|
||||||
$response->assertJsonFragment(['id' => $forum->id]);
|
|
||||||
|
|
||||||
$response = $this->getJson('/api/forums?type=category');
|
|
||||||
$response->assertOk();
|
|
||||||
$response->assertJsonFragment(['id' => $category->id]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('shows forum with last post data', function (): void {
|
|
||||||
$role = Role::create(['name' => 'ROLE_MEMBER', 'color' => '#00ff00']);
|
|
||||||
$user = User::factory()->create();
|
|
||||||
$user->roles()->attach($role);
|
|
||||||
$user->load('roles');
|
|
||||||
|
|
||||||
$category = Forum::create([
|
|
||||||
'name' => 'Category 3',
|
|
||||||
'description' => null,
|
|
||||||
'type' => 'category',
|
|
||||||
'parent_id' => null,
|
|
||||||
'position' => 1,
|
|
||||||
]);
|
|
||||||
$forum = Forum::create([
|
|
||||||
'name' => 'Forum C',
|
|
||||||
'description' => null,
|
|
||||||
'type' => 'forum',
|
|
||||||
'parent_id' => $category->id,
|
|
||||||
'position' => 1,
|
|
||||||
]);
|
|
||||||
$thread = Thread::create([
|
|
||||||
'forum_id' => $forum->id,
|
|
||||||
'user_id' => $user->id,
|
|
||||||
'title' => 'Thread',
|
|
||||||
'body' => 'Body',
|
|
||||||
]);
|
|
||||||
$post = Post::create([
|
|
||||||
'thread_id' => $thread->id,
|
|
||||||
'user_id' => $user->id,
|
|
||||||
'body' => 'Reply',
|
|
||||||
]);
|
|
||||||
|
|
||||||
$response = $this->getJson("/api/forums/{$forum->id}");
|
|
||||||
$response->assertOk();
|
|
||||||
$response->assertJsonFragment([
|
|
||||||
'id' => $forum->id,
|
|
||||||
'last_post_user_id' => $user->id,
|
|
||||||
]);
|
|
||||||
$payload = $response->getData(true);
|
|
||||||
expect($payload['last_post_user_group_color'])->toBe('#00ff00');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('creates category and shifts positions', function (): void {
|
|
||||||
Sanctum::actingAs(User::factory()->create());
|
|
||||||
|
|
||||||
Forum::create([
|
|
||||||
'name' => 'Category A',
|
|
||||||
'description' => null,
|
|
||||||
'type' => 'category',
|
|
||||||
'parent_id' => null,
|
|
||||||
'position' => 1,
|
|
||||||
]);
|
|
||||||
|
|
||||||
$response = $this->postJson('/api/forums', [
|
|
||||||
'name' => 'Category B',
|
|
||||||
'type' => 'category',
|
|
||||||
'description' => 'Desc',
|
|
||||||
]);
|
|
||||||
|
|
||||||
$response->assertStatus(201);
|
|
||||||
$this->assertDatabaseHas('forums', [
|
|
||||||
'name' => 'Category A',
|
|
||||||
'position' => 2,
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('updates forum parent and description', function (): void {
|
|
||||||
Sanctum::actingAs(User::factory()->create());
|
|
||||||
|
|
||||||
$categoryA = Forum::create([
|
|
||||||
'name' => 'Category A',
|
|
||||||
'description' => null,
|
|
||||||
'type' => 'category',
|
|
||||||
'parent_id' => null,
|
|
||||||
'position' => 1,
|
|
||||||
]);
|
|
||||||
$categoryB = Forum::create([
|
|
||||||
'name' => 'Category B',
|
|
||||||
'description' => null,
|
|
||||||
'type' => 'category',
|
|
||||||
'parent_id' => null,
|
|
||||||
'position' => 2,
|
|
||||||
]);
|
|
||||||
$forum = Forum::create([
|
|
||||||
'name' => 'Forum D',
|
|
||||||
'description' => null,
|
|
||||||
'type' => 'forum',
|
|
||||||
'parent_id' => $categoryA->id,
|
|
||||||
'position' => 1,
|
|
||||||
]);
|
|
||||||
|
|
||||||
$response = $this->patchJson("/api/forums/{$forum->id}", [
|
|
||||||
'parent' => "/api/forums/{$categoryB->id}",
|
|
||||||
'description' => 'Updated',
|
|
||||||
]);
|
|
||||||
|
|
||||||
$response->assertOk();
|
|
||||||
$this->assertDatabaseHas('forums', [
|
|
||||||
'id' => $forum->id,
|
|
||||||
'parent_id' => $categoryB->id,
|
|
||||||
'description' => 'Updated',
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('updates forum name and type', function (): void {
|
|
||||||
Sanctum::actingAs(User::factory()->create());
|
|
||||||
|
|
||||||
$category = Forum::create([
|
|
||||||
'name' => 'Category H',
|
|
||||||
'description' => null,
|
|
||||||
'type' => 'category',
|
|
||||||
'parent_id' => null,
|
|
||||||
'position' => 1,
|
|
||||||
]);
|
|
||||||
$forum = Forum::create([
|
|
||||||
'name' => 'Forum H',
|
|
||||||
'description' => null,
|
|
||||||
'type' => 'forum',
|
|
||||||
'parent_id' => $category->id,
|
|
||||||
'position' => 1,
|
|
||||||
]);
|
|
||||||
|
|
||||||
$response = $this->patchJson("/api/forums/{$forum->id}", [
|
|
||||||
'name' => 'Forum H Updated',
|
|
||||||
'type' => 'forum',
|
|
||||||
]);
|
|
||||||
|
|
||||||
$response->assertOk();
|
|
||||||
$this->assertDatabaseHas('forums', [
|
|
||||||
'id' => $forum->id,
|
|
||||||
'name' => 'Forum H Updated',
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects forum update without category parent', function (): void {
|
|
||||||
Sanctum::actingAs(User::factory()->create());
|
|
||||||
|
|
||||||
$category = Forum::create([
|
|
||||||
'name' => 'Category Z',
|
|
||||||
'description' => null,
|
|
||||||
'type' => 'category',
|
|
||||||
'parent_id' => null,
|
|
||||||
'position' => 1,
|
|
||||||
]);
|
|
||||||
$forum = Forum::create([
|
|
||||||
'name' => 'Forum E',
|
|
||||||
'description' => null,
|
|
||||||
'type' => 'forum',
|
|
||||||
'parent_id' => $category->id,
|
|
||||||
'position' => 1,
|
|
||||||
]);
|
|
||||||
|
|
||||||
$response = $this->patchJson("/api/forums/{$forum->id}", [
|
|
||||||
'parent' => null,
|
|
||||||
]);
|
|
||||||
|
|
||||||
$response->assertStatus(422);
|
|
||||||
$response->assertJsonFragment(['message' => 'Forums must belong to a category.']);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects forum update with non-category parent', function (): void {
|
|
||||||
Sanctum::actingAs(User::factory()->create());
|
|
||||||
|
|
||||||
$category = Forum::create([
|
|
||||||
'name' => 'Category X',
|
|
||||||
'description' => null,
|
|
||||||
'type' => 'category',
|
|
||||||
'parent_id' => null,
|
|
||||||
'position' => 1,
|
|
||||||
]);
|
|
||||||
$parent = Forum::create([
|
|
||||||
'name' => 'Not Category',
|
|
||||||
'description' => null,
|
|
||||||
'type' => 'forum',
|
|
||||||
'parent_id' => $category->id,
|
|
||||||
'position' => 1,
|
|
||||||
]);
|
|
||||||
$forum = Forum::create([
|
|
||||||
'name' => 'Forum G',
|
|
||||||
'description' => null,
|
|
||||||
'type' => 'forum',
|
|
||||||
'parent_id' => $category->id,
|
|
||||||
'position' => 1,
|
|
||||||
]);
|
|
||||||
|
|
||||||
$response = $this->patchJson("/api/forums/{$forum->id}", [
|
|
||||||
'parent' => "/api/forums/{$parent->id}",
|
|
||||||
]);
|
|
||||||
|
|
||||||
$response->assertStatus(422);
|
|
||||||
$response->assertJsonFragment(['message' => 'Parent must be a category.']);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('destroys forum and sets deleted_by', function (): void {
|
|
||||||
$user = User::factory()->create();
|
|
||||||
Sanctum::actingAs($user);
|
|
||||||
|
|
||||||
$forum = Forum::create([
|
|
||||||
'name' => 'Forum F',
|
|
||||||
'description' => null,
|
|
||||||
'type' => 'category',
|
|
||||||
'parent_id' => null,
|
|
||||||
'position' => 1,
|
|
||||||
]);
|
|
||||||
|
|
||||||
$response = $this->deleteJson("/api/forums/{$forum->id}");
|
|
||||||
$response->assertStatus(204);
|
|
||||||
|
|
||||||
$forum->refresh();
|
|
||||||
expect($forum->deleted_by)->toBe($user->id);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('reorders with string parent id', function (): void {
|
|
||||||
Sanctum::actingAs(User::factory()->create());
|
|
||||||
|
|
||||||
$parent = Forum::create([
|
|
||||||
'name' => 'Cat Parent',
|
|
||||||
'description' => null,
|
|
||||||
'type' => 'category',
|
|
||||||
'parent_id' => null,
|
|
||||||
'position' => 1,
|
|
||||||
]);
|
|
||||||
$first = Forum::create([
|
|
||||||
'name' => 'Forum 1',
|
|
||||||
'description' => null,
|
|
||||||
'type' => 'forum',
|
|
||||||
'parent_id' => $parent->id,
|
|
||||||
'position' => 1,
|
|
||||||
]);
|
|
||||||
$second = Forum::create([
|
|
||||||
'name' => 'Forum 2',
|
|
||||||
'description' => null,
|
|
||||||
'type' => 'forum',
|
|
||||||
'parent_id' => $parent->id,
|
|
||||||
'position' => 2,
|
|
||||||
]);
|
|
||||||
|
|
||||||
$response = $this->postJson('/api/forums/reorder', [
|
|
||||||
'parentId' => (string) $parent->id,
|
|
||||||
'orderedIds' => [$second->id, $first->id],
|
|
||||||
]);
|
|
||||||
|
|
||||||
$response->assertOk();
|
|
||||||
$this->assertDatabaseHas('forums', ['id' => $second->id, 'position' => 1]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('reorders with empty parent id string', function (): void {
|
|
||||||
Sanctum::actingAs(User::factory()->create());
|
|
||||||
|
|
||||||
$first = Forum::create([
|
|
||||||
'name' => 'Cat X',
|
|
||||||
'description' => null,
|
|
||||||
'type' => 'category',
|
|
||||||
'parent_id' => null,
|
|
||||||
'position' => 1,
|
|
||||||
]);
|
|
||||||
$second = Forum::create([
|
|
||||||
'name' => 'Cat Y',
|
|
||||||
'description' => null,
|
|
||||||
'type' => 'category',
|
|
||||||
'parent_id' => null,
|
|
||||||
'position' => 2,
|
|
||||||
]);
|
|
||||||
|
|
||||||
$response = $this->postJson('/api/forums/reorder', [
|
|
||||||
'parentId' => '',
|
|
||||||
'orderedIds' => [$second->id, $first->id],
|
|
||||||
]);
|
|
||||||
|
|
||||||
$response->assertOk();
|
|
||||||
$this->assertDatabaseHas('forums', ['id' => $second->id, 'position' => 1]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('reorders with parent id null string', function (): void {
|
|
||||||
Sanctum::actingAs(User::factory()->create());
|
|
||||||
|
|
||||||
$first = Forum::create([
|
|
||||||
'name' => 'Cat N1',
|
|
||||||
'description' => null,
|
|
||||||
'type' => 'category',
|
|
||||||
'parent_id' => null,
|
|
||||||
'position' => 1,
|
|
||||||
]);
|
|
||||||
$second = Forum::create([
|
|
||||||
'name' => 'Cat N2',
|
|
||||||
'description' => null,
|
|
||||||
'type' => 'category',
|
|
||||||
'parent_id' => null,
|
|
||||||
'position' => 2,
|
|
||||||
]);
|
|
||||||
|
|
||||||
$response = $this->postJson('/api/forums/reorder', [
|
|
||||||
'parentId' => 'null',
|
|
||||||
'orderedIds' => [$second->id, $first->id],
|
|
||||||
]);
|
|
||||||
|
|
||||||
$response->assertOk();
|
|
||||||
$this->assertDatabaseHas('forums', ['id' => $second->id, 'position' => 1]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('creates forum under category and increments position', function (): void {
|
|
||||||
Sanctum::actingAs(User::factory()->create());
|
|
||||||
|
|
||||||
$category = Forum::create([
|
|
||||||
'name' => 'Category P',
|
|
||||||
'description' => null,
|
|
||||||
'type' => 'category',
|
|
||||||
'parent_id' => null,
|
|
||||||
'position' => 1,
|
|
||||||
]);
|
|
||||||
|
|
||||||
Forum::create([
|
|
||||||
'name' => 'Forum P1',
|
|
||||||
'description' => null,
|
|
||||||
'type' => 'forum',
|
|
||||||
'parent_id' => $category->id,
|
|
||||||
'position' => 1,
|
|
||||||
]);
|
|
||||||
|
|
||||||
$response = $this->postJson('/api/forums', [
|
|
||||||
'name' => 'Forum P2',
|
|
||||||
'type' => 'forum',
|
|
||||||
'parent' => "/api/forums/{$category->id}",
|
|
||||||
]);
|
|
||||||
|
|
||||||
$response->assertStatus(201);
|
|
||||||
$this->assertDatabaseHas('forums', [
|
|
||||||
'name' => 'Forum P2',
|
|
||||||
'position' => 2,
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects forum without category parent', function (): void {
|
|
||||||
Sanctum::actingAs(User::factory()->create());
|
|
||||||
|
|
||||||
$response = $this->postJson('/api/forums', [
|
|
||||||
'name' => 'Bad Forum',
|
|
||||||
'type' => 'forum',
|
|
||||||
'parent' => null,
|
|
||||||
]);
|
|
||||||
|
|
||||||
$response->assertStatus(422);
|
|
||||||
$response->assertJsonFragment(['message' => 'Forums must belong to a category.']);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects non-category parent', function (): void {
|
|
||||||
Sanctum::actingAs(User::factory()->create());
|
|
||||||
|
|
||||||
$category = Forum::create([
|
|
||||||
'name' => 'Category',
|
|
||||||
'description' => null,
|
|
||||||
'type' => 'category',
|
|
||||||
'parent_id' => null,
|
|
||||||
'position' => 1,
|
|
||||||
]);
|
|
||||||
|
|
||||||
$parent = Forum::create([
|
|
||||||
'name' => 'Not Category',
|
|
||||||
'description' => null,
|
|
||||||
'type' => 'forum',
|
|
||||||
'parent_id' => $category->id,
|
|
||||||
'position' => 1,
|
|
||||||
]);
|
|
||||||
|
|
||||||
$response = $this->postJson('/api/forums', [
|
|
||||||
'name' => 'Child Forum',
|
|
||||||
'type' => 'forum',
|
|
||||||
'parent' => "/api/forums/{$parent->id}",
|
|
||||||
]);
|
|
||||||
|
|
||||||
$response->assertStatus(422);
|
|
||||||
$response->assertJsonFragment(['message' => 'Parent must be a category.']);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('reorders positions within parent scope', function (): void {
|
|
||||||
Sanctum::actingAs(User::factory()->create());
|
|
||||||
|
|
||||||
$first = Forum::create([
|
|
||||||
'name' => 'Cat A',
|
|
||||||
'description' => null,
|
|
||||||
'type' => 'category',
|
|
||||||
'parent_id' => null,
|
|
||||||
'position' => 1,
|
|
||||||
]);
|
|
||||||
|
|
||||||
$second = Forum::create([
|
|
||||||
'name' => 'Cat B',
|
|
||||||
'description' => null,
|
|
||||||
'type' => 'category',
|
|
||||||
'parent_id' => null,
|
|
||||||
'position' => 2,
|
|
||||||
]);
|
|
||||||
|
|
||||||
$response = $this->postJson('/api/forums/reorder', [
|
|
||||||
'parentId' => null,
|
|
||||||
'orderedIds' => [$second->id, $first->id],
|
|
||||||
]);
|
|
||||||
|
|
||||||
$response->assertOk();
|
|
||||||
|
|
||||||
$this->assertDatabaseHas('forums', [
|
|
||||||
'id' => $second->id,
|
|
||||||
'position' => 1,
|
|
||||||
]);
|
|
||||||
$this->assertDatabaseHas('forums', [
|
|
||||||
'id' => $first->id,
|
|
||||||
'position' => 2,
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
it('returns translations for valid locale', function (): void {
|
|
||||||
$response = $this->getJson('/api/i18n/en');
|
|
||||||
|
|
||||||
$response->assertOk();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns 404 for missing locale', function (): void {
|
|
||||||
$response = $this->getJson('/api/i18n/xx');
|
|
||||||
|
|
||||||
$response->assertStatus(404);
|
|
||||||
});
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
it('redirects installer when env exists', function (): void {
|
|
||||||
$response = $this->get('/install');
|
|
||||||
|
|
||||||
$response->assertRedirect('/');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('blocks installer post when env exists', function (): void {
|
|
||||||
$response = $this->post('/install', [
|
|
||||||
'app_url' => 'https://example.com',
|
|
||||||
'db_host' => '127.0.0.1',
|
|
||||||
'db_port' => 3306,
|
|
||||||
'db_database' => 'test',
|
|
||||||
'db_username' => 'user',
|
|
||||||
'db_password' => 'pass',
|
|
||||||
'admin_name' => 'Admin',
|
|
||||||
'admin_email' => 'admin@example.com',
|
|
||||||
'admin_password' => 'Password123!',
|
|
||||||
]);
|
|
||||||
|
|
||||||
$response->assertRedirect('/');
|
|
||||||
});
|
|
||||||
@@ -1,135 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
use App\Models\Forum;
|
|
||||||
use App\Models\Post;
|
|
||||||
use App\Models\Rank;
|
|
||||||
use App\Models\Role;
|
|
||||||
use App\Models\Thread;
|
|
||||||
use App\Models\User;
|
|
||||||
use Laravel\Sanctum\Sanctum;
|
|
||||||
|
|
||||||
it('returns portal summary payload', function (): void {
|
|
||||||
$user = User::factory()->create();
|
|
||||||
|
|
||||||
$category = Forum::create([
|
|
||||||
'name' => 'Category',
|
|
||||||
'description' => null,
|
|
||||||
'type' => 'category',
|
|
||||||
'parent_id' => null,
|
|
||||||
'position' => 1,
|
|
||||||
]);
|
|
||||||
|
|
||||||
$forum = Forum::create([
|
|
||||||
'name' => 'Forum',
|
|
||||||
'description' => null,
|
|
||||||
'type' => 'forum',
|
|
||||||
'parent_id' => $category->id,
|
|
||||||
'position' => 1,
|
|
||||||
]);
|
|
||||||
|
|
||||||
$thread = Thread::create([
|
|
||||||
'forum_id' => $forum->id,
|
|
||||||
'user_id' => $user->id,
|
|
||||||
'title' => 'Thread',
|
|
||||||
'body' => 'Body',
|
|
||||||
]);
|
|
||||||
|
|
||||||
Post::create([
|
|
||||||
'thread_id' => $thread->id,
|
|
||||||
'user_id' => $user->id,
|
|
||||||
'body' => 'Reply',
|
|
||||||
]);
|
|
||||||
|
|
||||||
Sanctum::actingAs($user);
|
|
||||||
$response = $this->getJson('/api/portal/summary');
|
|
||||||
|
|
||||||
$response->assertOk();
|
|
||||||
$response->assertJsonStructure(['forums', 'threads', 'stats', 'profile']);
|
|
||||||
$response->assertJsonFragment(['name' => 'Forum']);
|
|
||||||
$response->assertJsonFragment(['title' => 'Thread']);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('includes avatar and rank data in portal threads', function (): void {
|
|
||||||
$rank = Rank::create([
|
|
||||||
'name' => 'Gold',
|
|
||||||
'badge_type' => 'image',
|
|
||||||
'badge_image_path' => 'ranks/gold.png',
|
|
||||||
]);
|
|
||||||
$role = Role::create(['name' => 'ROLE_SPECIAL', 'color' => '#ff0000']);
|
|
||||||
$user = User::factory()->create([
|
|
||||||
'avatar_path' => 'avatars/u.png',
|
|
||||||
'rank_id' => $rank->id,
|
|
||||||
]);
|
|
||||||
$user->roles()->attach($role);
|
|
||||||
|
|
||||||
$category = Forum::create([
|
|
||||||
'name' => 'Category',
|
|
||||||
'description' => null,
|
|
||||||
'type' => 'category',
|
|
||||||
'parent_id' => null,
|
|
||||||
'position' => 1,
|
|
||||||
]);
|
|
||||||
|
|
||||||
$forum = Forum::create([
|
|
||||||
'name' => 'Forum',
|
|
||||||
'description' => null,
|
|
||||||
'type' => 'forum',
|
|
||||||
'parent_id' => $category->id,
|
|
||||||
'position' => 1,
|
|
||||||
]);
|
|
||||||
|
|
||||||
$thread = Thread::create([
|
|
||||||
'forum_id' => $forum->id,
|
|
||||||
'user_id' => $user->id,
|
|
||||||
'title' => 'Thread',
|
|
||||||
'body' => 'Body',
|
|
||||||
]);
|
|
||||||
|
|
||||||
Sanctum::actingAs($user);
|
|
||||||
$response = $this->getJson('/api/portal/summary');
|
|
||||||
|
|
||||||
$response->assertOk();
|
|
||||||
$payload = $response->getData(true);
|
|
||||||
expect($payload['threads'][0]['user_avatar_url'])->not->toBeNull();
|
|
||||||
expect($payload['threads'][0]['user_rank_badge_url'])->not->toBeNull();
|
|
||||||
expect($payload['threads'][0]['user_group_color'])->toBe('#ff0000');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('handles empty forum last posts and resolveGroupColor', function (): void {
|
|
||||||
$user = User::factory()->create();
|
|
||||||
$user->setRelation('roles', null);
|
|
||||||
|
|
||||||
$category = Forum::create([
|
|
||||||
'name' => 'Category2',
|
|
||||||
'description' => null,
|
|
||||||
'type' => 'category',
|
|
||||||
'parent_id' => null,
|
|
||||||
'position' => 1,
|
|
||||||
]);
|
|
||||||
|
|
||||||
$forum = Forum::create([
|
|
||||||
'name' => 'Forum2',
|
|
||||||
'description' => null,
|
|
||||||
'type' => 'forum',
|
|
||||||
'parent_id' => $category->id,
|
|
||||||
'position' => 1,
|
|
||||||
]);
|
|
||||||
|
|
||||||
Sanctum::actingAs($user);
|
|
||||||
$response = $this->getJson('/api/portal/summary');
|
|
||||||
|
|
||||||
$response->assertOk();
|
|
||||||
$payload = $response->getData(true);
|
|
||||||
expect($payload['forums'][0]['last_post_user_group_color'])->toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('handles summary when no forums exist', function (): void {
|
|
||||||
$user = User::factory()->create();
|
|
||||||
Sanctum::actingAs($user);
|
|
||||||
|
|
||||||
$response = $this->getJson('/api/portal/summary');
|
|
||||||
|
|
||||||
$response->assertOk();
|
|
||||||
$payload = $response->getData(true);
|
|
||||||
expect($payload['forums'])->toBe([]);
|
|
||||||
});
|
|
||||||
@@ -1,233 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
use App\Models\Forum;
|
|
||||||
use App\Models\Post;
|
|
||||||
use App\Models\PostThank;
|
|
||||||
use App\Models\Role;
|
|
||||||
use App\Models\Thread;
|
|
||||||
use App\Models\User;
|
|
||||||
use Laravel\Sanctum\Sanctum;
|
|
||||||
|
|
||||||
beforeEach(function (): void {
|
|
||||||
$parserProp = new ReflectionProperty(\App\Actions\BbcodeFormatter::class, 'parser');
|
|
||||||
$parserProp->setAccessible(true);
|
|
||||||
$parserProp->setValue(
|
|
||||||
\Mockery::mock(\s9e\TextFormatter\Parser::class)
|
|
||||||
->shouldReceive('parse')
|
|
||||||
->andReturn('<r/>')
|
|
||||||
->getMock()
|
|
||||||
);
|
|
||||||
|
|
||||||
$rendererProp = new ReflectionProperty(\App\Actions\BbcodeFormatter::class, 'renderer');
|
|
||||||
$rendererProp->setAccessible(true);
|
|
||||||
$rendererProp->setValue(
|
|
||||||
\Mockery::mock(\s9e\TextFormatter\Renderer::class)
|
|
||||||
->shouldReceive('render')
|
|
||||||
->andReturn('<p></p>')
|
|
||||||
->getMock()
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(function (): void {
|
|
||||||
\Mockery::close();
|
|
||||||
});
|
|
||||||
|
|
||||||
function makeThread(): Thread
|
|
||||||
{
|
|
||||||
$category = Forum::create([
|
|
||||||
'name' => 'Category',
|
|
||||||
'description' => null,
|
|
||||||
'type' => 'category',
|
|
||||||
'parent_id' => null,
|
|
||||||
'position' => 1,
|
|
||||||
]);
|
|
||||||
|
|
||||||
$forum = Forum::create([
|
|
||||||
'name' => 'Forum',
|
|
||||||
'description' => null,
|
|
||||||
'type' => 'forum',
|
|
||||||
'parent_id' => $category->id,
|
|
||||||
'position' => 1,
|
|
||||||
]);
|
|
||||||
|
|
||||||
return Thread::create([
|
|
||||||
'forum_id' => $forum->id,
|
|
||||||
'user_id' => null,
|
|
||||||
'title' => 'Thread Title',
|
|
||||||
'body' => 'Thread Body',
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
it('creates a post in a thread', function (): void {
|
|
||||||
$user = User::factory()->create();
|
|
||||||
Sanctum::actingAs($user);
|
|
||||||
|
|
||||||
$thread = makeThread();
|
|
||||||
|
|
||||||
$response = $this->postJson('/api/posts', [
|
|
||||||
'body' => 'First reply',
|
|
||||||
'thread' => "/api/threads/{$thread->id}",
|
|
||||||
]);
|
|
||||||
|
|
||||||
$response->assertStatus(201);
|
|
||||||
$response->assertJsonFragment([
|
|
||||||
'body' => 'First reply',
|
|
||||||
'thread' => "/api/threads/{$thread->id}",
|
|
||||||
]);
|
|
||||||
|
|
||||||
$this->assertDatabaseHas('posts', [
|
|
||||||
'thread_id' => $thread->id,
|
|
||||||
'user_id' => $user->id,
|
|
||||||
'body' => 'First reply',
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('validates required fields when creating posts', function (): void {
|
|
||||||
$user = User::factory()->create();
|
|
||||||
Sanctum::actingAs($user);
|
|
||||||
|
|
||||||
$response = $this->postJson('/api/posts', []);
|
|
||||||
|
|
||||||
$response->assertStatus(422);
|
|
||||||
$response->assertJsonValidationErrors(['body', 'thread']);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('enforces post update permissions', function (): void {
|
|
||||||
$thread = makeThread();
|
|
||||||
$owner = User::factory()->create();
|
|
||||||
$other = User::factory()->create();
|
|
||||||
|
|
||||||
$post = Post::create([
|
|
||||||
'thread_id' => $thread->id,
|
|
||||||
'user_id' => $owner->id,
|
|
||||||
'body' => 'Original body',
|
|
||||||
]);
|
|
||||||
|
|
||||||
Sanctum::actingAs($other);
|
|
||||||
$response = $this->patchJson("/api/posts/{$post->id}", [
|
|
||||||
'body' => 'Hacked body',
|
|
||||||
]);
|
|
||||||
|
|
||||||
$response->assertStatus(403);
|
|
||||||
|
|
||||||
Sanctum::actingAs($owner);
|
|
||||||
$response = $this->patchJson("/api/posts/{$post->id}", [
|
|
||||||
'body' => 'Owner update',
|
|
||||||
]);
|
|
||||||
|
|
||||||
$response->assertOk();
|
|
||||||
$this->assertDatabaseHas('posts', [
|
|
||||||
'id' => $post->id,
|
|
||||||
'body' => 'Owner update',
|
|
||||||
]);
|
|
||||||
|
|
||||||
$admin = User::factory()->create();
|
|
||||||
$role = Role::create(['name' => 'ROLE_ADMIN', 'color' => '#111111']);
|
|
||||||
$admin->roles()->attach($role);
|
|
||||||
|
|
||||||
Sanctum::actingAs($admin);
|
|
||||||
$response = $this->patchJson("/api/posts/{$post->id}", [
|
|
||||||
'body' => 'Admin update',
|
|
||||||
]);
|
|
||||||
|
|
||||||
$response->assertOk();
|
|
||||||
$this->assertDatabaseHas('posts', [
|
|
||||||
'id' => $post->id,
|
|
||||||
'body' => 'Admin update',
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('requires authentication to update a post', function (): void {
|
|
||||||
$thread = makeThread();
|
|
||||||
$post = Post::create([
|
|
||||||
'thread_id' => $thread->id,
|
|
||||||
'user_id' => null,
|
|
||||||
'body' => 'Original body',
|
|
||||||
]);
|
|
||||||
|
|
||||||
$response = $this->patchJson("/api/posts/{$post->id}", [
|
|
||||||
'body' => 'Updated body',
|
|
||||||
]);
|
|
||||||
|
|
||||||
$response->assertStatus(401);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('deletes a post and tracks deleted_by', function (): void {
|
|
||||||
$thread = makeThread();
|
|
||||||
$user = User::factory()->create();
|
|
||||||
|
|
||||||
$post = Post::create([
|
|
||||||
'thread_id' => $thread->id,
|
|
||||||
'user_id' => $user->id,
|
|
||||||
'body' => 'To be deleted',
|
|
||||||
]);
|
|
||||||
|
|
||||||
Sanctum::actingAs($user);
|
|
||||||
$response = $this->deleteJson("/api/posts/{$post->id}");
|
|
||||||
|
|
||||||
$response->assertStatus(204);
|
|
||||||
|
|
||||||
$this->assertSoftDeleted('posts', [
|
|
||||||
'id' => $post->id,
|
|
||||||
]);
|
|
||||||
|
|
||||||
$this->assertDatabaseHas('posts', [
|
|
||||||
'id' => $post->id,
|
|
||||||
'deleted_by' => $user->id,
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('filters posts by thread', function (): void {
|
|
||||||
$threadA = makeThread();
|
|
||||||
$threadB = makeThread();
|
|
||||||
|
|
||||||
$postA = Post::create([
|
|
||||||
'thread_id' => $threadA->id,
|
|
||||||
'user_id' => null,
|
|
||||||
'body' => 'Post A',
|
|
||||||
]);
|
|
||||||
|
|
||||||
Post::create([
|
|
||||||
'thread_id' => $threadB->id,
|
|
||||||
'user_id' => null,
|
|
||||||
'body' => 'Post B',
|
|
||||||
]);
|
|
||||||
|
|
||||||
$response = $this->getJson("/api/posts?thread=/api/threads/{$threadA->id}");
|
|
||||||
|
|
||||||
$response->assertOk();
|
|
||||||
$response->assertJsonCount(1);
|
|
||||||
$response->assertJsonFragment([
|
|
||||||
'id' => $postA->id,
|
|
||||||
'body' => 'Post A',
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('allows users to thank and unthank posts', function (): void {
|
|
||||||
$thread = makeThread();
|
|
||||||
$author = User::factory()->create();
|
|
||||||
$thanker = User::factory()->create();
|
|
||||||
|
|
||||||
$post = Post::create([
|
|
||||||
'thread_id' => $thread->id,
|
|
||||||
'user_id' => $author->id,
|
|
||||||
'body' => 'Helpful answer',
|
|
||||||
]);
|
|
||||||
|
|
||||||
Sanctum::actingAs($thanker);
|
|
||||||
$response = $this->postJson("/api/posts/{$post->id}/thanks");
|
|
||||||
|
|
||||||
$response->assertStatus(201);
|
|
||||||
$this->assertDatabaseHas('post_thanks', [
|
|
||||||
'post_id' => $post->id,
|
|
||||||
'user_id' => $thanker->id,
|
|
||||||
]);
|
|
||||||
|
|
||||||
$response = $this->deleteJson("/api/posts/{$post->id}/thanks");
|
|
||||||
|
|
||||||
$response->assertStatus(204);
|
|
||||||
$this->assertDatabaseMissing('post_thanks', [
|
|
||||||
'post_id' => $post->id,
|
|
||||||
'user_id' => $thanker->id,
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
@@ -1,162 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
use App\Models\Forum;
|
|
||||||
use App\Models\Post;
|
|
||||||
use App\Models\PostThank;
|
|
||||||
use App\Models\Thread;
|
|
||||||
use App\Models\User;
|
|
||||||
use Laravel\Sanctum\Sanctum;
|
|
||||||
|
|
||||||
function makeThanksThread(): Thread
|
|
||||||
{
|
|
||||||
$category = Forum::create([
|
|
||||||
'name' => 'Category',
|
|
||||||
'description' => null,
|
|
||||||
'type' => 'category',
|
|
||||||
'parent_id' => null,
|
|
||||||
'position' => 1,
|
|
||||||
]);
|
|
||||||
|
|
||||||
$forum = Forum::create([
|
|
||||||
'name' => 'Forum',
|
|
||||||
'description' => null,
|
|
||||||
'type' => 'forum',
|
|
||||||
'parent_id' => $category->id,
|
|
||||||
'position' => 1,
|
|
||||||
]);
|
|
||||||
|
|
||||||
return Thread::create([
|
|
||||||
'forum_id' => $forum->id,
|
|
||||||
'user_id' => null,
|
|
||||||
'title' => 'Thanks Thread',
|
|
||||||
'body' => 'Thread Body',
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
it('lists thanks given by a user', function (): void {
|
|
||||||
$thread = makeThanksThread();
|
|
||||||
$author = User::factory()->create(['name' => 'Author']);
|
|
||||||
$thanker = User::factory()->create(['name' => 'ThanksGiver']);
|
|
||||||
|
|
||||||
$post = Post::create([
|
|
||||||
'thread_id' => $thread->id,
|
|
||||||
'user_id' => $author->id,
|
|
||||||
'body' => 'Helpful post',
|
|
||||||
]);
|
|
||||||
|
|
||||||
$thank = PostThank::create([
|
|
||||||
'post_id' => $post->id,
|
|
||||||
'user_id' => $thanker->id,
|
|
||||||
]);
|
|
||||||
|
|
||||||
Sanctum::actingAs($thanker);
|
|
||||||
$response = $this->getJson("/api/user/{$thanker->id}/thanks/given");
|
|
||||||
|
|
||||||
$response->assertOk();
|
|
||||||
$response->assertJsonFragment([
|
|
||||||
'id' => $thank->id,
|
|
||||||
'post_id' => $post->id,
|
|
||||||
'thread_id' => $thread->id,
|
|
||||||
'thread_title' => 'Thanks Thread',
|
|
||||||
'post_author_name' => 'Author',
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('lists thanks received for a user', function (): void {
|
|
||||||
$thread = makeThanksThread();
|
|
||||||
$author = User::factory()->create(['name' => 'Author']);
|
|
||||||
$thanker = User::factory()->create(['name' => 'ThanksGiver']);
|
|
||||||
|
|
||||||
$post = Post::create([
|
|
||||||
'thread_id' => $thread->id,
|
|
||||||
'user_id' => $author->id,
|
|
||||||
'body' => 'Helpful post',
|
|
||||||
]);
|
|
||||||
|
|
||||||
$thank = PostThank::create([
|
|
||||||
'post_id' => $post->id,
|
|
||||||
'user_id' => $thanker->id,
|
|
||||||
]);
|
|
||||||
|
|
||||||
Sanctum::actingAs($author);
|
|
||||||
$response = $this->getJson("/api/user/{$author->id}/thanks/received");
|
|
||||||
|
|
||||||
$response->assertOk();
|
|
||||||
$response->assertJsonFragment([
|
|
||||||
'id' => $thank->id,
|
|
||||||
'post_id' => $post->id,
|
|
||||||
'thread_id' => $thread->id,
|
|
||||||
'thread_title' => 'Thanks Thread',
|
|
||||||
'thanker_name' => 'ThanksGiver',
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('requires auth to thank and unthank posts', function (): void {
|
|
||||||
$thread = makeThanksThread();
|
|
||||||
$post = Post::create([
|
|
||||||
'thread_id' => $thread->id,
|
|
||||||
'user_id' => null,
|
|
||||||
'body' => 'Post',
|
|
||||||
]);
|
|
||||||
|
|
||||||
$this->app['auth']->forgetGuards();
|
|
||||||
$response = $this->postJson("/api/posts/{$post->id}/thanks");
|
|
||||||
$response->assertStatus(401);
|
|
||||||
|
|
||||||
$this->app['auth']->forgetGuards();
|
|
||||||
$response = $this->deleteJson("/api/posts/{$post->id}/thanks");
|
|
||||||
$response->assertStatus(401);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('creates and deletes thanks for a post', function (): void {
|
|
||||||
$thread = makeThanksThread();
|
|
||||||
$user = User::factory()->create();
|
|
||||||
$post = Post::create([
|
|
||||||
'thread_id' => $thread->id,
|
|
||||||
'user_id' => $user->id,
|
|
||||||
'body' => 'Post',
|
|
||||||
]);
|
|
||||||
|
|
||||||
Sanctum::actingAs($user);
|
|
||||||
$response = $this->postJson("/api/posts/{$post->id}/thanks");
|
|
||||||
$response->assertStatus(201);
|
|
||||||
|
|
||||||
$response = $this->deleteJson("/api/posts/{$post->id}/thanks");
|
|
||||||
$response->assertStatus(204);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('serializes group colors for thanks', function (): void {
|
|
||||||
$thread = makeThanksThread();
|
|
||||||
$authorRole = \App\Models\Role::create(['name' => 'ROLE_AUTHOR', 'color' => '#ff0000']);
|
|
||||||
$thankerRole = \App\Models\Role::create(['name' => 'ROLE_THANKER', 'color' => '#00ff00']);
|
|
||||||
|
|
||||||
$author = User::factory()->create(['name' => 'Author']);
|
|
||||||
$author->roles()->attach($authorRole);
|
|
||||||
$author->load('roles');
|
|
||||||
$thanker = User::factory()->create(['name' => 'ThanksGiver']);
|
|
||||||
$thanker->roles()->attach($thankerRole);
|
|
||||||
$thanker->load('roles');
|
|
||||||
|
|
||||||
$post = Post::create([
|
|
||||||
'thread_id' => $thread->id,
|
|
||||||
'user_id' => $author->id,
|
|
||||||
'body' => 'Helpful post',
|
|
||||||
]);
|
|
||||||
|
|
||||||
PostThank::create([
|
|
||||||
'post_id' => $post->id,
|
|
||||||
'user_id' => $thanker->id,
|
|
||||||
]);
|
|
||||||
|
|
||||||
Sanctum::actingAs($thanker);
|
|
||||||
$response = $this->getJson("/api/user/{$thanker->id}/thanks/given");
|
|
||||||
$response->assertOk();
|
|
||||||
$payload = $response->getData(true);
|
|
||||||
expect($payload[0]['post_author_group_color'])->toBe('#ff0000');
|
|
||||||
|
|
||||||
Sanctum::actingAs($author);
|
|
||||||
$response = $this->getJson("/api/user/{$author->id}/thanks/received");
|
|
||||||
$response->assertOk();
|
|
||||||
$payload = $response->getData(true);
|
|
||||||
expect($payload[0]['thanker_group_color'])->toBe('#00ff00');
|
|
||||||
});
|
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
it('renders bbcode preview', function (): void {
|
|
||||||
$parserProp = new ReflectionProperty(\App\Actions\BbcodeFormatter::class, 'parser');
|
|
||||||
$parserProp->setAccessible(true);
|
|
||||||
$parserProp->setValue(
|
|
||||||
\Mockery::mock(\s9e\TextFormatter\Parser::class)
|
|
||||||
->shouldReceive('parse')
|
|
||||||
->andReturn('<r/>')
|
|
||||||
->getMock()
|
|
||||||
);
|
|
||||||
|
|
||||||
$rendererProp = new ReflectionProperty(\App\Actions\BbcodeFormatter::class, 'renderer');
|
|
||||||
$rendererProp->setAccessible(true);
|
|
||||||
$rendererProp->setValue(
|
|
||||||
\Mockery::mock(\s9e\TextFormatter\Renderer::class)
|
|
||||||
->shouldReceive('render')
|
|
||||||
->andReturn('<p></p>')
|
|
||||||
->getMock()
|
|
||||||
);
|
|
||||||
|
|
||||||
$user = \App\Models\User::factory()->create();
|
|
||||||
\Laravel\Sanctum\Sanctum::actingAs($user);
|
|
||||||
|
|
||||||
$response = $this->postJson('/api/preview', [
|
|
||||||
'body' => '[b]Hello[/b]',
|
|
||||||
]);
|
|
||||||
|
|
||||||
$response->assertOk();
|
|
||||||
$response->assertJsonStructure(['html']);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('validates preview body', function (): void {
|
|
||||||
$parserProp = new ReflectionProperty(\App\Actions\BbcodeFormatter::class, 'parser');
|
|
||||||
$parserProp->setAccessible(true);
|
|
||||||
$parserProp->setValue(
|
|
||||||
\Mockery::mock(\s9e\TextFormatter\Parser::class)
|
|
||||||
->shouldReceive('parse')
|
|
||||||
->andReturn('<r/>')
|
|
||||||
->getMock()
|
|
||||||
);
|
|
||||||
|
|
||||||
$rendererProp = new ReflectionProperty(\App\Actions\BbcodeFormatter::class, 'renderer');
|
|
||||||
$rendererProp->setAccessible(true);
|
|
||||||
$rendererProp->setValue(
|
|
||||||
\Mockery::mock(\s9e\TextFormatter\Renderer::class)
|
|
||||||
->shouldReceive('render')
|
|
||||||
->andReturn('<p></p>')
|
|
||||||
->getMock()
|
|
||||||
);
|
|
||||||
|
|
||||||
$user = \App\Models\User::factory()->create();
|
|
||||||
\Laravel\Sanctum\Sanctum::actingAs($user);
|
|
||||||
|
|
||||||
$response = $this->postJson('/api/preview', []);
|
|
||||||
|
|
||||||
$response->assertStatus(422);
|
|
||||||
$response->assertJsonValidationErrors(['body']);
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(function (): void {
|
|
||||||
\Mockery::close();
|
|
||||||
});
|
|
||||||
@@ -1,231 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
use App\Models\Rank;
|
|
||||||
use App\Models\Role;
|
|
||||||
use App\Models\User;
|
|
||||||
use Illuminate\Http\UploadedFile;
|
|
||||||
use Illuminate\Support\Facades\Storage;
|
|
||||||
use Laravel\Sanctum\Sanctum;
|
|
||||||
|
|
||||||
function makeAdminForRanks(): User
|
|
||||||
{
|
|
||||||
$admin = User::factory()->create();
|
|
||||||
$role = Role::firstOrCreate(['name' => 'ROLE_ADMIN'], ['color' => '#111111']);
|
|
||||||
$admin->roles()->attach($role);
|
|
||||||
|
|
||||||
return $admin;
|
|
||||||
}
|
|
||||||
|
|
||||||
it('lists ranks for authenticated users', function (): void {
|
|
||||||
$user = User::factory()->create();
|
|
||||||
Rank::create(['name' => 'Bronze']);
|
|
||||||
|
|
||||||
Sanctum::actingAs($user);
|
|
||||||
$response = $this->getJson('/api/ranks');
|
|
||||||
|
|
||||||
$response->assertOk();
|
|
||||||
$response->assertJsonFragment(['name' => 'Bronze']);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('forbids non-admin rank changes', function (): void {
|
|
||||||
$user = User::factory()->create();
|
|
||||||
$rank = Rank::create(['name' => 'Nope']);
|
|
||||||
|
|
||||||
Sanctum::actingAs($user);
|
|
||||||
|
|
||||||
$response = $this->postJson('/api/ranks', [
|
|
||||||
'name' => 'Silver',
|
|
||||||
]);
|
|
||||||
$response->assertStatus(403);
|
|
||||||
|
|
||||||
$response = $this->patchJson("/api/ranks/{$rank->id}", [
|
|
||||||
'name' => 'Nope',
|
|
||||||
]);
|
|
||||||
$response->assertStatus(403);
|
|
||||||
|
|
||||||
$response = $this->deleteJson("/api/ranks/{$rank->id}");
|
|
||||||
$response->assertStatus(403);
|
|
||||||
|
|
||||||
$response = $this->postJson("/api/ranks/{$rank->id}/badge-image", [
|
|
||||||
'file' => UploadedFile::fake()->image('badge.png', 50, 50),
|
|
||||||
]);
|
|
||||||
$response->assertStatus(403);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('creates ranks as admin', function (): void {
|
|
||||||
$admin = makeAdminForRanks();
|
|
||||||
Sanctum::actingAs($admin);
|
|
||||||
|
|
||||||
$response = $this->postJson('/api/ranks', [
|
|
||||||
'name' => 'Silver',
|
|
||||||
'badge_type' => 'text',
|
|
||||||
'badge_text' => 'S',
|
|
||||||
'color' => '#abcdef',
|
|
||||||
]);
|
|
||||||
|
|
||||||
$response->assertStatus(201);
|
|
||||||
$response->assertJsonFragment([
|
|
||||||
'name' => 'Silver',
|
|
||||||
'badge_text' => 'S',
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('creates ranks with none badge type', function (): void {
|
|
||||||
$admin = makeAdminForRanks();
|
|
||||||
Sanctum::actingAs($admin);
|
|
||||||
|
|
||||||
$response = $this->postJson('/api/ranks', [
|
|
||||||
'name' => 'NoBadge',
|
|
||||||
'badge_type' => 'none',
|
|
||||||
]);
|
|
||||||
|
|
||||||
$response->assertStatus(201);
|
|
||||||
$response->assertJsonFragment([
|
|
||||||
'name' => 'NoBadge',
|
|
||||||
'badge_text' => null,
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('updates ranks and clears badge images when switching to text', function (): void {
|
|
||||||
Storage::fake('public');
|
|
||||||
|
|
||||||
$admin = makeAdminForRanks();
|
|
||||||
$rank = Rank::create([
|
|
||||||
'name' => 'Gold',
|
|
||||||
'badge_type' => 'image',
|
|
||||||
'badge_text' => null,
|
|
||||||
'badge_image_path' => 'rank-badges/old.png',
|
|
||||||
'color' => '#ffaa00',
|
|
||||||
]);
|
|
||||||
|
|
||||||
Storage::disk('public')->put('rank-badges/old.png', 'old');
|
|
||||||
|
|
||||||
Sanctum::actingAs($admin);
|
|
||||||
$response = $this->patchJson("/api/ranks/{$rank->id}", [
|
|
||||||
'name' => 'Gold',
|
|
||||||
'badge_type' => 'text',
|
|
||||||
'badge_text' => 'G',
|
|
||||||
'color' => '#ffaa00',
|
|
||||||
]);
|
|
||||||
|
|
||||||
$response->assertOk();
|
|
||||||
Storage::disk('public')->assertMissing('rank-badges/old.png');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('updates ranks with badge_type none', function (): void {
|
|
||||||
$admin = makeAdminForRanks();
|
|
||||||
$rank = Rank::create([
|
|
||||||
'name' => 'Plain',
|
|
||||||
'badge_type' => 'text',
|
|
||||||
'badge_text' => 'P',
|
|
||||||
]);
|
|
||||||
|
|
||||||
Sanctum::actingAs($admin);
|
|
||||||
$response = $this->patchJson("/api/ranks/{$rank->id}", [
|
|
||||||
'name' => 'Plain',
|
|
||||||
'badge_type' => 'none',
|
|
||||||
]);
|
|
||||||
|
|
||||||
$response->assertOk();
|
|
||||||
$response->assertJsonFragment(['badge_text' => null]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('updates ranks to image badge and keeps existing image', function (): void {
|
|
||||||
Storage::fake('public');
|
|
||||||
|
|
||||||
$admin = makeAdminForRanks();
|
|
||||||
$rank = Rank::create([
|
|
||||||
'name' => 'ImageRank',
|
|
||||||
'badge_type' => 'image',
|
|
||||||
'badge_text' => null,
|
|
||||||
'badge_image_path' => 'rank-badges/existing.png',
|
|
||||||
]);
|
|
||||||
|
|
||||||
Storage::disk('public')->put('rank-badges/existing.png', 'existing');
|
|
||||||
|
|
||||||
Sanctum::actingAs($admin);
|
|
||||||
$response = $this->patchJson("/api/ranks/{$rank->id}", [
|
|
||||||
'name' => 'ImageRank',
|
|
||||||
'badge_type' => 'image',
|
|
||||||
]);
|
|
||||||
|
|
||||||
$response->assertOk();
|
|
||||||
Storage::disk('public')->assertExists('rank-badges/existing.png');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('uploads a rank badge image', function (): void {
|
|
||||||
Storage::fake('public');
|
|
||||||
|
|
||||||
$admin = makeAdminForRanks();
|
|
||||||
$rank = Rank::create(['name' => 'Platinum']);
|
|
||||||
|
|
||||||
Sanctum::actingAs($admin);
|
|
||||||
$response = $this->postJson("/api/ranks/{$rank->id}/badge-image", [
|
|
||||||
'file' => UploadedFile::fake()->image('badge.png', 50, 50),
|
|
||||||
]);
|
|
||||||
|
|
||||||
$response->assertOk();
|
|
||||||
$response->assertJsonFragment(['badge_type' => 'image']);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('includes badge image url in rank list when present', function (): void {
|
|
||||||
Storage::fake('public');
|
|
||||||
Storage::disk('public')->put('rank-badges/show.png', 'img');
|
|
||||||
|
|
||||||
$user = User::factory()->create();
|
|
||||||
Rank::create([
|
|
||||||
'name' => 'WithImage',
|
|
||||||
'badge_type' => 'image',
|
|
||||||
'badge_image_path' => 'rank-badges/show.png',
|
|
||||||
]);
|
|
||||||
|
|
||||||
Sanctum::actingAs($user);
|
|
||||||
$response = $this->getJson('/api/ranks');
|
|
||||||
|
|
||||||
$response->assertOk();
|
|
||||||
$response->assertJsonFragment([
|
|
||||||
'name' => 'WithImage',
|
|
||||||
]);
|
|
||||||
expect($response->getData(true)[0]['badge_image_url'])->not->toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('uploads badge image replaces existing one', function (): void {
|
|
||||||
Storage::fake('public');
|
|
||||||
|
|
||||||
$admin = makeAdminForRanks();
|
|
||||||
$rank = Rank::create([
|
|
||||||
'name' => 'Replace',
|
|
||||||
'badge_type' => 'image',
|
|
||||||
'badge_image_path' => 'rank-badges/old.png',
|
|
||||||
]);
|
|
||||||
|
|
||||||
Storage::disk('public')->put('rank-badges/old.png', 'old');
|
|
||||||
|
|
||||||
Sanctum::actingAs($admin);
|
|
||||||
$response = $this->postJson("/api/ranks/{$rank->id}/badge-image", [
|
|
||||||
'file' => UploadedFile::fake()->image('badge.png', 50, 50),
|
|
||||||
]);
|
|
||||||
|
|
||||||
$response->assertOk();
|
|
||||||
Storage::disk('public')->assertMissing('rank-badges/old.png');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('deletes ranks as admin', function (): void {
|
|
||||||
Storage::fake('public');
|
|
||||||
|
|
||||||
$admin = makeAdminForRanks();
|
|
||||||
$rank = Rank::create([
|
|
||||||
'name' => 'ToDelete',
|
|
||||||
'badge_type' => 'image',
|
|
||||||
'badge_image_path' => 'rank-badges/delete.png',
|
|
||||||
]);
|
|
||||||
|
|
||||||
Storage::disk('public')->put('rank-badges/delete.png', 'old');
|
|
||||||
|
|
||||||
Sanctum::actingAs($admin);
|
|
||||||
$response = $this->deleteJson("/api/ranks/{$rank->id}");
|
|
||||||
|
|
||||||
$response->assertStatus(204);
|
|
||||||
Storage::disk('public')->assertMissing('rank-badges/delete.png');
|
|
||||||
$this->assertDatabaseMissing('ranks', ['id' => $rank->id]);
|
|
||||||
});
|
|
||||||
@@ -1,180 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
use App\Models\Role;
|
|
||||||
use App\Models\User;
|
|
||||||
use Laravel\Sanctum\Sanctum;
|
|
||||||
|
|
||||||
function makeAdminForRoles(): User
|
|
||||||
{
|
|
||||||
$admin = User::factory()->create();
|
|
||||||
$role = Role::firstOrCreate(['name' => 'ROLE_ADMIN'], ['color' => '#111111']);
|
|
||||||
$admin->roles()->attach($role);
|
|
||||||
|
|
||||||
return $admin;
|
|
||||||
}
|
|
||||||
|
|
||||||
it('forbids non-admin role access', function (): void {
|
|
||||||
$user = User::factory()->create();
|
|
||||||
Sanctum::actingAs($user);
|
|
||||||
|
|
||||||
$response = $this->getJson('/api/roles');
|
|
||||||
|
|
||||||
$response->assertStatus(403);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('creates normalized roles as admin', function (): void {
|
|
||||||
$admin = makeAdminForRoles();
|
|
||||||
Sanctum::actingAs($admin);
|
|
||||||
|
|
||||||
$response = $this->postJson('/api/roles', [
|
|
||||||
'name' => 'moderator',
|
|
||||||
'color' => '#abcdef',
|
|
||||||
]);
|
|
||||||
|
|
||||||
$response->assertStatus(201);
|
|
||||||
$response->assertJsonFragment([
|
|
||||||
'name' => 'ROLE_MODERATOR',
|
|
||||||
'color' => '#abcdef',
|
|
||||||
]);
|
|
||||||
|
|
||||||
$this->assertDatabaseHas('roles', [
|
|
||||||
'name' => 'ROLE_MODERATOR',
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('lists roles for admins', function (): void {
|
|
||||||
$admin = makeAdminForRoles();
|
|
||||||
Role::create(['name' => 'ROLE_ALPHA', 'color' => '#111111']);
|
|
||||||
|
|
||||||
Sanctum::actingAs($admin);
|
|
||||||
$response = $this->getJson('/api/roles');
|
|
||||||
|
|
||||||
$response->assertOk();
|
|
||||||
$response->assertJsonFragment(['name' => 'ROLE_ALPHA']);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('prevents renaming core roles', function (): void {
|
|
||||||
$admin = makeAdminForRoles();
|
|
||||||
$core = Role::firstOrCreate(['name' => 'ROLE_ADMIN'], ['color' => '#111111']);
|
|
||||||
|
|
||||||
Sanctum::actingAs($admin);
|
|
||||||
$response = $this->patchJson("/api/roles/{$core->id}", [
|
|
||||||
'name' => 'ROLE_SUPER',
|
|
||||||
'color' => '#123456',
|
|
||||||
]);
|
|
||||||
|
|
||||||
$response->assertStatus(422);
|
|
||||||
$response->assertJsonFragment(['message' => 'Core roles cannot be renamed.']);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('prevents creating duplicate roles after normalization', function (): void {
|
|
||||||
$admin = makeAdminForRoles();
|
|
||||||
Role::create(['name' => 'ROLE_TEST', 'color' => '#111111']);
|
|
||||||
|
|
||||||
Sanctum::actingAs($admin);
|
|
||||||
$response = $this->postJson('/api/roles', [
|
|
||||||
'name' => 'test',
|
|
||||||
'color' => '#222222',
|
|
||||||
]);
|
|
||||||
|
|
||||||
$response->assertStatus(422);
|
|
||||||
$response->assertJsonFragment(['message' => 'Role already exists.']);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('updates role color when provided and keeps name', function (): void {
|
|
||||||
$admin = makeAdminForRoles();
|
|
||||||
$role = Role::create(['name' => 'ROLE_EDIT', 'color' => '#111111']);
|
|
||||||
|
|
||||||
Sanctum::actingAs($admin);
|
|
||||||
$response = $this->patchJson("/api/roles/{$role->id}", [
|
|
||||||
'name' => 'ROLE_EDIT',
|
|
||||||
'color' => '#222222',
|
|
||||||
]);
|
|
||||||
|
|
||||||
$response->assertOk();
|
|
||||||
$response->assertJsonFragment(['color' => '#222222']);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('prevents updating to duplicate normalized name', function (): void {
|
|
||||||
$admin = makeAdminForRoles();
|
|
||||||
$first = Role::create(['name' => 'ROLE_FIRST', 'color' => '#111111']);
|
|
||||||
$second = Role::create(['name' => 'ROLE_SECOND', 'color' => '#111111']);
|
|
||||||
|
|
||||||
Sanctum::actingAs($admin);
|
|
||||||
$response = $this->patchJson("/api/roles/{$second->id}", [
|
|
||||||
'name' => 'first',
|
|
||||||
'color' => '#111111',
|
|
||||||
]);
|
|
||||||
|
|
||||||
$response->assertStatus(422);
|
|
||||||
$response->assertJsonFragment(['message' => 'Role already exists.']);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('prevents deleting core roles', function (): void {
|
|
||||||
$admin = makeAdminForRoles();
|
|
||||||
$core = Role::firstOrCreate(['name' => 'ROLE_USER'], ['color' => '#111111']);
|
|
||||||
|
|
||||||
Sanctum::actingAs($admin);
|
|
||||||
$response = $this->deleteJson("/api/roles/{$core->id}");
|
|
||||||
|
|
||||||
$response->assertStatus(422);
|
|
||||||
$response->assertJsonFragment(['message' => 'Core roles cannot be deleted.']);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('prevents deleting roles assigned to users', function (): void {
|
|
||||||
$admin = makeAdminForRoles();
|
|
||||||
$role = Role::create(['name' => 'ROLE_HELPER', 'color' => '#222222']);
|
|
||||||
$user = User::factory()->create();
|
|
||||||
$user->roles()->attach($role);
|
|
||||||
|
|
||||||
Sanctum::actingAs($admin);
|
|
||||||
$response = $this->deleteJson("/api/roles/{$role->id}");
|
|
||||||
|
|
||||||
$response->assertStatus(422);
|
|
||||||
$response->assertJsonFragment(['message' => 'Role is assigned to users.']);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('deletes non-core roles without assignments', function (): void {
|
|
||||||
$admin = makeAdminForRoles();
|
|
||||||
$role = Role::create(['name' => 'ROLE_CUSTOM', 'color' => '#333333']);
|
|
||||||
|
|
||||||
Sanctum::actingAs($admin);
|
|
||||||
$response = $this->deleteJson("/api/roles/{$role->id}");
|
|
||||||
|
|
||||||
$response->assertStatus(204);
|
|
||||||
$this->assertDatabaseMissing('roles', ['id' => $role->id]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('forbids non-admin create update delete', function (): void {
|
|
||||||
$user = User::factory()->create();
|
|
||||||
Sanctum::actingAs($user);
|
|
||||||
|
|
||||||
$response = $this->postJson('/api/roles', [
|
|
||||||
'name' => 'helper',
|
|
||||||
'color' => '#111111',
|
|
||||||
]);
|
|
||||||
$response->assertStatus(403);
|
|
||||||
|
|
||||||
$role = Role::create(['name' => 'ROLE_TEMP', 'color' => '#111111']);
|
|
||||||
$response = $this->patchJson("/api/roles/{$role->id}", [
|
|
||||||
'name' => 'ROLE_TEMP',
|
|
||||||
'color' => '#222222',
|
|
||||||
]);
|
|
||||||
$response->assertStatus(403);
|
|
||||||
|
|
||||||
$response = $this->deleteJson("/api/roles/{$role->id}");
|
|
||||||
$response->assertStatus(403);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('normalizes invalid role names to ROLE_', function (): void {
|
|
||||||
$admin = makeAdminForRoles();
|
|
||||||
Sanctum::actingAs($admin);
|
|
||||||
|
|
||||||
$response = $this->postJson('/api/roles', [
|
|
||||||
'name' => '!!!',
|
|
||||||
'color' => '#111111',
|
|
||||||
]);
|
|
||||||
|
|
||||||
$response->assertStatus(201);
|
|
||||||
$response->assertJsonFragment(['name' => 'ROLE_']);
|
|
||||||
});
|
|
||||||
@@ -1,102 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
use App\Models\Role;
|
|
||||||
use App\Models\Setting;
|
|
||||||
use App\Models\User;
|
|
||||||
use Laravel\Sanctum\Sanctum;
|
|
||||||
|
|
||||||
function makeAdminUser(): User
|
|
||||||
{
|
|
||||||
$admin = User::factory()->create();
|
|
||||||
$role = Role::firstOrCreate(['name' => 'ROLE_ADMIN'], ['color' => '#111111']);
|
|
||||||
$admin->roles()->attach($role);
|
|
||||||
|
|
||||||
return $admin;
|
|
||||||
}
|
|
||||||
|
|
||||||
it('lists settings and supports key filtering', function (): void {
|
|
||||||
Setting::create(['key' => 'site.name', 'value' => 'SpeedBB']);
|
|
||||||
Setting::create(['key' => 'site.tagline', 'value' => 'Fast']);
|
|
||||||
|
|
||||||
$response = $this->getJson('/api/settings');
|
|
||||||
|
|
||||||
$response->assertOk();
|
|
||||||
$payload = $response->json();
|
|
||||||
expect($payload)->toBeArray();
|
|
||||||
expect(count($payload))->toBeGreaterThanOrEqual(2);
|
|
||||||
$response->assertJsonFragment(['key' => 'site.name', 'value' => 'SpeedBB']);
|
|
||||||
$response->assertJsonFragment(['key' => 'site.tagline', 'value' => 'Fast']);
|
|
||||||
|
|
||||||
$response = $this->getJson('/api/settings?key=site.name');
|
|
||||||
$response->assertOk();
|
|
||||||
$response->assertJsonCount(1);
|
|
||||||
$response->assertJsonFragment(['key' => 'site.name', 'value' => 'SpeedBB']);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('forbids non-admin setting creation', function (): void {
|
|
||||||
$user = User::factory()->create();
|
|
||||||
Sanctum::actingAs($user);
|
|
||||||
|
|
||||||
$response = $this->postJson('/api/settings', [
|
|
||||||
'key' => 'site.name',
|
|
||||||
'value' => 'SpeedBB',
|
|
||||||
]);
|
|
||||||
|
|
||||||
$response->assertStatus(403);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('creates or updates settings as admin', function (): void {
|
|
||||||
$admin = makeAdminUser();
|
|
||||||
Sanctum::actingAs($admin);
|
|
||||||
|
|
||||||
$response = $this->postJson('/api/settings', [
|
|
||||||
'key' => 'site.name',
|
|
||||||
'value' => 'SpeedBB',
|
|
||||||
]);
|
|
||||||
|
|
||||||
$response->assertOk();
|
|
||||||
$response->assertJsonFragment(['key' => 'site.name', 'value' => 'SpeedBB']);
|
|
||||||
|
|
||||||
$response = $this->postJson('/api/settings', [
|
|
||||||
'key' => 'site.name',
|
|
||||||
'value' => 'SpeedBB 2',
|
|
||||||
]);
|
|
||||||
|
|
||||||
$response->assertOk();
|
|
||||||
$this->assertDatabaseHas('settings', [
|
|
||||||
'key' => 'site.name',
|
|
||||||
'value' => 'SpeedBB 2',
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('bulk stores settings as admin', function (): void {
|
|
||||||
$admin = makeAdminUser();
|
|
||||||
Sanctum::actingAs($admin);
|
|
||||||
|
|
||||||
$response = $this->postJson('/api/settings/bulk', [
|
|
||||||
'settings' => [
|
|
||||||
['key' => 'site.name', 'value' => 'SpeedBB'],
|
|
||||||
['key' => 'site.tagline', 'value' => 'Fast'],
|
|
||||||
],
|
|
||||||
]);
|
|
||||||
|
|
||||||
$response->assertOk();
|
|
||||||
$response->assertJsonCount(2);
|
|
||||||
$this->assertDatabaseHas('settings', [
|
|
||||||
'key' => 'site.tagline',
|
|
||||||
'value' => 'Fast',
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('bulk store forbids non-admin users', function (): void {
|
|
||||||
$user = User::factory()->create();
|
|
||||||
Sanctum::actingAs($user);
|
|
||||||
|
|
||||||
$response = $this->postJson('/api/settings/bulk', [
|
|
||||||
'settings' => [
|
|
||||||
['key' => 'site.name', 'value' => 'SpeedBB'],
|
|
||||||
],
|
|
||||||
]);
|
|
||||||
|
|
||||||
$response->assertStatus(403);
|
|
||||||
});
|
|
||||||
@@ -1,85 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
use App\Models\Attachment;
|
|
||||||
use App\Models\Forum;
|
|
||||||
use App\Models\Post;
|
|
||||||
use App\Models\Thread;
|
|
||||||
use App\Models\User;
|
|
||||||
use Illuminate\Support\Facades\Storage;
|
|
||||||
|
|
||||||
it('returns forum statistics summary', function (): void {
|
|
||||||
Storage::fake('public');
|
|
||||||
|
|
||||||
$user = User::factory()->create();
|
|
||||||
$forum = Forum::create([
|
|
||||||
'name' => 'Category',
|
|
||||||
'description' => null,
|
|
||||||
'type' => 'category',
|
|
||||||
'parent_id' => null,
|
|
||||||
'position' => 1,
|
|
||||||
]);
|
|
||||||
$child = Forum::create([
|
|
||||||
'name' => 'Forum',
|
|
||||||
'description' => null,
|
|
||||||
'type' => 'forum',
|
|
||||||
'parent_id' => $forum->id,
|
|
||||||
'position' => 1,
|
|
||||||
]);
|
|
||||||
|
|
||||||
$thread = Thread::create([
|
|
||||||
'forum_id' => $child->id,
|
|
||||||
'user_id' => $user->id,
|
|
||||||
'title' => 'Thread',
|
|
||||||
'body' => 'Body',
|
|
||||||
]);
|
|
||||||
|
|
||||||
Post::create([
|
|
||||||
'thread_id' => $thread->id,
|
|
||||||
'user_id' => $user->id,
|
|
||||||
'body' => 'Post',
|
|
||||||
]);
|
|
||||||
|
|
||||||
Attachment::create([
|
|
||||||
'thread_id' => $thread->id,
|
|
||||||
'post_id' => null,
|
|
||||||
'attachment_extension_id' => null,
|
|
||||||
'attachment_group_id' => null,
|
|
||||||
'user_id' => $user->id,
|
|
||||||
'disk' => 'local',
|
|
||||||
'path' => 'attachments/threads/'.$thread->id.'/file.pdf',
|
|
||||||
'original_name' => 'file.pdf',
|
|
||||||
'extension' => 'pdf',
|
|
||||||
'mime_type' => 'application/pdf',
|
|
||||||
'size_bytes' => 123,
|
|
||||||
]);
|
|
||||||
|
|
||||||
$response = $this->getJson('/api/stats');
|
|
||||||
|
|
||||||
$response->assertOk();
|
|
||||||
$response->assertJsonStructure([
|
|
||||||
'threads',
|
|
||||||
'posts',
|
|
||||||
'users',
|
|
||||||
'attachments',
|
|
||||||
'board_started_at',
|
|
||||||
'attachments_size_bytes',
|
|
||||||
'avatar_directory_size_bytes',
|
|
||||||
'database_size_bytes',
|
|
||||||
'database_server',
|
|
||||||
'gzip_compression',
|
|
||||||
'php_version',
|
|
||||||
'orphan_attachments',
|
|
||||||
'board_version',
|
|
||||||
'posts_per_day',
|
|
||||||
'topics_per_day',
|
|
||||||
'users_per_day',
|
|
||||||
'attachments_per_day',
|
|
||||||
]);
|
|
||||||
|
|
||||||
$response->assertJsonFragment([
|
|
||||||
'threads' => 1,
|
|
||||||
'users' => 1,
|
|
||||||
'attachments' => 1,
|
|
||||||
'attachments_size_bytes' => 123,
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
use App\Models\User;
|
|
||||||
use Laravel\Sanctum\Sanctum;
|
|
||||||
|
|
||||||
it('forbids system status for non-admins', function (): void {
|
|
||||||
$user = User::factory()->create();
|
|
||||||
|
|
||||||
Sanctum::actingAs($user);
|
|
||||||
$response = $this->getJson('/api/system/status');
|
|
||||||
|
|
||||||
$response->assertStatus(403);
|
|
||||||
});
|
|
||||||
@@ -1,456 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
use App\Models\Role;
|
|
||||||
use App\Models\User;
|
|
||||||
use Illuminate\Support\Facades\File;
|
|
||||||
use Illuminate\Support\Facades\Http;
|
|
||||||
use Illuminate\Support\Str;
|
|
||||||
use Laravel\Sanctum\Sanctum;
|
|
||||||
|
|
||||||
function makeAdminForSystemUpdate(): User
|
|
||||||
{
|
|
||||||
$admin = User::factory()->create();
|
|
||||||
$role = Role::firstOrCreate(['name' => 'ROLE_ADMIN'], ['color' => '#111111']);
|
|
||||||
$admin->roles()->attach($role);
|
|
||||||
|
|
||||||
return $admin;
|
|
||||||
}
|
|
||||||
|
|
||||||
function withFakeBin(array $scripts, callable $callback): void
|
|
||||||
{
|
|
||||||
$dir = storage_path('app/test-bin-' . Str::random(6));
|
|
||||||
if (!is_dir($dir)) {
|
|
||||||
mkdir($dir, 0777, true);
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach ($scripts as $name => $body) {
|
|
||||||
$path = $dir . DIRECTORY_SEPARATOR . $name;
|
|
||||||
file_put_contents($path, $body);
|
|
||||||
chmod($path, 0755);
|
|
||||||
}
|
|
||||||
|
|
||||||
$originalPath = getenv('PATH') ?: '';
|
|
||||||
putenv("PATH={$dir}");
|
|
||||||
$_ENV['PATH'] = $dir;
|
|
||||||
$_SERVER['PATH'] = $dir;
|
|
||||||
|
|
||||||
try {
|
|
||||||
$callback();
|
|
||||||
} finally {
|
|
||||||
putenv("PATH={$originalPath}");
|
|
||||||
$_ENV['PATH'] = $originalPath;
|
|
||||||
$_SERVER['PATH'] = $originalPath;
|
|
||||||
if (is_dir($dir)) {
|
|
||||||
$items = scandir($dir);
|
|
||||||
if (is_array($items)) {
|
|
||||||
foreach ($items as $item) {
|
|
||||||
if ($item === '.' || $item === '..') {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
$path = $dir . DIRECTORY_SEPARATOR . $item;
|
|
||||||
if (is_file($path)) {
|
|
||||||
unlink($path);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
rmdir($dir);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
it('uses token auth header and tarball template', function (): void {
|
|
||||||
putenv('GITEA_OWNER=acme');
|
|
||||||
putenv('GITEA_REPO=speedbb');
|
|
||||||
putenv('GITEA_API_BASE=https://git.example.test/api/v1');
|
|
||||||
putenv('GITEA_TGZ_URL_TEMPLATE=https://git.example.test/tarball/{{TAG}}-{{VERSION}}.tgz');
|
|
||||||
putenv('GITEA_TOKEN=secrettoken');
|
|
||||||
|
|
||||||
$tarballUrl = 'https://git.example.test/tarball/v1.2.3-1.2.3.tgz';
|
|
||||||
|
|
||||||
Http::fake([
|
|
||||||
'https://git.example.test/api/v1/repos/acme/speedbb/releases/latest' => Http::response([
|
|
||||||
'tag_name' => 'v1.2.3',
|
|
||||||
'tarball_url' => '',
|
|
||||||
], 200),
|
|
||||||
$tarballUrl => Http::response('archive-bytes', 200),
|
|
||||||
]);
|
|
||||||
|
|
||||||
File::shouldReceive('ensureDirectoryExists')->andReturnTrue();
|
|
||||||
File::shouldReceive('put')->andReturnTrue();
|
|
||||||
File::shouldReceive('directories')->andReturn(['/tmp/extract/speedbb']);
|
|
||||||
File::shouldReceive('copyDirectory')->andReturnTrue();
|
|
||||||
|
|
||||||
$artisanPath = base_path('artisan');
|
|
||||||
$originalArtisan = file_get_contents($artisanPath);
|
|
||||||
file_put_contents($artisanPath, "#!/usr/bin/env php\n<?php exit(0);\n");
|
|
||||||
chmod($artisanPath, 0755);
|
|
||||||
|
|
||||||
withFakeBin([
|
|
||||||
'tar' => "#!/bin/sh\nexit 0\n",
|
|
||||||
'composer' => "#!/bin/sh\nexit 0\n",
|
|
||||||
'npm' => "#!/bin/sh\nexit 0\n",
|
|
||||||
], function () use ($artisanPath, $originalArtisan): void {
|
|
||||||
try {
|
|
||||||
Sanctum::actingAs(makeAdminForSystemUpdate());
|
|
||||||
$response = $this->postJson('/api/system/update');
|
|
||||||
|
|
||||||
$response->assertOk();
|
|
||||||
$response->assertJsonFragment(['tag' => 'v1.2.3']);
|
|
||||||
} finally {
|
|
||||||
file_put_contents($artisanPath, $originalArtisan);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
Http::assertSent(function ($request) use ($tarballUrl) {
|
|
||||||
if ($request->url() === $tarballUrl) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return $request->hasHeader('Authorization', 'token secrettoken');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns update failed on unexpected exception', function (): void {
|
|
||||||
putenv('GITEA_OWNER=acme');
|
|
||||||
putenv('GITEA_REPO=speedbb');
|
|
||||||
putenv('GITEA_API_BASE=https://git.example.test/api/v1');
|
|
||||||
|
|
||||||
Http::fake(function () {
|
|
||||||
throw new RuntimeException('boom');
|
|
||||||
});
|
|
||||||
|
|
||||||
Sanctum::actingAs(makeAdminForSystemUpdate());
|
|
||||||
$response = $this->postJson('/api/system/update');
|
|
||||||
|
|
||||||
$response->assertStatus(500);
|
|
||||||
$response->assertJsonFragment(['message' => 'Update failed.']);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('handles release check failures', function (): void {
|
|
||||||
putenv('GITEA_OWNER=acme');
|
|
||||||
putenv('GITEA_REPO=speedbb');
|
|
||||||
putenv('GITEA_API_BASE=https://git.example.test/api/v1');
|
|
||||||
|
|
||||||
Http::fake([
|
|
||||||
'https://git.example.test/api/v1/repos/acme/speedbb/releases/latest' => Http::response([], 500),
|
|
||||||
]);
|
|
||||||
|
|
||||||
Sanctum::actingAs(makeAdminForSystemUpdate());
|
|
||||||
$response = $this->postJson('/api/system/update');
|
|
||||||
|
|
||||||
$response->assertStatus(500);
|
|
||||||
$response->assertJsonFragment(['message' => 'Release check failed: 500']);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('handles missing tag in release response', function (): void {
|
|
||||||
putenv('GITEA_OWNER=acme');
|
|
||||||
putenv('GITEA_REPO=speedbb');
|
|
||||||
putenv('GITEA_API_BASE=https://git.example.test/api/v1');
|
|
||||||
|
|
||||||
Http::fake([
|
|
||||||
'https://git.example.test/api/v1/repos/acme/speedbb/releases/latest' => Http::response([
|
|
||||||
'tag_name' => '',
|
|
||||||
], 200),
|
|
||||||
]);
|
|
||||||
|
|
||||||
Sanctum::actingAs(makeAdminForSystemUpdate());
|
|
||||||
$response = $this->postJson('/api/system/update');
|
|
||||||
|
|
||||||
$response->assertStatus(500);
|
|
||||||
$response->assertJsonFragment(['message' => 'Release tag not found.']);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('handles missing tarball url', function (): void {
|
|
||||||
putenv('GITEA_OWNER=acme');
|
|
||||||
putenv('GITEA_REPO=speedbb');
|
|
||||||
putenv('GITEA_API_BASE=https://git.example.test/api/v1');
|
|
||||||
putenv('GITEA_TGZ_URL_TEMPLATE=');
|
|
||||||
|
|
||||||
Http::fake([
|
|
||||||
'https://git.example.test/api/v1/repos/acme/speedbb/releases/latest' => Http::response([
|
|
||||||
'tag_name' => 'v1.2.3',
|
|
||||||
'tarball_url' => '',
|
|
||||||
], 200),
|
|
||||||
]);
|
|
||||||
|
|
||||||
Sanctum::actingAs(makeAdminForSystemUpdate());
|
|
||||||
$response = $this->postJson('/api/system/update');
|
|
||||||
|
|
||||||
$response->assertStatus(500);
|
|
||||||
$response->assertJsonFragment(['message' => 'No tarball URL available.']);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('handles tarball download failure', function (): void {
|
|
||||||
putenv('GITEA_OWNER=acme');
|
|
||||||
putenv('GITEA_REPO=speedbb');
|
|
||||||
putenv('GITEA_API_BASE=https://git.example.test/api/v1');
|
|
||||||
|
|
||||||
Http::fake([
|
|
||||||
'https://git.example.test/api/v1/repos/acme/speedbb/releases/latest' => Http::response([
|
|
||||||
'tag_name' => 'v1.2.3',
|
|
||||||
'tarball_url' => 'https://git.example.test/archive.tgz',
|
|
||||||
], 200),
|
|
||||||
'https://git.example.test/archive.tgz' => Http::response('fail', 500),
|
|
||||||
]);
|
|
||||||
|
|
||||||
File::shouldReceive('ensureDirectoryExists')->andReturnTrue();
|
|
||||||
|
|
||||||
Sanctum::actingAs(makeAdminForSystemUpdate());
|
|
||||||
$response = $this->postJson('/api/system/update');
|
|
||||||
|
|
||||||
$response->assertStatus(500);
|
|
||||||
$response->assertJsonFragment(['message' => 'Download failed: 500']);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('handles extract failure', function (): void {
|
|
||||||
putenv('GITEA_OWNER=acme');
|
|
||||||
putenv('GITEA_REPO=speedbb');
|
|
||||||
putenv('GITEA_API_BASE=https://git.example.test/api/v1');
|
|
||||||
|
|
||||||
Http::fake([
|
|
||||||
'https://git.example.test/api/v1/repos/acme/speedbb/releases/latest' => Http::response([
|
|
||||||
'tag_name' => 'v1.2.3',
|
|
||||||
'tarball_url' => 'https://git.example.test/archive.tgz',
|
|
||||||
], 200),
|
|
||||||
'https://git.example.test/archive.tgz' => Http::response('archive-bytes', 200),
|
|
||||||
]);
|
|
||||||
|
|
||||||
File::shouldReceive('ensureDirectoryExists')->andReturnTrue();
|
|
||||||
File::shouldReceive('put')->andReturnTrue();
|
|
||||||
|
|
||||||
withFakeBin([
|
|
||||||
'tar' => "#!/bin/sh\nexit 1\n",
|
|
||||||
'composer' => "#!/bin/sh\nexit 0\n",
|
|
||||||
'npm' => "#!/bin/sh\nexit 0\n",
|
|
||||||
], function (): void {
|
|
||||||
Sanctum::actingAs(makeAdminForSystemUpdate());
|
|
||||||
$response = $this->postJson('/api/system/update');
|
|
||||||
|
|
||||||
$response->assertStatus(500);
|
|
||||||
$response->assertJsonFragment(['message' => 'Failed to extract archive.']);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('handles missing extracted folder', function (): void {
|
|
||||||
putenv('GITEA_OWNER=acme');
|
|
||||||
putenv('GITEA_REPO=speedbb');
|
|
||||||
putenv('GITEA_API_BASE=https://git.example.test/api/v1');
|
|
||||||
|
|
||||||
Http::fake([
|
|
||||||
'https://git.example.test/api/v1/repos/acme/speedbb/releases/latest' => Http::response([
|
|
||||||
'tag_name' => 'v1.2.3',
|
|
||||||
'tarball_url' => 'https://git.example.test/archive.tgz',
|
|
||||||
], 200),
|
|
||||||
'https://git.example.test/archive.tgz' => Http::response('archive-bytes', 200),
|
|
||||||
]);
|
|
||||||
|
|
||||||
File::shouldReceive('ensureDirectoryExists')->andReturnTrue();
|
|
||||||
File::shouldReceive('put')->andReturnTrue();
|
|
||||||
File::shouldReceive('directories')->andReturn([]);
|
|
||||||
|
|
||||||
withFakeBin([
|
|
||||||
'tar' => "#!/bin/sh\nexit 0\n",
|
|
||||||
'composer' => "#!/bin/sh\nexit 0\n",
|
|
||||||
'npm' => "#!/bin/sh\nexit 0\n",
|
|
||||||
], function (): void {
|
|
||||||
Sanctum::actingAs(makeAdminForSystemUpdate());
|
|
||||||
$response = $this->postJson('/api/system/update');
|
|
||||||
|
|
||||||
$response->assertStatus(500);
|
|
||||||
$response->assertJsonFragment(['message' => 'No extracted folder found.']);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('handles rsync failure when available', function (): void {
|
|
||||||
putenv('GITEA_OWNER=acme');
|
|
||||||
putenv('GITEA_REPO=speedbb');
|
|
||||||
putenv('GITEA_API_BASE=https://git.example.test/api/v1');
|
|
||||||
|
|
||||||
Http::fake([
|
|
||||||
'https://git.example.test/api/v1/repos/acme/speedbb/releases/latest' => Http::response([
|
|
||||||
'tag_name' => 'v1.2.3',
|
|
||||||
'tarball_url' => 'https://git.example.test/archive.tgz',
|
|
||||||
], 200),
|
|
||||||
'https://git.example.test/archive.tgz' => Http::response('archive-bytes', 200),
|
|
||||||
]);
|
|
||||||
|
|
||||||
File::shouldReceive('ensureDirectoryExists')->andReturnTrue();
|
|
||||||
File::shouldReceive('put')->andReturnTrue();
|
|
||||||
File::shouldReceive('directories')->andReturn(['/tmp/extract/speedbb']);
|
|
||||||
|
|
||||||
withFakeBin([
|
|
||||||
'tar' => "#!/bin/sh\nexit 0\n",
|
|
||||||
'rsync' => "#!/bin/sh\nexit 1\n",
|
|
||||||
'composer' => "#!/bin/sh\nexit 0\n",
|
|
||||||
'npm' => "#!/bin/sh\nexit 0\n",
|
|
||||||
], function (): void {
|
|
||||||
Sanctum::actingAs(makeAdminForSystemUpdate());
|
|
||||||
$response = $this->postJson('/api/system/update');
|
|
||||||
|
|
||||||
$response->assertStatus(500);
|
|
||||||
$response->assertJsonFragment(['message' => 'rsync failed.']);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('handles composer install failure after copyDirectory', function (): void {
|
|
||||||
putenv('GITEA_OWNER=acme');
|
|
||||||
putenv('GITEA_REPO=speedbb');
|
|
||||||
putenv('GITEA_API_BASE=https://git.example.test/api/v1');
|
|
||||||
Http::fake([
|
|
||||||
'https://git.example.test/api/v1/repos/acme/speedbb/releases/latest' => Http::response([
|
|
||||||
'tag_name' => 'v1.2.3',
|
|
||||||
'tarball_url' => 'https://git.example.test/archive.tgz',
|
|
||||||
], 200),
|
|
||||||
'https://git.example.test/archive.tgz' => Http::response('archive-bytes', 200),
|
|
||||||
]);
|
|
||||||
|
|
||||||
File::shouldReceive('ensureDirectoryExists')->andReturnTrue();
|
|
||||||
File::shouldReceive('put')->andReturnTrue();
|
|
||||||
File::shouldReceive('directories')->andReturn(['/tmp/extract/speedbb']);
|
|
||||||
File::shouldReceive('copyDirectory')->andReturnTrue();
|
|
||||||
|
|
||||||
withFakeBin([
|
|
||||||
'tar' => "#!/bin/sh\nexit 0\n",
|
|
||||||
'composer' => "#!/bin/sh\nexit 1\n",
|
|
||||||
'npm' => "#!/bin/sh\nexit 0\n",
|
|
||||||
], function (): void {
|
|
||||||
Sanctum::actingAs(makeAdminForSystemUpdate());
|
|
||||||
$response = $this->postJson('/api/system/update');
|
|
||||||
|
|
||||||
$response->assertStatus(500);
|
|
||||||
$response->assertJsonFragment(['message' => 'Composer install failed.']);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('handles npm install failure', function (): void {
|
|
||||||
putenv('GITEA_OWNER=acme');
|
|
||||||
putenv('GITEA_REPO=speedbb');
|
|
||||||
putenv('GITEA_API_BASE=https://git.example.test/api/v1');
|
|
||||||
Http::fake([
|
|
||||||
'https://git.example.test/api/v1/repos/acme/speedbb/releases/latest' => Http::response([
|
|
||||||
'tag_name' => 'v1.2.3',
|
|
||||||
'tarball_url' => 'https://git.example.test/archive.tgz',
|
|
||||||
], 200),
|
|
||||||
'https://git.example.test/archive.tgz' => Http::response('archive-bytes', 200),
|
|
||||||
]);
|
|
||||||
|
|
||||||
File::shouldReceive('ensureDirectoryExists')->andReturnTrue();
|
|
||||||
File::shouldReceive('put')->andReturnTrue();
|
|
||||||
File::shouldReceive('directories')->andReturn(['/tmp/extract/speedbb']);
|
|
||||||
File::shouldReceive('copyDirectory')->andReturnTrue();
|
|
||||||
|
|
||||||
withFakeBin([
|
|
||||||
'tar' => "#!/bin/sh\nexit 0\n",
|
|
||||||
'composer' => "#!/bin/sh\nexit 0\n",
|
|
||||||
'npm' => "#!/bin/sh\nif [ \"$1\" = \"install\" ]; then exit 1; fi\nexit 0\n",
|
|
||||||
], function (): void {
|
|
||||||
Sanctum::actingAs(makeAdminForSystemUpdate());
|
|
||||||
$response = $this->postJson('/api/system/update');
|
|
||||||
|
|
||||||
$response->assertStatus(500);
|
|
||||||
$response->assertJsonFragment(['message' => 'npm install failed.']);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('handles npm build failure', function (): void {
|
|
||||||
putenv('GITEA_OWNER=acme');
|
|
||||||
putenv('GITEA_REPO=speedbb');
|
|
||||||
putenv('GITEA_API_BASE=https://git.example.test/api/v1');
|
|
||||||
Http::fake([
|
|
||||||
'https://git.example.test/api/v1/repos/acme/speedbb/releases/latest' => Http::response([
|
|
||||||
'tag_name' => 'v1.2.3',
|
|
||||||
'tarball_url' => 'https://git.example.test/archive.tgz',
|
|
||||||
], 200),
|
|
||||||
'https://git.example.test/archive.tgz' => Http::response('archive-bytes', 200),
|
|
||||||
]);
|
|
||||||
|
|
||||||
File::shouldReceive('ensureDirectoryExists')->andReturnTrue();
|
|
||||||
File::shouldReceive('put')->andReturnTrue();
|
|
||||||
File::shouldReceive('directories')->andReturn(['/tmp/extract/speedbb']);
|
|
||||||
File::shouldReceive('copyDirectory')->andReturnTrue();
|
|
||||||
|
|
||||||
withFakeBin([
|
|
||||||
'tar' => "#!/bin/sh\nexit 0\n",
|
|
||||||
'composer' => "#!/bin/sh\nexit 0\n",
|
|
||||||
'npm' => "#!/bin/sh\nif [ \"$1\" = \"run\" ] && [ \"$2\" = \"build\" ]; then exit 1; fi\nexit 0\n",
|
|
||||||
], function (): void {
|
|
||||||
Sanctum::actingAs(makeAdminForSystemUpdate());
|
|
||||||
$response = $this->postJson('/api/system/update');
|
|
||||||
|
|
||||||
$response->assertStatus(500);
|
|
||||||
$response->assertJsonFragment(['message' => 'npm run build failed.']);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('handles migration failure', function (): void {
|
|
||||||
putenv('GITEA_OWNER=acme');
|
|
||||||
putenv('GITEA_REPO=speedbb');
|
|
||||||
putenv('GITEA_API_BASE=https://git.example.test/api/v1');
|
|
||||||
Http::fake([
|
|
||||||
'https://git.example.test/api/v1/repos/acme/speedbb/releases/latest' => Http::response([
|
|
||||||
'tag_name' => 'v1.2.3',
|
|
||||||
'tarball_url' => 'https://git.example.test/archive.tgz',
|
|
||||||
], 200),
|
|
||||||
'https://git.example.test/archive.tgz' => Http::response('archive-bytes', 200),
|
|
||||||
]);
|
|
||||||
|
|
||||||
File::shouldReceive('ensureDirectoryExists')->andReturnTrue();
|
|
||||||
File::shouldReceive('put')->andReturnTrue();
|
|
||||||
File::shouldReceive('directories')->andReturn(['/tmp/extract/speedbb']);
|
|
||||||
File::shouldReceive('copyDirectory')->andReturnTrue();
|
|
||||||
|
|
||||||
putenv('SYSTEM_UPDATE_PHP_BINARY=/nope');
|
|
||||||
$_ENV['SYSTEM_UPDATE_PHP_BINARY'] = '/nope';
|
|
||||||
$_SERVER['SYSTEM_UPDATE_PHP_BINARY'] = '/nope';
|
|
||||||
|
|
||||||
withFakeBin([
|
|
||||||
'tar' => "#!/bin/sh\nexit 0\n",
|
|
||||||
'composer' => "#!/bin/sh\nexit 0\n",
|
|
||||||
'npm' => "#!/bin/sh\nexit 0\n",
|
|
||||||
], function (): void {
|
|
||||||
Sanctum::actingAs(makeAdminForSystemUpdate());
|
|
||||||
$response = $this->postJson('/api/system/update');
|
|
||||||
|
|
||||||
$response->assertStatus(500);
|
|
||||||
$response->assertJsonFragment(['message' => 'Migrations failed.']);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('handles fallback copyDirectory update success', function (): void {
|
|
||||||
putenv('GITEA_OWNER=acme');
|
|
||||||
putenv('GITEA_REPO=speedbb');
|
|
||||||
putenv('GITEA_API_BASE=https://git.example.test/api/v1');
|
|
||||||
putenv('SYSTEM_UPDATE_PHP_BINARY=php');
|
|
||||||
$_ENV['SYSTEM_UPDATE_PHP_BINARY'] = 'php';
|
|
||||||
$_SERVER['SYSTEM_UPDATE_PHP_BINARY'] = 'php';
|
|
||||||
|
|
||||||
Http::fake([
|
|
||||||
'https://git.example.test/api/v1/repos/acme/speedbb/releases/latest' => Http::response([
|
|
||||||
'tag_name' => 'v1.2.3',
|
|
||||||
'tarball_url' => 'https://git.example.test/archive.tgz',
|
|
||||||
], 200),
|
|
||||||
'https://git.example.test/archive.tgz' => Http::response('archive-bytes', 200),
|
|
||||||
]);
|
|
||||||
|
|
||||||
File::shouldReceive('ensureDirectoryExists')->andReturnTrue();
|
|
||||||
File::shouldReceive('put')->andReturnTrue();
|
|
||||||
File::shouldReceive('directories')->andReturn(['/tmp/extract/speedbb']);
|
|
||||||
File::shouldReceive('copyDirectory')->andReturnTrue();
|
|
||||||
|
|
||||||
putenv('SYSTEM_UPDATE_PHP_BINARY=php');
|
|
||||||
$_ENV['SYSTEM_UPDATE_PHP_BINARY'] = 'php';
|
|
||||||
$_SERVER['SYSTEM_UPDATE_PHP_BINARY'] = 'php';
|
|
||||||
|
|
||||||
withFakeBin([
|
|
||||||
'tar' => "#!/bin/sh\nexit 0\n",
|
|
||||||
'composer' => "#!/bin/sh\nexit 0\n",
|
|
||||||
'npm' => "#!/bin/sh\nexit 0\n",
|
|
||||||
'php' => "#!/bin/sh\nexit 0\n",
|
|
||||||
], function (): void {
|
|
||||||
Sanctum::actingAs(makeAdminForSystemUpdate());
|
|
||||||
$response = $this->postJson('/api/system/update');
|
|
||||||
|
|
||||||
$response->assertOk();
|
|
||||||
$response->assertJsonFragment(['message' => 'Update finished.']);
|
|
||||||
$response->assertJsonStructure(['used_rsync']);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
use App\Models\Role;
|
|
||||||
use App\Models\User;
|
|
||||||
use Illuminate\Support\Facades\File;
|
|
||||||
use Illuminate\Support\Facades\Http;
|
|
||||||
use Laravel\Sanctum\Sanctum;
|
|
||||||
|
|
||||||
it('forbids system update for non-admins', function (): void {
|
|
||||||
$user = User::factory()->create();
|
|
||||||
|
|
||||||
Sanctum::actingAs($user);
|
|
||||||
$response = $this->postJson('/api/system/update');
|
|
||||||
|
|
||||||
$response->assertStatus(403);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns validation error when gitea config is missing', function (): void {
|
|
||||||
putenv('GITEA_OWNER=');
|
|
||||||
putenv('GITEA_REPO=');
|
|
||||||
|
|
||||||
$admin = User::factory()->create();
|
|
||||||
$role = Role::firstOrCreate(['name' => 'ROLE_ADMIN'], ['color' => '#111111']);
|
|
||||||
$admin->roles()->attach($role);
|
|
||||||
|
|
||||||
Sanctum::actingAs($admin);
|
|
||||||
$response = $this->postJson('/api/system/update');
|
|
||||||
|
|
||||||
$response->assertStatus(422);
|
|
||||||
$response->assertJsonFragment(['message' => 'Missing Gitea configuration.']);
|
|
||||||
});
|
|
||||||
@@ -1,272 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
use App\Models\Forum;
|
|
||||||
use App\Models\Role;
|
|
||||||
use App\Models\Thread;
|
|
||||||
use App\Models\User;
|
|
||||||
use Laravel\Sanctum\Sanctum;
|
|
||||||
|
|
||||||
beforeEach(function (): void {
|
|
||||||
$parserProp = new ReflectionProperty(\App\Actions\BbcodeFormatter::class, 'parser');
|
|
||||||
$parserProp->setAccessible(true);
|
|
||||||
$parserProp->setValue(
|
|
||||||
\Mockery::mock(\s9e\TextFormatter\Parser::class)
|
|
||||||
->shouldReceive('parse')
|
|
||||||
->andReturn('<r/>')
|
|
||||||
->getMock()
|
|
||||||
);
|
|
||||||
|
|
||||||
$rendererProp = new ReflectionProperty(\App\Actions\BbcodeFormatter::class, 'renderer');
|
|
||||||
$rendererProp->setAccessible(true);
|
|
||||||
$rendererProp->setValue(
|
|
||||||
\Mockery::mock(\s9e\TextFormatter\Renderer::class)
|
|
||||||
->shouldReceive('render')
|
|
||||||
->andReturn('<p></p>')
|
|
||||||
->getMock()
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(function (): void {
|
|
||||||
\Mockery::close();
|
|
||||||
});
|
|
||||||
|
|
||||||
function makeForum(): Forum
|
|
||||||
{
|
|
||||||
$category = Forum::create([
|
|
||||||
'name' => 'Category',
|
|
||||||
'description' => null,
|
|
||||||
'type' => 'category',
|
|
||||||
'parent_id' => null,
|
|
||||||
'position' => 1,
|
|
||||||
]);
|
|
||||||
|
|
||||||
return Forum::create([
|
|
||||||
'name' => 'Forum',
|
|
||||||
'description' => null,
|
|
||||||
'type' => 'forum',
|
|
||||||
'parent_id' => $category->id,
|
|
||||||
'position' => 1,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
it('creates a thread inside a forum', function (): void {
|
|
||||||
$user = User::factory()->create();
|
|
||||||
Sanctum::actingAs($user);
|
|
||||||
|
|
||||||
$forum = makeForum();
|
|
||||||
|
|
||||||
$response = $this->postJson('/api/threads', [
|
|
||||||
'title' => 'First Thread',
|
|
||||||
'body' => 'Hello world',
|
|
||||||
'forum' => "/api/forums/{$forum->id}",
|
|
||||||
]);
|
|
||||||
|
|
||||||
$response->assertStatus(201);
|
|
||||||
$response->assertJsonFragment([
|
|
||||||
'title' => 'First Thread',
|
|
||||||
'forum' => "/api/forums/{$forum->id}",
|
|
||||||
]);
|
|
||||||
|
|
||||||
$this->assertDatabaseHas('threads', [
|
|
||||||
'forum_id' => $forum->id,
|
|
||||||
'user_id' => $user->id,
|
|
||||||
'title' => 'First Thread',
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects creating threads in a category', function (): void {
|
|
||||||
$user = User::factory()->create();
|
|
||||||
Sanctum::actingAs($user);
|
|
||||||
|
|
||||||
$category = Forum::create([
|
|
||||||
'name' => 'Category Only',
|
|
||||||
'description' => null,
|
|
||||||
'type' => 'category',
|
|
||||||
'parent_id' => null,
|
|
||||||
'position' => 1,
|
|
||||||
]);
|
|
||||||
|
|
||||||
$response = $this->postJson('/api/threads', [
|
|
||||||
'title' => 'Nope',
|
|
||||||
'body' => 'Not allowed',
|
|
||||||
'forum' => "/api/forums/{$category->id}",
|
|
||||||
]);
|
|
||||||
|
|
||||||
$response->assertStatus(422);
|
|
||||||
$response->assertJsonFragment(['message' => 'Threads can only be created inside forums.']);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('requires authentication to update a thread', function (): void {
|
|
||||||
$forum = makeForum();
|
|
||||||
$owner = User::factory()->create();
|
|
||||||
|
|
||||||
$thread = Thread::create([
|
|
||||||
'forum_id' => $forum->id,
|
|
||||||
'user_id' => $owner->id,
|
|
||||||
'title' => 'Original',
|
|
||||||
'body' => '',
|
|
||||||
]);
|
|
||||||
|
|
||||||
$response = $this->patchJson("/api/threads/{$thread->id}", [
|
|
||||||
'title' => 'Updated',
|
|
||||||
'body' => 'Updated body',
|
|
||||||
]);
|
|
||||||
|
|
||||||
$response->assertStatus(401);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('enforces thread update permissions', function (): void {
|
|
||||||
$forum = makeForum();
|
|
||||||
$owner = User::factory()->create();
|
|
||||||
$other = User::factory()->create();
|
|
||||||
|
|
||||||
$thread = Thread::create([
|
|
||||||
'forum_id' => $forum->id,
|
|
||||||
'user_id' => $owner->id,
|
|
||||||
'title' => 'Original',
|
|
||||||
'body' => '',
|
|
||||||
]);
|
|
||||||
|
|
||||||
Sanctum::actingAs($other);
|
|
||||||
$response = $this->patchJson("/api/threads/{$thread->id}", [
|
|
||||||
'title' => 'Updated',
|
|
||||||
'body' => 'Updated body',
|
|
||||||
]);
|
|
||||||
|
|
||||||
$response->assertStatus(403);
|
|
||||||
|
|
||||||
Sanctum::actingAs($owner);
|
|
||||||
$response = $this->patchJson("/api/threads/{$thread->id}", [
|
|
||||||
'title' => 'Owner Update',
|
|
||||||
'body' => 'Owner body',
|
|
||||||
]);
|
|
||||||
|
|
||||||
$response->assertOk();
|
|
||||||
$this->assertDatabaseHas('threads', [
|
|
||||||
'id' => $thread->id,
|
|
||||||
'title' => 'Owner Update',
|
|
||||||
'body' => 'Owner body',
|
|
||||||
]);
|
|
||||||
|
|
||||||
$admin = User::factory()->create();
|
|
||||||
$role = Role::create(['name' => 'ROLE_ADMIN', 'color' => '#111111']);
|
|
||||||
$admin->roles()->attach($role);
|
|
||||||
|
|
||||||
Sanctum::actingAs($admin);
|
|
||||||
$response = $this->patchJson("/api/threads/{$thread->id}", [
|
|
||||||
'title' => 'Admin Update',
|
|
||||||
'body' => 'Admin body',
|
|
||||||
]);
|
|
||||||
|
|
||||||
$response->assertOk();
|
|
||||||
$this->assertDatabaseHas('threads', [
|
|
||||||
'id' => $thread->id,
|
|
||||||
'title' => 'Admin Update',
|
|
||||||
'body' => 'Admin body',
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('enforces solved status permissions', function (): void {
|
|
||||||
$forum = makeForum();
|
|
||||||
$owner = User::factory()->create();
|
|
||||||
$other = User::factory()->create();
|
|
||||||
|
|
||||||
$thread = Thread::create([
|
|
||||||
'forum_id' => $forum->id,
|
|
||||||
'user_id' => $owner->id,
|
|
||||||
'title' => 'Original',
|
|
||||||
'body' => '',
|
|
||||||
'solved' => false,
|
|
||||||
]);
|
|
||||||
|
|
||||||
Sanctum::actingAs($other);
|
|
||||||
$response = $this->patchJson("/api/threads/{$thread->id}/solved", [
|
|
||||||
'solved' => true,
|
|
||||||
]);
|
|
||||||
|
|
||||||
$response->assertStatus(403);
|
|
||||||
|
|
||||||
Sanctum::actingAs($owner);
|
|
||||||
$response = $this->patchJson("/api/threads/{$thread->id}/solved", [
|
|
||||||
'solved' => true,
|
|
||||||
]);
|
|
||||||
|
|
||||||
$response->assertOk();
|
|
||||||
$this->assertDatabaseHas('threads', [
|
|
||||||
'id' => $thread->id,
|
|
||||||
'solved' => 1,
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('filters threads by forum', function (): void {
|
|
||||||
$forumA = makeForum();
|
|
||||||
$forumB = makeForum();
|
|
||||||
|
|
||||||
$threadA = Thread::create([
|
|
||||||
'forum_id' => $forumA->id,
|
|
||||||
'user_id' => null,
|
|
||||||
'title' => 'Thread A',
|
|
||||||
'body' => '',
|
|
||||||
]);
|
|
||||||
|
|
||||||
Thread::create([
|
|
||||||
'forum_id' => $forumB->id,
|
|
||||||
'user_id' => null,
|
|
||||||
'title' => 'Thread B',
|
|
||||||
'body' => '',
|
|
||||||
]);
|
|
||||||
|
|
||||||
$response = $this->getJson("/api/threads?forum=/api/forums/{$forumA->id}");
|
|
||||||
|
|
||||||
$response->assertOk();
|
|
||||||
$response->assertJsonCount(1);
|
|
||||||
$response->assertJsonFragment([
|
|
||||||
'id' => $threadA->id,
|
|
||||||
'title' => 'Thread A',
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('increments views count when showing a thread', function (): void {
|
|
||||||
$forum = makeForum();
|
|
||||||
$thread = Thread::create([
|
|
||||||
'forum_id' => $forum->id,
|
|
||||||
'user_id' => null,
|
|
||||||
'title' => 'Viewed Thread',
|
|
||||||
'body' => '',
|
|
||||||
'views_count' => 0,
|
|
||||||
]);
|
|
||||||
|
|
||||||
$response = $this->getJson("/api/threads/{$thread->id}");
|
|
||||||
|
|
||||||
$response->assertOk();
|
|
||||||
$response->assertJsonFragment([
|
|
||||||
'id' => $thread->id,
|
|
||||||
'views_count' => 1,
|
|
||||||
]);
|
|
||||||
|
|
||||||
$thread->refresh();
|
|
||||||
expect($thread->views_count)->toBe(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('soft deletes a thread and tracks deleted_by', function (): void {
|
|
||||||
$forum = makeForum();
|
|
||||||
$user = User::factory()->create();
|
|
||||||
$thread = Thread::create([
|
|
||||||
'forum_id' => $forum->id,
|
|
||||||
'user_id' => $user->id,
|
|
||||||
'title' => 'Delete Me',
|
|
||||||
'body' => 'Body',
|
|
||||||
]);
|
|
||||||
|
|
||||||
Sanctum::actingAs($user);
|
|
||||||
$response = $this->deleteJson("/api/threads/{$thread->id}");
|
|
||||||
|
|
||||||
$response->assertStatus(204);
|
|
||||||
$this->assertSoftDeleted('threads', [
|
|
||||||
'id' => $thread->id,
|
|
||||||
]);
|
|
||||||
$this->assertDatabaseHas('threads', [
|
|
||||||
'id' => $thread->id,
|
|
||||||
'deleted_by' => $user->id,
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
@@ -1,97 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
use App\Models\Role;
|
|
||||||
use App\Models\User;
|
|
||||||
use Illuminate\Http\UploadedFile;
|
|
||||||
use Illuminate\Support\Facades\Storage;
|
|
||||||
use Laravel\Sanctum\Sanctum;
|
|
||||||
|
|
||||||
it('requires authentication for avatar upload', function (): void {
|
|
||||||
$response = $this->postJson('/api/user/avatar', [
|
|
||||||
'file' => UploadedFile::fake()->image('avatar.jpg', 100, 100),
|
|
||||||
]);
|
|
||||||
|
|
||||||
$response->assertStatus(401);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('uploads avatars for authenticated users', function (): void {
|
|
||||||
Storage::fake('public');
|
|
||||||
|
|
||||||
$user = User::factory()->create();
|
|
||||||
Sanctum::actingAs($user);
|
|
||||||
|
|
||||||
$response = $this->postJson('/api/user/avatar', [
|
|
||||||
'file' => UploadedFile::fake()->image('avatar.jpg', 100, 100),
|
|
||||||
]);
|
|
||||||
|
|
||||||
$response->assertOk();
|
|
||||||
$path = $response->json('path');
|
|
||||||
|
|
||||||
Storage::disk('public')->assertExists($path);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('replaces existing avatar when uploading a new one', function (): void {
|
|
||||||
Storage::fake('public');
|
|
||||||
|
|
||||||
$user = User::factory()->create([
|
|
||||||
'avatar_path' => 'avatars/old.png',
|
|
||||||
]);
|
|
||||||
Storage::disk('public')->put('avatars/old.png', 'old');
|
|
||||||
|
|
||||||
Sanctum::actingAs($user);
|
|
||||||
$response = $this->postJson('/api/user/avatar', [
|
|
||||||
'file' => UploadedFile::fake()->image('avatar.jpg', 100, 100),
|
|
||||||
]);
|
|
||||||
|
|
||||||
$response->assertOk();
|
|
||||||
Storage::disk('public')->assertMissing('avatars/old.png');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('forbids logo uploads for non-admins', function (): void {
|
|
||||||
Storage::fake('public');
|
|
||||||
|
|
||||||
$user = User::factory()->create();
|
|
||||||
Sanctum::actingAs($user);
|
|
||||||
|
|
||||||
$response = $this->postJson('/api/uploads/logo', [
|
|
||||||
'file' => UploadedFile::fake()->image('logo.png', 200, 200),
|
|
||||||
]);
|
|
||||||
|
|
||||||
$response->assertStatus(403);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('forbids favicon uploads for non-admins', function (): void {
|
|
||||||
Storage::fake('public');
|
|
||||||
|
|
||||||
$user = User::factory()->create();
|
|
||||||
Sanctum::actingAs($user);
|
|
||||||
|
|
||||||
$response = $this->postJson('/api/uploads/favicon', [
|
|
||||||
'file' => UploadedFile::fake()->image('favicon.png', 32, 32),
|
|
||||||
]);
|
|
||||||
|
|
||||||
$response->assertStatus(403);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('uploads logos and favicons as admin', function (): void {
|
|
||||||
Storage::fake('public');
|
|
||||||
|
|
||||||
$admin = User::factory()->create();
|
|
||||||
$role = Role::firstOrCreate(['name' => 'ROLE_ADMIN'], ['color' => '#111111']);
|
|
||||||
$admin->roles()->attach($role);
|
|
||||||
|
|
||||||
Sanctum::actingAs($admin);
|
|
||||||
$logo = $this->postJson('/api/uploads/logo', [
|
|
||||||
'file' => UploadedFile::fake()->image('logo.png', 200, 200),
|
|
||||||
]);
|
|
||||||
|
|
||||||
$logo->assertOk();
|
|
||||||
Storage::disk('public')->assertExists($logo->json('path'));
|
|
||||||
|
|
||||||
$favicon = $this->postJson('/api/uploads/favicon', [
|
|
||||||
'file' => UploadedFile::fake()->image('favicon.png', 32, 32),
|
|
||||||
]);
|
|
||||||
|
|
||||||
$favicon->assertOk();
|
|
||||||
Storage::disk('public')->assertExists($favicon->json('path'));
|
|
||||||
});
|
|
||||||
@@ -1,287 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
use App\Models\Rank;
|
|
||||||
use App\Models\Role;
|
|
||||||
use App\Models\User;
|
|
||||||
use Laravel\Sanctum\Sanctum;
|
|
||||||
|
|
||||||
function makeAdmin(): User
|
|
||||||
{
|
|
||||||
$admin = User::factory()->create();
|
|
||||||
$role = Role::firstOrCreate(['name' => 'ROLE_ADMIN'], ['color' => '#111111']);
|
|
||||||
$admin->roles()->attach($role);
|
|
||||||
|
|
||||||
return $admin;
|
|
||||||
}
|
|
||||||
|
|
||||||
it('requires authentication to list users', function (): void {
|
|
||||||
$response = $this->getJson('/api/users');
|
|
||||||
|
|
||||||
$response->assertStatus(401);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('lists users with roles and group color', function (): void {
|
|
||||||
$admin = makeAdmin();
|
|
||||||
$role = Role::firstOrCreate(['name' => 'ROLE_MOD'], ['color' => '#ff0000']);
|
|
||||||
$user = User::factory()->create(['name' => 'Alice']);
|
|
||||||
$user->roles()->attach($role);
|
|
||||||
|
|
||||||
Sanctum::actingAs($admin);
|
|
||||||
$response = $this->getJson('/api/users');
|
|
||||||
|
|
||||||
$response->assertOk();
|
|
||||||
$response->assertJsonFragment([
|
|
||||||
'id' => $user->id,
|
|
||||||
'name' => 'Alice',
|
|
||||||
'group_color' => '#ff0000',
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns current user profile from me endpoint', function (): void {
|
|
||||||
$user = User::factory()->create(['name' => 'Me']);
|
|
||||||
|
|
||||||
Sanctum::actingAs($user);
|
|
||||||
$response = $this->getJson('/api/user/me');
|
|
||||||
|
|
||||||
$response->assertOk();
|
|
||||||
$response->assertJsonFragment([
|
|
||||||
'id' => $user->id,
|
|
||||||
'name' => 'Me',
|
|
||||||
'email' => $user->email,
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects unauthenticated me requests', function (): void {
|
|
||||||
$response = $this->getJson('/api/user/me');
|
|
||||||
|
|
||||||
$response->assertStatus(401);
|
|
||||||
$response->assertJsonFragment(['message' => 'Unauthenticated.']);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns user profile details', function (): void {
|
|
||||||
$viewer = User::factory()->create();
|
|
||||||
$target = User::factory()->create(['name' => 'ProfileUser']);
|
|
||||||
|
|
||||||
Sanctum::actingAs($viewer);
|
|
||||||
$response = $this->getJson("/api/user/profile/{$target->id}");
|
|
||||||
|
|
||||||
$response->assertOk();
|
|
||||||
$response->assertJsonFragment([
|
|
||||||
'id' => $target->id,
|
|
||||||
'name' => 'ProfileUser',
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('updates user location via updateMe', function (): void {
|
|
||||||
$user = User::factory()->create(['location' => null]);
|
|
||||||
|
|
||||||
Sanctum::actingAs($user);
|
|
||||||
$response = $this->patchJson('/api/user/me', [
|
|
||||||
'location' => ' New York ',
|
|
||||||
]);
|
|
||||||
|
|
||||||
$response->assertOk();
|
|
||||||
$response->assertJsonFragment([
|
|
||||||
'id' => $user->id,
|
|
||||||
'location' => 'New York',
|
|
||||||
]);
|
|
||||||
|
|
||||||
$user->refresh();
|
|
||||||
expect($user->location)->toBe('New York');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects updateMe when unauthenticated', function (): void {
|
|
||||||
$response = $this->patchJson('/api/user/me', [
|
|
||||||
'location' => 'Somewhere',
|
|
||||||
]);
|
|
||||||
|
|
||||||
$response->assertStatus(401);
|
|
||||||
$response->assertJsonFragment(['message' => 'Unauthenticated.']);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('clears location when updateMe receives blank value', function (): void {
|
|
||||||
$user = User::factory()->create(['location' => 'Somewhere']);
|
|
||||||
|
|
||||||
Sanctum::actingAs($user);
|
|
||||||
$response = $this->patchJson('/api/user/me', [
|
|
||||||
'location' => ' ',
|
|
||||||
]);
|
|
||||||
|
|
||||||
$response->assertOk();
|
|
||||||
$response->assertJsonFragment([
|
|
||||||
'id' => $user->id,
|
|
||||||
'location' => null,
|
|
||||||
]);
|
|
||||||
|
|
||||||
$user->refresh();
|
|
||||||
expect($user->location)->toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('forbids non-admin rank updates', function (): void {
|
|
||||||
$user = User::factory()->create();
|
|
||||||
$target = User::factory()->create();
|
|
||||||
$rank = Rank::create(['name' => 'Silver']);
|
|
||||||
|
|
||||||
Sanctum::actingAs($user);
|
|
||||||
$response = $this->patchJson("/api/users/{$target->id}/rank", [
|
|
||||||
'rank_id' => $rank->id,
|
|
||||||
]);
|
|
||||||
|
|
||||||
$response->assertStatus(403);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('forbids founder rank updates by non-founder admin', function (): void {
|
|
||||||
$admin = makeAdmin();
|
|
||||||
$founderRole = Role::firstOrCreate(['name' => 'ROLE_FOUNDER'], ['color' => '#111111']);
|
|
||||||
$founder = User::factory()->create();
|
|
||||||
$founder->roles()->attach($founderRole);
|
|
||||||
$rank = Rank::create(['name' => 'Founder Rank']);
|
|
||||||
|
|
||||||
Sanctum::actingAs($admin);
|
|
||||||
$response = $this->patchJson("/api/users/{$founder->id}/rank", [
|
|
||||||
'rank_id' => $rank->id,
|
|
||||||
]);
|
|
||||||
|
|
||||||
$response->assertStatus(403);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('allows admins to update user rank', function (): void {
|
|
||||||
$admin = makeAdmin();
|
|
||||||
$target = User::factory()->create();
|
|
||||||
$rank = Rank::create(['name' => 'Gold']);
|
|
||||||
|
|
||||||
Sanctum::actingAs($admin);
|
|
||||||
$response = $this->patchJson("/api/users/{$target->id}/rank", [
|
|
||||||
'rank_id' => $rank->id,
|
|
||||||
]);
|
|
||||||
|
|
||||||
$response->assertOk();
|
|
||||||
$response->assertJsonPath('id', $target->id);
|
|
||||||
$response->assertJsonPath('rank.id', $rank->id);
|
|
||||||
$response->assertJsonPath('rank.name', 'Gold');
|
|
||||||
|
|
||||||
$target->refresh();
|
|
||||||
expect($target->rank_id)->toBe($rank->id);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects update without admin role', function (): void {
|
|
||||||
$user = User::factory()->create();
|
|
||||||
$target = User::factory()->create();
|
|
||||||
|
|
||||||
Sanctum::actingAs($user);
|
|
||||||
$response = $this->patchJson("/api/users/{$target->id}", [
|
|
||||||
'name' => 'New Name',
|
|
||||||
'email' => 'new@example.com',
|
|
||||||
'rank_id' => null,
|
|
||||||
]);
|
|
||||||
|
|
||||||
$response->assertStatus(403);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('forbids updating founder user when actor is not founder', function (): void {
|
|
||||||
$admin = makeAdmin();
|
|
||||||
$founderRole = Role::firstOrCreate(['name' => 'ROLE_FOUNDER'], ['color' => '#111111']);
|
|
||||||
$founder = User::factory()->create();
|
|
||||||
$founder->roles()->attach($founderRole);
|
|
||||||
|
|
||||||
Sanctum::actingAs($admin);
|
|
||||||
$response = $this->patchJson("/api/users/{$founder->id}", [
|
|
||||||
'name' => 'New Name',
|
|
||||||
'email' => 'new@example.com',
|
|
||||||
'rank_id' => null,
|
|
||||||
]);
|
|
||||||
|
|
||||||
$response->assertStatus(403);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects assigning founder role for non-founder admin', function (): void {
|
|
||||||
$admin = makeAdmin();
|
|
||||||
$target = User::factory()->create();
|
|
||||||
Role::firstOrCreate(['name' => 'ROLE_FOUNDER'], ['color' => '#111111']);
|
|
||||||
|
|
||||||
Sanctum::actingAs($admin);
|
|
||||||
$response = $this->patchJson("/api/users/{$target->id}", [
|
|
||||||
'name' => 'New Name',
|
|
||||||
'email' => 'new@example.com',
|
|
||||||
'rank_id' => null,
|
|
||||||
'roles' => ['ROLE_FOUNDER'],
|
|
||||||
]);
|
|
||||||
|
|
||||||
$response->assertStatus(403);
|
|
||||||
$response->assertJsonFragment(['message' => 'Forbidden']);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects duplicate canonical names', function (): void {
|
|
||||||
$admin = makeAdmin();
|
|
||||||
User::factory()->create([
|
|
||||||
'name' => 'Dupe',
|
|
||||||
'name_canonical' => 'dupe',
|
|
||||||
'email' => 'dupe@example.com',
|
|
||||||
]);
|
|
||||||
$target = User::factory()->create([
|
|
||||||
'name' => 'Other',
|
|
||||||
'name_canonical' => 'other',
|
|
||||||
'email' => 'other@example.com',
|
|
||||||
]);
|
|
||||||
|
|
||||||
Sanctum::actingAs($admin);
|
|
||||||
$response = $this->patchJson("/api/users/{$target->id}", [
|
|
||||||
'name' => 'Dupe',
|
|
||||||
'email' => 'other@example.com',
|
|
||||||
'rank_id' => null,
|
|
||||||
]);
|
|
||||||
|
|
||||||
$response->assertStatus(422);
|
|
||||||
$response->assertJsonFragment(['message' => 'Name already exists.']);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('normalizes roles and updates group color', function (): void {
|
|
||||||
$admin = makeAdmin();
|
|
||||||
$target = User::factory()->create([
|
|
||||||
'name' => 'Target',
|
|
||||||
'email' => 'target@example.com',
|
|
||||||
]);
|
|
||||||
|
|
||||||
Role::firstOrCreate(['name' => 'ROLE_MOD'], ['color' => '#00ff00']);
|
|
||||||
|
|
||||||
Sanctum::actingAs($admin);
|
|
||||||
$response = $this->patchJson("/api/users/{$target->id}", [
|
|
||||||
'name' => 'Target',
|
|
||||||
'email' => 'target@example.com',
|
|
||||||
'rank_id' => null,
|
|
||||||
'roles' => ['ROLE_MOD'],
|
|
||||||
]);
|
|
||||||
|
|
||||||
$response->assertOk();
|
|
||||||
$response->assertJsonFragment(['group_color' => '#00ff00']);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('updates user name and email as admin', function (): void {
|
|
||||||
$admin = makeAdmin();
|
|
||||||
$target = User::factory()->create([
|
|
||||||
'name' => 'Old Name',
|
|
||||||
'email' => 'old@example.com',
|
|
||||||
'email_verified_at' => now(),
|
|
||||||
]);
|
|
||||||
Role::firstOrCreate(['name' => 'ROLE_MOD'], ['color' => '#00aa00']);
|
|
||||||
|
|
||||||
Sanctum::actingAs($admin);
|
|
||||||
$response = $this->patchJson("/api/users/{$target->id}", [
|
|
||||||
'name' => 'New Name',
|
|
||||||
'email' => 'new@example.com',
|
|
||||||
'rank_id' => null,
|
|
||||||
'roles' => ['ROLE_MOD'],
|
|
||||||
]);
|
|
||||||
|
|
||||||
$response->assertOk();
|
|
||||||
$response->assertJsonFragment([
|
|
||||||
'id' => $target->id,
|
|
||||||
'name' => 'New Name',
|
|
||||||
'email' => 'new@example.com',
|
|
||||||
]);
|
|
||||||
|
|
||||||
$target->refresh();
|
|
||||||
expect($target->name)->toBe('New Name');
|
|
||||||
expect($target->email)->toBe('new@example.com');
|
|
||||||
expect($target->email_verified_at)->toBeNull();
|
|
||||||
});
|
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
use App\Models\User;
|
|
||||||
use App\Models\UserSetting;
|
|
||||||
use Laravel\Sanctum\Sanctum;
|
|
||||||
|
|
||||||
it('lists user settings with optional key filter', function (): void {
|
|
||||||
$user = User::factory()->create();
|
|
||||||
$other = User::factory()->create();
|
|
||||||
|
|
||||||
UserSetting::create([
|
|
||||||
'user_id' => $user->id,
|
|
||||||
'key' => 'editor',
|
|
||||||
'value' => ['theme' => 'dark'],
|
|
||||||
]);
|
|
||||||
|
|
||||||
UserSetting::create([
|
|
||||||
'user_id' => $user->id,
|
|
||||||
'key' => 'notifications',
|
|
||||||
'value' => ['email' => true],
|
|
||||||
]);
|
|
||||||
|
|
||||||
UserSetting::create([
|
|
||||||
'user_id' => $other->id,
|
|
||||||
'key' => 'editor',
|
|
||||||
'value' => ['theme' => 'light'],
|
|
||||||
]);
|
|
||||||
|
|
||||||
Sanctum::actingAs($user);
|
|
||||||
$response = $this->getJson('/api/user-settings');
|
|
||||||
|
|
||||||
$response->assertOk();
|
|
||||||
$response->assertJsonCount(2);
|
|
||||||
|
|
||||||
$response = $this->getJson('/api/user-settings?key=editor');
|
|
||||||
$response->assertOk();
|
|
||||||
$response->assertJsonCount(1);
|
|
||||||
$response->assertJsonFragment(['key' => 'editor']);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('creates or updates user settings', function (): void {
|
|
||||||
$user = User::factory()->create();
|
|
||||||
Sanctum::actingAs($user);
|
|
||||||
|
|
||||||
$response = $this->postJson('/api/user-settings', [
|
|
||||||
'key' => 'editor',
|
|
||||||
'value' => ['theme' => 'dark'],
|
|
||||||
]);
|
|
||||||
|
|
||||||
$response->assertOk();
|
|
||||||
$response->assertJsonFragment(['key' => 'editor']);
|
|
||||||
|
|
||||||
$response = $this->postJson('/api/user-settings', [
|
|
||||||
'key' => 'editor',
|
|
||||||
'value' => ['theme' => 'light'],
|
|
||||||
]);
|
|
||||||
|
|
||||||
$response->assertOk();
|
|
||||||
$this->assertDatabaseHas('user_settings', [
|
|
||||||
'user_id' => $user->id,
|
|
||||||
'key' => 'editor',
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
@@ -1,107 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
use App\Models\Setting;
|
|
||||||
use Illuminate\Support\Facades\Http;
|
|
||||||
|
|
||||||
function setGiteaEnv(?string $owner, ?string $repo, ?string $apiBase = null, ?string $token = null): void
|
|
||||||
{
|
|
||||||
$pairs = [
|
|
||||||
'GITEA_OWNER' => $owner,
|
|
||||||
'GITEA_REPO' => $repo,
|
|
||||||
'GITEA_API_BASE' => $apiBase,
|
|
||||||
'GITEA_TOKEN' => $token,
|
|
||||||
];
|
|
||||||
|
|
||||||
foreach ($pairs as $key => $value) {
|
|
||||||
if ($value === null || $value === '') {
|
|
||||||
putenv("{$key}=");
|
|
||||||
unset($_ENV[$key], $_SERVER[$key]);
|
|
||||||
} else {
|
|
||||||
putenv("{$key}={$value}");
|
|
||||||
$_ENV[$key] = $value;
|
|
||||||
$_SERVER[$key] = $value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
it('returns error when gitea config missing', function (): void {
|
|
||||||
setGiteaEnv(null, null);
|
|
||||||
|
|
||||||
Setting::updateOrCreate(['key' => 'version'], ['value' => '1.0.0']);
|
|
||||||
|
|
||||||
$response = $this->getJson('/api/version/check');
|
|
||||||
|
|
||||||
$response->assertOk();
|
|
||||||
$response->assertJsonFragment([
|
|
||||||
'current_version' => '1.0.0',
|
|
||||||
'latest_tag' => null,
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('checks latest release and reports status', function (): void {
|
|
||||||
setGiteaEnv('acme', 'speedbb', 'https://git.example.test/api/v1', 'secrettoken');
|
|
||||||
|
|
||||||
Setting::updateOrCreate(['key' => 'version'], ['value' => '1.2.3']);
|
|
||||||
Setting::updateOrCreate(['key' => 'build'], ['value' => '7']);
|
|
||||||
|
|
||||||
Http::fake([
|
|
||||||
'https://git.example.test/api/v1/repos/acme/speedbb/releases/latest' => Http::response([
|
|
||||||
'tag_name' => 'v1.2.3',
|
|
||||||
], 200),
|
|
||||||
]);
|
|
||||||
|
|
||||||
$response = $this->getJson('/api/version/check');
|
|
||||||
|
|
||||||
$response->assertOk();
|
|
||||||
$response->assertJsonFragment([
|
|
||||||
'current_version' => '1.2.3',
|
|
||||||
'latest_tag' => 'v1.2.3',
|
|
||||||
'is_latest' => true,
|
|
||||||
]);
|
|
||||||
|
|
||||||
Http::assertSent(function ($request) {
|
|
||||||
return $request->hasHeader('Authorization', 'token secrettoken');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('handles failed release responses', function (): void {
|
|
||||||
setGiteaEnv('acme', 'speedbb', 'https://git.example.test/api/v1');
|
|
||||||
|
|
||||||
Setting::updateOrCreate(['key' => 'version'], ['value' => '1.2.3']);
|
|
||||||
|
|
||||||
Http::fake([
|
|
||||||
'https://git.example.test/api/v1/repos/acme/speedbb/releases/latest' => Http::response([
|
|
||||||
'message' => 'oops',
|
|
||||||
], 500),
|
|
||||||
]);
|
|
||||||
|
|
||||||
$response = $this->getJson('/api/version/check');
|
|
||||||
|
|
||||||
$response->assertOk();
|
|
||||||
$response->assertJsonFragment([
|
|
||||||
'current_version' => '1.2.3',
|
|
||||||
'latest_tag' => null,
|
|
||||||
'is_latest' => null,
|
|
||||||
'error' => 'Release check failed: 500',
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('handles release check exceptions', function (): void {
|
|
||||||
setGiteaEnv('acme', 'speedbb', 'https://git.example.test/api/v1');
|
|
||||||
|
|
||||||
Setting::updateOrCreate(['key' => 'version'], ['value' => '1.2.3']);
|
|
||||||
|
|
||||||
Http::fake(function () {
|
|
||||||
throw new RuntimeException('boom');
|
|
||||||
});
|
|
||||||
|
|
||||||
$response = $this->getJson('/api/version/check');
|
|
||||||
|
|
||||||
$response->assertOk();
|
|
||||||
$response->assertJsonFragment([
|
|
||||||
'current_version' => '1.2.3',
|
|
||||||
'latest_tag' => null,
|
|
||||||
'is_latest' => null,
|
|
||||||
'error' => 'Version check failed.',
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user