Compare commits
38 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d178b8da91 | |||
| 7ecb6378fe | |||
| 9496078644 | |||
| 3aab864c34 | |||
| 5eb5404061 | |||
| d9040f1e6c | |||
| 8270e635d6 | |||
| d724f80cad | |||
| 1f5f340ce4 | |||
| 40e111b3a6 | |||
| 506011f933 | |||
| 80a8b86a08 | |||
| c1cb3f394a | |||
| 31c8491aaf | |||
| 0ad5916504 | |||
| bac70c3927 | |||
| bf23e46e2d | |||
| 55b9a69c42 | |||
| b6ce5160f9 | |||
| d279e7f36f | |||
| a0d914ea24 | |||
| ce3b89d54e | |||
| 5cd8a1a9d6 | |||
| 6f9d9f9e7a | |||
| db7f088b36 | |||
| 54d4cd7f99 | |||
| af03c23c9f | |||
| 68dd17f895 | |||
| 8249df15df | |||
| f167e64d00 | |||
| 95ebc7778d | |||
| c67a3ec6d0 | |||
| bf278667bc | |||
| 30a06e18f0 | |||
| 0bc893dd35 | |||
| 88e4a70f88 | |||
| 160430e128 | |||
| 9c60a8944e |
@@ -37,3 +37,24 @@ jobs:
|
||||
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,6 +4,7 @@
|
||||
.env
|
||||
.env.backup
|
||||
.env.production
|
||||
.env.test
|
||||
.env.*.local
|
||||
.phpactor.json
|
||||
.phpunit.result.cache
|
||||
@@ -21,11 +22,14 @@
|
||||
/public/build
|
||||
/public/hot
|
||||
/public/storage
|
||||
/public/custom
|
||||
/storage/app
|
||||
/storage/framework
|
||||
/storage/*.key
|
||||
/storage/pail
|
||||
/storage/framework/views/*.php
|
||||
/bootstrap/cache/*.php
|
||||
/custom
|
||||
/vendor
|
||||
Homestead.json
|
||||
Homestead.yaml
|
||||
|
||||
17
CHANGELOG.md
17
CHANGELOG.md
@@ -1,5 +1,22 @@
|
||||
# Changelog
|
||||
|
||||
## 2026-02-12
|
||||
- Refined ACP System tab with left navigation, section-specific requirements, and CLI PHP selector.
|
||||
- Added CLI PHP interpreter options (php, keyhelp-php-domain, custom) with KeyHelp guidance.
|
||||
- Updated CLI update tooling and automation notes (KeyHelp PHP handling, CI runner requirements).
|
||||
- Adjusted ACP layout and tab styling for better dark-mode readability and auto-sizing sidebars.
|
||||
- Added Custom top-level ACP tab and preserved /custom paths during in-app updates.
|
||||
|
||||
## 2026-02-10
|
||||
- Reshaped ACP System tab with left navigation and dedicated views (Overview, Live Update, CLI, CI/CD).
|
||||
- Moved system requirements table into the CI/CD view with refresh controls.
|
||||
|
||||
## 2026-02-08
|
||||
- Achieved 100% test coverage across the backend.
|
||||
- Added comprehensive Feature and Unit tests for controllers, models, services, and console commands.
|
||||
- Added extensive edge-case and error-path coverage (system update/status, versioning, attachments, forums, roles, ranks, settings, portal, etc.).
|
||||
- Added `git_update.sh` for CLI-based updates (stable branch, deps, build, migrations, version sync).
|
||||
|
||||
## 2026-01-12
|
||||
- Switched main SPA layouts to fluid containers to reduce wasted space.
|
||||
- Added username-or-email login with case-insensitive unique usernames.
|
||||
|
||||
15
NOTES.md
Normal file
15
NOTES.md
Normal file
@@ -0,0 +1,15 @@
|
||||
TODO: Remove remaining IIFEs in ACP UI; prefer plain components/helpers.
|
||||
Add git_update.sh script to update the forum and core.
|
||||
Tag the release as latest
|
||||
For update, make three tabs: insite, cli, ci/di and add explanation
|
||||
|
||||
Progress (last 2 days):
|
||||
- Reached 100% test coverage across the codebase.
|
||||
- Added extensive Feature and Unit tests for controllers, models, services, and console commands.
|
||||
- Added coverage scripts and cleanup (tests for update/version flows, system update/status, attachments, forums, roles, ranks, settings, portal, etc.).
|
||||
- Hardened tests with fakes/mocks to cover error paths and edge cases.
|
||||
|
||||
TODO: Make the PHP binary path configurable for updates if the default PHP is outdated (ACP -> System).
|
||||
CI/CD: Runner must have PHP 8.4+ as the default CLI interpreter.
|
||||
KeyHelp: `keyhelp-php-domain` can select the PHP version based on the domain of the script location.
|
||||
KeyHelp: `keyhelp-php-domain` is a Pro feature; on non-Pro setups we must fake the command.
|
||||
@@ -21,12 +21,31 @@
|
||||
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:
|
||||
|
||||
@@ -30,6 +30,10 @@ class BbcodeFormatter
|
||||
|
||||
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');
|
||||
@@ -45,6 +49,17 @@ class BbcodeFormatter
|
||||
|
||||
$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;
|
||||
|
||||
93
app/Console/Commands/CronRun.php
Normal file
93
app/Console/Commands/CronRun.php
Normal file
@@ -0,0 +1,93 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\Attachment;
|
||||
use App\Services\AttachmentThumbnailService;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
class CronRun extends Command
|
||||
{
|
||||
protected $signature = 'speedbb:cron {--force : Recreate thumbnails even if already present} {--dry-run : Report without writing}';
|
||||
|
||||
protected $description = 'Run periodic maintenance tasks (currently: attachment thumbnail recreation).';
|
||||
|
||||
public function handle(AttachmentThumbnailService $thumbnailService): int
|
||||
{
|
||||
$force = (bool) $this->option('force');
|
||||
$dryRun = (bool) $this->option('dry-run');
|
||||
|
||||
$stats = [
|
||||
'checked' => 0,
|
||||
'created' => 0,
|
||||
'skipped' => 0,
|
||||
'missing' => 0,
|
||||
'non_image' => 0,
|
||||
];
|
||||
|
||||
$this->info('Processing attachment thumbnails...');
|
||||
|
||||
Attachment::query()
|
||||
->orderBy('id')
|
||||
->chunkById(200, function ($attachments) use ($thumbnailService, $force, $dryRun, &$stats) {
|
||||
foreach ($attachments as $attachment) {
|
||||
$stats['checked']++;
|
||||
|
||||
$mime = $attachment->mime_type ?? '';
|
||||
if (!str_starts_with($mime, 'image/')) {
|
||||
$stats['non_image']++;
|
||||
continue;
|
||||
}
|
||||
|
||||
$disk = Storage::disk($attachment->disk);
|
||||
if (!$disk->exists($attachment->path)) {
|
||||
$stats['missing']++;
|
||||
continue;
|
||||
}
|
||||
|
||||
$needsThumbnail = $force
|
||||
|| !$attachment->thumbnail_path
|
||||
|| !$disk->exists($attachment->thumbnail_path);
|
||||
|
||||
if (!$needsThumbnail) {
|
||||
$stats['skipped']++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($dryRun) {
|
||||
$stats['created']++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($force && $attachment->thumbnail_path && $disk->exists($attachment->thumbnail_path)) {
|
||||
$disk->delete($attachment->thumbnail_path);
|
||||
}
|
||||
|
||||
$payload = $thumbnailService->createForAttachment($attachment, $force);
|
||||
if (!$payload) {
|
||||
$stats['skipped']++;
|
||||
continue;
|
||||
}
|
||||
|
||||
$attachment->thumbnail_path = $payload['path'] ?? null;
|
||||
$attachment->thumbnail_mime_type = $payload['mime'] ?? null;
|
||||
$attachment->thumbnail_size_bytes = $payload['size'] ?? null;
|
||||
$attachment->save();
|
||||
|
||||
$stats['created']++;
|
||||
}
|
||||
});
|
||||
|
||||
$this->info(sprintf(
|
||||
'Checked: %d | Created: %d | Skipped: %d | Missing: %d | Non-image: %d',
|
||||
$stats['checked'],
|
||||
$stats['created'],
|
||||
$stats['skipped'],
|
||||
$stats['missing'],
|
||||
$stats['non_image']
|
||||
));
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
89
app/Console/Commands/VersionBump.php
Normal file
89
app/Console/Commands/VersionBump.php
Normal file
@@ -0,0 +1,89 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\Setting;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class VersionBump extends Command
|
||||
{
|
||||
protected $signature = 'version:bump';
|
||||
|
||||
protected $description = 'Bump the patch version (e.g. 26.0.1 -> 26.0.2).';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$current = Setting::query()->where('key', 'version')->value('value');
|
||||
if (!$current) {
|
||||
$this->error('Unable to determine current version from settings.');
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$next = $this->bumpPatch($current);
|
||||
if ($next === null) {
|
||||
$this->error('Version format must be X.Y.Z (optionally with suffix).');
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
Setting::updateOrCreate(['key' => 'version'], ['value' => $next]);
|
||||
|
||||
if (!$this->syncComposerVersion($next)) {
|
||||
$this->error('Failed to sync version to composer.json.');
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$this->info("Version bumped: {$current} -> {$next}");
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
private function bumpPatch(string $version): ?string
|
||||
{
|
||||
if (!preg_match('/^(\d+)\.(\d+)\.(\d+)(.*)?$/', $version, $matches)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$major = $matches[1];
|
||||
$minor = $matches[2];
|
||||
$patch = $matches[3];
|
||||
$suffix = $matches[4] ?? '';
|
||||
|
||||
$patchWidth = strlen($patch);
|
||||
$nextPatch = (string) ((int) $patch + 1);
|
||||
if ($patchWidth > 1) {
|
||||
$nextPatch = str_pad($nextPatch, $patchWidth, '0', STR_PAD_LEFT);
|
||||
}
|
||||
|
||||
return "{$major}.{$minor}.{$nextPatch}{$suffix}";
|
||||
}
|
||||
|
||||
private function syncComposerVersion(string $version): bool
|
||||
{
|
||||
$composerPath = base_path('composer.json');
|
||||
|
||||
if (!is_file($composerPath) || !is_readable($composerPath)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$raw = file_get_contents($composerPath);
|
||||
if ($raw === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$data = json_decode($raw, true);
|
||||
if (!is_array($data)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$data['version'] = $version;
|
||||
|
||||
$encoded = json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
|
||||
if ($encoded === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$encoded .= "\n";
|
||||
|
||||
return file_put_contents($composerPath, $encoded) !== false;
|
||||
}
|
||||
}
|
||||
113
app/Console/Commands/VersionRelease.php
Normal file
113
app/Console/Commands/VersionRelease.php
Normal file
@@ -0,0 +1,113 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\Setting;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
class VersionRelease extends Command
|
||||
{
|
||||
protected $signature = 'version:release {--prerelease : Mark this release as a prerelease} {--target= : Override target commit (defaults to env GITEA_TARGET_COMMIT or master)}';
|
||||
|
||||
protected $description = 'Create or update a Gitea release for the current version.';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$version = Setting::query()->where('key', 'version')->value('value');
|
||||
if (!$version) {
|
||||
$this->error('Unable to determine version from settings.');
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$token = env('GITEA_TOKEN');
|
||||
$owner = env('GITEA_OWNER');
|
||||
$repo = env('GITEA_REPO');
|
||||
$apiBase = rtrim((string) env('GITEA_API_BASE', 'https://git.24unix.net/api/v1'), '/');
|
||||
$target = $this->option('target') ?: env('GITEA_TARGET_COMMIT', 'master');
|
||||
$prerelease = $this->option('prerelease') || filter_var(env('GITEA_PRERELEASE', false), FILTER_VALIDATE_BOOLEAN);
|
||||
|
||||
if (!$token || !$owner || !$repo) {
|
||||
$this->error('Missing Gitea config. Set GITEA_TOKEN, GITEA_OWNER, and GITEA_REPO in .env.');
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$tag = "v{$version}";
|
||||
$body = $this->resolveChangelogBody($version);
|
||||
|
||||
$client = Http::withHeaders([
|
||||
'Authorization' => "token {$token}",
|
||||
'Accept' => 'application/json',
|
||||
]);
|
||||
|
||||
$payload = [
|
||||
'tag_name' => $tag,
|
||||
'target_commitish' => $target,
|
||||
'name' => $tag,
|
||||
'body' => $body,
|
||||
'prerelease' => (bool) $prerelease,
|
||||
];
|
||||
|
||||
$createUrl = "{$apiBase}/repos/{$owner}/{$repo}/releases";
|
||||
$response = $client->post($createUrl, $payload);
|
||||
|
||||
if ($response->successful()) {
|
||||
$this->info("Release created: {$tag}");
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
if ($response->status() === 409 || $response->status() === 422) {
|
||||
$getUrl = "{$apiBase}/repos/{$owner}/{$repo}/releases/tags/{$tag}";
|
||||
$existing = $client->get($getUrl);
|
||||
if (!$existing->successful()) {
|
||||
$this->error('Release already exists, but failed to fetch it for update.');
|
||||
return self::FAILURE;
|
||||
}
|
||||
$id = $existing->json('id');
|
||||
if (!$id) {
|
||||
$this->error('Release already exists, but no ID was returned.');
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$updateUrl = "{$apiBase}/repos/{$owner}/{$repo}/releases/{$id}";
|
||||
$updatePayload = [
|
||||
'name' => $tag,
|
||||
'body' => $body,
|
||||
'prerelease' => (bool) $prerelease,
|
||||
'target_commitish' => $target,
|
||||
];
|
||||
$updated = $client->patch($updateUrl, $updatePayload);
|
||||
if ($updated->successful()) {
|
||||
$this->info("Release updated: {$tag}");
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$this->error("Failed to update release: {$updated->status()}");
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$this->error("Failed to create release: {$response->status()}");
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
private function resolveChangelogBody(string $version): string
|
||||
{
|
||||
$path = base_path('CHANGELOG.md');
|
||||
if (!is_file($path) || !is_readable($path)) {
|
||||
return 'See commit history for details.';
|
||||
}
|
||||
|
||||
$raw = file_get_contents($path);
|
||||
if ($raw === false) {
|
||||
return 'See commit history for details.';
|
||||
}
|
||||
|
||||
$pattern = '/^##\\s+' . preg_quote($version, '/') . '\\s*\\R(.*?)(?=^##\\s+|\\z)/ms';
|
||||
if (preg_match($pattern, $raw, $matches)) {
|
||||
$body = trim($matches[1] ?? '');
|
||||
return $body !== '' ? $body : 'See commit history for details.';
|
||||
}
|
||||
|
||||
return 'See commit history for details.';
|
||||
}
|
||||
}
|
||||
73
app/Console/Commands/VersionSet.php
Normal file
73
app/Console/Commands/VersionSet.php
Normal file
@@ -0,0 +1,73 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\Setting;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class VersionSet extends Command
|
||||
{
|
||||
protected $signature = 'version:set {version}';
|
||||
|
||||
protected $description = 'Set the forum version (e.g. 26.0.1).';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$version = trim((string) $this->argument('version'));
|
||||
if (!$this->isValidVersion($version)) {
|
||||
$this->error('Version format must be X.Y or X.Y.Z (optionally with suffix).');
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$current = Setting::query()->where('key', 'version')->value('value');
|
||||
Setting::updateOrCreate(['key' => 'version'], ['value' => $version]);
|
||||
|
||||
if (!$this->syncComposerVersion($version)) {
|
||||
$this->error('Failed to sync version to composer.json.');
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
if ($current) {
|
||||
$this->info("Version updated: {$current} -> {$version}");
|
||||
} else {
|
||||
$this->info("Version set to {$version}");
|
||||
}
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
private function isValidVersion(string $version): bool
|
||||
{
|
||||
return (bool) preg_match('/^\d+\.\d+(?:\.\d+)?(?:[-._][0-9A-Za-z.-]+)?$/', $version);
|
||||
}
|
||||
|
||||
private function syncComposerVersion(string $version): bool
|
||||
{
|
||||
$composerPath = base_path('composer.json');
|
||||
|
||||
if (!is_file($composerPath) || !is_readable($composerPath)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$raw = file_get_contents($composerPath);
|
||||
if ($raw === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$data = json_decode($raw, true);
|
||||
if (!is_array($data)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$data['version'] = $version;
|
||||
|
||||
$encoded = json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
|
||||
if ($encoded === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$encoded .= "\n";
|
||||
|
||||
return file_put_contents($composerPath, $encoded) !== false;
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,8 @@ namespace App\Http\Controllers;
|
||||
use App\Models\Attachment;
|
||||
use App\Models\AttachmentExtension;
|
||||
use App\Models\Post;
|
||||
use App\Models\Setting;
|
||||
use App\Services\AttachmentThumbnailService;
|
||||
use App\Services\AuditLogger;
|
||||
use App\Models\Thread;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
@@ -115,7 +116,8 @@ class AttachmentController extends Controller
|
||||
$path = "attachments/{$scopeFolder}/{$filename}";
|
||||
Storage::disk($disk)->putFileAs("attachments/{$scopeFolder}", $file, $filename);
|
||||
|
||||
$thumbnailPayload = $this->maybeCreateThumbnail($file, $scopeFolder);
|
||||
$thumbnailPayload = app(AttachmentThumbnailService::class)
|
||||
->createForUpload($file, $scopeFolder, $disk);
|
||||
|
||||
$attachment = Attachment::create([
|
||||
'thread_id' => $threadId,
|
||||
@@ -134,6 +136,13 @@ class AttachmentController extends Controller
|
||||
'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);
|
||||
@@ -201,6 +210,13 @@ class AttachmentController extends Controller
|
||||
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);
|
||||
@@ -332,151 +348,4 @@ class AttachmentController extends Controller
|
||||
];
|
||||
}
|
||||
|
||||
private function maybeCreateThumbnail($file, string $scopeFolder): ?array
|
||||
{
|
||||
$enabled = $this->settingBool('attachments.create_thumbnails', true);
|
||||
if (!$enabled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$mime = $file->getMimeType() ?? '';
|
||||
if (!str_starts_with($mime, 'image/')) {
|
||||
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;
|
||||
}
|
||||
|
||||
$sourcePath = $file->getPathname();
|
||||
$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();
|
||||
$extension = strtolower((string) $file->getClientOriginalExtension());
|
||||
if ($extension !== '') {
|
||||
$filename .= ".{$extension}";
|
||||
}
|
||||
|
||||
$disk = 'local';
|
||||
$thumbPath = "attachments/{$scopeFolder}/thumbs/{$filename}";
|
||||
Storage::disk($disk)->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;
|
||||
}
|
||||
}
|
||||
|
||||
55
app/Http/Controllers/AuditLogController.php
Normal file
55
app/Http/Controllers/AuditLogController.php
Normal file
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\AuditLog;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class AuditLogController extends Controller
|
||||
{
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
$user = $request->user();
|
||||
if (!$user) {
|
||||
return response()->json(['message' => 'Unauthorized.'], 401);
|
||||
}
|
||||
|
||||
$isAdmin = $user->roles()->where('name', 'ROLE_ADMIN')->exists();
|
||||
if (!$isAdmin) {
|
||||
return response()->json(['message' => 'Not authorized.'], 403);
|
||||
}
|
||||
|
||||
$limit = (int) $request->query('limit', 200);
|
||||
$limit = max(1, min(500, $limit));
|
||||
|
||||
$logs = AuditLog::query()
|
||||
->with(['user.roles'])
|
||||
->latest('created_at')
|
||||
->limit($limit)
|
||||
->get()
|
||||
->map(fn (AuditLog $log) => $this->serializeLog($log));
|
||||
|
||||
return response()->json($logs);
|
||||
}
|
||||
|
||||
private function serializeLog(AuditLog $log): array
|
||||
{
|
||||
return [
|
||||
'id' => $log->id,
|
||||
'action' => $log->action,
|
||||
'subject_type' => $log->subject_type,
|
||||
'subject_id' => $log->subject_id,
|
||||
'metadata' => $log->metadata,
|
||||
'ip_address' => $log->ip_address,
|
||||
'user_agent' => $log->user_agent,
|
||||
'created_at' => $log->created_at?->toIso8601String(),
|
||||
'user' => $log->user ? [
|
||||
'id' => $log->user->id,
|
||||
'name' => $log->user->name,
|
||||
'email' => $log->user->email,
|
||||
'roles' => $log->user->roles?->pluck('name')->values(),
|
||||
] : null,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ namespace App\Http\Controllers;
|
||||
use App\Actions\Fortify\CreateNewUser;
|
||||
use App\Actions\Fortify\PasswordValidationRules;
|
||||
use App\Models\User;
|
||||
use App\Services\AuditLogger;
|
||||
use Illuminate\Auth\Events\Verified;
|
||||
use Illuminate\Auth\Events\PasswordReset;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
@@ -32,6 +33,9 @@ class AuthController extends Controller
|
||||
$user = $creator->create(input: $input);
|
||||
|
||||
$user->sendEmailVerificationNotification();
|
||||
app(AuditLogger::class)->log($request, 'user.registered', $user, [
|
||||
'email' => $user->email,
|
||||
], $user);
|
||||
|
||||
return response()->json(data: [
|
||||
'user_id' => $user->id,
|
||||
@@ -77,6 +81,10 @@ class AuthController extends Controller
|
||||
|
||||
$token = $user->createToken(name: 'api')->plainTextToken;
|
||||
|
||||
app(AuditLogger::class)->log($request, 'user.login', $user, [
|
||||
'login' => $login,
|
||||
], $user);
|
||||
|
||||
return response()->json(data: [
|
||||
'token' => $token,
|
||||
'user_id' => $user->id,
|
||||
@@ -130,13 +138,14 @@ class AuthController extends Controller
|
||||
|
||||
$status = Password::reset(
|
||||
$request->only('email', 'password', 'password_confirmation', 'token'),
|
||||
function (User $user, string $password) {
|
||||
function (User $user, string $password) use ($request) {
|
||||
$user->forceFill(attributes: [
|
||||
'password' => Hash::make(value: $password),
|
||||
'remember_token' => Str::random(length: 60),
|
||||
])->save();
|
||||
|
||||
event(new PasswordReset(user: $user));
|
||||
app(AuditLogger::class)->log($request, 'user.password_reset', $user, [], $user);
|
||||
}
|
||||
);
|
||||
|
||||
@@ -169,11 +178,14 @@ class AuthController extends Controller
|
||||
'remember_token' => Str::random(length: 60),
|
||||
])->save();
|
||||
|
||||
app(AuditLogger::class)->log($request, 'user.password_changed', $user, [], $user);
|
||||
|
||||
return response()->json(data: ['message' => 'Password updated.']);
|
||||
}
|
||||
|
||||
public function logout(Request $request): JsonResponse
|
||||
{
|
||||
app(AuditLogger::class)->log($request, 'user.logout', $request->user());
|
||||
$request->user()?->currentAccessToken()?->delete();
|
||||
|
||||
return response()->json(data: null, status: 204);
|
||||
|
||||
@@ -6,6 +6,7 @@ use App\Actions\BbcodeFormatter;
|
||||
use App\Models\Post;
|
||||
use App\Models\Thread;
|
||||
use App\Models\Setting;
|
||||
use App\Services\AuditLogger;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
@@ -54,6 +55,10 @@ class PostController extends Controller
|
||||
'body' => $data['body'],
|
||||
]);
|
||||
|
||||
app(AuditLogger::class)->log($request, 'post.created', $post, [
|
||||
'thread_id' => $thread->id,
|
||||
]);
|
||||
|
||||
$post->loadMissing([
|
||||
'user' => fn ($query) => $query
|
||||
->withCount(['posts', 'threads', 'thanksGiven', 'thanksReceived'])
|
||||
@@ -67,6 +72,13 @@ class PostController extends Controller
|
||||
|
||||
public function destroy(Request $request, Post $post): JsonResponse
|
||||
{
|
||||
$reason = $request->input('reason');
|
||||
$reasonText = $request->input('reason_text');
|
||||
app(AuditLogger::class)->log($request, 'post.deleted', $post, [
|
||||
'thread_id' => $post->thread_id,
|
||||
'reason' => $reason,
|
||||
'reason_text' => $reasonText,
|
||||
]);
|
||||
$post->deleted_by = $request->user()?->id;
|
||||
$post->save();
|
||||
$post->delete();
|
||||
@@ -74,6 +86,41 @@ class PostController extends Controller
|
||||
return response()->json(null, 204);
|
||||
}
|
||||
|
||||
public function update(Request $request, Post $post): JsonResponse
|
||||
{
|
||||
$user = $request->user();
|
||||
if (!$user) {
|
||||
return response()->json(['message' => 'Unauthorized.'], 401);
|
||||
}
|
||||
|
||||
$isAdmin = $user->roles()->where('name', 'ROLE_ADMIN')->exists();
|
||||
if (!$isAdmin && $post->user_id !== $user->id) {
|
||||
return response()->json(['message' => 'Not authorized to edit posts.'], 403);
|
||||
}
|
||||
|
||||
$data = $request->validate([
|
||||
'body' => ['required', 'string'],
|
||||
]);
|
||||
|
||||
$post->body = $data['body'];
|
||||
$post->save();
|
||||
$post->refresh();
|
||||
|
||||
app(AuditLogger::class)->log($request, 'post.edited', $post, [
|
||||
'thread_id' => $post->thread_id,
|
||||
]);
|
||||
|
||||
$post->loadMissing([
|
||||
'user' => fn ($query) => $query
|
||||
->withCount(['posts', 'threads', 'thanksGiven', 'thanksReceived'])
|
||||
->with(['rank', 'roles']),
|
||||
'attachments.extension',
|
||||
'attachments.group',
|
||||
]);
|
||||
|
||||
return response()->json($this->serializePost($post));
|
||||
}
|
||||
|
||||
private function parseIriId(?string $value): ?int
|
||||
{
|
||||
if (!$value) {
|
||||
@@ -163,6 +210,9 @@ class PostController extends Controller
|
||||
$map[$name] = [
|
||||
'url' => "/api/attachments/{$attachment->id}/download",
|
||||
'mime' => $attachment->mime_type ?? '',
|
||||
'thumb' => $attachment->thumbnail_path
|
||||
? "/api/attachments/{$attachment->id}/thumbnail"
|
||||
: null,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -181,6 +231,10 @@ class PostController extends Controller
|
||||
$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]";
|
||||
|
||||
@@ -5,17 +5,157 @@ namespace App\Http\Controllers;
|
||||
use App\Models\Post;
|
||||
use App\Models\Thread;
|
||||
use App\Models\User;
|
||||
use App\Models\Attachment;
|
||||
use App\Models\Setting;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
|
||||
class StatsController extends Controller
|
||||
{
|
||||
public function __invoke(): JsonResponse
|
||||
{
|
||||
$threadsCount = Thread::query()->withoutTrashed()->count();
|
||||
$postsCount = Post::query()->withoutTrashed()->count();
|
||||
$usersCount = User::query()->count();
|
||||
$attachmentsCount = Attachment::query()->withoutTrashed()->count();
|
||||
$attachmentsSizeBytes = (int) Attachment::query()->withoutTrashed()->sum('size_bytes');
|
||||
|
||||
$boardStartedAt = $this->resolveBoardStartedAt();
|
||||
$daysSinceStart = $boardStartedAt
|
||||
? max(1, Carbon::parse($boardStartedAt)->diffInSeconds(now()) / 86400)
|
||||
: null;
|
||||
|
||||
$dbSizeBytes = $this->resolveDatabaseSize();
|
||||
$dbServer = $this->resolveDatabaseServer();
|
||||
$avatarSizeBytes = $this->resolveAvatarDirectorySize();
|
||||
$orphanAttachments = $this->resolveOrphanAttachments();
|
||||
|
||||
$version = Setting::query()->where('key', 'version')->value('value');
|
||||
$build = Setting::query()->where('key', 'build')->value('value');
|
||||
$boardVersion = $version
|
||||
? ($build ? "{$version} (build {$build})" : $version)
|
||||
: null;
|
||||
|
||||
return response()->json([
|
||||
'threads' => Thread::query()->withoutTrashed()->count(),
|
||||
'posts' => Post::query()->withoutTrashed()->count()
|
||||
+ Thread::query()->withoutTrashed()->count(),
|
||||
'users' => User::query()->count(),
|
||||
'threads' => $threadsCount,
|
||||
'posts' => $postsCount + $threadsCount,
|
||||
'users' => $usersCount,
|
||||
'attachments' => $attachmentsCount,
|
||||
'board_started_at' => $boardStartedAt,
|
||||
'attachments_size_bytes' => $attachmentsSizeBytes,
|
||||
'avatar_directory_size_bytes' => $avatarSizeBytes,
|
||||
'database_size_bytes' => $dbSizeBytes,
|
||||
'database_server' => $dbServer,
|
||||
'gzip_compression' => $this->resolveGzipCompression(),
|
||||
'php_version' => PHP_VERSION,
|
||||
'orphan_attachments' => $orphanAttachments,
|
||||
'board_version' => $boardVersion,
|
||||
'posts_per_day' => $daysSinceStart ? ($postsCount + $threadsCount) / $daysSinceStart : null,
|
||||
'topics_per_day' => $daysSinceStart ? $threadsCount / $daysSinceStart : null,
|
||||
'users_per_day' => $daysSinceStart ? $usersCount / $daysSinceStart : null,
|
||||
'attachments_per_day' => $daysSinceStart ? $attachmentsCount / $daysSinceStart : null,
|
||||
]);
|
||||
}
|
||||
|
||||
private function resolveBoardStartedAt(): ?string
|
||||
{
|
||||
$timestamps = [
|
||||
User::query()->min('created_at'),
|
||||
Thread::query()->min('created_at'),
|
||||
Post::query()->min('created_at'),
|
||||
];
|
||||
|
||||
$min = null;
|
||||
foreach ($timestamps as $value) {
|
||||
if (!$value) {
|
||||
continue;
|
||||
}
|
||||
$time = Carbon::parse($value)->timestamp;
|
||||
if ($min === null || $time < $min) {
|
||||
$min = $time;
|
||||
}
|
||||
}
|
||||
|
||||
return $min !== null ? Carbon::createFromTimestamp($min)->toIso8601String() : null;
|
||||
}
|
||||
|
||||
private function resolveDatabaseSize(): ?int
|
||||
{
|
||||
try {
|
||||
$driver = DB::connection()->getDriverName();
|
||||
if ($driver === 'mysql') {
|
||||
$row = DB::selectOne('SELECT SUM(data_length + index_length) AS size FROM information_schema.tables WHERE table_schema = DATABASE()');
|
||||
return $row && isset($row->size) ? (int) $row->size : null;
|
||||
}
|
||||
} catch (\Throwable) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function resolveDatabaseServer(): ?string
|
||||
{
|
||||
try {
|
||||
$row = DB::selectOne('SELECT VERSION() AS version');
|
||||
return $row && isset($row->version) ? (string) $row->version : null;
|
||||
} catch (\Throwable) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private function resolveAvatarDirectorySize(): ?int
|
||||
{
|
||||
try {
|
||||
$disk = Storage::disk('public');
|
||||
$files = $disk->allFiles('avatars');
|
||||
$total = 0;
|
||||
foreach ($files as $file) {
|
||||
$total += $disk->size($file);
|
||||
}
|
||||
return $total;
|
||||
} catch (\Throwable) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private function resolveOrphanAttachments(): int
|
||||
{
|
||||
try {
|
||||
return (int) DB::table('attachments')
|
||||
->leftJoin('threads', 'attachments.thread_id', '=', 'threads.id')
|
||||
->leftJoin('posts', 'attachments.post_id', '=', 'posts.id')
|
||||
->whereNull('attachments.deleted_at')
|
||||
->where(function ($query) {
|
||||
$query
|
||||
->whereNull('attachments.thread_id')
|
||||
->whereNull('attachments.post_id')
|
||||
->orWhere(function ($inner) {
|
||||
$inner->whereNotNull('attachments.thread_id')
|
||||
->where(function ($inner2) {
|
||||
$inner2->whereNull('threads.id')
|
||||
->orWhereNotNull('threads.deleted_at');
|
||||
});
|
||||
})
|
||||
->orWhere(function ($inner) {
|
||||
$inner->whereNotNull('attachments.post_id')
|
||||
->where(function ($inner2) {
|
||||
$inner2->whereNull('posts.id')
|
||||
->orWhereNotNull('posts.deleted_at');
|
||||
});
|
||||
});
|
||||
})
|
||||
->count();
|
||||
} catch (\Throwable) {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
private function resolveGzipCompression(): bool
|
||||
{
|
||||
$value = ini_get('zlib.output_compression');
|
||||
return in_array(strtolower((string) $value), ['1', 'on', 'true'], true);
|
||||
}
|
||||
}
|
||||
|
||||
155
app/Http/Controllers/SystemStatusController.php
Normal file
155
app/Http/Controllers/SystemStatusController.php
Normal file
@@ -0,0 +1,155 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Setting;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\Process\Process;
|
||||
|
||||
class SystemStatusController extends Controller
|
||||
{
|
||||
public function __invoke(Request $request): JsonResponse
|
||||
{
|
||||
$user = $request->user();
|
||||
if (!$user || !$user->roles()->where('name', 'ROLE_ADMIN')->exists()) {
|
||||
return response()->json(['message' => 'Forbidden'], 403);
|
||||
}
|
||||
|
||||
$phpDefaultPath = $this->resolveBinary('php');
|
||||
$phpConfiguredPath = trim((string) Setting::where('key', 'system.php_binary')->value('value'));
|
||||
$phpSelectedPath = $phpConfiguredPath ?: (PHP_BINARY ?: $phpDefaultPath);
|
||||
$phpSelectedOk = (bool) $phpSelectedPath;
|
||||
$phpSelectedVersion = $phpSelectedPath
|
||||
? ($this->resolvePhpVersion($phpSelectedPath) ?? PHP_VERSION)
|
||||
: PHP_VERSION;
|
||||
$minVersions = $this->resolveMinVersions();
|
||||
$composerPath = $this->resolveBinary('composer');
|
||||
$nodePath = $this->resolveBinary('node');
|
||||
$npmPath = $this->resolveBinary('npm');
|
||||
$tarPath = $this->resolveBinary('tar');
|
||||
$rsyncPath = $this->resolveBinary('rsync');
|
||||
$procFunctions = [
|
||||
'proc_open',
|
||||
'proc_get_status',
|
||||
'proc_close',
|
||||
];
|
||||
$disabledFunctions = array_filter(array_map('trim', explode(',', (string) ini_get('disable_functions'))));
|
||||
$disabledLookup = array_fill_keys($disabledFunctions, true);
|
||||
$procFunctionStatus = [];
|
||||
foreach ($procFunctions as $function) {
|
||||
$procFunctionStatus[$function] = function_exists($function) && !isset($disabledLookup[$function]);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'php' => PHP_VERSION,
|
||||
'php_default' => $phpDefaultPath,
|
||||
'php_configured' => $phpConfiguredPath ?: null,
|
||||
'php_selected_path' => $phpSelectedPath,
|
||||
'php_selected_ok' => $phpSelectedOk,
|
||||
'php_selected_version' => $phpSelectedVersion,
|
||||
'min_versions' => $minVersions,
|
||||
'composer' => $composerPath,
|
||||
'composer_version' => $this->resolveBinaryVersion($composerPath, ['--version']),
|
||||
'node' => $nodePath,
|
||||
'node_version' => $this->resolveBinaryVersion($nodePath, ['--version']),
|
||||
'npm' => $npmPath,
|
||||
'npm_version' => $this->resolveBinaryVersion($npmPath, ['--version']),
|
||||
'tar' => $tarPath,
|
||||
'tar_version' => $this->resolveBinaryVersion($tarPath, ['--version']),
|
||||
'rsync' => $rsyncPath,
|
||||
'rsync_version' => $this->resolveBinaryVersion($rsyncPath, ['--version']),
|
||||
'proc_functions' => $procFunctionStatus,
|
||||
'storage_writable' => is_writable(storage_path()),
|
||||
'updates_writable' => is_writable(storage_path('app/updates')) || @mkdir(storage_path('app/updates'), 0755, true),
|
||||
]);
|
||||
}
|
||||
|
||||
private function resolveBinary(string $name): ?string
|
||||
{
|
||||
$process = new Process(['sh', '-lc', "command -v {$name}"]);
|
||||
$process->setTimeout(5);
|
||||
$process->run();
|
||||
|
||||
if (!$process->isSuccessful()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$output = trim($process->getOutput());
|
||||
return $output !== '' ? $output : null;
|
||||
}
|
||||
|
||||
private function resolvePhpVersion(string $path): ?string
|
||||
{
|
||||
$process = new Process([$path, '-r', 'echo PHP_VERSION;']);
|
||||
$process->setTimeout(5);
|
||||
$process->run();
|
||||
|
||||
if (!$process->isSuccessful()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$output = trim($process->getOutput());
|
||||
return $output !== '' ? $output : null;
|
||||
}
|
||||
|
||||
private function resolveBinaryVersion(?string $path, array $args): ?string
|
||||
{
|
||||
if (!$path) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$process = new Process(array_merge([$path], $args));
|
||||
$process->setTimeout(5);
|
||||
$process->run();
|
||||
|
||||
if (!$process->isSuccessful()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$output = trim($process->getOutput());
|
||||
if ($output === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$line = strtok($output, "\n") ?: $output;
|
||||
if (preg_match('/(\\d+\\.\\d+(?:\\.\\d+)?)/', $line, $matches)) {
|
||||
return $matches[1];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function resolveMinVersions(): array
|
||||
{
|
||||
$composerJson = $this->readJson(base_path('composer.json'));
|
||||
$packageJson = $this->readJson(base_path('package.json'));
|
||||
|
||||
$php = $composerJson['require']['php'] ?? null;
|
||||
$node = $packageJson['engines']['node'] ?? null;
|
||||
$npm = $packageJson['engines']['npm'] ?? null;
|
||||
$composer = $composerJson['require']['composer-runtime-api'] ?? null;
|
||||
|
||||
return [
|
||||
'php' => is_string($php) ? $php : null,
|
||||
'node' => is_string($node) ? $node : null,
|
||||
'npm' => is_string($npm) ? $npm : null,
|
||||
'composer' => is_string($composer) ? $composer : null,
|
||||
];
|
||||
}
|
||||
|
||||
private function readJson(string $path): array
|
||||
{
|
||||
if (!is_file($path)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$contents = file_get_contents($path);
|
||||
if ($contents === false) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$data = json_decode($contents, true);
|
||||
return is_array($data) ? $data : [];
|
||||
}
|
||||
}
|
||||
215
app/Http/Controllers/SystemUpdateController.php
Normal file
215
app/Http/Controllers/SystemUpdateController.php
Normal file
@@ -0,0 +1,215 @@
|
||||
<?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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ use App\Models\Forum;
|
||||
use App\Models\Thread;
|
||||
use App\Actions\BbcodeFormatter;
|
||||
use App\Models\Setting;
|
||||
use App\Services\AuditLogger;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
@@ -81,6 +82,11 @@ class ThreadController extends Controller
|
||||
'body' => $data['body'],
|
||||
]);
|
||||
|
||||
app(AuditLogger::class)->log($request, 'thread.created', $thread, [
|
||||
'forum_id' => $forum->id,
|
||||
'title' => $thread->title,
|
||||
]);
|
||||
|
||||
$thread->loadMissing([
|
||||
'user' => fn ($query) => $query
|
||||
->withCount(['posts', 'threads', 'thanksGiven', 'thanksReceived'])
|
||||
@@ -96,6 +102,14 @@ class ThreadController extends Controller
|
||||
|
||||
public function destroy(Request $request, Thread $thread): JsonResponse
|
||||
{
|
||||
$reason = $request->input('reason');
|
||||
$reasonText = $request->input('reason_text');
|
||||
app(AuditLogger::class)->log($request, 'thread.deleted', $thread, [
|
||||
'forum_id' => $thread->forum_id,
|
||||
'title' => $thread->title,
|
||||
'reason' => $reason,
|
||||
'reason_text' => $reasonText,
|
||||
]);
|
||||
$thread->deleted_by = $request->user()?->id;
|
||||
$thread->save();
|
||||
$thread->delete();
|
||||
@@ -103,6 +117,51 @@ class ThreadController extends Controller
|
||||
return response()->json(null, 204);
|
||||
}
|
||||
|
||||
public function update(Request $request, Thread $thread): JsonResponse
|
||||
{
|
||||
$user = $request->user();
|
||||
if (!$user) {
|
||||
return response()->json(['message' => 'Unauthorized.'], 401);
|
||||
}
|
||||
|
||||
$isAdmin = $user->roles()->where('name', 'ROLE_ADMIN')->exists();
|
||||
if (!$isAdmin && $thread->user_id !== $user->id) {
|
||||
return response()->json(['message' => 'Not authorized to edit threads.'], 403);
|
||||
}
|
||||
|
||||
$data = $request->validate([
|
||||
'title' => ['sometimes', 'required', 'string'],
|
||||
'body' => ['sometimes', 'required', 'string'],
|
||||
]);
|
||||
|
||||
if (array_key_exists('title', $data)) {
|
||||
$thread->title = $data['title'];
|
||||
}
|
||||
if (array_key_exists('body', $data)) {
|
||||
$thread->body = $data['body'];
|
||||
}
|
||||
|
||||
$thread->save();
|
||||
$thread->refresh();
|
||||
|
||||
app(AuditLogger::class)->log($request, 'thread.edited', $thread, [
|
||||
'forum_id' => $thread->forum_id,
|
||||
'title' => $thread->title,
|
||||
]);
|
||||
|
||||
$thread->loadMissing([
|
||||
'user' => fn ($query) => $query
|
||||
->withCount(['posts', 'threads', 'thanksGiven', 'thanksReceived'])
|
||||
->with(['rank', 'roles']),
|
||||
'attachments.extension',
|
||||
'attachments.group',
|
||||
'latestPost.user.rank',
|
||||
'latestPost.user.roles',
|
||||
])->loadCount('posts');
|
||||
|
||||
return response()->json($this->serializeThread($thread));
|
||||
}
|
||||
|
||||
public function updateSolved(Request $request, Thread $thread): JsonResponse
|
||||
{
|
||||
$user = $request->user();
|
||||
@@ -121,6 +180,9 @@ class ThreadController extends Controller
|
||||
|
||||
$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
|
||||
@@ -238,6 +300,9 @@ class ThreadController extends Controller
|
||||
$map[$name] = [
|
||||
'url' => "/api/attachments/{$attachment->id}/download",
|
||||
'mime' => $attachment->mime_type ?? '',
|
||||
'thumb' => $attachment->thumbnail_path
|
||||
? "/api/attachments/{$attachment->id}/thumbnail"
|
||||
: null,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -256,6 +321,10 @@ class ThreadController extends Controller
|
||||
$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]";
|
||||
|
||||
72
app/Http/Controllers/VersionCheckController.php
Normal file
72
app/Http/Controllers/VersionCheckController.php
Normal file
@@ -0,0 +1,72 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Setting;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
class VersionCheckController extends Controller
|
||||
{
|
||||
public function __invoke(): JsonResponse
|
||||
{
|
||||
$current = Setting::query()->where('key', 'version')->value('value');
|
||||
$build = Setting::query()->where('key', 'build')->value('value');
|
||||
|
||||
$owner = env('GITEA_OWNER');
|
||||
$repo = env('GITEA_REPO');
|
||||
$apiBase = rtrim((string) env('GITEA_API_BASE', 'https://git.24unix.net/api/v1'), '/');
|
||||
$token = env('GITEA_TOKEN');
|
||||
|
||||
if (!$owner || !$repo) {
|
||||
return response()->json([
|
||||
'current_version' => $current,
|
||||
'current_build' => $build !== null ? (int) $build : null,
|
||||
'latest_tag' => null,
|
||||
'latest_version' => null,
|
||||
'is_latest' => null,
|
||||
'error' => 'Missing GITEA_OWNER/GITEA_REPO configuration.',
|
||||
]);
|
||||
}
|
||||
|
||||
try {
|
||||
$client = Http::acceptJson();
|
||||
if ($token) {
|
||||
$client = $client->withHeaders(['Authorization' => "token {$token}"]);
|
||||
}
|
||||
|
||||
$response = $client->get("{$apiBase}/repos/{$owner}/{$repo}/releases/latest");
|
||||
if (!$response->successful()) {
|
||||
return response()->json([
|
||||
'current_version' => $current,
|
||||
'current_build' => $build !== null ? (int) $build : null,
|
||||
'latest_tag' => null,
|
||||
'latest_version' => null,
|
||||
'is_latest' => null,
|
||||
'error' => "Release check failed: {$response->status()}",
|
||||
]);
|
||||
}
|
||||
|
||||
$tag = (string) ($response->json('tag_name') ?? '');
|
||||
$latestVersion = ltrim($tag, 'v');
|
||||
$isLatest = $current && $latestVersion ? $current === $latestVersion : null;
|
||||
|
||||
return response()->json([
|
||||
'current_version' => $current,
|
||||
'current_build' => $build !== null ? (int) $build : null,
|
||||
'latest_tag' => $tag ?: null,
|
||||
'latest_version' => $latestVersion ?: null,
|
||||
'is_latest' => $isLatest,
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
return response()->json([
|
||||
'current_version' => $current,
|
||||
'current_build' => $build !== null ? (int) $build : null,
|
||||
'latest_tag' => null,
|
||||
'latest_version' => null,
|
||||
'is_latest' => null,
|
||||
'error' => 'Version check failed.',
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
41
app/Models/AuditLog.php
Normal file
41
app/Models/AuditLog.php
Normal file
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
/**
|
||||
* @property int $id
|
||||
* @property int|null $user_id
|
||||
* @property string $action
|
||||
* @property string|null $subject_type
|
||||
* @property int|null $subject_id
|
||||
* @property array|null $metadata
|
||||
* @property string|null $ip_address
|
||||
* @property string|null $user_agent
|
||||
* @property \Illuminate\Support\Carbon|null $created_at
|
||||
* @property \Illuminate\Support\Carbon|null $updated_at
|
||||
* @mixin \Eloquent
|
||||
*/
|
||||
class AuditLog extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'user_id',
|
||||
'action',
|
||||
'subject_type',
|
||||
'subject_id',
|
||||
'metadata',
|
||||
'ip_address',
|
||||
'user_agent',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'metadata' => 'array',
|
||||
];
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
}
|
||||
208
app/Services/AttachmentThumbnailService.php
Normal file
208
app/Services/AttachmentThumbnailService.php
Normal file
@@ -0,0 +1,208 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Attachment;
|
||||
use App\Models\Setting;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class AttachmentThumbnailService
|
||||
{
|
||||
public function createForUpload(UploadedFile $file, string $scopeFolder, string $disk = 'local'): ?array
|
||||
{
|
||||
$mime = $file->getMimeType() ?? '';
|
||||
if (!str_starts_with($mime, 'image/')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$sourcePath = $file->getPathname();
|
||||
$extension = strtolower((string) $file->getClientOriginalExtension());
|
||||
|
||||
return $this->createThumbnail($sourcePath, $mime, $extension, $scopeFolder, $disk);
|
||||
}
|
||||
|
||||
public function createForAttachment(Attachment $attachment, bool $force = false): ?array
|
||||
{
|
||||
if (!$force && $attachment->thumbnail_path) {
|
||||
$thumbDisk = Storage::disk($attachment->disk);
|
||||
if ($thumbDisk->exists($attachment->thumbnail_path)) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
$mime = $attachment->mime_type ?? '';
|
||||
if (!str_starts_with($mime, 'image/')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$disk = Storage::disk($attachment->disk);
|
||||
if (!$disk->exists($attachment->path)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$sourcePath = $disk->path($attachment->path);
|
||||
$scopeFolder = $this->resolveScopeFolder($attachment);
|
||||
$extension = strtolower((string) ($attachment->extension ?? ''));
|
||||
|
||||
return $this->createThumbnail($sourcePath, $mime, $extension, $scopeFolder, $attachment->disk);
|
||||
}
|
||||
|
||||
private function resolveScopeFolder(Attachment $attachment): string
|
||||
{
|
||||
if ($attachment->thread_id) {
|
||||
return "threads/{$attachment->thread_id}";
|
||||
}
|
||||
|
||||
if ($attachment->post_id) {
|
||||
return "posts/{$attachment->post_id}";
|
||||
}
|
||||
|
||||
return 'misc';
|
||||
}
|
||||
|
||||
private function createThumbnail(
|
||||
string $sourcePath,
|
||||
string $mime,
|
||||
string $extension,
|
||||
string $scopeFolder,
|
||||
string $diskName
|
||||
): ?array {
|
||||
if (!$this->settingBool('attachments.create_thumbnails', true)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$maxWidth = $this->settingInt('attachments.thumbnail_max_width', 300);
|
||||
$maxHeight = $this->settingInt('attachments.thumbnail_max_height', 300);
|
||||
if ($maxWidth <= 0 || $maxHeight <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$info = @getimagesize($sourcePath);
|
||||
if (!$info) {
|
||||
return null;
|
||||
}
|
||||
|
||||
[$width, $height] = $info;
|
||||
if ($width <= 0 || $height <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($width <= $maxWidth && $height <= $maxHeight) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$ratio = min($maxWidth / $width, $maxHeight / $height);
|
||||
$targetWidth = max(1, (int) round($width * $ratio));
|
||||
$targetHeight = max(1, (int) round($height * $ratio));
|
||||
|
||||
$sourceImage = $this->createImageFromFile($sourcePath, $mime);
|
||||
if (!$sourceImage) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$thumbImage = imagecreatetruecolor($targetWidth, $targetHeight);
|
||||
if (!$thumbImage) {
|
||||
imagedestroy($sourceImage);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (in_array($mime, ['image/png', 'image/gif'], true)) {
|
||||
imagecolortransparent($thumbImage, imagecolorallocatealpha($thumbImage, 0, 0, 0, 127));
|
||||
imagealphablending($thumbImage, false);
|
||||
imagesavealpha($thumbImage, true);
|
||||
}
|
||||
|
||||
imagecopyresampled(
|
||||
$thumbImage,
|
||||
$sourceImage,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
$targetWidth,
|
||||
$targetHeight,
|
||||
$width,
|
||||
$height
|
||||
);
|
||||
|
||||
$quality = $this->settingInt('attachments.thumbnail_quality', 85);
|
||||
$thumbBinary = $this->renderImageBinary($thumbImage, $mime, $quality);
|
||||
|
||||
imagedestroy($sourceImage);
|
||||
imagedestroy($thumbImage);
|
||||
|
||||
if ($thumbBinary === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$filename = Str::uuid()->toString();
|
||||
if ($extension !== '') {
|
||||
$filename .= ".{$extension}";
|
||||
}
|
||||
|
||||
$thumbPath = "attachments/{$scopeFolder}/thumbs/{$filename}";
|
||||
Storage::disk($diskName)->put($thumbPath, $thumbBinary);
|
||||
|
||||
return [
|
||||
'path' => $thumbPath,
|
||||
'mime' => $mime,
|
||||
'size' => strlen($thumbBinary),
|
||||
];
|
||||
}
|
||||
|
||||
private function createImageFromFile(string $path, string $mime)
|
||||
{
|
||||
return match ($mime) {
|
||||
'image/jpeg', 'image/jpg' => @imagecreatefromjpeg($path),
|
||||
'image/png' => @imagecreatefrompng($path),
|
||||
'image/gif' => @imagecreatefromgif($path),
|
||||
'image/webp' => function_exists('imagecreatefromwebp') ? @imagecreatefromwebp($path) : null,
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
|
||||
private function renderImageBinary($image, string $mime, int $quality): ?string
|
||||
{
|
||||
ob_start();
|
||||
$success = false;
|
||||
|
||||
if (in_array($mime, ['image/jpeg', 'image/jpg'], true)) {
|
||||
$success = imagejpeg($image, null, max(10, min(95, $quality)));
|
||||
} elseif ($mime === 'image/png') {
|
||||
$compression = (int) round(9 - (max(10, min(95, $quality)) / 100) * 9);
|
||||
$success = imagepng($image, null, $compression);
|
||||
} elseif ($mime === 'image/gif') {
|
||||
$success = imagegif($image);
|
||||
} elseif ($mime === 'image/webp' && function_exists('imagewebp')) {
|
||||
$success = imagewebp($image, null, max(10, min(95, $quality)));
|
||||
}
|
||||
|
||||
$data = ob_get_clean();
|
||||
|
||||
if (!$success) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $data !== false ? $data : null;
|
||||
}
|
||||
|
||||
private function settingBool(string $key, bool $default): bool
|
||||
{
|
||||
$value = Setting::query()->where('key', $key)->value('value');
|
||||
if ($value === null) {
|
||||
return $default;
|
||||
}
|
||||
return in_array(strtolower($value), ['1', 'true', 'yes', 'on'], true);
|
||||
}
|
||||
|
||||
private function settingInt(string $key, int $default): int
|
||||
{
|
||||
$value = Setting::query()->where('key', $key)->value('value');
|
||||
if ($value === null) {
|
||||
return $default;
|
||||
}
|
||||
return (int) $value;
|
||||
}
|
||||
}
|
||||
34
app/Services/AuditLogger.php
Normal file
34
app/Services/AuditLogger.php
Normal file
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\AuditLog;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class AuditLogger
|
||||
{
|
||||
public function log(
|
||||
Request $request,
|
||||
string $action,
|
||||
?Model $subject = null,
|
||||
array $metadata = [],
|
||||
?Model $actor = null
|
||||
): ?AuditLog {
|
||||
try {
|
||||
$actorUser = $actor ?? $request->user();
|
||||
|
||||
return AuditLog::create([
|
||||
'user_id' => $actorUser?->id,
|
||||
'action' => $action,
|
||||
'subject_type' => $subject ? get_class($subject) : null,
|
||||
'subject_id' => $subject?->getKey(),
|
||||
'metadata' => $metadata ?: null,
|
||||
'ip_address' => $request->ip(),
|
||||
'user_agent' => $request->userAgent(),
|
||||
]);
|
||||
} catch (\Throwable) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
17
artisan
17
artisan
@@ -1,18 +1,19 @@
|
||||
#!/usr/bin/env php
|
||||
<?php
|
||||
|
||||
use Illuminate\Foundation\Application;
|
||||
use Symfony\Component\Console\Input\ArgvInput;
|
||||
define('LARAVEL_START', microtime(true));
|
||||
|
||||
define(constant_name: 'LARAVEL_START', value: microtime(as_float: true));
|
||||
|
||||
// Register the Composer autoloader...
|
||||
require __DIR__.'/vendor/autoload.php';
|
||||
|
||||
// Bootstrap Laravel and handle the command...
|
||||
/** @var Application $app */
|
||||
$app = require_once __DIR__.'/bootstrap/app.php';
|
||||
|
||||
$status = $app->handleCommand(input: new ArgvInput);
|
||||
$kernel = $app->make(Illuminate\Contracts\Console\Kernel::class);
|
||||
|
||||
$status = $kernel->handle(
|
||||
$input = new Symfony\Component\Console\Input\ArgvInput,
|
||||
new Symfony\Component\Console\Output\ConsoleOutput
|
||||
);
|
||||
|
||||
$kernel->terminate($input, $status);
|
||||
|
||||
exit($status);
|
||||
|
||||
0
bootstrap/cache/.gitkeep
vendored
Normal file
0
bootstrap/cache/.gitkeep
vendored
Normal file
65
bootstrap/cache/packages.php
vendored
65
bootstrap/cache/packages.php
vendored
@@ -1,65 +0,0 @@
|
||||
<?php return array (
|
||||
'barryvdh/laravel-ide-helper' =>
|
||||
array (
|
||||
'providers' =>
|
||||
array (
|
||||
0 => 'Barryvdh\\LaravelIdeHelper\\IdeHelperServiceProvider',
|
||||
),
|
||||
),
|
||||
'laravel/fortify' =>
|
||||
array (
|
||||
'providers' =>
|
||||
array (
|
||||
0 => 'Laravel\\Fortify\\FortifyServiceProvider',
|
||||
),
|
||||
),
|
||||
'laravel/pail' =>
|
||||
array (
|
||||
'providers' =>
|
||||
array (
|
||||
0 => 'Laravel\\Pail\\PailServiceProvider',
|
||||
),
|
||||
),
|
||||
'laravel/sail' =>
|
||||
array (
|
||||
'providers' =>
|
||||
array (
|
||||
0 => 'Laravel\\Sail\\SailServiceProvider',
|
||||
),
|
||||
),
|
||||
'laravel/sanctum' =>
|
||||
array (
|
||||
'providers' =>
|
||||
array (
|
||||
0 => 'Laravel\\Sanctum\\SanctumServiceProvider',
|
||||
),
|
||||
),
|
||||
'laravel/tinker' =>
|
||||
array (
|
||||
'providers' =>
|
||||
array (
|
||||
0 => 'Laravel\\Tinker\\TinkerServiceProvider',
|
||||
),
|
||||
),
|
||||
'nesbot/carbon' =>
|
||||
array (
|
||||
'providers' =>
|
||||
array (
|
||||
0 => 'Carbon\\Laravel\\ServiceProvider',
|
||||
),
|
||||
),
|
||||
'nunomaduro/collision' =>
|
||||
array (
|
||||
'providers' =>
|
||||
array (
|
||||
0 => 'NunoMaduro\\Collision\\Adapters\\Laravel\\CollisionServiceProvider',
|
||||
),
|
||||
),
|
||||
'nunomaduro/termwind' =>
|
||||
array (
|
||||
'providers' =>
|
||||
array (
|
||||
0 => 'Termwind\\Laravel\\TermwindServiceProvider',
|
||||
),
|
||||
),
|
||||
);
|
||||
275
bootstrap/cache/services.php
vendored
275
bootstrap/cache/services.php
vendored
@@ -1,275 +0,0 @@
|
||||
<?php return array (
|
||||
'providers' =>
|
||||
array (
|
||||
0 => 'Illuminate\\Auth\\AuthServiceProvider',
|
||||
1 => 'Illuminate\\Broadcasting\\BroadcastServiceProvider',
|
||||
2 => 'Illuminate\\Bus\\BusServiceProvider',
|
||||
3 => 'Illuminate\\Cache\\CacheServiceProvider',
|
||||
4 => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
|
||||
5 => 'Illuminate\\Concurrency\\ConcurrencyServiceProvider',
|
||||
6 => 'Illuminate\\Cookie\\CookieServiceProvider',
|
||||
7 => 'Illuminate\\Database\\DatabaseServiceProvider',
|
||||
8 => 'Illuminate\\Encryption\\EncryptionServiceProvider',
|
||||
9 => 'Illuminate\\Filesystem\\FilesystemServiceProvider',
|
||||
10 => 'Illuminate\\Foundation\\Providers\\FoundationServiceProvider',
|
||||
11 => 'Illuminate\\Hashing\\HashServiceProvider',
|
||||
12 => 'Illuminate\\Mail\\MailServiceProvider',
|
||||
13 => 'Illuminate\\Notifications\\NotificationServiceProvider',
|
||||
14 => 'Illuminate\\Pagination\\PaginationServiceProvider',
|
||||
15 => 'Illuminate\\Auth\\Passwords\\PasswordResetServiceProvider',
|
||||
16 => 'Illuminate\\Pipeline\\PipelineServiceProvider',
|
||||
17 => 'Illuminate\\Queue\\QueueServiceProvider',
|
||||
18 => 'Illuminate\\Redis\\RedisServiceProvider',
|
||||
19 => 'Illuminate\\Session\\SessionServiceProvider',
|
||||
20 => 'Illuminate\\Translation\\TranslationServiceProvider',
|
||||
21 => 'Illuminate\\Validation\\ValidationServiceProvider',
|
||||
22 => 'Illuminate\\View\\ViewServiceProvider',
|
||||
23 => 'Barryvdh\\LaravelIdeHelper\\IdeHelperServiceProvider',
|
||||
24 => 'Laravel\\Fortify\\FortifyServiceProvider',
|
||||
25 => 'Laravel\\Pail\\PailServiceProvider',
|
||||
26 => 'Laravel\\Sail\\SailServiceProvider',
|
||||
27 => 'Laravel\\Sanctum\\SanctumServiceProvider',
|
||||
28 => 'Laravel\\Tinker\\TinkerServiceProvider',
|
||||
29 => 'Carbon\\Laravel\\ServiceProvider',
|
||||
30 => 'NunoMaduro\\Collision\\Adapters\\Laravel\\CollisionServiceProvider',
|
||||
31 => 'Termwind\\Laravel\\TermwindServiceProvider',
|
||||
32 => 'App\\Providers\\AppServiceProvider',
|
||||
33 => 'App\\Providers\\FortifyServiceProvider',
|
||||
),
|
||||
'eager' =>
|
||||
array (
|
||||
0 => 'Illuminate\\Auth\\AuthServiceProvider',
|
||||
1 => 'Illuminate\\Cookie\\CookieServiceProvider',
|
||||
2 => 'Illuminate\\Database\\DatabaseServiceProvider',
|
||||
3 => 'Illuminate\\Encryption\\EncryptionServiceProvider',
|
||||
4 => 'Illuminate\\Filesystem\\FilesystemServiceProvider',
|
||||
5 => 'Illuminate\\Foundation\\Providers\\FoundationServiceProvider',
|
||||
6 => 'Illuminate\\Notifications\\NotificationServiceProvider',
|
||||
7 => 'Illuminate\\Pagination\\PaginationServiceProvider',
|
||||
8 => 'Illuminate\\Session\\SessionServiceProvider',
|
||||
9 => 'Illuminate\\View\\ViewServiceProvider',
|
||||
10 => 'Laravel\\Fortify\\FortifyServiceProvider',
|
||||
11 => 'Laravel\\Pail\\PailServiceProvider',
|
||||
12 => 'Laravel\\Sanctum\\SanctumServiceProvider',
|
||||
13 => 'Carbon\\Laravel\\ServiceProvider',
|
||||
14 => 'NunoMaduro\\Collision\\Adapters\\Laravel\\CollisionServiceProvider',
|
||||
15 => 'Termwind\\Laravel\\TermwindServiceProvider',
|
||||
16 => 'App\\Providers\\AppServiceProvider',
|
||||
17 => 'App\\Providers\\FortifyServiceProvider',
|
||||
),
|
||||
'deferred' =>
|
||||
array (
|
||||
'Illuminate\\Broadcasting\\BroadcastManager' => 'Illuminate\\Broadcasting\\BroadcastServiceProvider',
|
||||
'Illuminate\\Contracts\\Broadcasting\\Factory' => 'Illuminate\\Broadcasting\\BroadcastServiceProvider',
|
||||
'Illuminate\\Contracts\\Broadcasting\\Broadcaster' => 'Illuminate\\Broadcasting\\BroadcastServiceProvider',
|
||||
'Illuminate\\Bus\\Dispatcher' => 'Illuminate\\Bus\\BusServiceProvider',
|
||||
'Illuminate\\Contracts\\Bus\\Dispatcher' => 'Illuminate\\Bus\\BusServiceProvider',
|
||||
'Illuminate\\Contracts\\Bus\\QueueingDispatcher' => 'Illuminate\\Bus\\BusServiceProvider',
|
||||
'Illuminate\\Bus\\BatchRepository' => 'Illuminate\\Bus\\BusServiceProvider',
|
||||
'Illuminate\\Bus\\DatabaseBatchRepository' => 'Illuminate\\Bus\\BusServiceProvider',
|
||||
'cache' => 'Illuminate\\Cache\\CacheServiceProvider',
|
||||
'cache.store' => 'Illuminate\\Cache\\CacheServiceProvider',
|
||||
'cache.psr6' => 'Illuminate\\Cache\\CacheServiceProvider',
|
||||
'memcached.connector' => 'Illuminate\\Cache\\CacheServiceProvider',
|
||||
'Illuminate\\Cache\\RateLimiter' => 'Illuminate\\Cache\\CacheServiceProvider',
|
||||
'Illuminate\\Foundation\\Console\\AboutCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
|
||||
'Illuminate\\Cache\\Console\\ClearCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
|
||||
'Illuminate\\Cache\\Console\\ForgetCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
|
||||
'Illuminate\\Foundation\\Console\\ClearCompiledCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
|
||||
'Illuminate\\Auth\\Console\\ClearResetsCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
|
||||
'Illuminate\\Foundation\\Console\\ConfigCacheCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
|
||||
'Illuminate\\Foundation\\Console\\ConfigClearCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
|
||||
'Illuminate\\Foundation\\Console\\ConfigShowCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
|
||||
'Illuminate\\Database\\Console\\DbCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
|
||||
'Illuminate\\Database\\Console\\MonitorCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
|
||||
'Illuminate\\Database\\Console\\PruneCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
|
||||
'Illuminate\\Database\\Console\\ShowCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
|
||||
'Illuminate\\Database\\Console\\TableCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
|
||||
'Illuminate\\Database\\Console\\WipeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
|
||||
'Illuminate\\Foundation\\Console\\DownCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
|
||||
'Illuminate\\Foundation\\Console\\EnvironmentCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
|
||||
'Illuminate\\Foundation\\Console\\EnvironmentDecryptCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
|
||||
'Illuminate\\Foundation\\Console\\EnvironmentEncryptCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
|
||||
'Illuminate\\Foundation\\Console\\EventCacheCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
|
||||
'Illuminate\\Foundation\\Console\\EventClearCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
|
||||
'Illuminate\\Foundation\\Console\\EventListCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
|
||||
'Illuminate\\Concurrency\\Console\\InvokeSerializedClosureCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
|
||||
'Illuminate\\Foundation\\Console\\KeyGenerateCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
|
||||
'Illuminate\\Foundation\\Console\\OptimizeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
|
||||
'Illuminate\\Foundation\\Console\\OptimizeClearCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
|
||||
'Illuminate\\Foundation\\Console\\PackageDiscoverCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
|
||||
'Illuminate\\Cache\\Console\\PruneStaleTagsCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
|
||||
'Illuminate\\Queue\\Console\\ClearCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
|
||||
'Illuminate\\Queue\\Console\\ListFailedCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
|
||||
'Illuminate\\Queue\\Console\\FlushFailedCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
|
||||
'Illuminate\\Queue\\Console\\ForgetFailedCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
|
||||
'Illuminate\\Queue\\Console\\ListenCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
|
||||
'Illuminate\\Queue\\Console\\MonitorCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
|
||||
'Illuminate\\Queue\\Console\\PauseCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
|
||||
'Illuminate\\Queue\\Console\\PruneBatchesCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
|
||||
'Illuminate\\Queue\\Console\\PruneFailedJobsCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
|
||||
'Illuminate\\Queue\\Console\\RestartCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
|
||||
'Illuminate\\Queue\\Console\\ResumeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
|
||||
'Illuminate\\Queue\\Console\\RetryCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
|
||||
'Illuminate\\Queue\\Console\\RetryBatchCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
|
||||
'Illuminate\\Queue\\Console\\WorkCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
|
||||
'Illuminate\\Foundation\\Console\\ReloadCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
|
||||
'Illuminate\\Foundation\\Console\\RouteCacheCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
|
||||
'Illuminate\\Foundation\\Console\\RouteClearCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
|
||||
'Illuminate\\Foundation\\Console\\RouteListCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
|
||||
'Illuminate\\Database\\Console\\DumpCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
|
||||
'Illuminate\\Database\\Console\\Seeds\\SeedCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
|
||||
'Illuminate\\Console\\Scheduling\\ScheduleFinishCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
|
||||
'Illuminate\\Console\\Scheduling\\ScheduleListCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
|
||||
'Illuminate\\Console\\Scheduling\\ScheduleRunCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
|
||||
'Illuminate\\Console\\Scheduling\\ScheduleClearCacheCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
|
||||
'Illuminate\\Console\\Scheduling\\ScheduleTestCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
|
||||
'Illuminate\\Console\\Scheduling\\ScheduleWorkCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
|
||||
'Illuminate\\Console\\Scheduling\\ScheduleInterruptCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
|
||||
'Illuminate\\Database\\Console\\ShowModelCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
|
||||
'Illuminate\\Foundation\\Console\\StorageLinkCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
|
||||
'Illuminate\\Foundation\\Console\\StorageUnlinkCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
|
||||
'Illuminate\\Foundation\\Console\\UpCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
|
||||
'Illuminate\\Foundation\\Console\\ViewCacheCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
|
||||
'Illuminate\\Foundation\\Console\\ViewClearCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
|
||||
'Illuminate\\Foundation\\Console\\ApiInstallCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
|
||||
'Illuminate\\Foundation\\Console\\BroadcastingInstallCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
|
||||
'Illuminate\\Cache\\Console\\CacheTableCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
|
||||
'Illuminate\\Foundation\\Console\\CastMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
|
||||
'Illuminate\\Foundation\\Console\\ChannelListCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
|
||||
'Illuminate\\Foundation\\Console\\ChannelMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
|
||||
'Illuminate\\Foundation\\Console\\ClassMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
|
||||
'Illuminate\\Foundation\\Console\\ComponentMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
|
||||
'Illuminate\\Foundation\\Console\\ConfigMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
|
||||
'Illuminate\\Foundation\\Console\\ConfigPublishCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
|
||||
'Illuminate\\Foundation\\Console\\ConsoleMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
|
||||
'Illuminate\\Routing\\Console\\ControllerMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
|
||||
'Illuminate\\Foundation\\Console\\DocsCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
|
||||
'Illuminate\\Foundation\\Console\\EnumMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
|
||||
'Illuminate\\Foundation\\Console\\EventGenerateCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
|
||||
'Illuminate\\Foundation\\Console\\EventMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
|
||||
'Illuminate\\Foundation\\Console\\ExceptionMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
|
||||
'Illuminate\\Database\\Console\\Factories\\FactoryMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
|
||||
'Illuminate\\Foundation\\Console\\InterfaceMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
|
||||
'Illuminate\\Foundation\\Console\\JobMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
|
||||
'Illuminate\\Foundation\\Console\\JobMiddlewareMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
|
||||
'Illuminate\\Foundation\\Console\\LangPublishCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
|
||||
'Illuminate\\Foundation\\Console\\ListenerMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
|
||||
'Illuminate\\Foundation\\Console\\MailMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
|
||||
'Illuminate\\Routing\\Console\\MiddlewareMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
|
||||
'Illuminate\\Foundation\\Console\\ModelMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
|
||||
'Illuminate\\Foundation\\Console\\NotificationMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
|
||||
'Illuminate\\Notifications\\Console\\NotificationTableCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
|
||||
'Illuminate\\Foundation\\Console\\ObserverMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
|
||||
'Illuminate\\Foundation\\Console\\PolicyMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
|
||||
'Illuminate\\Foundation\\Console\\ProviderMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
|
||||
'Illuminate\\Queue\\Console\\FailedTableCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
|
||||
'Illuminate\\Queue\\Console\\TableCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
|
||||
'Illuminate\\Queue\\Console\\BatchesTableCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
|
||||
'Illuminate\\Foundation\\Console\\RequestMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
|
||||
'Illuminate\\Foundation\\Console\\ResourceMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
|
||||
'Illuminate\\Foundation\\Console\\RuleMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
|
||||
'Illuminate\\Foundation\\Console\\ScopeMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
|
||||
'Illuminate\\Database\\Console\\Seeds\\SeederMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
|
||||
'Illuminate\\Session\\Console\\SessionTableCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
|
||||
'Illuminate\\Foundation\\Console\\ServeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
|
||||
'Illuminate\\Foundation\\Console\\StubPublishCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
|
||||
'Illuminate\\Foundation\\Console\\TestMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
|
||||
'Illuminate\\Foundation\\Console\\TraitMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
|
||||
'Illuminate\\Foundation\\Console\\VendorPublishCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
|
||||
'Illuminate\\Foundation\\Console\\ViewMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
|
||||
'migrator' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
|
||||
'migration.repository' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
|
||||
'migration.creator' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
|
||||
'Illuminate\\Database\\Migrations\\Migrator' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
|
||||
'Illuminate\\Database\\Console\\Migrations\\MigrateCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
|
||||
'Illuminate\\Database\\Console\\Migrations\\FreshCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
|
||||
'Illuminate\\Database\\Console\\Migrations\\InstallCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
|
||||
'Illuminate\\Database\\Console\\Migrations\\RefreshCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
|
||||
'Illuminate\\Database\\Console\\Migrations\\ResetCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
|
||||
'Illuminate\\Database\\Console\\Migrations\\RollbackCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
|
||||
'Illuminate\\Database\\Console\\Migrations\\StatusCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
|
||||
'Illuminate\\Database\\Console\\Migrations\\MigrateMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
|
||||
'composer' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
|
||||
'Illuminate\\Concurrency\\ConcurrencyManager' => 'Illuminate\\Concurrency\\ConcurrencyServiceProvider',
|
||||
'hash' => 'Illuminate\\Hashing\\HashServiceProvider',
|
||||
'hash.driver' => 'Illuminate\\Hashing\\HashServiceProvider',
|
||||
'mail.manager' => 'Illuminate\\Mail\\MailServiceProvider',
|
||||
'mailer' => 'Illuminate\\Mail\\MailServiceProvider',
|
||||
'Illuminate\\Mail\\Markdown' => 'Illuminate\\Mail\\MailServiceProvider',
|
||||
'auth.password' => 'Illuminate\\Auth\\Passwords\\PasswordResetServiceProvider',
|
||||
'auth.password.broker' => 'Illuminate\\Auth\\Passwords\\PasswordResetServiceProvider',
|
||||
'Illuminate\\Contracts\\Pipeline\\Hub' => 'Illuminate\\Pipeline\\PipelineServiceProvider',
|
||||
'pipeline' => 'Illuminate\\Pipeline\\PipelineServiceProvider',
|
||||
'queue' => 'Illuminate\\Queue\\QueueServiceProvider',
|
||||
'queue.connection' => 'Illuminate\\Queue\\QueueServiceProvider',
|
||||
'queue.failer' => 'Illuminate\\Queue\\QueueServiceProvider',
|
||||
'queue.listener' => 'Illuminate\\Queue\\QueueServiceProvider',
|
||||
'queue.worker' => 'Illuminate\\Queue\\QueueServiceProvider',
|
||||
'redis' => 'Illuminate\\Redis\\RedisServiceProvider',
|
||||
'redis.connection' => 'Illuminate\\Redis\\RedisServiceProvider',
|
||||
'translator' => 'Illuminate\\Translation\\TranslationServiceProvider',
|
||||
'translation.loader' => 'Illuminate\\Translation\\TranslationServiceProvider',
|
||||
'validator' => 'Illuminate\\Validation\\ValidationServiceProvider',
|
||||
'validation.presence' => 'Illuminate\\Validation\\ValidationServiceProvider',
|
||||
'Illuminate\\Contracts\\Validation\\UncompromisedVerifier' => 'Illuminate\\Validation\\ValidationServiceProvider',
|
||||
'Barryvdh\\LaravelIdeHelper\\Console\\GeneratorCommand' => 'Barryvdh\\LaravelIdeHelper\\IdeHelperServiceProvider',
|
||||
'Barryvdh\\LaravelIdeHelper\\Console\\ModelsCommand' => 'Barryvdh\\LaravelIdeHelper\\IdeHelperServiceProvider',
|
||||
'Barryvdh\\LaravelIdeHelper\\Console\\MetaCommand' => 'Barryvdh\\LaravelIdeHelper\\IdeHelperServiceProvider',
|
||||
'Barryvdh\\LaravelIdeHelper\\Console\\EloquentCommand' => 'Barryvdh\\LaravelIdeHelper\\IdeHelperServiceProvider',
|
||||
'Laravel\\Sail\\Console\\InstallCommand' => 'Laravel\\Sail\\SailServiceProvider',
|
||||
'Laravel\\Sail\\Console\\PublishCommand' => 'Laravel\\Sail\\SailServiceProvider',
|
||||
'command.tinker' => 'Laravel\\Tinker\\TinkerServiceProvider',
|
||||
),
|
||||
'when' =>
|
||||
array (
|
||||
'Illuminate\\Broadcasting\\BroadcastServiceProvider' =>
|
||||
array (
|
||||
),
|
||||
'Illuminate\\Bus\\BusServiceProvider' =>
|
||||
array (
|
||||
),
|
||||
'Illuminate\\Cache\\CacheServiceProvider' =>
|
||||
array (
|
||||
),
|
||||
'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider' =>
|
||||
array (
|
||||
),
|
||||
'Illuminate\\Concurrency\\ConcurrencyServiceProvider' =>
|
||||
array (
|
||||
),
|
||||
'Illuminate\\Hashing\\HashServiceProvider' =>
|
||||
array (
|
||||
),
|
||||
'Illuminate\\Mail\\MailServiceProvider' =>
|
||||
array (
|
||||
),
|
||||
'Illuminate\\Auth\\Passwords\\PasswordResetServiceProvider' =>
|
||||
array (
|
||||
),
|
||||
'Illuminate\\Pipeline\\PipelineServiceProvider' =>
|
||||
array (
|
||||
),
|
||||
'Illuminate\\Queue\\QueueServiceProvider' =>
|
||||
array (
|
||||
),
|
||||
'Illuminate\\Redis\\RedisServiceProvider' =>
|
||||
array (
|
||||
),
|
||||
'Illuminate\\Translation\\TranslationServiceProvider' =>
|
||||
array (
|
||||
),
|
||||
'Illuminate\\Validation\\ValidationServiceProvider' =>
|
||||
array (
|
||||
),
|
||||
'Barryvdh\\LaravelIdeHelper\\IdeHelperServiceProvider' =>
|
||||
array (
|
||||
),
|
||||
'Laravel\\Sail\\SailServiceProvider' =>
|
||||
array (
|
||||
),
|
||||
'Laravel\\Tinker\\TinkerServiceProvider' =>
|
||||
array (
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -3,7 +3,10 @@
|
||||
"name": "laravel/laravel",
|
||||
"type": "project",
|
||||
"description": "The skeleton application for the Laravel framework.",
|
||||
"keywords": ["laravel", "framework"],
|
||||
"keywords": [
|
||||
"laravel",
|
||||
"framework"
|
||||
],
|
||||
"license": "MIT",
|
||||
"require": {
|
||||
"php": "^8.4",
|
||||
@@ -12,6 +15,7 @@
|
||||
"laravel/sanctum": "*",
|
||||
"laravel/tinker": "^2.10.1",
|
||||
"s9e/text-formatter": "^2.5",
|
||||
"composer-runtime-api": "^2.2",
|
||||
"ext-pdo": "*"
|
||||
},
|
||||
"require-dev": {
|
||||
@@ -22,7 +26,9 @@
|
||||
"laravel/sail": "^1.41",
|
||||
"mockery/mockery": "^1.6",
|
||||
"nunomaduro/collision": "^8.6",
|
||||
"phpunit/phpunit": "^11.5.3",
|
||||
"pestphp/pest": "^4.0",
|
||||
"pestphp/pest-plugin-laravel": "^4.0",
|
||||
"phpunit/phpunit": "^12.3",
|
||||
"squizlabs/php_codesniffer": "^4.0"
|
||||
},
|
||||
"autoload": {
|
||||
@@ -55,6 +61,7 @@
|
||||
"@php artisan config:clear --ansi",
|
||||
"@php artisan test"
|
||||
],
|
||||
"test:coverage": "./vendor/bin/pest --coverage",
|
||||
"post-autoload-dump": [
|
||||
"Illuminate\\Foundation\\ComposerScripts::postAutoloadDump",
|
||||
"@php artisan package:discover --ansi"
|
||||
@@ -89,5 +96,7 @@
|
||||
}
|
||||
},
|
||||
"minimum-stability": "stable",
|
||||
"prefer-stable": true
|
||||
"prefer-stable": true,
|
||||
"version": "26.0.2",
|
||||
"build": "72"
|
||||
}
|
||||
|
||||
1959
composer.lock
generated
1959
composer.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration {
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('audit_logs', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('user_id')->nullable()->constrained()->nullOnDelete();
|
||||
$table->string('action');
|
||||
$table->string('subject_type')->nullable();
|
||||
$table->unsignedBigInteger('subject_id')->nullable();
|
||||
$table->json('metadata')->nullable();
|
||||
$table->string('ip_address', 45)->nullable();
|
||||
$table->string('user_agent', 255)->nullable();
|
||||
$table->timestamps();
|
||||
|
||||
$table->index(['action', 'created_at']);
|
||||
$table->index(['user_id', 'created_at']);
|
||||
$table->index(['subject_type', 'subject_id']);
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('audit_logs');
|
||||
}
|
||||
};
|
||||
199
git_update.sh
Executable file
199
git_update.sh
Executable file
@@ -0,0 +1,199 @@
|
||||
#!/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."
|
||||
@@ -2,6 +2,10 @@
|
||||
"$schema": "https://www.schemastore.org/package.json",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"engines": {
|
||||
"node": ">=20",
|
||||
"npm": ">=10"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "vite build",
|
||||
"dev": "vite",
|
||||
|
||||
@@ -18,13 +18,11 @@
|
||||
</include>
|
||||
</source>
|
||||
<php>
|
||||
<env name="APP_ENV" value="testing"/>
|
||||
<env name="APP_ENV" value="test"/>
|
||||
<env name="APP_MAINTENANCE_DRIVER" value="file"/>
|
||||
<env name="BCRYPT_ROUNDS" value="4"/>
|
||||
<env name="BROADCAST_CONNECTION" value="null"/>
|
||||
<env name="CACHE_STORE" value="array"/>
|
||||
<env name="DB_CONNECTION" value="sqlite"/>
|
||||
<env name="DB_DATABASE" value=":memory:"/>
|
||||
<env name="MAIL_MAILER" value="array"/>
|
||||
<env name="QUEUE_CONNECTION" value="sync"/>
|
||||
<env name="SESSION_DRIVER" value="array"/>
|
||||
|
||||
@@ -7,7 +7,7 @@ import ForumView from './pages/ForumView'
|
||||
import ThreadView from './pages/ThreadView'
|
||||
import Login from './pages/Login'
|
||||
import Register from './pages/Register'
|
||||
import Acp from './pages/Acp'
|
||||
import { Acp } from './pages/Acp'
|
||||
import BoardIndex from './pages/BoardIndex'
|
||||
import Ucp from './pages/Ucp'
|
||||
import Profile from './pages/Profile'
|
||||
|
||||
@@ -62,6 +62,12 @@ export async function registerUser({ email, username, plainPassword }) {
|
||||
})
|
||||
}
|
||||
|
||||
export async function logoutUser() {
|
||||
return apiFetch('/logout', {
|
||||
method: 'POST',
|
||||
})
|
||||
}
|
||||
|
||||
export async function listRootForums() {
|
||||
return getCollection('/forums?parent[exists]=false')
|
||||
}
|
||||
@@ -109,6 +115,20 @@ export async function fetchVersion() {
|
||||
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() {
|
||||
return apiFetch('/stats')
|
||||
}
|
||||
@@ -253,6 +273,23 @@ export async function getThread(id) {
|
||||
return apiFetch(`/threads/${id}`)
|
||||
}
|
||||
|
||||
export async function deleteThread(id, payload = null) {
|
||||
return apiFetch(`/threads/${id}`, {
|
||||
method: 'DELETE',
|
||||
...(payload ? { body: JSON.stringify(payload) } : {}),
|
||||
})
|
||||
}
|
||||
|
||||
export async function updateThread(id, payload) {
|
||||
return apiFetch(`/threads/${id}`, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Content-Type': 'application/merge-patch+json',
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
}
|
||||
|
||||
export async function updateThreadSolved(threadId, solved) {
|
||||
return apiFetch(`/threads/${threadId}/solved`, {
|
||||
method: 'PATCH',
|
||||
@@ -351,10 +388,32 @@ export async function listPostsByThread(threadId) {
|
||||
return getCollection(`/posts?thread=/api/threads/${threadId}`)
|
||||
}
|
||||
|
||||
export async function updatePost(id, payload) {
|
||||
return apiFetch(`/posts/${id}`, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Content-Type': 'application/merge-patch+json',
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
}
|
||||
|
||||
export async function deletePost(id, payload = null) {
|
||||
return apiFetch(`/posts/${id}`, {
|
||||
method: 'DELETE',
|
||||
...(payload ? { body: JSON.stringify(payload) } : {}),
|
||||
})
|
||||
}
|
||||
|
||||
export async function listUsers() {
|
||||
return getCollection('/users')
|
||||
}
|
||||
|
||||
export async function listAuditLogs(limit = 200) {
|
||||
const query = Number.isFinite(limit) ? `?limit=${limit}` : ''
|
||||
return getCollection(`/audit-logs${query}`)
|
||||
}
|
||||
|
||||
export async function listRanks() {
|
||||
return getCollection('/ranks')
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createContext, useContext, useMemo, useState, useEffect } from 'react'
|
||||
import { login as apiLogin } from '../api/client'
|
||||
import { login as apiLogin, logoutUser } from '../api/client'
|
||||
|
||||
const AuthContext = createContext(null)
|
||||
|
||||
@@ -46,7 +46,12 @@ export function AuthProvider({ children }) {
|
||||
setToken(data.token)
|
||||
setEmail(data.email || loginInput)
|
||||
},
|
||||
logout() {
|
||||
async logout() {
|
||||
try {
|
||||
await logoutUser()
|
||||
} catch {
|
||||
// Ignore logout failures; client state is cleared regardless.
|
||||
}
|
||||
localStorage.removeItem('speedbb_token')
|
||||
localStorage.removeItem('speedbb_email')
|
||||
localStorage.removeItem('speedbb_user_id')
|
||||
|
||||
@@ -216,6 +216,39 @@ a {
|
||||
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;
|
||||
@@ -740,6 +773,27 @@ a {
|
||||
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 {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
@@ -808,8 +862,8 @@ a {
|
||||
}
|
||||
|
||||
[data-bs-theme="dark"] {
|
||||
--bb-ink: #e6e8eb;
|
||||
--bb-ink-muted: #9aa4b2;
|
||||
--bb-ink: #aaaeb4;
|
||||
--bb-ink-muted: #6b7483;
|
||||
--bb-border: #2a2f3a;
|
||||
--bb-page-bg: radial-gradient(circle at 10% 20%, #141823 0%, #10131a 45%, #0b0e14 100%);
|
||||
}
|
||||
@@ -868,10 +922,18 @@ a {
|
||||
|
||||
.nav-tabs .nav-link {
|
||||
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 {
|
||||
color: inherit;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border-color: var(--bb-border);
|
||||
border-bottom-color: transparent;
|
||||
}
|
||||
|
||||
.bb-version {
|
||||
@@ -2229,6 +2291,166 @@ a {
|
||||
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 {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
@@ -2587,6 +2809,10 @@ a {
|
||||
max-width: 320px;
|
||||
}
|
||||
|
||||
.bb-audit-limit {
|
||||
max-width: 120px;
|
||||
}
|
||||
|
||||
.bb-sort-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Accordion, Button, ButtonGroup, Col, Container, Form, Modal, Row, Tab, Tabs } from 'react-bootstrap'
|
||||
import { useEffect, useMemo, useRef, useState, useId } from 'react'
|
||||
import { Accordion, Button, ButtonGroup, Col, Container, Form, Modal, Row, Tab, Tabs, OverlayTrigger, Tooltip } from 'react-bootstrap'
|
||||
import DataTable, { createTheme } from 'react-data-table-component'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useDropzone } from 'react-dropzone'
|
||||
@@ -8,10 +8,15 @@ import {
|
||||
createForum,
|
||||
deleteForum,
|
||||
fetchSettings,
|
||||
fetchStats,
|
||||
fetchVersionCheck,
|
||||
runSystemUpdate,
|
||||
fetchSystemStatus,
|
||||
listAllForums,
|
||||
listRanks,
|
||||
listRoles,
|
||||
listUsers,
|
||||
listAuditLogs,
|
||||
reorderForums,
|
||||
saveSetting,
|
||||
saveSettings,
|
||||
@@ -38,7 +43,26 @@ import {
|
||||
deleteAttachmentExtension,
|
||||
} from '../api/client'
|
||||
|
||||
export default function Acp({ isAdmin }) {
|
||||
const StatusIcon = ({ status = 'bad', tooltip }) => {
|
||||
const id = useId()
|
||||
const iconClass =
|
||||
status === 'ok' ? 'bi-check-circle-fill' : status === 'warn' ? 'bi-question-circle-fill' : 'bi-x-circle-fill'
|
||||
const content = (
|
||||
<span className={`bb-status-icon is-${status}`}>
|
||||
<i className={`bi ${iconClass}`} aria-hidden="true" />
|
||||
</span>
|
||||
)
|
||||
if (!tooltip) {
|
||||
return content
|
||||
}
|
||||
return (
|
||||
<OverlayTrigger placement="top" overlay={<Tooltip id={id}>{tooltip}</Tooltip>}>
|
||||
<span>{content}</span>
|
||||
</OverlayTrigger>
|
||||
)
|
||||
}
|
||||
|
||||
function Acp({ isAdmin }) {
|
||||
const { t } = useTranslation()
|
||||
const { roles: authRoles } = useAuth()
|
||||
const canManageFounder = authRoles.includes('ROLE_FOUNDER')
|
||||
@@ -54,6 +78,25 @@ export default function Acp({ isAdmin }) {
|
||||
const [userSearch, setUserSearch] = useState('')
|
||||
const [usersLoading, setUsersLoading] = useState(false)
|
||||
const [usersError, setUsersError] = useState('')
|
||||
const [auditLogs, setAuditLogs] = useState([])
|
||||
const [auditSearch, setAuditSearch] = useState('')
|
||||
const [auditLoading, setAuditLoading] = useState(false)
|
||||
const [auditError, setAuditError] = useState('')
|
||||
const [auditLimit, setAuditLimit] = useState(200)
|
||||
const [boardStats, setBoardStats] = useState(null)
|
||||
const [boardStatsLoading, setBoardStatsLoading] = useState(false)
|
||||
const [boardStatsError, setBoardStatsError] = useState('')
|
||||
const [versionCheck, setVersionCheck] = useState(null)
|
||||
const [versionChecking, setVersionChecking] = useState(false)
|
||||
const [versionCheckError, setVersionCheckError] = useState('')
|
||||
const [updateModalOpen, setUpdateModalOpen] = useState(false)
|
||||
const [updateLog, setUpdateLog] = useState([])
|
||||
const [updateRunning, setUpdateRunning] = useState(false)
|
||||
const [updateError, setUpdateError] = useState('')
|
||||
const [systemStatus, setSystemStatus] = useState(null)
|
||||
const [systemLoading, setSystemLoading] = useState(false)
|
||||
const [systemError, setSystemError] = useState('')
|
||||
const [systemSection, setSystemSection] = useState('info')
|
||||
const [usersPage, setUsersPage] = useState(1)
|
||||
const [usersPerPage, setUsersPerPage] = useState(10)
|
||||
const [userSort, setUserSort] = useState({ columnId: 'name', direction: 'asc' })
|
||||
@@ -158,6 +201,12 @@ export default function Acp({ isAdmin }) {
|
||||
favicon_128: '',
|
||||
favicon_256: '',
|
||||
})
|
||||
const [systemCliSettings, setSystemCliSettings] = useState({
|
||||
php_mode: 'php',
|
||||
php_custom: '',
|
||||
})
|
||||
const [systemCliSaving, setSystemCliSaving] = useState(false)
|
||||
const [systemCliError, setSystemCliError] = useState('')
|
||||
const settingsDetailMap = {
|
||||
forum_name: 'forumName',
|
||||
default_theme: 'defaultTheme',
|
||||
@@ -249,6 +298,16 @@ export default function Acp({ isAdmin }) {
|
||||
favicon_256: settingsMap.get('favicon_256') || '',
|
||||
}
|
||||
setGeneralSettings(next)
|
||||
const configuredPhp = settingsMap.get('system.php_binary') || ''
|
||||
const phpMode = configuredPhp === 'keyhelp-php-domain'
|
||||
? 'keyhelp'
|
||||
: configuredPhp === '' || configuredPhp === 'php'
|
||||
? 'php'
|
||||
: 'custom'
|
||||
setSystemCliSettings({
|
||||
php_mode: phpMode,
|
||||
php_custom: phpMode === 'custom' ? configuredPhp : '',
|
||||
})
|
||||
setAttachmentSettings({
|
||||
display_images_inline: settingsMap.get('attachments.display_images_inline') || 'true',
|
||||
create_thumbnails: settingsMap.get('attachments.create_thumbnails') || 'true',
|
||||
@@ -330,6 +389,135 @@ export default function Acp({ isAdmin }) {
|
||||
}
|
||||
}
|
||||
|
||||
const handleSystemCliSave = async (event) => {
|
||||
event.preventDefault()
|
||||
setSystemCliSaving(true)
|
||||
setSystemCliError('')
|
||||
try {
|
||||
let value = ''
|
||||
if (systemCliSettings.php_mode === 'custom') {
|
||||
value = typeof systemCliSettings.php_custom === 'string'
|
||||
? systemCliSettings.php_custom.trim()
|
||||
: String(systemCliSettings.php_custom ?? '')
|
||||
} else if (systemCliSettings.php_mode === 'keyhelp') {
|
||||
value = 'keyhelp-php-domain'
|
||||
} else {
|
||||
value = 'php'
|
||||
}
|
||||
await saveSetting('system.php_binary', value)
|
||||
setSystemCliSettings((prev) => ({
|
||||
...prev,
|
||||
php_mode: systemCliSettings.php_mode,
|
||||
php_custom: systemCliSettings.php_mode === 'custom' ? value : '',
|
||||
}))
|
||||
} catch (err) {
|
||||
setSystemCliError(err.message)
|
||||
} finally {
|
||||
setSystemCliSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const systemChecks = useMemo(() => {
|
||||
if (!systemStatus) return []
|
||||
return [
|
||||
{
|
||||
id: 'php',
|
||||
label: 'PHP',
|
||||
path: systemStatus.php_selected_path || '—',
|
||||
min: systemStatus.min_versions?.php || '—',
|
||||
current: systemStatus.php_selected_version || '—',
|
||||
status: systemStatus.php_selected_ok ? 'ok' : 'bad',
|
||||
},
|
||||
{
|
||||
id: 'composer',
|
||||
label: 'Composer',
|
||||
path: systemStatus.composer || t('system.not_found'),
|
||||
min: systemStatus.min_versions?.composer || '—',
|
||||
current: systemStatus.composer_version || '—',
|
||||
status: systemStatus.composer ? 'ok' : 'bad',
|
||||
},
|
||||
{
|
||||
id: 'node',
|
||||
label: 'Node',
|
||||
path: systemStatus.node || t('system.not_found'),
|
||||
min: systemStatus.min_versions?.node || '—',
|
||||
current: systemStatus.node_version || '—',
|
||||
status: systemStatus.node ? 'ok' : 'bad',
|
||||
},
|
||||
{
|
||||
id: 'npm',
|
||||
label: 'npm',
|
||||
path: systemStatus.npm || t('system.not_found'),
|
||||
min: systemStatus.min_versions?.npm || '—',
|
||||
current: systemStatus.npm_version || '—',
|
||||
status: systemStatus.npm ? 'ok' : 'bad',
|
||||
},
|
||||
{
|
||||
id: 'tar',
|
||||
label: 'tar',
|
||||
path: systemStatus.tar || t('system.not_found'),
|
||||
min: '—',
|
||||
current: systemStatus.tar_version || '—',
|
||||
status: systemStatus.tar ? 'ok' : 'bad',
|
||||
},
|
||||
{
|
||||
id: 'rsync',
|
||||
label: 'rsync',
|
||||
path: systemStatus.rsync || t('system.not_found'),
|
||||
min: '—',
|
||||
current: systemStatus.rsync_version || '—',
|
||||
status: systemStatus.rsync ? 'ok' : 'bad',
|
||||
},
|
||||
{
|
||||
id: 'proc',
|
||||
label: 'proc_* functions',
|
||||
path: systemStatus.proc_functions
|
||||
? Object.entries(systemStatus.proc_functions)
|
||||
.filter(([, ok]) => !ok)
|
||||
.map(([name]) => name)
|
||||
.join(', ')
|
||||
: '—',
|
||||
min: '—',
|
||||
current: '—',
|
||||
note: 'Optional. Needed for automated version checks.',
|
||||
status:
|
||||
Boolean(systemStatus.proc_functions) &&
|
||||
Object.values(systemStatus.proc_functions).every(Boolean)
|
||||
? 'ok'
|
||||
: 'bad',
|
||||
pathColSpan: 3,
|
||||
},
|
||||
{
|
||||
id: 'storage',
|
||||
label: t('system.storage_writable'),
|
||||
path: 'storage/',
|
||||
min: '—',
|
||||
current: '—',
|
||||
status: systemStatus.storage_writable ? 'ok' : 'bad',
|
||||
},
|
||||
{
|
||||
id: 'updates',
|
||||
label: t('system.updates_writable'),
|
||||
path: 'storage/app/updates',
|
||||
min: '—',
|
||||
current: '—',
|
||||
status: systemStatus.updates_writable ? 'ok' : 'bad',
|
||||
},
|
||||
]
|
||||
}, [systemStatus, t])
|
||||
|
||||
const visibleSystemChecks = useMemo(() => {
|
||||
const visibilityBySection = {
|
||||
insite: ['php', 'proc', 'storage', 'updates'],
|
||||
cli: ['php', 'composer', 'node', 'npm', 'proc', 'storage', 'updates'],
|
||||
ci: ['php', 'composer', 'node', 'npm', 'tar', 'rsync', 'proc', 'storage', 'updates'],
|
||||
info: [],
|
||||
}
|
||||
const allowed = new Set(visibilityBySection[systemSection] || [])
|
||||
return systemChecks.filter((check) => allowed.has(check.id))
|
||||
}, [systemChecks, systemSection])
|
||||
|
||||
|
||||
const handleLogoUpload = async (file, settingKey) => {
|
||||
if (!file) return
|
||||
setGeneralUploading(true)
|
||||
@@ -656,6 +844,317 @@ export default function Acp({ isAdmin }) {
|
||||
[themeMode]
|
||||
)
|
||||
|
||||
const formatAuditAction = (action) => {
|
||||
if (!action) return ''
|
||||
return action
|
||||
.replace(/[._]/g, ' ')
|
||||
.replace(/\b\w/g, (match) => match.toUpperCase())
|
||||
}
|
||||
|
||||
const formatAuditSubject = (entry) => {
|
||||
if (!entry) return '-'
|
||||
const meta = entry.metadata || {}
|
||||
if (meta.title) return meta.title
|
||||
if (meta.original_name) return meta.original_name
|
||||
if (meta.name) return meta.name
|
||||
if (entry.subject_type) {
|
||||
const base = entry.subject_type.split('\\').pop()
|
||||
return entry.subject_id ? `${base} #${entry.subject_id}` : base
|
||||
}
|
||||
return '-'
|
||||
}
|
||||
|
||||
const filteredAuditLogs = useMemo(() => {
|
||||
const term = auditSearch.trim().toLowerCase()
|
||||
if (!term) return auditLogs
|
||||
return auditLogs.filter((entry) => {
|
||||
const metaValues = []
|
||||
if (entry.metadata && typeof entry.metadata === 'object') {
|
||||
Object.values(entry.metadata).forEach((value) => {
|
||||
if (value !== null && value !== undefined) {
|
||||
metaValues.push(String(value))
|
||||
}
|
||||
})
|
||||
}
|
||||
const haystack = [
|
||||
formatAuditAction(entry.action),
|
||||
formatAuditSubject(entry),
|
||||
entry.user?.name || '',
|
||||
entry.user?.email || '',
|
||||
entry.ip_address || '',
|
||||
...metaValues,
|
||||
]
|
||||
return haystack.some((value) => value.toLowerCase().includes(term))
|
||||
})
|
||||
}, [auditLogs, auditSearch])
|
||||
|
||||
const adminAuditLogs = useMemo(() => {
|
||||
return auditLogs.filter((entry) =>
|
||||
Array.isArray(entry.user?.roles) && entry.user.roles.includes('ROLE_ADMIN')
|
||||
)
|
||||
}, [auditLogs])
|
||||
|
||||
const recentAdminLogs = useMemo(() => adminAuditLogs.slice(0, 5), [adminAuditLogs])
|
||||
|
||||
const formatNumber = (value) => {
|
||||
if (value === null || value === undefined) return '—'
|
||||
return new Intl.NumberFormat().format(value)
|
||||
}
|
||||
|
||||
const formatDecimal = (value) => {
|
||||
if (value === null || value === undefined) return '—'
|
||||
return new Intl.NumberFormat(undefined, { maximumFractionDigits: 2 }).format(value)
|
||||
}
|
||||
|
||||
const formatBytes = (bytes) => {
|
||||
if (bytes === null || bytes === undefined) return '—'
|
||||
if (bytes === 0) return '0 B'
|
||||
const units = ['B', 'KiB', 'MiB', 'GiB', 'TiB']
|
||||
const idx = Math.min(units.length - 1, Math.floor(Math.log(bytes) / Math.log(1024)))
|
||||
const value = bytes / 1024 ** idx
|
||||
return `${value.toFixed(value >= 100 ? 0 : value >= 10 ? 1 : 2)} ${units[idx]}`
|
||||
}
|
||||
|
||||
const formatDateTime = (value) => {
|
||||
if (!value) return '—'
|
||||
const date = new Date(value)
|
||||
if (Number.isNaN(date.getTime())) return '—'
|
||||
return date.toLocaleString()
|
||||
}
|
||||
|
||||
const formatBool = (value) => {
|
||||
if (value === null || value === undefined) return '—'
|
||||
return value ? t('stats.on') : t('stats.off')
|
||||
}
|
||||
|
||||
const handleVersionCheck = async () => {
|
||||
setVersionChecking(true)
|
||||
setVersionCheckError('')
|
||||
try {
|
||||
const data = await fetchVersionCheck()
|
||||
setVersionCheck(data)
|
||||
} catch (err) {
|
||||
setVersionCheckError(err.message)
|
||||
} finally {
|
||||
setVersionChecking(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleRunUpdate = async () => {
|
||||
setUpdateRunning(true)
|
||||
setUpdateError('')
|
||||
setUpdateLog([])
|
||||
try {
|
||||
const data = await runSystemUpdate()
|
||||
setUpdateLog(data.log || [])
|
||||
} catch (err) {
|
||||
setUpdateError(err.message)
|
||||
} finally {
|
||||
setUpdateRunning(false)
|
||||
handleVersionCheck()
|
||||
}
|
||||
}
|
||||
|
||||
const loadSystemStatus = async () => {
|
||||
setSystemLoading(true)
|
||||
setSystemError('')
|
||||
try {
|
||||
const data = await fetchSystemStatus()
|
||||
setSystemStatus(data)
|
||||
} catch (err) {
|
||||
setSystemError(err.message)
|
||||
} finally {
|
||||
setSystemLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
function renderSystemRequirementsPanel() {
|
||||
return (
|
||||
<div className="bb-acp-panel">
|
||||
<div className="bb-acp-panel-header">
|
||||
<div className="d-flex align-items-center justify-content-between">
|
||||
<h5 className="mb-0">{t('system.requirements')}</h5>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="dark"
|
||||
onClick={loadSystemStatus}
|
||||
disabled={systemLoading}
|
||||
>
|
||||
{t('acp.refresh')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bb-acp-panel-body">
|
||||
{!systemStatus && <p className="bb-muted mb-0">{t('system.not_found')}</p>}
|
||||
{systemStatus && (
|
||||
<table className="bb-acp-stats-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t('system.check')}</th>
|
||||
<th>{t('system.path')}</th>
|
||||
<th>{t('system.min_version')}</th>
|
||||
<th>{t('system.current_version')}</th>
|
||||
<th>{t('system.status')}</th>
|
||||
<th>{t('system.recheck')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{visibleSystemChecks.map((check) => (
|
||||
<tr key={check.id}>
|
||||
<td>{check.label}</td>
|
||||
<td className="bb-acp-stats-value text-start" colSpan={check.pathColSpan || 1}>
|
||||
{check.path}
|
||||
{check.note && <div className="bb-muted mt-1 text-center">{check.note}</div>}
|
||||
</td>
|
||||
{!check.pathColSpan && (
|
||||
<>
|
||||
<td className="bb-acp-stats-value">{check.min}</td>
|
||||
<td className="bb-acp-stats-value">{check.current}</td>
|
||||
</>
|
||||
)}
|
||||
<td className="bb-acp-stats-value">
|
||||
<StatusIcon status={check.status} />
|
||||
</td>
|
||||
<td className="bb-acp-stats-value">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="dark"
|
||||
onClick={loadSystemStatus}
|
||||
disabled={systemLoading}
|
||||
>
|
||||
{t('system.recheck')}
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (isAdmin) {
|
||||
handleVersionCheck()
|
||||
}
|
||||
}, [isAdmin])
|
||||
|
||||
useEffect(() => {
|
||||
if (isAdmin) {
|
||||
loadSystemStatus()
|
||||
}
|
||||
}, [isAdmin])
|
||||
|
||||
const statsLeft = useMemo(() => {
|
||||
const versionMeta = (() => {
|
||||
if (versionChecking) return t('version.checking')
|
||||
if (versionCheckError) return t('version.unknown')
|
||||
if (!versionCheck) return t('version.unknown')
|
||||
if (versionCheck.is_latest === true) return t('version.up_to_date')
|
||||
if (versionCheck.is_latest === false) {
|
||||
return versionCheck.latest_version
|
||||
? t('version.update_available', { version: versionCheck.latest_version })
|
||||
: t('version.update_available_short')
|
||||
}
|
||||
return t('version.unknown')
|
||||
})()
|
||||
const showUpdate = versionCheck?.is_latest === false
|
||||
|
||||
return [
|
||||
{ label: t('stats.board_started'), value: formatDateTime(boardStats?.board_started_at) },
|
||||
{ label: t('stats.avatar_directory_size'), value: formatBytes(boardStats?.avatar_directory_size_bytes) },
|
||||
{ label: t('stats.database_size'), value: formatBytes(boardStats?.database_size_bytes) },
|
||||
{ label: t('stats.attachments_size'), value: formatBytes(boardStats?.attachments_size_bytes) },
|
||||
{ label: t('stats.database_server'), value: boardStats?.database_server || '—' },
|
||||
{ label: t('stats.gzip_compression'), value: formatBool(boardStats?.gzip_compression) },
|
||||
{ label: t('stats.php_version'), value: boardStats?.php_version || '—' },
|
||||
{ label: t('stats.orphan_attachments'), value: formatNumber(boardStats?.orphan_attachments) },
|
||||
{
|
||||
label: t('stats.board_version'),
|
||||
value: (
|
||||
<div className="bb-acp-version-inline">
|
||||
<span>{boardStats?.board_version || '—'}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-link p-0 bb-acp-version-link"
|
||||
onClick={handleVersionCheck}
|
||||
disabled={versionChecking}
|
||||
>
|
||||
{t('version.recheck')}
|
||||
</button>
|
||||
{showUpdate && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-link p-0 bb-acp-version-link"
|
||||
onClick={() => setUpdateModalOpen(true)}
|
||||
disabled={updateRunning}
|
||||
>
|
||||
{t('version.update_now')}
|
||||
</button>
|
||||
)}
|
||||
<span className="bb-acp-version-meta">{versionMeta}</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]
|
||||
}, [t, boardStats, formatBool, versionCheck, versionChecking, versionCheckError, updateRunning])
|
||||
|
||||
const statsRight = useMemo(() => {
|
||||
return [
|
||||
{ label: t('stats.posts'), value: formatNumber(boardStats?.posts) },
|
||||
{ label: t('stats.posts_per_day'), value: formatDecimal(boardStats?.posts_per_day) },
|
||||
{ label: t('stats.topics'), value: formatNumber(boardStats?.threads) },
|
||||
{ label: t('stats.topics_per_day'), value: formatDecimal(boardStats?.topics_per_day) },
|
||||
{ label: t('stats.users'), value: formatNumber(boardStats?.users) },
|
||||
{ label: t('stats.users_per_day'), value: formatDecimal(boardStats?.users_per_day) },
|
||||
{ label: t('stats.attachments'), value: formatNumber(boardStats?.attachments) },
|
||||
{ label: t('stats.attachments_per_day'), value: formatDecimal(boardStats?.attachments_per_day) },
|
||||
]
|
||||
}, [t, boardStats])
|
||||
|
||||
const auditColumns = useMemo(
|
||||
() => [
|
||||
{
|
||||
name: t('audit.created_at'),
|
||||
selector: (row) => row.created_at,
|
||||
sortable: true,
|
||||
width: '190px',
|
||||
cell: (row) =>
|
||||
row.created_at ? new Date(row.created_at).toLocaleString() : '-',
|
||||
},
|
||||
{
|
||||
name: t('audit.user'),
|
||||
selector: (row) => row.user?.name || '',
|
||||
sortable: true,
|
||||
cell: (row) => row.user?.name || row.user?.email || '-',
|
||||
},
|
||||
{
|
||||
name: t('audit.action'),
|
||||
selector: (row) => row.action || '',
|
||||
sortable: true,
|
||||
cell: (row) => formatAuditAction(row.action),
|
||||
},
|
||||
{
|
||||
name: t('audit.subject'),
|
||||
selector: (row) => formatAuditSubject(row),
|
||||
sortable: true,
|
||||
grow: 2,
|
||||
cell: (row) => formatAuditSubject(row),
|
||||
},
|
||||
{
|
||||
name: t('audit.ip'),
|
||||
selector: (row) => row.ip_address || '',
|
||||
sortable: true,
|
||||
width: '160px',
|
||||
cell: (row) => row.ip_address || '-',
|
||||
},
|
||||
],
|
||||
[t]
|
||||
)
|
||||
|
||||
const UsersPagination = ({ rowsPerPage, rowCount, onChangePage }) => {
|
||||
const totalPages = Math.max(1, Math.ceil(rowCount / rowsPerPage))
|
||||
const current = Math.min(usersPage, totalPages)
|
||||
@@ -763,6 +1262,44 @@ export default function Acp({ isAdmin }) {
|
||||
}
|
||||
}, [isAdmin])
|
||||
|
||||
const refreshBoardStats = async () => {
|
||||
setBoardStatsLoading(true)
|
||||
setBoardStatsError('')
|
||||
try {
|
||||
const data = await fetchStats()
|
||||
setBoardStats(data)
|
||||
} catch (err) {
|
||||
setBoardStatsError(err.message)
|
||||
} finally {
|
||||
setBoardStatsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (isAdmin) {
|
||||
refreshBoardStats()
|
||||
}
|
||||
}, [isAdmin])
|
||||
|
||||
const refreshAuditLogs = async () => {
|
||||
setAuditLoading(true)
|
||||
setAuditError('')
|
||||
try {
|
||||
const data = await listAuditLogs(auditLimit)
|
||||
setAuditLogs(data)
|
||||
} catch (err) {
|
||||
setAuditError(err.message)
|
||||
} finally {
|
||||
setAuditLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (isAdmin) {
|
||||
refreshAuditLogs()
|
||||
}
|
||||
}, [isAdmin])
|
||||
|
||||
useEffect(() => {
|
||||
if (!roleMenuOpen) return
|
||||
const handleClick = (event) => {
|
||||
@@ -2193,7 +2730,7 @@ export default function Acp({ isAdmin }) {
|
||||
<Tabs defaultActiveKey="general" className="mb-3">
|
||||
<Tab eventKey="general" title={t('acp.general')}>
|
||||
<Row className="g-4">
|
||||
<Col lg={3} xl={2}>
|
||||
<Col xs={12} lg="auto">
|
||||
<div className="bb-acp-sidebar">
|
||||
<div className="bb-acp-sidebar-section">
|
||||
<div className="bb-acp-sidebar-title">{t('acp.quick_access')}</div>
|
||||
@@ -2253,11 +2790,61 @@ export default function Acp({ isAdmin }) {
|
||||
</div>
|
||||
</div>
|
||||
</Col>
|
||||
<Col lg={9} xl={10}>
|
||||
<Col xs={12} lg>
|
||||
<div className="bb-acp-panel mb-4">
|
||||
<div className="bb-acp-panel-header">
|
||||
<h5 className="mb-1">{t('acp.welcome_title')}</h5>
|
||||
<p className="bb-muted mb-0">{t('acp.general_hint')}</p>
|
||||
<div className="d-flex align-items-center justify-content-between">
|
||||
<h5 className="mb-0">{t('acp.statistics')}</h5>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="dark"
|
||||
onClick={refreshBoardStats}
|
||||
disabled={boardStatsLoading}
|
||||
>
|
||||
{t('acp.refresh')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bb-acp-panel-body">
|
||||
{boardStatsError && <p className="text-danger mb-2">{boardStatsError}</p>}
|
||||
{boardStatsLoading && <p className="bb-muted mb-0">{t('acp.loading')}</p>}
|
||||
{!boardStatsLoading && (
|
||||
<div className="bb-acp-stats-grid">
|
||||
<table className="bb-acp-stats-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t('stats.statistic')}</th>
|
||||
<th>{t('stats.value')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{statsLeft.map((stat) => (
|
||||
<tr key={stat.label}>
|
||||
<td>{stat.label}</td>
|
||||
<td className="bb-acp-stats-value">{stat.value}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<table className="bb-acp-stats-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t('stats.statistic')}</th>
|
||||
<th>{t('stats.value')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{statsRight.map((stat) => (
|
||||
<tr key={stat.label}>
|
||||
<td>{stat.label}</td>
|
||||
<td className="bb-acp-stats-value">{stat.value}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{generalError && <p className="text-danger">{generalError}</p>}
|
||||
@@ -2560,6 +3147,71 @@ export default function Acp({ isAdmin }) {
|
||||
</Form>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bb-acp-panel mt-4">
|
||||
<div className="bb-acp-panel-header">
|
||||
<div className="d-flex align-items-center justify-content-between">
|
||||
<div>
|
||||
<h5 className="mb-1">{t('acp.admin_log_title')}</h5>
|
||||
<p className="bb-muted mb-0">{t('acp.admin_log_hint')}</p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="dark"
|
||||
onClick={refreshAuditLogs}
|
||||
disabled={auditLoading}
|
||||
>
|
||||
{t('acp.refresh')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bb-acp-panel-body">
|
||||
{auditLoading && <p className="bb-muted mb-0">{t('acp.loading')}</p>}
|
||||
{!auditLoading && recentAdminLogs.length === 0 && (
|
||||
<p className="bb-muted mb-0">{t('admin_log.empty')}</p>
|
||||
)}
|
||||
{!auditLoading && recentAdminLogs.length > 0 && (
|
||||
<div className="bb-acp-admin-log">
|
||||
<table className="bb-acp-admin-log__table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t('admin_log.username')}</th>
|
||||
<th>{t('admin_log.user_ip')}</th>
|
||||
<th>{t('admin_log.time')}</th>
|
||||
<th>{t('admin_log.action')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{recentAdminLogs.map((entry) => (
|
||||
<tr key={entry.id}>
|
||||
<td>{entry.user?.name || entry.user?.email || '—'}</td>
|
||||
<td>{entry.ip_address || '—'}</td>
|
||||
<td>{formatDateTime(entry.created_at)}</td>
|
||||
<td>{formatAuditAction(entry.action)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr>
|
||||
<td colSpan={4}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-link p-0"
|
||||
onClick={() => {
|
||||
const target = document.querySelector('[data-rb-event-key="audit"]')
|
||||
if (target) target.click()
|
||||
}}
|
||||
>
|
||||
{t('acp.view_admin_log')}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Col>
|
||||
</Row>
|
||||
</Tab>
|
||||
@@ -3011,6 +3663,216 @@ export default function Acp({ isAdmin }) {
|
||||
</div>
|
||||
</div>
|
||||
</Tab>
|
||||
<Tab eventKey="audit" title={t('acp.audit_logs')}>
|
||||
{auditError && <p className="text-danger">{auditError}</p>}
|
||||
<div className="d-flex flex-wrap justify-content-between align-items-center gap-2 mb-3">
|
||||
<Form.Control
|
||||
className="bb-user-search"
|
||||
value={auditSearch}
|
||||
onChange={(event) => setAuditSearch(event.target.value)}
|
||||
placeholder={t('audit.search')}
|
||||
/>
|
||||
<div className="d-flex align-items-center gap-2">
|
||||
<Form.Control
|
||||
type="number"
|
||||
min="50"
|
||||
max="500"
|
||||
value={auditLimit}
|
||||
onChange={(event) => setAuditLimit(Number(event.target.value) || 200)}
|
||||
className="bb-audit-limit"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="dark"
|
||||
onClick={refreshAuditLogs}
|
||||
disabled={auditLoading}
|
||||
>
|
||||
{t('acp.refresh')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{auditLoading && <p className="bb-muted">{t('acp.loading')}</p>}
|
||||
{!auditLoading && filteredAuditLogs.length === 0 && (
|
||||
<p className="bb-muted">{t('audit.empty')}</p>
|
||||
)}
|
||||
{!auditLoading && filteredAuditLogs.length > 0 && (
|
||||
<DataTable
|
||||
columns={auditColumns}
|
||||
data={filteredAuditLogs}
|
||||
pagination
|
||||
striped
|
||||
highlightOnHover={themeMode !== 'dark'}
|
||||
dense
|
||||
theme={themeMode === 'dark' ? 'speedbb-dark' : 'speedbb-light'}
|
||||
customStyles={userTableStyles}
|
||||
paginationComponentOptions={{
|
||||
rowsPerPageText: t('table.rows_per_page'),
|
||||
rangeSeparatorText: t('table.range_separator'),
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Tab>
|
||||
<Tab eventKey="system" title={t('acp.system')}>
|
||||
{systemError && <p className="text-danger">{systemError}</p>}
|
||||
{systemLoading && <p className="bb-muted">{t('acp.loading')}</p>}
|
||||
{!systemLoading && (
|
||||
<Row className="g-4">
|
||||
<Col xs={12} lg="auto">
|
||||
<div className="bb-acp-sidebar">
|
||||
<div className="bb-acp-sidebar-section">
|
||||
<div className="bb-acp-sidebar-title">{t('acp.system')}</div>
|
||||
<div className="list-group">
|
||||
<button
|
||||
type="button"
|
||||
className={`list-group-item list-group-item-action ${
|
||||
systemSection === 'info' ? 'is-active' : ''
|
||||
}`}
|
||||
onClick={() => setSystemSection('info')}
|
||||
>
|
||||
Overview
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`list-group-item list-group-item-action ${
|
||||
systemSection === 'insite' ? 'is-active' : ''
|
||||
}`}
|
||||
onClick={() => setSystemSection('insite')}
|
||||
>
|
||||
Live Update
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`list-group-item list-group-item-action ${
|
||||
systemSection === 'cli' ? 'is-active' : ''
|
||||
}`}
|
||||
onClick={() => setSystemSection('cli')}
|
||||
>
|
||||
CLI
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`list-group-item list-group-item-action ${
|
||||
systemSection === 'ci' ? 'is-active' : ''
|
||||
}`}
|
||||
onClick={() => setSystemSection('ci')}
|
||||
>
|
||||
CI/CD
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Col>
|
||||
<Col xs={12} lg>
|
||||
{systemSection === 'info' && (
|
||||
<div className="bb-acp-panel">
|
||||
<div className="bb-acp-panel-header">
|
||||
<h5 className="mb-0">System overview</h5>
|
||||
</div>
|
||||
<div className="bb-acp-panel-body">
|
||||
<p className="bb-muted mb-0">
|
||||
Placeholder: summary, upgrade guidance, and environment health notes will
|
||||
live here.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{systemSection === 'insite' && (
|
||||
<>
|
||||
<div className="bb-acp-panel mb-3">
|
||||
<div className="bb-acp-panel-body">
|
||||
<p className="bb-muted mb-0">Live update controls will appear here.</p>
|
||||
</div>
|
||||
</div>
|
||||
{renderSystemRequirementsPanel()}
|
||||
</>
|
||||
)}
|
||||
{systemSection === 'cli' && (
|
||||
<div className="bb-acp-panel">
|
||||
<div className="bb-acp-panel-header">
|
||||
<h5 className="mb-0">CLI</h5>
|
||||
</div>
|
||||
<div className="bb-acp-panel-body">
|
||||
{systemCliError && <p className="text-danger">{systemCliError}</p>}
|
||||
<Form onSubmit={handleSystemCliSave}>
|
||||
<Form.Group className="mb-3">
|
||||
<Form.Label>PHP interpreter</Form.Label>
|
||||
<Form.Select
|
||||
className="mb-2"
|
||||
value={systemCliSettings.php_mode}
|
||||
onChange={(event) =>
|
||||
setSystemCliSettings((prev) => ({
|
||||
...prev,
|
||||
php_mode: event.target.value,
|
||||
}))
|
||||
}
|
||||
>
|
||||
<option value="php">php (system default)</option>
|
||||
<option value="keyhelp">keyhelp-php-domain</option>
|
||||
<option value="custom">Custom binary (e.g. keyhelp-php84)</option>
|
||||
</Form.Select>
|
||||
{systemCliSettings.php_mode === 'custom' && (
|
||||
<Form.Control
|
||||
type="text"
|
||||
placeholder="keyhelp-php84"
|
||||
value={systemCliSettings.php_custom}
|
||||
onChange={(event) =>
|
||||
setSystemCliSettings((prev) => ({
|
||||
...prev,
|
||||
php_custom: event.target.value,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<Form.Text className="bb-muted">
|
||||
Used for CLI-based updates and maintenance tasks. `keyhelp-php-domain`
|
||||
is available on KeyHelp Pro; on non-Pro setups use a custom binary.
|
||||
</Form.Text>
|
||||
</Form.Group>
|
||||
<Button type="submit" variant="dark" disabled={systemCliSaving}>
|
||||
{t('acp.save')}
|
||||
</Button>
|
||||
</Form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{systemSection === 'cli' && renderSystemRequirementsPanel()}
|
||||
{systemSection === 'ci' && (
|
||||
<>
|
||||
<div className="bb-acp-panel">
|
||||
<div className="bb-acp-panel-header">
|
||||
<h5 className="mb-0">CI/CD</h5>
|
||||
</div>
|
||||
<div className="bb-acp-panel-body">
|
||||
<p className="bb-muted mb-0">
|
||||
Placeholder: CI/CD pipelines, runner requirements, and deployment logs will
|
||||
live here.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{renderSystemRequirementsPanel()}
|
||||
</>
|
||||
)}
|
||||
</Col>
|
||||
</Row>
|
||||
)}
|
||||
</Tab>
|
||||
<Tab eventKey="custom" title="Custom">
|
||||
<Row className="g-4">
|
||||
<Col xs={12}>
|
||||
<div className="bb-acp-panel">
|
||||
<div className="bb-acp-panel-header">
|
||||
<h5 className="mb-0">Custom</h5>
|
||||
</div>
|
||||
<div className="bb-acp-panel-body">
|
||||
<p className="bb-muted mb-0">
|
||||
Place site-specific assets or overrides in `/custom` and `public/custom`.
|
||||
These paths are preserved during in-place updates.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Col>
|
||||
</Row>
|
||||
</Tab>
|
||||
</Tabs>
|
||||
<Modal show={showModal} onHide={handleReset} centered size="lg">
|
||||
<Modal.Header closeButton closeVariant="white">
|
||||
@@ -3625,6 +4487,36 @@ export default function Acp({ isAdmin }) {
|
||||
</Form>
|
||||
</Modal.Body>
|
||||
</Modal>
|
||||
<Modal show={updateModalOpen} onHide={() => setUpdateModalOpen(false)} centered>
|
||||
<Modal.Header closeButton>
|
||||
<Modal.Title>{t('version.update_title')}</Modal.Title>
|
||||
</Modal.Header>
|
||||
<Modal.Body>
|
||||
<p className="bb-muted mb-3">{t('version.update_hint')}</p>
|
||||
{updateError && <p className="text-danger">{updateError}</p>}
|
||||
{updateLog.length > 0 && (
|
||||
<pre className="bb-acp-update-log">
|
||||
{updateLog.join('\n')}
|
||||
</pre>
|
||||
)}
|
||||
</Modal.Body>
|
||||
<Modal.Footer className="justify-content-between">
|
||||
<Button
|
||||
variant="outline-secondary"
|
||||
onClick={() => setUpdateModalOpen(false)}
|
||||
disabled={updateRunning}
|
||||
>
|
||||
{t('acp.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
className="bb-accent-button"
|
||||
onClick={handleRunUpdate}
|
||||
disabled={updateRunning}
|
||||
>
|
||||
{updateRunning ? t('version.updating') : t('version.update_now')}
|
||||
</Button>
|
||||
</Modal.Footer>
|
||||
</Modal>
|
||||
<Modal
|
||||
show={showRankCreate}
|
||||
onHide={() => setShowRankCreate(false)}
|
||||
@@ -3961,3 +4853,6 @@ export default function Acp({ isAdmin }) {
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
||||
export { Acp }
|
||||
export default Acp
|
||||
|
||||
@@ -3,9 +3,13 @@ import { Button, Container, Form, Modal } from 'react-bootstrap'
|
||||
import { useParams } from 'react-router-dom'
|
||||
import {
|
||||
createPost,
|
||||
deleteThread,
|
||||
getThread,
|
||||
listPostsByThread,
|
||||
updateThreadSolved,
|
||||
updateThread,
|
||||
updatePost,
|
||||
deletePost,
|
||||
uploadAttachment,
|
||||
listAttachmentExtensionsPublic,
|
||||
previewBbcode,
|
||||
@@ -34,7 +38,16 @@ export default function ThreadView() {
|
||||
const [previewHtml, setPreviewHtml] = useState('')
|
||||
const [previewLoading, setPreviewLoading] = useState(false)
|
||||
const [previewUrls, setPreviewUrls] = useState([])
|
||||
const [lightboxImage, setLightboxImage] = useState('')
|
||||
const [lightboxIndex, setLightboxIndex] = useState(null)
|
||||
const [lightboxOverride, setLightboxOverride] = useState(null)
|
||||
const [editPost, setEditPost] = useState(null)
|
||||
const [editBody, setEditBody] = useState('')
|
||||
const [editTitle, setEditTitle] = useState('')
|
||||
const [editSaving, setEditSaving] = useState(false)
|
||||
const [deleteTarget, setDeleteTarget] = useState(null)
|
||||
const [deleteLoading, setDeleteLoading] = useState(false)
|
||||
const [deleteReason, setDeleteReason] = useState('obsolete')
|
||||
const [deleteReasonText, setDeleteReasonText] = useState('')
|
||||
const [replyAttachmentTab, setReplyAttachmentTab] = useState('options')
|
||||
const [replyAttachmentOptions, setReplyAttachmentOptions] = useState({
|
||||
disableBbcode: false,
|
||||
@@ -458,7 +471,32 @@ export default function ThreadView() {
|
||||
key={attachment.id}
|
||||
type="button"
|
||||
className="bb-attachment-item border-0 text-start"
|
||||
onClick={() => setLightboxImage(attachment.download_url)}
|
||||
onClick={() => {
|
||||
const idKey = attachment.id !== undefined && attachment.id !== null
|
||||
? String(attachment.id)
|
||||
: null
|
||||
let index = idKey ? lightboxIndexById.get(idKey) : undefined
|
||||
if (index === undefined) {
|
||||
const target = attachment.download_url
|
||||
index = lightboxImages.findIndex((entry) =>
|
||||
matchesLightboxEntry(target, entry)
|
||||
)
|
||||
}
|
||||
if (index !== undefined && index >= 0) {
|
||||
setLightboxOverride(null)
|
||||
setLightboxIndex(index)
|
||||
return
|
||||
}
|
||||
setLightboxOverride([
|
||||
{
|
||||
id: attachment.id,
|
||||
url: attachment.download_url,
|
||||
thumb: attachment.thumbnail_url,
|
||||
name: attachment.original_name,
|
||||
},
|
||||
])
|
||||
setLightboxIndex(0)
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={attachment.thumbnail_url || attachment.download_url}
|
||||
@@ -516,6 +554,145 @@ export default function ThreadView() {
|
||||
return [rootPost, ...posts]
|
||||
}, [posts, thread])
|
||||
|
||||
const lightboxImages = useMemo(() => {
|
||||
return allPosts
|
||||
.flatMap((post) => post.attachments || [])
|
||||
.filter((attachment) => attachment?.is_image)
|
||||
.map((attachment) => ({
|
||||
id: attachment.id,
|
||||
url: attachment.download_url,
|
||||
thumb: attachment.thumbnail_url,
|
||||
name: attachment.original_name,
|
||||
}))
|
||||
}, [allPosts])
|
||||
|
||||
const lightboxItems = lightboxOverride || lightboxImages
|
||||
|
||||
const matchesLightboxEntry = (src, entry) => {
|
||||
if (!src || !entry) return false
|
||||
return src.endsWith(entry.url) || (entry.thumb && src.endsWith(entry.thumb))
|
||||
}
|
||||
|
||||
const canEditPost = (post) => {
|
||||
if (!token || !post) return false
|
||||
return isAdmin || Number(post.user_id) === Number(userId)
|
||||
}
|
||||
|
||||
const replaceAttachmentTags = (body, attachments) => {
|
||||
if (!body || !attachments || attachments.length === 0) return body
|
||||
const map = new Map()
|
||||
attachments.forEach((attachment) => {
|
||||
if (!attachment?.original_name) return
|
||||
map.set(String(attachment.original_name).toLowerCase(), attachment)
|
||||
})
|
||||
if (map.size === 0) return body
|
||||
return body.replace(/\[attachment\](.+?)\[\/attachment\]/gi, (match, name) => {
|
||||
const key = String(name).trim().toLowerCase()
|
||||
const attachment = map.get(key)
|
||||
if (!attachment) return match
|
||||
const url = attachment.download_url
|
||||
if (attachment.is_image) {
|
||||
if (attachment.thumbnail_url) {
|
||||
return `[url=${url}][img]${attachment.thumbnail_url}[/img][/url]`
|
||||
}
|
||||
return `[img]${url}[/img]`
|
||||
}
|
||||
return `[url=${url}]${attachment.original_name}[/url]`
|
||||
})
|
||||
}
|
||||
|
||||
const buildQuoteBody = (post) => {
|
||||
if (!post) return ''
|
||||
const author = post.user_name || t('thread.anonymous')
|
||||
const content = replaceAttachmentTags(post.body || '', post.attachments || [])
|
||||
return `[quote=${author}]${content}[/quote]\n`
|
||||
}
|
||||
|
||||
const handleQuote = (post) => {
|
||||
const snippet = buildQuoteBody(post)
|
||||
setBody((prev) => (prev ? `${prev}\n${snippet}` : snippet))
|
||||
replyRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
||||
}
|
||||
|
||||
const handleEditStart = (post) => {
|
||||
if (!post) return
|
||||
if (post.isRoot) {
|
||||
setEditTitle(thread?.title || '')
|
||||
} else {
|
||||
setEditTitle('')
|
||||
}
|
||||
setEditBody(post.body || '')
|
||||
setEditPost(post)
|
||||
}
|
||||
|
||||
const handleEditSave = async () => {
|
||||
if (!editPost || editSaving) return
|
||||
setEditSaving(true)
|
||||
setError('')
|
||||
try {
|
||||
if (editPost.isRoot) {
|
||||
const payload = {
|
||||
title: editTitle.trim(),
|
||||
body: editBody,
|
||||
}
|
||||
const updated = await updateThread(thread.id, payload)
|
||||
setThread(updated)
|
||||
} else {
|
||||
const updated = await updatePost(editPost.id, { body: editBody })
|
||||
setPosts((prev) => prev.map((post) => (post.id === updated.id ? updated : post)))
|
||||
}
|
||||
setEditPost(null)
|
||||
} catch (err) {
|
||||
setError(err.message)
|
||||
} finally {
|
||||
setEditSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeletePost = (post) => {
|
||||
if (!post) return
|
||||
setDeleteReason('obsolete')
|
||||
setDeleteReasonText('')
|
||||
setDeleteTarget({
|
||||
post,
|
||||
isThread: Boolean(post.isRoot),
|
||||
})
|
||||
}
|
||||
|
||||
const handleDeleteConfirm = async () => {
|
||||
if (!deleteTarget || deleteLoading) return
|
||||
setDeleteLoading(true)
|
||||
setError('')
|
||||
try {
|
||||
const payload = {
|
||||
reason: deleteReason,
|
||||
reason_text: deleteReason === 'other' ? deleteReasonText.trim() : '',
|
||||
}
|
||||
if (deleteTarget.isThread) {
|
||||
await deleteThread(thread.id, payload)
|
||||
window.location.href = '/forums'
|
||||
} else {
|
||||
await deletePost(deleteTarget.post.id, payload)
|
||||
setPosts((prev) => prev.filter((item) => item.id !== deleteTarget.post.id))
|
||||
}
|
||||
setDeleteTarget(null)
|
||||
} catch (err) {
|
||||
setError(err.message)
|
||||
} finally {
|
||||
setDeleteLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const lightboxIndexById = useMemo(() => {
|
||||
const map = new Map()
|
||||
lightboxImages.forEach((entry, index) => {
|
||||
if (entry.id !== undefined && entry.id !== null) {
|
||||
map.set(String(entry.id), index)
|
||||
}
|
||||
})
|
||||
return map
|
||||
}, [lightboxImages])
|
||||
|
||||
const handleJumpToReply = () => {
|
||||
replyRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
||||
}
|
||||
@@ -725,10 +902,24 @@ export default function ThreadView() {
|
||||
)}
|
||||
</div>
|
||||
<div className="bb-post-actions">
|
||||
<button type="button" className="bb-post-action" aria-label="Edit post">
|
||||
<button
|
||||
type="button"
|
||||
className="bb-post-action"
|
||||
aria-label={t('thread.edit')}
|
||||
title={t('thread.edit')}
|
||||
onClick={() => handleEditStart(post)}
|
||||
disabled={!canEditPost(post)}
|
||||
>
|
||||
<i className="bi bi-pencil" aria-hidden="true" />
|
||||
</button>
|
||||
<button type="button" className="bb-post-action" aria-label="Delete post">
|
||||
<button
|
||||
type="button"
|
||||
className="bb-post-action"
|
||||
aria-label={t('thread.delete')}
|
||||
title={t('thread.delete')}
|
||||
onClick={() => handleDeletePost(post)}
|
||||
disabled={!canEditPost(post)}
|
||||
>
|
||||
<i className="bi bi-x-lg" aria-hidden="true" />
|
||||
</button>
|
||||
<button type="button" className="bb-post-action" aria-label="Report post">
|
||||
@@ -737,7 +928,13 @@ export default function ThreadView() {
|
||||
<button type="button" className="bb-post-action" aria-label="Post info">
|
||||
<i className="bi bi-info-lg" aria-hidden="true" />
|
||||
</button>
|
||||
<button type="button" className="bb-post-action" aria-label="Quote post">
|
||||
<button
|
||||
type="button"
|
||||
className="bb-post-action"
|
||||
aria-label={t('thread.quote')}
|
||||
title={t('thread.quote')}
|
||||
onClick={() => handleQuote(post)}
|
||||
>
|
||||
<i className="bi bi-quote" aria-hidden="true" />
|
||||
</button>
|
||||
{canThank && (
|
||||
@@ -752,7 +949,17 @@ export default function ThreadView() {
|
||||
onClick={(event) => {
|
||||
if (event.target?.tagName === 'IMG') {
|
||||
event.preventDefault()
|
||||
setLightboxImage(event.target.src)
|
||||
const src = event.target.src
|
||||
const index = lightboxImages.findIndex((entry) =>
|
||||
matchesLightboxEntry(src, entry)
|
||||
)
|
||||
if (index >= 0) {
|
||||
setLightboxOverride(null)
|
||||
setLightboxIndex(index)
|
||||
return
|
||||
}
|
||||
setLightboxOverride([{ id: null, url: src, thumb: src, name: '' }])
|
||||
setLightboxIndex(0)
|
||||
}
|
||||
}}
|
||||
dangerouslySetInnerHTML={{ __html: post.body_html || post.body }}
|
||||
@@ -881,15 +1088,176 @@ export default function ThreadView() {
|
||||
</Modal.Body>
|
||||
</Modal>
|
||||
<Modal
|
||||
show={Boolean(lightboxImage)}
|
||||
onHide={() => setLightboxImage('')}
|
||||
show={Boolean(editPost)}
|
||||
onHide={() => setEditPost(null)}
|
||||
centered
|
||||
size="lg"
|
||||
>
|
||||
<Modal.Body className="text-center">
|
||||
{lightboxImage && (
|
||||
<img src={lightboxImage} alt="" className="img-fluid rounded" />
|
||||
<Modal.Header closeButton>
|
||||
<Modal.Title>{t('thread.edit')}</Modal.Title>
|
||||
</Modal.Header>
|
||||
<Modal.Body>
|
||||
{editPost?.isRoot && (
|
||||
<Form.Group className="mb-3">
|
||||
<Form.Label>{t('thread.title')}</Form.Label>
|
||||
<Form.Control
|
||||
type="text"
|
||||
value={editTitle}
|
||||
onChange={(event) => setEditTitle(event.target.value)}
|
||||
/>
|
||||
</Form.Group>
|
||||
)}
|
||||
<Form.Group>
|
||||
<Form.Label>{t('form.message')}</Form.Label>
|
||||
<Form.Control
|
||||
as="textarea"
|
||||
rows={8}
|
||||
value={editBody}
|
||||
onChange={(event) => setEditBody(event.target.value)}
|
||||
/>
|
||||
</Form.Group>
|
||||
</Modal.Body>
|
||||
<Modal.Footer className="justify-content-between">
|
||||
<Button variant="outline-secondary" onClick={() => setEditPost(null)}>
|
||||
{t('acp.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
className="bb-accent-button"
|
||||
onClick={handleEditSave}
|
||||
disabled={editSaving || !editBody.trim() || (editPost?.isRoot && !editTitle.trim())}
|
||||
>
|
||||
{editSaving ? t('form.saving') : t('acp.save')}
|
||||
</Button>
|
||||
</Modal.Footer>
|
||||
</Modal>
|
||||
<Modal
|
||||
show={Boolean(deleteTarget)}
|
||||
onHide={() => {
|
||||
if (deleteLoading) return
|
||||
setDeleteTarget(null)
|
||||
}}
|
||||
centered
|
||||
>
|
||||
<Modal.Header closeButton>
|
||||
<Modal.Title>
|
||||
{deleteTarget?.isThread ? t('thread.delete_confirm') : t('thread.delete_post_confirm')}
|
||||
</Modal.Title>
|
||||
</Modal.Header>
|
||||
<Modal.Body>
|
||||
<p className="mb-3">
|
||||
{deleteTarget?.isThread
|
||||
? t('thread.delete_confirm')
|
||||
: t('thread.delete_post_confirm')}
|
||||
</p>
|
||||
<Form.Group className="mb-3">
|
||||
<Form.Label>{t('thread.delete_reason')}</Form.Label>
|
||||
<Form.Select
|
||||
value={deleteReason}
|
||||
onChange={(event) => setDeleteReason(event.target.value)}
|
||||
disabled={deleteLoading}
|
||||
>
|
||||
<option value="obsolete">{t('thread.delete_reason_obsolete')}</option>
|
||||
<option value="double">{t('thread.delete_reason_double')}</option>
|
||||
<option value="other">{t('thread.delete_reason_other')}</option>
|
||||
</Form.Select>
|
||||
</Form.Group>
|
||||
{deleteReason === 'other' && (
|
||||
<Form.Group>
|
||||
<Form.Label>{t('thread.delete_reason_other_label')}</Form.Label>
|
||||
<Form.Control
|
||||
type="text"
|
||||
value={deleteReasonText}
|
||||
onChange={(event) => setDeleteReasonText(event.target.value)}
|
||||
placeholder={t('thread.delete_reason_other_placeholder')}
|
||||
disabled={deleteLoading}
|
||||
/>
|
||||
</Form.Group>
|
||||
)}
|
||||
</Modal.Body>
|
||||
<Modal.Footer className="justify-content-between">
|
||||
<Button
|
||||
variant="outline-secondary"
|
||||
onClick={() => setDeleteTarget(null)}
|
||||
disabled={deleteLoading}
|
||||
>
|
||||
{t('acp.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="danger"
|
||||
onClick={handleDeleteConfirm}
|
||||
disabled={deleteLoading}
|
||||
>
|
||||
{deleteLoading ? t('form.saving') : t('acp.delete')}
|
||||
</Button>
|
||||
</Modal.Footer>
|
||||
</Modal>
|
||||
<Modal
|
||||
show={lightboxIndex !== null && lightboxItems.length > 0}
|
||||
onHide={() => {
|
||||
setLightboxIndex(null)
|
||||
setLightboxOverride(null)
|
||||
}}
|
||||
centered
|
||||
size="lg"
|
||||
dialogClassName="bb-lightbox-modal"
|
||||
>
|
||||
<Modal.Header closeButton>
|
||||
<Modal.Title>
|
||||
{lightboxIndex !== null
|
||||
? `${(lightboxIndex ?? 0) + 1} / ${lightboxItems.length}`
|
||||
: ''}
|
||||
</Modal.Title>
|
||||
</Modal.Header>
|
||||
<Modal.Body className="text-center bb-lightbox-body">
|
||||
{lightboxIndex !== null && lightboxItems[lightboxIndex] && (
|
||||
<img
|
||||
src={lightboxItems[lightboxIndex].url}
|
||||
alt={lightboxItems[lightboxIndex].name || ''}
|
||||
className="img-fluid rounded"
|
||||
/>
|
||||
)}
|
||||
<div className="bb-lightbox-controls">
|
||||
<Button
|
||||
type="button"
|
||||
variant="dark"
|
||||
className="bb-lightbox-btn"
|
||||
onClick={() =>
|
||||
setLightboxIndex((prev) => (prev === null ? prev : Math.max(0, prev - 1)))
|
||||
}
|
||||
disabled={lightboxIndex === null || lightboxIndex <= 0}
|
||||
style={{
|
||||
visibility:
|
||||
lightboxIndex === null || lightboxIndex <= 0 ? 'hidden' : 'visible',
|
||||
}}
|
||||
>
|
||||
<i className="bi bi-chevron-left" aria-hidden="true" />
|
||||
<span className="visually-hidden">{t('lightbox.prev')}</span>
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="dark"
|
||||
className="bb-lightbox-btn"
|
||||
onClick={() =>
|
||||
setLightboxIndex((prev) =>
|
||||
prev === null
|
||||
? prev
|
||||
: Math.min(lightboxItems.length - 1, prev + 1)
|
||||
)
|
||||
}
|
||||
disabled={
|
||||
lightboxIndex === null || lightboxIndex >= lightboxItems.length - 1
|
||||
}
|
||||
style={{
|
||||
visibility:
|
||||
lightboxIndex === null || lightboxIndex >= lightboxItems.length - 1
|
||||
? 'hidden'
|
||||
: 'visible',
|
||||
}}
|
||||
>
|
||||
<i className="bi bi-chevron-right" aria-hidden="true" />
|
||||
<span className="visually-hidden">{t('lightbox.next')}</span>
|
||||
</Button>
|
||||
</div>
|
||||
</Modal.Body>
|
||||
</Modal>
|
||||
</Container>
|
||||
|
||||
@@ -69,8 +69,14 @@
|
||||
"acp.refresh": "Aktualisieren",
|
||||
"acp.reset": "Zurücksetzen",
|
||||
"acp.save": "Speichern",
|
||||
"acp.statistics": "Statistik",
|
||||
"acp.title": "Administrationsbereich",
|
||||
"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_title": "Anmelden",
|
||||
"auth.login_identifier": "E-Mail oder Benutzername",
|
||||
@@ -100,6 +106,18 @@
|
||||
"form.thread_title_placeholder": "Thema",
|
||||
"form.title": "Titel",
|
||||
"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.empty_children": "Noch keine Unterforen vorhanden.",
|
||||
"forum.empty_threads": "Noch keine Threads vorhanden. Starte unten einen.",
|
||||
@@ -118,6 +136,64 @@
|
||||
"user.email": "E-Mail",
|
||||
"user.rank": "Rang",
|
||||
"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.search": "Benutzer suchen...",
|
||||
"rank.name": "Rangname",
|
||||
|
||||
@@ -69,8 +69,14 @@
|
||||
"acp.refresh": "Refresh",
|
||||
"acp.reset": "Reset",
|
||||
"acp.save": "Save",
|
||||
"acp.statistics": "Statistics",
|
||||
"acp.title": "Admin control panel",
|
||||
"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_title": "Log in",
|
||||
"auth.login_identifier": "Email or username",
|
||||
@@ -100,6 +106,18 @@
|
||||
"form.thread_title_placeholder": "Topic headline",
|
||||
"form.title": "Title",
|
||||
"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.empty_children": "No sub-forums yet.",
|
||||
"forum.empty_threads": "No threads here yet. Start one below.",
|
||||
@@ -120,6 +138,64 @@
|
||||
"user.rank_unassigned": "Unassigned",
|
||||
"user.edit_title": "Edit user",
|
||||
"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_placeholder": "e.g. Operator",
|
||||
"rank.create": "Create rank",
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
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\ForumController;
|
||||
use App\Http\Controllers\I18nController;
|
||||
@@ -17,6 +18,9 @@ use App\Http\Controllers\UploadController;
|
||||
use App\Http\Controllers\UserSettingController;
|
||||
use App\Http\Controllers\UserController;
|
||||
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\RoleController;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
@@ -32,11 +36,15 @@ Route::post('/logout', [AuthController::class, 'logout'])->middleware('auth:sanc
|
||||
Route::post('/user/password', [AuthController::class, 'updatePassword'])->middleware('auth:sanctum');
|
||||
|
||||
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('/stats', StatsController::class);
|
||||
Route::get('/settings', [SettingController::class, 'index']);
|
||||
Route::post('/settings', [SettingController::class, 'store'])->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::post('/user-settings', [UserSettingController::class, 'store'])->middleware('auth:sanctum');
|
||||
Route::post('/uploads/logo', [UploadController::class, 'storeLogo'])->middleware('auth:sanctum');
|
||||
@@ -90,6 +98,7 @@ Route::delete('/forums/{forum}', [ForumController::class, 'destroy'])->middlewar
|
||||
Route::get('/threads', [ThreadController::class, 'index']);
|
||||
Route::get('/threads/{thread}', [ThreadController::class, 'show']);
|
||||
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');
|
||||
|
||||
@@ -97,5 +106,6 @@ Route::get('/posts', [PostController::class, 'index']);
|
||||
Route::post('/posts', [PostController::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::patch('/posts/{post}', [PostController::class, 'update'])->middleware('auth:sanctum');
|
||||
Route::delete('/posts/{post}', [PostController::class, 'destroy'])->middleware('auth:sanctum');
|
||||
Route::post('/preview', [PreviewController::class, 'preview'])->middleware('auth:sanctum');
|
||||
|
||||
71
scripts/build_release_assets.sh
Normal file
71
scripts/build_release_assets.sh
Normal file
@@ -0,0 +1,71 @@
|
||||
#!/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"
|
||||
8
scripts/hooks/pre-commit
Normal file
8
scripts/hooks/pre-commit
Normal file
@@ -0,0 +1,8 @@
|
||||
#!/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
|
||||
284
tests/Feature/AttachmentControllerTest.php
Normal file
284
tests/Feature/AttachmentControllerTest.php
Normal file
@@ -0,0 +1,284 @@
|
||||
<?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]);
|
||||
});
|
||||
114
tests/Feature/AttachmentExtensionControllerTest.php
Normal file
114
tests/Feature/AttachmentExtensionControllerTest.php
Normal file
@@ -0,0 +1,114 @@
|
||||
<?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.']);
|
||||
});
|
||||
136
tests/Feature/AttachmentGroupControllerTest.php
Normal file
136
tests/Feature/AttachmentGroupControllerTest.php
Normal file
@@ -0,0 +1,136 @@
|
||||
<?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,
|
||||
]);
|
||||
});
|
||||
46
tests/Feature/AuditLogControllerTest.php
Normal file
46
tests/Feature/AuditLogControllerTest.php
Normal file
@@ -0,0 +1,46 @@
|
||||
<?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',
|
||||
]);
|
||||
});
|
||||
222
tests/Feature/AuthControllerTest.php
Normal file
222
tests/Feature/AuthControllerTest.php
Normal file
@@ -0,0 +1,222 @@
|
||||
<?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);
|
||||
});
|
||||
473
tests/Feature/ForumControllerTest.php
Normal file
473
tests/Feature/ForumControllerTest.php
Normal file
@@ -0,0 +1,473 @@
|
||||
<?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,
|
||||
]);
|
||||
});
|
||||
13
tests/Feature/I18nControllerTest.php
Normal file
13
tests/Feature/I18nControllerTest.php
Normal file
@@ -0,0 +1,13 @@
|
||||
<?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);
|
||||
});
|
||||
23
tests/Feature/InstallerControllerTest.php
Normal file
23
tests/Feature/InstallerControllerTest.php
Normal file
@@ -0,0 +1,23 @@
|
||||
<?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('/');
|
||||
});
|
||||
135
tests/Feature/PortalControllerTest.php
Normal file
135
tests/Feature/PortalControllerTest.php
Normal file
@@ -0,0 +1,135 @@
|
||||
<?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([]);
|
||||
});
|
||||
233
tests/Feature/PostControllerTest.php
Normal file
233
tests/Feature/PostControllerTest.php
Normal file
@@ -0,0 +1,233 @@
|
||||
<?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,
|
||||
]);
|
||||
});
|
||||
162
tests/Feature/PostThankControllerTest.php
Normal file
162
tests/Feature/PostThankControllerTest.php
Normal file
@@ -0,0 +1,162 @@
|
||||
<?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');
|
||||
});
|
||||
63
tests/Feature/PreviewControllerTest.php
Normal file
63
tests/Feature/PreviewControllerTest.php
Normal file
@@ -0,0 +1,63 @@
|
||||
<?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();
|
||||
});
|
||||
231
tests/Feature/RankControllerTest.php
Normal file
231
tests/Feature/RankControllerTest.php
Normal file
@@ -0,0 +1,231 @@
|
||||
<?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]);
|
||||
});
|
||||
180
tests/Feature/RoleControllerTest.php
Normal file
180
tests/Feature/RoleControllerTest.php
Normal file
@@ -0,0 +1,180 @@
|
||||
<?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_']);
|
||||
});
|
||||
102
tests/Feature/SettingControllerTest.php
Normal file
102
tests/Feature/SettingControllerTest.php
Normal file
@@ -0,0 +1,102 @@
|
||||
<?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);
|
||||
});
|
||||
85
tests/Feature/StatsControllerTest.php
Normal file
85
tests/Feature/StatsControllerTest.php
Normal file
@@ -0,0 +1,85 @@
|
||||
<?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,
|
||||
]);
|
||||
});
|
||||
13
tests/Feature/SystemStatusControllerTest.php
Normal file
13
tests/Feature/SystemStatusControllerTest.php
Normal file
@@ -0,0 +1,13 @@
|
||||
<?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);
|
||||
});
|
||||
456
tests/Feature/SystemUpdateControllerBranchesTest.php
Normal file
456
tests/Feature/SystemUpdateControllerBranchesTest.php
Normal file
@@ -0,0 +1,456 @@
|
||||
<?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']);
|
||||
});
|
||||
});
|
||||
31
tests/Feature/SystemUpdateControllerTest.php
Normal file
31
tests/Feature/SystemUpdateControllerTest.php
Normal file
@@ -0,0 +1,31 @@
|
||||
<?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.']);
|
||||
});
|
||||
272
tests/Feature/ThreadControllerTest.php
Normal file
272
tests/Feature/ThreadControllerTest.php
Normal file
@@ -0,0 +1,272 @@
|
||||
<?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,
|
||||
]);
|
||||
});
|
||||
97
tests/Feature/UploadControllerTest.php
Normal file
97
tests/Feature/UploadControllerTest.php
Normal file
@@ -0,0 +1,97 @@
|
||||
<?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'));
|
||||
});
|
||||
287
tests/Feature/UserControllerTest.php
Normal file
287
tests/Feature/UserControllerTest.php
Normal file
@@ -0,0 +1,287 @@
|
||||
<?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();
|
||||
});
|
||||
63
tests/Feature/UserSettingControllerTest.php
Normal file
63
tests/Feature/UserSettingControllerTest.php
Normal file
@@ -0,0 +1,63 @@
|
||||
<?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',
|
||||
]);
|
||||
});
|
||||
107
tests/Feature/VersionCheckControllerTest.php
Normal file
107
tests/Feature/VersionCheckControllerTest.php
Normal file
@@ -0,0 +1,107 @@
|
||||
<?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.',
|
||||
]);
|
||||
});
|
||||
16
tests/Feature/VersionControllerTest.php
Normal file
16
tests/Feature/VersionControllerTest.php
Normal file
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Setting;
|
||||
|
||||
it('returns version and build info', function (): void {
|
||||
Setting::updateOrCreate(['key' => 'version'], ['value' => '1.2.3']);
|
||||
Setting::updateOrCreate(['key' => 'build'], ['value' => '42']);
|
||||
|
||||
$response = $this->getJson('/api/version');
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertJsonFragment([
|
||||
'version' => '1.2.3',
|
||||
'build' => 42,
|
||||
]);
|
||||
});
|
||||
5
tests/Pest.php
Normal file
5
tests/Pest.php
Normal file
@@ -0,0 +1,5 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(Tests\TestCase::class, RefreshDatabase::class)->in('Feature', 'Unit');
|
||||
555
tests/Unit/AttachmentControllerUnitTest.php
Normal file
555
tests/Unit/AttachmentControllerUnitTest.php
Normal file
@@ -0,0 +1,555 @@
|
||||
<?php
|
||||
|
||||
use App\Http\Controllers\AttachmentController;
|
||||
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\Request;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Symfony\Component\HttpKernel\Exception\HttpException;
|
||||
|
||||
function makeForumForAttachments(): 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('store returns unauthorized without user', function (): void {
|
||||
$controller = new AttachmentController();
|
||||
$request = Request::create('/api/attachments', 'POST');
|
||||
$request->setUserResolver(fn () => null);
|
||||
|
||||
$response = $controller->store($request);
|
||||
|
||||
expect($response->getStatusCode())->toBe(401);
|
||||
});
|
||||
|
||||
it('store returns file missing when file not provided', function (): void {
|
||||
$controller = new AttachmentController();
|
||||
$user = User::factory()->create();
|
||||
|
||||
$request = Request::create('/api/attachments', 'POST', [
|
||||
'thread' => '/api/threads/1',
|
||||
]);
|
||||
$request->setUserResolver(fn () => $user);
|
||||
|
||||
try {
|
||||
$controller->store($request);
|
||||
$this->fail('Expected ValidationException not thrown.');
|
||||
} catch (Illuminate\Validation\ValidationException $e) {
|
||||
expect($e->errors())->toHaveKey('file');
|
||||
}
|
||||
});
|
||||
|
||||
it('store returns file missing when request file is null after validation', function (): void {
|
||||
$controller = new AttachmentController();
|
||||
$user = User::factory()->create();
|
||||
$forum = makeForumForAttachments();
|
||||
$thread = Thread::create([
|
||||
'forum_id' => $forum->id,
|
||||
'user_id' => $user->id,
|
||||
'title' => 'Thread',
|
||||
'body' => 'Body',
|
||||
]);
|
||||
|
||||
$request = Mockery::mock(Request::class)->makePartial();
|
||||
$request->shouldReceive('user')->andReturn($user);
|
||||
$request->shouldReceive('validate')->andReturn([
|
||||
'thread' => "/api/threads/{$thread->id}",
|
||||
'post' => null,
|
||||
'file' => 'ignored',
|
||||
]);
|
||||
$request->shouldReceive('file')->andReturn(null);
|
||||
|
||||
$response = $controller->store($request);
|
||||
|
||||
expect($response->getStatusCode())->toBe(422);
|
||||
});
|
||||
|
||||
it('store rejects disallowed extension and mime and size', function (): void {
|
||||
Storage::fake('local');
|
||||
|
||||
$controller = new AttachmentController();
|
||||
$user = User::factory()->create();
|
||||
$forum = makeForumForAttachments();
|
||||
$thread = Thread::create([
|
||||
'forum_id' => $forum->id,
|
||||
'user_id' => $user->id,
|
||||
'title' => 'Thread',
|
||||
'body' => 'Body',
|
||||
]);
|
||||
|
||||
$group = AttachmentGroup::create([
|
||||
'name' => 'Docs',
|
||||
'max_size_kb' => 1,
|
||||
'is_active' => false,
|
||||
]);
|
||||
|
||||
$ext = AttachmentExtension::create([
|
||||
'extension' => 'pdf',
|
||||
'attachment_group_id' => $group->id,
|
||||
'allowed_mimes' => ['application/pdf'],
|
||||
]);
|
||||
|
||||
$file = UploadedFile::fake()->create('doc.pdf', 2, 'application/pdf');
|
||||
|
||||
$request = Request::create('/api/attachments', 'POST', [
|
||||
'thread' => "/api/threads/{$thread->id}",
|
||||
], [], ['file' => $file]);
|
||||
$request->setUserResolver(fn () => $user);
|
||||
|
||||
$response = $controller->store($request);
|
||||
|
||||
expect($response->getStatusCode())->toBe(422);
|
||||
|
||||
$group->is_active = true;
|
||||
$group->save();
|
||||
|
||||
$response = $controller->store($request);
|
||||
expect($response->getStatusCode())->toBe(422);
|
||||
|
||||
$group->max_size_kb = 1000;
|
||||
$group->save();
|
||||
|
||||
$ext->allowed_mimes = ['image/png'];
|
||||
$ext->save();
|
||||
|
||||
$response = $controller->store($request);
|
||||
expect($response->getStatusCode())->toBe(422);
|
||||
|
||||
$ext->allowed_mimes = ['application/pdf'];
|
||||
$ext->save();
|
||||
|
||||
$group->max_size_kb = 0;
|
||||
$group->save();
|
||||
|
||||
$response = $controller->store($request);
|
||||
expect($response->getStatusCode())->toBe(422);
|
||||
});
|
||||
|
||||
it('store returns forbidden when user cannot attach to post', function (): void {
|
||||
Storage::fake('local');
|
||||
|
||||
$controller = new AttachmentController();
|
||||
$owner = User::factory()->create();
|
||||
$viewer = User::factory()->create();
|
||||
$forum = makeForumForAttachments();
|
||||
$thread = Thread::create([
|
||||
'forum_id' => $forum->id,
|
||||
'user_id' => $owner->id,
|
||||
'title' => 'Thread',
|
||||
'body' => 'Body',
|
||||
]);
|
||||
$post = Post::create([
|
||||
'thread_id' => $thread->id,
|
||||
'user_id' => $owner->id,
|
||||
'body' => 'Post',
|
||||
]);
|
||||
|
||||
$group = AttachmentGroup::create([
|
||||
'name' => 'Images',
|
||||
'max_size_kb' => 100,
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
AttachmentExtension::create([
|
||||
'extension' => 'png',
|
||||
'attachment_group_id' => $group->id,
|
||||
'allowed_mimes' => ['image/png'],
|
||||
]);
|
||||
|
||||
$file = UploadedFile::fake()->image('photo.png');
|
||||
|
||||
$request = Request::create('/api/attachments', 'POST', [
|
||||
'post' => "/api/posts/{$post->id}",
|
||||
], [], ['file' => $file]);
|
||||
$request->setUserResolver(fn () => $viewer);
|
||||
|
||||
$response = $controller->store($request);
|
||||
|
||||
expect($response->getStatusCode())->toBe(403);
|
||||
});
|
||||
|
||||
it('index filters by post id', function (): void {
|
||||
$controller = new AttachmentController();
|
||||
$forum = makeForumForAttachments();
|
||||
$thread = Thread::create([
|
||||
'forum_id' => $forum->id,
|
||||
'user_id' => null,
|
||||
'title' => 'Thread',
|
||||
'body' => 'Body',
|
||||
]);
|
||||
$postA = Post::create([
|
||||
'thread_id' => $thread->id,
|
||||
'user_id' => null,
|
||||
'body' => 'Post A',
|
||||
]);
|
||||
$postB = Post::create([
|
||||
'thread_id' => $thread->id,
|
||||
'user_id' => null,
|
||||
'body' => 'Post B',
|
||||
]);
|
||||
|
||||
Attachment::create([
|
||||
'thread_id' => null,
|
||||
'post_id' => $postA->id,
|
||||
'attachment_extension_id' => null,
|
||||
'attachment_group_id' => null,
|
||||
'user_id' => null,
|
||||
'disk' => 'local',
|
||||
'path' => 'attachments/posts/'.$postA->id.'/a.txt',
|
||||
'original_name' => 'a.txt',
|
||||
'extension' => 'txt',
|
||||
'mime_type' => 'text/plain',
|
||||
'size_bytes' => 1,
|
||||
]);
|
||||
Attachment::create([
|
||||
'thread_id' => null,
|
||||
'post_id' => $postB->id,
|
||||
'attachment_extension_id' => null,
|
||||
'attachment_group_id' => null,
|
||||
'user_id' => null,
|
||||
'disk' => 'local',
|
||||
'path' => 'attachments/posts/'.$postB->id.'/b.txt',
|
||||
'original_name' => 'b.txt',
|
||||
'extension' => 'txt',
|
||||
'mime_type' => 'text/plain',
|
||||
'size_bytes' => 1,
|
||||
]);
|
||||
|
||||
$request = Request::create('/api/attachments', 'GET', [
|
||||
'post' => "/api/posts/{$postA->id}",
|
||||
]);
|
||||
|
||||
$response = $controller->index($request);
|
||||
|
||||
expect($response->getStatusCode())->toBe(200);
|
||||
$payload = $response->getData(true);
|
||||
expect(count($payload))->toBe(1);
|
||||
expect($payload[0]['post_id'])->toBe($postA->id);
|
||||
});
|
||||
|
||||
it('show returns not found when attachment is not viewable', function (): void {
|
||||
$controller = new AttachmentController();
|
||||
$attachment = new Attachment([
|
||||
'disk' => 'local',
|
||||
'path' => 'missing',
|
||||
]);
|
||||
$attachment->setRawAttributes(['id' => 1, 'deleted_at' => now()]);
|
||||
|
||||
$response = $controller->show($attachment);
|
||||
|
||||
expect($response->getStatusCode())->toBe(404);
|
||||
});
|
||||
|
||||
it('show returns attachment when viewable', function (): void {
|
||||
$controller = new AttachmentController();
|
||||
$forum = makeForumForAttachments();
|
||||
$thread = Thread::create([
|
||||
'forum_id' => $forum->id,
|
||||
'user_id' => null,
|
||||
'title' => 'Thread',
|
||||
'body' => 'Body',
|
||||
]);
|
||||
$attachment = Attachment::create([
|
||||
'thread_id' => $thread->id,
|
||||
'post_id' => null,
|
||||
'attachment_extension_id' => null,
|
||||
'attachment_group_id' => null,
|
||||
'user_id' => null,
|
||||
'disk' => 'local',
|
||||
'path' => 'attachments/threads/'.$thread->id.'/file.pdf',
|
||||
'original_name' => 'file.pdf',
|
||||
'extension' => 'pdf',
|
||||
'mime_type' => 'application/pdf',
|
||||
'size_bytes' => 10,
|
||||
]);
|
||||
|
||||
$response = $controller->show($attachment);
|
||||
|
||||
expect($response->getStatusCode())->toBe(200);
|
||||
});
|
||||
|
||||
it('download aborts when file missing or not viewable', function (): void {
|
||||
Storage::fake('local');
|
||||
|
||||
$controller = new AttachmentController();
|
||||
$forum = makeForumForAttachments();
|
||||
$thread = Thread::create([
|
||||
'forum_id' => $forum->id,
|
||||
'user_id' => null,
|
||||
'title' => 'Thread',
|
||||
'body' => 'Body',
|
||||
]);
|
||||
$attachment = Attachment::create([
|
||||
'thread_id' => $thread->id,
|
||||
'post_id' => null,
|
||||
'attachment_extension_id' => null,
|
||||
'attachment_group_id' => null,
|
||||
'user_id' => null,
|
||||
'disk' => 'local',
|
||||
'path' => 'attachments/threads/'.$thread->id.'/missing.pdf',
|
||||
'original_name' => 'missing.pdf',
|
||||
'extension' => 'pdf',
|
||||
'mime_type' => 'application/pdf',
|
||||
'size_bytes' => 10,
|
||||
]);
|
||||
|
||||
expect(fn () => $controller->download($attachment))->toThrow(HttpException::class);
|
||||
});
|
||||
|
||||
it('download aborts when attachment is not viewable', function (): void {
|
||||
Storage::fake('local');
|
||||
|
||||
$controller = new AttachmentController();
|
||||
$attachment = Attachment::create([
|
||||
'thread_id' => null,
|
||||
'post_id' => null,
|
||||
'attachment_extension_id' => null,
|
||||
'attachment_group_id' => null,
|
||||
'user_id' => null,
|
||||
'disk' => 'local',
|
||||
'path' => 'attachments/threads/1/missing.pdf',
|
||||
'original_name' => 'missing.pdf',
|
||||
'extension' => 'pdf',
|
||||
'mime_type' => 'application/pdf',
|
||||
'size_bytes' => 10,
|
||||
]);
|
||||
|
||||
expect(fn () => $controller->download($attachment))->toThrow(HttpException::class);
|
||||
});
|
||||
|
||||
it('thumbnail aborts when missing path or file', function (): void {
|
||||
Storage::fake('local');
|
||||
|
||||
$controller = new AttachmentController();
|
||||
$forum = makeForumForAttachments();
|
||||
$thread = Thread::create([
|
||||
'forum_id' => $forum->id,
|
||||
'user_id' => null,
|
||||
'title' => 'Thread',
|
||||
'body' => 'Body',
|
||||
]);
|
||||
$attachment = Attachment::create([
|
||||
'thread_id' => $thread->id,
|
||||
'post_id' => null,
|
||||
'attachment_extension_id' => null,
|
||||
'attachment_group_id' => null,
|
||||
'user_id' => null,
|
||||
'disk' => 'local',
|
||||
'path' => 'attachments/threads/'.$thread->id.'/file.pdf',
|
||||
'original_name' => 'file.pdf',
|
||||
'extension' => 'pdf',
|
||||
'mime_type' => 'application/pdf',
|
||||
'size_bytes' => 10,
|
||||
]);
|
||||
|
||||
expect(fn () => $controller->thumbnail($attachment))->toThrow(HttpException::class);
|
||||
|
||||
$attachment->thumbnail_path = 'attachments/threads/'.$thread->id.'/thumb.jpg';
|
||||
$attachment->save();
|
||||
|
||||
expect(fn () => $controller->thumbnail($attachment))->toThrow(HttpException::class);
|
||||
});
|
||||
|
||||
it('thumbnail aborts when attachment is not viewable', function (): void {
|
||||
Storage::fake('local');
|
||||
|
||||
$controller = new AttachmentController();
|
||||
$attachment = Attachment::create([
|
||||
'thread_id' => null,
|
||||
'post_id' => null,
|
||||
'attachment_extension_id' => null,
|
||||
'attachment_group_id' => null,
|
||||
'user_id' => null,
|
||||
'disk' => 'local',
|
||||
'path' => 'attachments/threads/1/file.pdf',
|
||||
'thumbnail_path' => 'attachments/threads/1/thumb.jpg',
|
||||
'original_name' => 'file.pdf',
|
||||
'extension' => 'pdf',
|
||||
'mime_type' => 'application/pdf',
|
||||
'size_bytes' => 10,
|
||||
]);
|
||||
|
||||
expect(fn () => $controller->thumbnail($attachment))->toThrow(HttpException::class);
|
||||
});
|
||||
|
||||
it('destroy returns unauthorized or forbidden', function (): void {
|
||||
$controller = new AttachmentController();
|
||||
$attachment = new Attachment(['user_id' => 999]);
|
||||
|
||||
$request = Request::create('/api/attachments/1', 'DELETE');
|
||||
$request->setUserResolver(fn () => null);
|
||||
$response = $controller->destroy($request, $attachment);
|
||||
expect($response->getStatusCode())->toBe(401);
|
||||
|
||||
$user = User::factory()->create();
|
||||
$request->setUserResolver(fn () => $user);
|
||||
$response = $controller->destroy($request, $attachment);
|
||||
expect($response->getStatusCode())->toBe(403);
|
||||
});
|
||||
|
||||
it('private helpers cover parse and match branches', function (): void {
|
||||
$controller = new AttachmentController();
|
||||
|
||||
$refThread = new ReflectionMethod($controller, 'parseThreadId');
|
||||
$refThread->setAccessible(true);
|
||||
$refPost = new ReflectionMethod($controller, 'parsePostId');
|
||||
$refPost->setAccessible(true);
|
||||
$refMatch = new ReflectionMethod($controller, 'matchesAllowed');
|
||||
$refMatch->setAccessible(true);
|
||||
$refResolve = new ReflectionMethod($controller, 'resolveExtension');
|
||||
$refResolve->setAccessible(true);
|
||||
|
||||
expect($refThread->invoke($controller, null))->toBeNull();
|
||||
expect($refThread->invoke($controller, '/threads/12'))->toBe(12);
|
||||
expect($refThread->invoke($controller, '5'))->toBe(5);
|
||||
expect($refThread->invoke($controller, 'abc'))->toBeNull();
|
||||
|
||||
expect($refPost->invoke($controller, null))->toBeNull();
|
||||
expect($refPost->invoke($controller, '/posts/7'))->toBe(7);
|
||||
expect($refPost->invoke($controller, '9'))->toBe(9);
|
||||
expect($refPost->invoke($controller, 'nope'))->toBeNull();
|
||||
|
||||
expect($refMatch->invoke($controller, 'image/png', null))->toBeTrue();
|
||||
expect($refMatch->invoke($controller, 'image/png', []))->toBeTrue();
|
||||
expect($refMatch->invoke($controller, 'image/png', ['image/jpeg']))->toBeFalse();
|
||||
expect($refMatch->invoke($controller, 'image/png', ['image/png']))->toBeTrue();
|
||||
|
||||
expect($refResolve->invoke($controller, ''))->toBeNull();
|
||||
});
|
||||
|
||||
it('canViewAttachment handles trashed and missing parents', function (): void {
|
||||
$controller = new AttachmentController();
|
||||
|
||||
$forum = makeForumForAttachments();
|
||||
$thread = Thread::create([
|
||||
'forum_id' => $forum->id,
|
||||
'user_id' => null,
|
||||
'title' => 'Thread',
|
||||
'body' => 'Body',
|
||||
]);
|
||||
|
||||
$attachment = Attachment::create([
|
||||
'thread_id' => $thread->id,
|
||||
'post_id' => null,
|
||||
'attachment_extension_id' => null,
|
||||
'attachment_group_id' => null,
|
||||
'user_id' => null,
|
||||
'disk' => 'local',
|
||||
'path' => 'attachments/threads/'.$thread->id.'/file.pdf',
|
||||
'original_name' => 'file.pdf',
|
||||
'extension' => 'pdf',
|
||||
'mime_type' => 'application/pdf',
|
||||
'size_bytes' => 10,
|
||||
]);
|
||||
|
||||
$refView = new ReflectionMethod($controller, 'canViewAttachment');
|
||||
$refView->setAccessible(true);
|
||||
|
||||
expect($refView->invoke($controller, $attachment))->toBeTrue();
|
||||
|
||||
$attachment->delete();
|
||||
expect($refView->invoke($controller, $attachment))->toBeFalse();
|
||||
|
||||
$attachment->restore();
|
||||
$thread->delete();
|
||||
expect($refView->invoke($controller, $attachment))->toBeFalse();
|
||||
|
||||
$thread->restore();
|
||||
|
||||
$post = Post::create([
|
||||
'thread_id' => $thread->id,
|
||||
'user_id' => null,
|
||||
'body' => 'Post',
|
||||
]);
|
||||
$attachment->post_id = $post->id;
|
||||
$attachment->thread_id = null;
|
||||
$attachment->save();
|
||||
expect($refView->invoke($controller, $attachment))->toBeTrue();
|
||||
|
||||
$post->delete();
|
||||
expect($refView->invoke($controller, $attachment))->toBeFalse();
|
||||
|
||||
$attachment->post_id = null;
|
||||
$attachment->thread_id = null;
|
||||
$attachment->save();
|
||||
expect($refView->invoke($controller, $attachment))->toBeFalse();
|
||||
});
|
||||
|
||||
it('serializeAttachment returns thumbnail_url null when missing', function (): void {
|
||||
$controller = new AttachmentController();
|
||||
|
||||
$attachment = new Attachment([
|
||||
'id' => 1,
|
||||
'thread_id' => null,
|
||||
'post_id' => null,
|
||||
'extension' => 'pdf',
|
||||
'original_name' => 'file.pdf',
|
||||
'mime_type' => 'application/pdf',
|
||||
'size_bytes' => 10,
|
||||
]);
|
||||
|
||||
$ref = new ReflectionMethod($controller, 'serializeAttachment');
|
||||
$ref->setAccessible(true);
|
||||
|
||||
$payload = $ref->invoke($controller, $attachment);
|
||||
|
||||
expect($payload['thumbnail_url'])->toBeNull();
|
||||
expect($payload['is_image'])->toBeFalse();
|
||||
});
|
||||
|
||||
it('serializeAttachment includes thumbnail url when present', function (): void {
|
||||
$controller = new AttachmentController();
|
||||
|
||||
$attachment = new Attachment([
|
||||
'id' => 2,
|
||||
'thread_id' => null,
|
||||
'post_id' => null,
|
||||
'extension' => 'png',
|
||||
'original_name' => 'file.png',
|
||||
'mime_type' => 'image/png',
|
||||
'thumbnail_path' => 'thumbs/file.png',
|
||||
'size_bytes' => 10,
|
||||
]);
|
||||
|
||||
$ref = new ReflectionMethod($controller, 'serializeAttachment');
|
||||
$ref->setAccessible(true);
|
||||
|
||||
$payload = $ref->invoke($controller, $attachment);
|
||||
|
||||
expect($payload['thumbnail_url'])->toContain('/thumbnail');
|
||||
expect($payload['is_image'])->toBeTrue();
|
||||
});
|
||||
|
||||
it('canManageAttachments handles null user and admin', function (): void {
|
||||
$controller = new AttachmentController();
|
||||
$ref = new ReflectionMethod($controller, 'canManageAttachments');
|
||||
$ref->setAccessible(true);
|
||||
|
||||
expect($ref->invoke($controller, null, 1))->toBeFalse();
|
||||
|
||||
$admin = User::factory()->create();
|
||||
$role = \App\Models\Role::create(['name' => 'ROLE_ADMIN']);
|
||||
$admin->roles()->attach($role);
|
||||
|
||||
expect($ref->invoke($controller, $admin, 999))->toBeTrue();
|
||||
});
|
||||
174
tests/Unit/AttachmentExtensionControllerUnitTest.php
Normal file
174
tests/Unit/AttachmentExtensionControllerUnitTest.php
Normal file
@@ -0,0 +1,174 @@
|
||||
<?php
|
||||
|
||||
use App\Http\Controllers\AttachmentExtensionController;
|
||||
use App\Models\Attachment;
|
||||
use App\Models\AttachmentExtension;
|
||||
use App\Models\AttachmentGroup;
|
||||
use App\Models\Role;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
function makeAdminUserForExtensions(): User
|
||||
{
|
||||
$admin = User::factory()->create();
|
||||
$role = Role::create(['name' => 'ROLE_ADMIN']);
|
||||
$admin->roles()->attach($role);
|
||||
return $admin;
|
||||
}
|
||||
|
||||
it('index returns forbidden for non admin', function (): void {
|
||||
$controller = new AttachmentExtensionController();
|
||||
$request = Request::create('/api/attachment-extensions', 'GET');
|
||||
$request->setUserResolver(fn () => null);
|
||||
|
||||
$response = $controller->index($request);
|
||||
|
||||
expect($response->getStatusCode())->toBe(403);
|
||||
});
|
||||
|
||||
it('store update destroy return forbidden for non admin', function (): void {
|
||||
$controller = new AttachmentExtensionController();
|
||||
$user = User::factory()->create();
|
||||
|
||||
$store = Request::create('/api/attachment-extensions', 'POST', [
|
||||
'extension' => 'png',
|
||||
]);
|
||||
$store->setUserResolver(fn () => $user);
|
||||
$response = $controller->store($store);
|
||||
expect($response->getStatusCode())->toBe(403);
|
||||
|
||||
$extension = AttachmentExtension::create(['extension' => 'gif']);
|
||||
|
||||
$update = Request::create('/api/attachment-extensions/'.$extension->id, 'PATCH', [
|
||||
'allowed_mimes' => ['image/gif'],
|
||||
]);
|
||||
$update->setUserResolver(fn () => $user);
|
||||
$response = $controller->update($update, $extension);
|
||||
expect($response->getStatusCode())->toBe(403);
|
||||
|
||||
$destroy = Request::create('/api/attachment-extensions/'.$extension->id, 'DELETE');
|
||||
$destroy->setUserResolver(fn () => $user);
|
||||
$response = $controller->destroy($destroy, $extension);
|
||||
expect($response->getStatusCode())->toBe(403);
|
||||
});
|
||||
|
||||
it('store rejects invalid or duplicate extension', function (): void {
|
||||
$controller = new AttachmentExtensionController();
|
||||
$admin = makeAdminUserForExtensions();
|
||||
|
||||
$request = Request::create('/api/attachment-extensions', 'POST', [
|
||||
'extension' => '.',
|
||||
]);
|
||||
$request->setUserResolver(fn () => $admin);
|
||||
|
||||
$response = $controller->store($request);
|
||||
expect($response->getStatusCode())->toBe(422);
|
||||
|
||||
AttachmentExtension::create(['extension' => 'png']);
|
||||
|
||||
$request = Request::create('/api/attachment-extensions', 'POST', [
|
||||
'extension' => 'PNG',
|
||||
]);
|
||||
$request->setUserResolver(fn () => $admin);
|
||||
|
||||
$response = $controller->store($request);
|
||||
expect($response->getStatusCode())->toBe(422);
|
||||
});
|
||||
|
||||
it('store and update serialize group info', function (): void {
|
||||
$controller = new AttachmentExtensionController();
|
||||
$admin = makeAdminUserForExtensions();
|
||||
|
||||
$group = AttachmentGroup::create([
|
||||
'name' => 'Images',
|
||||
'max_size_kb' => 100,
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
$request = Request::create('/api/attachment-extensions', 'POST', [
|
||||
'extension' => 'png',
|
||||
'attachment_group_id' => $group->id,
|
||||
'allowed_mimes' => ['image/png'],
|
||||
]);
|
||||
$request->setUserResolver(fn () => $admin);
|
||||
|
||||
$response = $controller->store($request);
|
||||
expect($response->getStatusCode())->toBe(201);
|
||||
|
||||
$extension = AttachmentExtension::query()->where('extension', 'png')->firstOrFail();
|
||||
|
||||
$request = Request::create('/api/attachment-extensions/'.$extension->id, 'PATCH', [
|
||||
'allowed_mimes' => ['image/png', 'image/webp'],
|
||||
]);
|
||||
$request->setUserResolver(fn () => $admin);
|
||||
|
||||
$response = $controller->update($request, $extension);
|
||||
expect($response->getStatusCode())->toBe(200);
|
||||
$payload = $response->getData(true);
|
||||
expect($payload['group']['name'])->toBe('Images');
|
||||
});
|
||||
|
||||
it('destroy returns error when extension in use then succeeds', function (): void {
|
||||
$controller = new AttachmentExtensionController();
|
||||
$admin = makeAdminUserForExtensions();
|
||||
|
||||
$extension = AttachmentExtension::create(['extension' => 'pdf']);
|
||||
|
||||
Attachment::create([
|
||||
'thread_id' => null,
|
||||
'post_id' => null,
|
||||
'attachment_extension_id' => $extension->id,
|
||||
'attachment_group_id' => null,
|
||||
'user_id' => null,
|
||||
'disk' => 'local',
|
||||
'path' => 'attachments/test.pdf',
|
||||
'original_name' => 'test.pdf',
|
||||
'extension' => 'pdf',
|
||||
'mime_type' => 'application/pdf',
|
||||
'size_bytes' => 10,
|
||||
]);
|
||||
|
||||
$request = Request::create('/api/attachment-extensions/'.$extension->id, 'DELETE');
|
||||
$request->setUserResolver(fn () => $admin);
|
||||
|
||||
$response = $controller->destroy($request, $extension);
|
||||
expect($response->getStatusCode())->toBe(422);
|
||||
|
||||
Attachment::query()->delete();
|
||||
|
||||
$response = $controller->destroy($request, $extension);
|
||||
expect($response->getStatusCode())->toBe(204);
|
||||
});
|
||||
|
||||
it('public index only returns active grouped extensions', function (): void {
|
||||
$controller = new AttachmentExtensionController();
|
||||
|
||||
$activeGroup = AttachmentGroup::create([
|
||||
'name' => 'Active',
|
||||
'max_size_kb' => 100,
|
||||
'is_active' => true,
|
||||
]);
|
||||
$inactiveGroup = AttachmentGroup::create([
|
||||
'name' => 'Inactive',
|
||||
'max_size_kb' => 100,
|
||||
'is_active' => false,
|
||||
]);
|
||||
|
||||
AttachmentExtension::create([
|
||||
'extension' => 'png',
|
||||
'attachment_group_id' => $activeGroup->id,
|
||||
]);
|
||||
AttachmentExtension::create([
|
||||
'extension' => 'zip',
|
||||
'attachment_group_id' => $inactiveGroup->id,
|
||||
]);
|
||||
AttachmentExtension::create([
|
||||
'extension' => 'orphan',
|
||||
'attachment_group_id' => null,
|
||||
]);
|
||||
|
||||
$response = $controller->publicIndex();
|
||||
$payload = $response->getData(true);
|
||||
|
||||
expect($payload)->toBe(['png']);
|
||||
});
|
||||
277
tests/Unit/AttachmentGroupControllerUnitTest.php
Normal file
277
tests/Unit/AttachmentGroupControllerUnitTest.php
Normal file
@@ -0,0 +1,277 @@
|
||||
<?php
|
||||
|
||||
use App\Http\Controllers\AttachmentGroupController;
|
||||
use App\Models\Attachment;
|
||||
use App\Models\AttachmentExtension;
|
||||
use App\Models\AttachmentGroup;
|
||||
use App\Models\Role;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
function makeAdminForGroupController(): User
|
||||
{
|
||||
$admin = User::factory()->create();
|
||||
$role = Role::create(['name' => 'ROLE_ADMIN']);
|
||||
$admin->roles()->attach($role);
|
||||
return $admin;
|
||||
}
|
||||
|
||||
it('returns forbidden for non admin', function (): void {
|
||||
$controller = new AttachmentGroupController();
|
||||
$user = User::factory()->create();
|
||||
|
||||
$index = Request::create('/api/attachment-groups', 'GET');
|
||||
$index->setUserResolver(fn () => $user);
|
||||
expect($controller->index($index)->getStatusCode())->toBe(403);
|
||||
|
||||
$store = Request::create('/api/attachment-groups', 'POST', [
|
||||
'name' => 'Images',
|
||||
'max_size_kb' => 10,
|
||||
'is_active' => true,
|
||||
]);
|
||||
$store->setUserResolver(fn () => $user);
|
||||
expect($controller->store($store)->getStatusCode())->toBe(403);
|
||||
|
||||
$group = AttachmentGroup::create([
|
||||
'name' => 'Docs',
|
||||
'position' => 1,
|
||||
'max_size_kb' => 10,
|
||||
'is_active' => true,
|
||||
]);
|
||||
$update = Request::create('/api/attachment-groups/'.$group->id, 'PATCH', [
|
||||
'name' => 'Docs',
|
||||
'max_size_kb' => 10,
|
||||
'is_active' => true,
|
||||
]);
|
||||
$update->setUserResolver(fn () => $user);
|
||||
expect($controller->update($update, $group)->getStatusCode())->toBe(403);
|
||||
|
||||
$destroy = Request::create('/api/attachment-groups/'.$group->id, 'DELETE');
|
||||
$destroy->setUserResolver(fn () => $user);
|
||||
expect($controller->destroy($destroy, $group)->getStatusCode())->toBe(403);
|
||||
|
||||
$reorder = Request::create('/api/attachment-groups/reorder', 'POST', [
|
||||
'parentId' => null,
|
||||
'orderedIds' => [],
|
||||
]);
|
||||
$reorder->setUserResolver(fn () => $user);
|
||||
expect($controller->reorder($reorder)->getStatusCode())->toBe(403);
|
||||
});
|
||||
|
||||
it('stores group and rejects duplicates', function (): void {
|
||||
$controller = new AttachmentGroupController();
|
||||
$admin = makeAdminForGroupController();
|
||||
|
||||
$request = Request::create('/api/attachment-groups', 'POST', [
|
||||
'name' => 'Images',
|
||||
'parent_id' => null,
|
||||
'max_size_kb' => 100,
|
||||
'is_active' => true,
|
||||
]);
|
||||
$request->setUserResolver(fn () => $admin);
|
||||
|
||||
$response = $controller->store($request);
|
||||
expect($response->getStatusCode())->toBe(201);
|
||||
|
||||
$response = $controller->store($request);
|
||||
expect($response->getStatusCode())->toBe(422);
|
||||
});
|
||||
|
||||
it('updates group and handles parent change position', function (): void {
|
||||
$controller = new AttachmentGroupController();
|
||||
$admin = makeAdminForGroupController();
|
||||
|
||||
$parentA = AttachmentGroup::create([
|
||||
'name' => 'Parent A',
|
||||
'position' => 1,
|
||||
'max_size_kb' => 100,
|
||||
'is_active' => true,
|
||||
]);
|
||||
$parentB = AttachmentGroup::create([
|
||||
'name' => 'Parent B',
|
||||
'position' => 2,
|
||||
'max_size_kb' => 100,
|
||||
'is_active' => true,
|
||||
]);
|
||||
$group = AttachmentGroup::create([
|
||||
'name' => 'Docs',
|
||||
'parent_id' => $parentA->id,
|
||||
'position' => 1,
|
||||
'max_size_kb' => 10,
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
$request = Request::create('/api/attachment-groups/'.$group->id, 'PATCH', [
|
||||
'name' => 'Docs',
|
||||
'parent_id' => $parentB->id,
|
||||
'max_size_kb' => 10,
|
||||
'is_active' => false,
|
||||
]);
|
||||
$request->setUserResolver(fn () => $admin);
|
||||
|
||||
$response = $controller->update($request, $group);
|
||||
expect($response->getStatusCode())->toBe(200);
|
||||
});
|
||||
|
||||
it('update rejects duplicate group name', function (): void {
|
||||
$controller = new AttachmentGroupController();
|
||||
$admin = makeAdminForGroupController();
|
||||
|
||||
AttachmentGroup::create([
|
||||
'name' => 'Images',
|
||||
'position' => 1,
|
||||
'max_size_kb' => 10,
|
||||
'is_active' => true,
|
||||
]);
|
||||
$group = AttachmentGroup::create([
|
||||
'name' => 'Docs',
|
||||
'position' => 2,
|
||||
'max_size_kb' => 10,
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
$request = Request::create('/api/attachment-groups/'.$group->id, 'PATCH', [
|
||||
'name' => 'images',
|
||||
'parent_id' => null,
|
||||
'max_size_kb' => 10,
|
||||
'is_active' => true,
|
||||
]);
|
||||
$request->setUserResolver(fn () => $admin);
|
||||
|
||||
$response = $controller->update($request, $group);
|
||||
expect($response->getStatusCode())->toBe(422);
|
||||
});
|
||||
|
||||
it('destroy returns errors for in-use group', function (): void {
|
||||
$controller = new AttachmentGroupController();
|
||||
$admin = makeAdminForGroupController();
|
||||
|
||||
$group = AttachmentGroup::create([
|
||||
'name' => 'Images',
|
||||
'position' => 1,
|
||||
'max_size_kb' => 10,
|
||||
'is_active' => true,
|
||||
]);
|
||||
AttachmentExtension::create([
|
||||
'extension' => 'png',
|
||||
'attachment_group_id' => $group->id,
|
||||
]);
|
||||
|
||||
$request = Request::create('/api/attachment-groups/'.$group->id, 'DELETE');
|
||||
$request->setUserResolver(fn () => $admin);
|
||||
|
||||
$response = $controller->destroy($request, $group);
|
||||
expect($response->getStatusCode())->toBe(422);
|
||||
|
||||
AttachmentExtension::query()->delete();
|
||||
Attachment::create([
|
||||
'thread_id' => null,
|
||||
'post_id' => null,
|
||||
'attachment_extension_id' => null,
|
||||
'attachment_group_id' => $group->id,
|
||||
'user_id' => null,
|
||||
'disk' => 'local',
|
||||
'path' => 'attachments/test.txt',
|
||||
'original_name' => 'test.txt',
|
||||
'extension' => 'txt',
|
||||
'mime_type' => 'text/plain',
|
||||
'size_bytes' => 1,
|
||||
]);
|
||||
|
||||
$response = $controller->destroy($request, $group);
|
||||
expect($response->getStatusCode())->toBe(422);
|
||||
});
|
||||
|
||||
it('destroy deletes empty group', function (): void {
|
||||
$controller = new AttachmentGroupController();
|
||||
$admin = makeAdminForGroupController();
|
||||
|
||||
$group = AttachmentGroup::create([
|
||||
'name' => 'Empty',
|
||||
'position' => 1,
|
||||
'max_size_kb' => 10,
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
$request = Request::create('/api/attachment-groups/'.$group->id, 'DELETE');
|
||||
$request->setUserResolver(fn () => $admin);
|
||||
|
||||
$response = $controller->destroy($request, $group);
|
||||
expect($response->getStatusCode())->toBe(204);
|
||||
});
|
||||
|
||||
it('reorders groups with string parent id handling', function (): void {
|
||||
$controller = new AttachmentGroupController();
|
||||
$admin = makeAdminForGroupController();
|
||||
|
||||
$groupA = AttachmentGroup::create([
|
||||
'name' => 'A',
|
||||
'position' => 1,
|
||||
'max_size_kb' => 10,
|
||||
'is_active' => true,
|
||||
]);
|
||||
$groupB = AttachmentGroup::create([
|
||||
'name' => 'B',
|
||||
'position' => 2,
|
||||
'max_size_kb' => 10,
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
$request = Request::create('/api/attachment-groups/reorder', 'POST', [
|
||||
'parentId' => 'null',
|
||||
'orderedIds' => [$groupB->id, $groupA->id],
|
||||
]);
|
||||
$request->setUserResolver(fn () => $admin);
|
||||
|
||||
$response = $controller->reorder($request);
|
||||
expect($response->getStatusCode())->toBe(200);
|
||||
$groupA->refresh();
|
||||
$groupB->refresh();
|
||||
expect($groupB->position)->toBe(1);
|
||||
expect($groupA->position)->toBe(2);
|
||||
});
|
||||
|
||||
it('reorders groups with numeric parent id string', function (): void {
|
||||
$controller = new AttachmentGroupController();
|
||||
$admin = makeAdminForGroupController();
|
||||
|
||||
$parent = AttachmentGroup::create([
|
||||
'name' => 'Parent',
|
||||
'position' => 1,
|
||||
'max_size_kb' => 10,
|
||||
'is_active' => true,
|
||||
]);
|
||||
$groupA = AttachmentGroup::create([
|
||||
'name' => 'A',
|
||||
'parent_id' => $parent->id,
|
||||
'position' => 1,
|
||||
'max_size_kb' => 10,
|
||||
'is_active' => true,
|
||||
]);
|
||||
$groupB = AttachmentGroup::create([
|
||||
'name' => 'B',
|
||||
'parent_id' => $parent->id,
|
||||
'position' => 2,
|
||||
'max_size_kb' => 10,
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
$request = Request::create('/api/attachment-groups/reorder', 'POST', [
|
||||
'parentId' => (string) $parent->id,
|
||||
'orderedIds' => [$groupB->id, $groupA->id],
|
||||
]);
|
||||
$request->setUserResolver(fn () => $admin);
|
||||
|
||||
$response = $controller->reorder($request);
|
||||
expect($response->getStatusCode())->toBe(200);
|
||||
});
|
||||
|
||||
it('normalizeParentId handles empty and null values', function (): void {
|
||||
$controller = new AttachmentGroupController();
|
||||
$ref = new ReflectionMethod($controller, 'normalizeParentId');
|
||||
$ref->setAccessible(true);
|
||||
|
||||
expect($ref->invoke($controller, ''))->toBeNull();
|
||||
expect($ref->invoke($controller, 'null'))->toBeNull();
|
||||
expect($ref->invoke($controller, null))->toBeNull();
|
||||
});
|
||||
33
tests/Unit/AttachmentGroupModelTest.php
Normal file
33
tests/Unit/AttachmentGroupModelTest.php
Normal file
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
use App\Models\AttachmentExtension;
|
||||
use App\Models\AttachmentGroup;
|
||||
|
||||
it('exposes attachment group hierarchy and extensions', function (): void {
|
||||
$parent = AttachmentGroup::create([
|
||||
'name' => 'Parent',
|
||||
'max_size_kb' => 100,
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
$child = AttachmentGroup::create([
|
||||
'name' => 'Child',
|
||||
'parent_id' => $parent->id,
|
||||
'position' => 1,
|
||||
'max_size_kb' => 100,
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
$extension = AttachmentExtension::create([
|
||||
'extension' => 'png',
|
||||
'attachment_group_id' => $parent->id,
|
||||
'allowed_mimes' => ['image/png'],
|
||||
]);
|
||||
|
||||
$parent->load(['children', 'extensions']);
|
||||
$child->load('parent');
|
||||
|
||||
expect($parent->children->first()->id)->toBe($child->id);
|
||||
expect($parent->extensions->first()->id)->toBe($extension->id);
|
||||
expect($child->parent?->id)->toBe($parent->id);
|
||||
});
|
||||
72
tests/Unit/AttachmentModelTest.php
Normal file
72
tests/Unit/AttachmentModelTest.php
Normal file
@@ -0,0 +1,72 @@
|
||||
<?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;
|
||||
|
||||
it('exposes attachment relationships', function (): void {
|
||||
$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,
|
||||
]);
|
||||
|
||||
$user = User::factory()->create();
|
||||
$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' => 'Post',
|
||||
]);
|
||||
|
||||
$group = AttachmentGroup::create([
|
||||
'name' => 'Docs',
|
||||
'max_size_kb' => 100,
|
||||
'is_active' => true,
|
||||
]);
|
||||
$extension = AttachmentExtension::create([
|
||||
'extension' => 'pdf',
|
||||
'attachment_group_id' => $group->id,
|
||||
'allowed_mimes' => ['application/pdf'],
|
||||
]);
|
||||
|
||||
$attachment = Attachment::create([
|
||||
'thread_id' => $thread->id,
|
||||
'post_id' => $post->id,
|
||||
'attachment_extension_id' => $extension->id,
|
||||
'attachment_group_id' => $group->id,
|
||||
'user_id' => $user->id,
|
||||
'disk' => 'local',
|
||||
'path' => 'attachments/posts/'.$post->id.'/file.pdf',
|
||||
'original_name' => 'file.pdf',
|
||||
'extension' => 'pdf',
|
||||
'mime_type' => 'application/pdf',
|
||||
'size_bytes' => 10,
|
||||
]);
|
||||
|
||||
$attachment->load(['thread', 'post', 'extension', 'group', 'user']);
|
||||
|
||||
expect($attachment->thread?->id)->toBe($thread->id);
|
||||
expect($attachment->post?->id)->toBe($post->id);
|
||||
expect($attachment->extension()->first()?->id)->toBe($extension->id);
|
||||
expect($attachment->group()->first()?->id)->toBe($group->id);
|
||||
expect($attachment->user?->id)->toBe($user->id);
|
||||
});
|
||||
214
tests/Unit/AttachmentThumbnailServiceBranchesTest.php
Normal file
214
tests/Unit/AttachmentThumbnailServiceBranchesTest.php
Normal file
@@ -0,0 +1,214 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services {
|
||||
if (!isset($GLOBALS['attachment_thumbnail_overrides'])) {
|
||||
$GLOBALS['attachment_thumbnail_overrides'] = [
|
||||
'force_imagecreatetruecolor_fail' => false,
|
||||
'force_imagejpeg_fail' => false,
|
||||
'force_imagecreatefromjpeg_fail' => false,
|
||||
'fake_getimagesize' => null,
|
||||
];
|
||||
}
|
||||
|
||||
if (!function_exists(__NAMESPACE__ . '\\imagecreatetruecolor')) {
|
||||
function imagecreatetruecolor($width, $height)
|
||||
{
|
||||
if (!empty($GLOBALS['attachment_thumbnail_overrides']['force_imagecreatetruecolor_fail'])) {
|
||||
return false;
|
||||
}
|
||||
return \imagecreatetruecolor($width, $height);
|
||||
}
|
||||
}
|
||||
|
||||
if (!function_exists(__NAMESPACE__ . '\\imagecreatefromjpeg')) {
|
||||
function imagecreatefromjpeg($path)
|
||||
{
|
||||
if (!empty($GLOBALS['attachment_thumbnail_overrides']['force_imagecreatefromjpeg_fail'])) {
|
||||
return false;
|
||||
}
|
||||
return \imagecreatefromjpeg($path);
|
||||
}
|
||||
}
|
||||
|
||||
if (!function_exists(__NAMESPACE__ . '\\getimagesize')) {
|
||||
function getimagesize($path)
|
||||
{
|
||||
$override = $GLOBALS['attachment_thumbnail_overrides']['fake_getimagesize'] ?? null;
|
||||
if ($override !== null) {
|
||||
return $override;
|
||||
}
|
||||
return \getimagesize($path);
|
||||
}
|
||||
}
|
||||
|
||||
if (!function_exists(__NAMESPACE__ . '\\imagejpeg')) {
|
||||
function imagejpeg($image, $to = null, $quality = null)
|
||||
{
|
||||
if (!empty($GLOBALS['attachment_thumbnail_overrides']['force_imagejpeg_fail'])) {
|
||||
return false;
|
||||
}
|
||||
if ($quality === null) {
|
||||
return \imagejpeg($image, $to);
|
||||
}
|
||||
return \imagejpeg($image, $to, $quality);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
namespace {
|
||||
use App\Models\Attachment;
|
||||
use App\Models\Forum;
|
||||
use App\Models\Post;
|
||||
use App\Models\Setting;
|
||||
use App\Models\Thread;
|
||||
use App\Services\AttachmentThumbnailService;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
if (!function_exists('imagewebp')) {
|
||||
function imagewebp($image, $to = null, $quality = null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(function (): void {
|
||||
$GLOBALS['attachment_thumbnail_overrides'] = [
|
||||
'force_imagecreatetruecolor_fail' => false,
|
||||
'force_imagejpeg_fail' => false,
|
||||
'force_imagecreatefromjpeg_fail' => false,
|
||||
'fake_getimagesize' => null,
|
||||
];
|
||||
});
|
||||
|
||||
it('uses misc scope for attachments without thread or post', function (): void {
|
||||
if (!function_exists('imagecreatetruecolor')) {
|
||||
$this->markTestSkipped('GD extension not available.');
|
||||
}
|
||||
|
||||
Storage::fake('local');
|
||||
|
||||
Setting::updateOrCreate(['key' => 'attachments.create_thumbnails'], ['value' => 'true']);
|
||||
Setting::updateOrCreate(['key' => 'attachments.thumbnail_max_width'], ['value' => '100']);
|
||||
Setting::updateOrCreate(['key' => 'attachments.thumbnail_max_height'], ['value' => '100']);
|
||||
|
||||
$image = UploadedFile::fake()->image('photo.jpg', 800, 600);
|
||||
$path = 'attachments/misc/photo.jpg';
|
||||
Storage::disk('local')->put($path, file_get_contents($image->getPathname()));
|
||||
|
||||
$attachment = Attachment::create([
|
||||
'thread_id' => null,
|
||||
'post_id' => null,
|
||||
'attachment_extension_id' => null,
|
||||
'attachment_group_id' => null,
|
||||
'user_id' => null,
|
||||
'disk' => 'local',
|
||||
'path' => $path,
|
||||
'original_name' => 'photo.jpg',
|
||||
'extension' => 'jpg',
|
||||
'mime_type' => 'image/jpeg',
|
||||
'size_bytes' => 1234,
|
||||
]);
|
||||
|
||||
$service = new AttachmentThumbnailService();
|
||||
$result = $service->createForAttachment($attachment, true);
|
||||
|
||||
expect($result)->not->toBeNull();
|
||||
expect($result['path'])->toContain('attachments/misc/thumbs/');
|
||||
});
|
||||
|
||||
it('returns null when image dimensions are zero', function (): void {
|
||||
Storage::fake('local');
|
||||
|
||||
Setting::updateOrCreate(['key' => 'attachments.create_thumbnails'], ['value' => 'true']);
|
||||
Setting::updateOrCreate(['key' => 'attachments.thumbnail_max_width'], ['value' => '100']);
|
||||
Setting::updateOrCreate(['key' => 'attachments.thumbnail_max_height'], ['value' => '100']);
|
||||
|
||||
$GLOBALS['attachment_thumbnail_overrides']['fake_getimagesize'] = [0, 0, IMAGETYPE_JPEG];
|
||||
|
||||
$service = new AttachmentThumbnailService();
|
||||
$file = UploadedFile::fake()->image('photo.jpg', 800, 600);
|
||||
|
||||
$result = $service->createForUpload($file, 'threads/1', 'local');
|
||||
|
||||
expect($result)->toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when thumbnail image creation fails', function (): void {
|
||||
if (!function_exists('imagecreatetruecolor')) {
|
||||
$this->markTestSkipped('GD extension not available.');
|
||||
}
|
||||
|
||||
Storage::fake('local');
|
||||
|
||||
Setting::updateOrCreate(['key' => 'attachments.create_thumbnails'], ['value' => 'true']);
|
||||
Setting::updateOrCreate(['key' => 'attachments.thumbnail_max_width'], ['value' => '100']);
|
||||
Setting::updateOrCreate(['key' => 'attachments.thumbnail_max_height'], ['value' => '100']);
|
||||
|
||||
$GLOBALS['attachment_thumbnail_overrides']['force_imagecreatetruecolor_fail'] = true;
|
||||
|
||||
$service = new AttachmentThumbnailService();
|
||||
$file = UploadedFile::fake()->image('photo.jpg', 800, 600);
|
||||
|
||||
$result = $service->createForUpload($file, 'threads/1', 'local');
|
||||
|
||||
expect($result)->toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when renderer fails to encode', function (): void {
|
||||
if (!function_exists('imagecreatetruecolor')) {
|
||||
$this->markTestSkipped('GD extension not available.');
|
||||
}
|
||||
|
||||
Storage::fake('local');
|
||||
|
||||
Setting::updateOrCreate(['key' => 'attachments.create_thumbnails'], ['value' => 'true']);
|
||||
Setting::updateOrCreate(['key' => 'attachments.thumbnail_max_width'], ['value' => '100']);
|
||||
Setting::updateOrCreate(['key' => 'attachments.thumbnail_max_height'], ['value' => '100']);
|
||||
|
||||
$GLOBALS['attachment_thumbnail_overrides']['force_imagejpeg_fail'] = true;
|
||||
|
||||
$service = new AttachmentThumbnailService();
|
||||
$file = UploadedFile::fake()->image('photo.jpg', 800, 600);
|
||||
|
||||
$result = $service->createForUpload($file, 'threads/1', 'local');
|
||||
|
||||
expect($result)->toBeNull();
|
||||
});
|
||||
|
||||
it('handles webp branch in image loader', function (): void {
|
||||
$service = new AttachmentThumbnailService();
|
||||
$ref = new ReflectionMethod($service, 'createImageFromFile');
|
||||
$ref->setAccessible(true);
|
||||
|
||||
$temp = tempnam(sys_get_temp_dir(), 'webp');
|
||||
file_put_contents($temp, 'not-a-real-webp');
|
||||
|
||||
$result = $ref->invoke($service, $temp, 'image/webp');
|
||||
expect($result === null || $result === false)->toBeTrue();
|
||||
|
||||
unlink($temp);
|
||||
});
|
||||
|
||||
it('handles webp branch in renderer when available', function (): void {
|
||||
$service = new AttachmentThumbnailService();
|
||||
$ref = new ReflectionMethod($service, 'renderImageBinary');
|
||||
$ref->setAccessible(true);
|
||||
|
||||
$image = \imagecreatetruecolor(10, 10);
|
||||
$data = $ref->invoke($service, $image, 'image/webp', 80);
|
||||
|
||||
expect($data === null || is_string($data))->toBeTrue();
|
||||
imagedestroy($image);
|
||||
});
|
||||
|
||||
it('returns default when setting is missing', function (): void {
|
||||
$service = new AttachmentThumbnailService();
|
||||
$ref = new ReflectionMethod($service, 'settingBool');
|
||||
$ref->setAccessible(true);
|
||||
|
||||
$result = $ref->invoke($service, 'attachments.missing_flag', true);
|
||||
|
||||
expect($result)->toBeTrue();
|
||||
});
|
||||
}
|
||||
446
tests/Unit/AttachmentThumbnailServiceTest.php
Normal file
446
tests/Unit/AttachmentThumbnailServiceTest.php
Normal file
@@ -0,0 +1,446 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Setting;
|
||||
use App\Services\AttachmentThumbnailService;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
it('returns null for non-images', function (): void {
|
||||
Storage::fake('local');
|
||||
|
||||
$service = new AttachmentThumbnailService();
|
||||
$file = UploadedFile::fake()->create('document.txt', 10, 'text/plain');
|
||||
|
||||
$result = $service->createForUpload($file, 'misc', 'local');
|
||||
|
||||
expect($result)->toBeNull();
|
||||
});
|
||||
|
||||
it('creates thumbnail for large images', function (): void {
|
||||
if (!function_exists('imagecreatetruecolor')) {
|
||||
$this->markTestSkipped('GD extension not available.');
|
||||
}
|
||||
|
||||
Storage::fake('local');
|
||||
|
||||
Setting::updateOrCreate(['key' => 'attachments.create_thumbnails'], ['value' => 'true']);
|
||||
Setting::updateOrCreate(['key' => 'attachments.thumbnail_max_width'], ['value' => '100']);
|
||||
Setting::updateOrCreate(['key' => 'attachments.thumbnail_max_height'], ['value' => '100']);
|
||||
Setting::updateOrCreate(['key' => 'attachments.thumbnail_quality'], ['value' => '80']);
|
||||
|
||||
$service = new AttachmentThumbnailService();
|
||||
$file = UploadedFile::fake()->image('photo.jpg', 800, 600);
|
||||
|
||||
$result = $service->createForUpload($file, 'threads/10', 'local');
|
||||
|
||||
expect($result)->not->toBeNull();
|
||||
expect($result)->toHaveKeys(['path', 'mime', 'size']);
|
||||
|
||||
Storage::disk('local')->assertExists($result['path']);
|
||||
});
|
||||
|
||||
it('skips thumbnail when disabled or too small', function (): void {
|
||||
if (!function_exists('imagecreatetruecolor')) {
|
||||
$this->markTestSkipped('GD extension not available.');
|
||||
}
|
||||
|
||||
Storage::fake('local');
|
||||
|
||||
Setting::updateOrCreate(['key' => 'attachments.create_thumbnails'], ['value' => 'false']);
|
||||
Setting::updateOrCreate(['key' => 'attachments.thumbnail_max_width'], ['value' => '300']);
|
||||
Setting::updateOrCreate(['key' => 'attachments.thumbnail_max_height'], ['value' => '300']);
|
||||
|
||||
$service = new AttachmentThumbnailService();
|
||||
$file = UploadedFile::fake()->image('small.jpg', 100, 100);
|
||||
|
||||
$result = $service->createForUpload($file, 'threads/1', 'local');
|
||||
|
||||
expect($result)->toBeNull();
|
||||
});
|
||||
|
||||
it('skips thumbnail when image is within size limits', function (): void {
|
||||
if (!function_exists('imagecreatetruecolor')) {
|
||||
$this->markTestSkipped('GD extension not available.');
|
||||
}
|
||||
|
||||
Storage::fake('local');
|
||||
|
||||
Setting::updateOrCreate(['key' => 'attachments.create_thumbnails'], ['value' => 'true']);
|
||||
Setting::updateOrCreate(['key' => 'attachments.thumbnail_max_width'], ['value' => '300']);
|
||||
Setting::updateOrCreate(['key' => 'attachments.thumbnail_max_height'], ['value' => '300']);
|
||||
|
||||
$service = new AttachmentThumbnailService();
|
||||
$file = UploadedFile::fake()->image('small.jpg', 100, 100);
|
||||
|
||||
$result = $service->createForUpload($file, 'threads/1', 'local');
|
||||
|
||||
expect($result)->toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when max dimensions are invalid', function (): void {
|
||||
if (!function_exists('imagecreatetruecolor')) {
|
||||
$this->markTestSkipped('GD extension not available.');
|
||||
}
|
||||
|
||||
Storage::fake('local');
|
||||
|
||||
Setting::updateOrCreate(['key' => 'attachments.create_thumbnails'], ['value' => 'true']);
|
||||
Setting::updateOrCreate(['key' => 'attachments.thumbnail_max_width'], ['value' => '0']);
|
||||
Setting::updateOrCreate(['key' => 'attachments.thumbnail_max_height'], ['value' => '0']);
|
||||
|
||||
$service = new AttachmentThumbnailService();
|
||||
$file = UploadedFile::fake()->image('photo.jpg', 800, 600);
|
||||
|
||||
$result = $service->createForUpload($file, 'threads/1', 'local');
|
||||
|
||||
expect($result)->toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for invalid image payloads', function (): void {
|
||||
Storage::fake('local');
|
||||
|
||||
Setting::updateOrCreate(['key' => 'attachments.create_thumbnails'], ['value' => 'true']);
|
||||
Setting::updateOrCreate(['key' => 'attachments.thumbnail_max_width'], ['value' => '100']);
|
||||
Setting::updateOrCreate(['key' => 'attachments.thumbnail_max_height'], ['value' => '100']);
|
||||
|
||||
$service = new AttachmentThumbnailService();
|
||||
$file = UploadedFile::fake()->create('bad.jpg', 10, 'image/jpeg');
|
||||
|
||||
$result = $service->createForUpload($file, 'threads/1', 'local');
|
||||
|
||||
expect($result)->toBeNull();
|
||||
});
|
||||
|
||||
it('creates thumbnail for existing attachments', function (): void {
|
||||
if (!function_exists('imagecreatetruecolor')) {
|
||||
$this->markTestSkipped('GD extension not available.');
|
||||
}
|
||||
|
||||
Storage::fake('local');
|
||||
|
||||
Setting::updateOrCreate(['key' => 'attachments.create_thumbnails'], ['value' => 'true']);
|
||||
Setting::updateOrCreate(['key' => 'attachments.thumbnail_max_width'], ['value' => '100']);
|
||||
Setting::updateOrCreate(['key' => 'attachments.thumbnail_max_height'], ['value' => '100']);
|
||||
|
||||
$category = \App\Models\Forum::create([
|
||||
'name' => 'Category',
|
||||
'description' => null,
|
||||
'type' => 'category',
|
||||
'parent_id' => null,
|
||||
'position' => 1,
|
||||
]);
|
||||
$forum = \App\Models\Forum::create([
|
||||
'name' => 'Forum',
|
||||
'description' => null,
|
||||
'type' => 'forum',
|
||||
'parent_id' => $category->id,
|
||||
'position' => 1,
|
||||
]);
|
||||
$thread = \App\Models\Thread::create([
|
||||
'forum_id' => $forum->id,
|
||||
'user_id' => null,
|
||||
'title' => 'Thread',
|
||||
'body' => 'Body',
|
||||
]);
|
||||
|
||||
$image = UploadedFile::fake()->image('photo.jpg', 800, 600);
|
||||
$path = "attachments/threads/{$thread->id}/photo.jpg";
|
||||
Storage::disk('local')->put($path, file_get_contents($image->getPathname()));
|
||||
|
||||
$attachment = \App\Models\Attachment::create([
|
||||
'thread_id' => $thread->id,
|
||||
'post_id' => null,
|
||||
'attachment_extension_id' => null,
|
||||
'attachment_group_id' => null,
|
||||
'user_id' => null,
|
||||
'disk' => 'local',
|
||||
'path' => $path,
|
||||
'original_name' => 'photo.jpg',
|
||||
'extension' => 'jpg',
|
||||
'mime_type' => 'image/jpeg',
|
||||
'size_bytes' => 1234,
|
||||
]);
|
||||
|
||||
$service = new AttachmentThumbnailService();
|
||||
$result = $service->createForAttachment($attachment, true);
|
||||
|
||||
expect($result)->not->toBeNull();
|
||||
Storage::disk('local')->assertExists($result['path']);
|
||||
});
|
||||
|
||||
it('returns null for attachments without stored files', function (): void {
|
||||
Storage::fake('local');
|
||||
|
||||
$category = \App\Models\Forum::create([
|
||||
'name' => 'Category',
|
||||
'description' => null,
|
||||
'type' => 'category',
|
||||
'parent_id' => null,
|
||||
'position' => 1,
|
||||
]);
|
||||
$forum = \App\Models\Forum::create([
|
||||
'name' => 'Forum',
|
||||
'description' => null,
|
||||
'type' => 'forum',
|
||||
'parent_id' => $category->id,
|
||||
'position' => 1,
|
||||
]);
|
||||
$thread = \App\Models\Thread::create([
|
||||
'forum_id' => $forum->id,
|
||||
'user_id' => null,
|
||||
'title' => 'Thread',
|
||||
'body' => 'Body',
|
||||
]);
|
||||
|
||||
$attachment = \App\Models\Attachment::create([
|
||||
'thread_id' => $thread->id,
|
||||
'post_id' => null,
|
||||
'attachment_extension_id' => null,
|
||||
'attachment_group_id' => null,
|
||||
'user_id' => null,
|
||||
'disk' => 'local',
|
||||
'path' => "attachments/threads/{$thread->id}/missing.jpg",
|
||||
'original_name' => 'missing.jpg',
|
||||
'extension' => 'jpg',
|
||||
'mime_type' => 'image/jpeg',
|
||||
'size_bytes' => 10,
|
||||
]);
|
||||
|
||||
$service = new AttachmentThumbnailService();
|
||||
$result = $service->createForAttachment($attachment, true);
|
||||
|
||||
expect($result)->toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when thumbnail already exists and not forcing', function (): void {
|
||||
Storage::fake('local');
|
||||
|
||||
$category = \App\Models\Forum::create([
|
||||
'name' => 'Category',
|
||||
'description' => null,
|
||||
'type' => 'category',
|
||||
'parent_id' => null,
|
||||
'position' => 1,
|
||||
]);
|
||||
$forum = \App\Models\Forum::create([
|
||||
'name' => 'Forum',
|
||||
'description' => null,
|
||||
'type' => 'forum',
|
||||
'parent_id' => $category->id,
|
||||
'position' => 1,
|
||||
]);
|
||||
$thread = \App\Models\Thread::create([
|
||||
'forum_id' => $forum->id,
|
||||
'user_id' => null,
|
||||
'title' => 'Thread',
|
||||
'body' => 'Body',
|
||||
]);
|
||||
|
||||
$attachment = \App\Models\Attachment::create([
|
||||
'thread_id' => $thread->id,
|
||||
'post_id' => null,
|
||||
'attachment_extension_id' => null,
|
||||
'attachment_group_id' => null,
|
||||
'user_id' => null,
|
||||
'disk' => 'local',
|
||||
'path' => "attachments/threads/{$thread->id}/photo.jpg",
|
||||
'thumbnail_path' => "attachments/threads/{$thread->id}/thumbs/existing.jpg",
|
||||
'original_name' => 'photo.jpg',
|
||||
'extension' => 'jpg',
|
||||
'mime_type' => 'image/jpeg',
|
||||
'size_bytes' => 10,
|
||||
]);
|
||||
|
||||
Storage::disk('local')->put($attachment->thumbnail_path, 'thumb');
|
||||
|
||||
$service = new AttachmentThumbnailService();
|
||||
$result = $service->createForAttachment($attachment, false);
|
||||
|
||||
expect($result)->toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for non-image attachments', function (): void {
|
||||
Storage::fake('local');
|
||||
|
||||
$category = \App\Models\Forum::create([
|
||||
'name' => 'Category',
|
||||
'description' => null,
|
||||
'type' => 'category',
|
||||
'parent_id' => null,
|
||||
'position' => 1,
|
||||
]);
|
||||
$forum = \App\Models\Forum::create([
|
||||
'name' => 'Forum',
|
||||
'description' => null,
|
||||
'type' => 'forum',
|
||||
'parent_id' => $category->id,
|
||||
'position' => 1,
|
||||
]);
|
||||
$thread = \App\Models\Thread::create([
|
||||
'forum_id' => $forum->id,
|
||||
'user_id' => null,
|
||||
'title' => 'Thread',
|
||||
'body' => 'Body',
|
||||
]);
|
||||
|
||||
$attachment = \App\Models\Attachment::create([
|
||||
'thread_id' => $thread->id,
|
||||
'post_id' => null,
|
||||
'attachment_extension_id' => null,
|
||||
'attachment_group_id' => null,
|
||||
'user_id' => null,
|
||||
'disk' => 'local',
|
||||
'path' => "attachments/threads/{$thread->id}/doc.pdf",
|
||||
'original_name' => 'doc.pdf',
|
||||
'extension' => 'pdf',
|
||||
'mime_type' => 'application/pdf',
|
||||
'size_bytes' => 10,
|
||||
]);
|
||||
|
||||
$service = new AttachmentThumbnailService();
|
||||
$result = $service->createForAttachment($attachment, true);
|
||||
|
||||
expect($result)->toBeNull();
|
||||
});
|
||||
|
||||
it('uses post scope when creating thumbnails', function (): void {
|
||||
if (!function_exists('imagecreatetruecolor')) {
|
||||
$this->markTestSkipped('GD extension not available.');
|
||||
}
|
||||
|
||||
Storage::fake('local');
|
||||
|
||||
Setting::updateOrCreate(['key' => 'attachments.create_thumbnails'], ['value' => 'true']);
|
||||
Setting::updateOrCreate(['key' => 'attachments.thumbnail_max_width'], ['value' => '100']);
|
||||
Setting::updateOrCreate(['key' => 'attachments.thumbnail_max_height'], ['value' => '100']);
|
||||
|
||||
$category = \App\Models\Forum::create([
|
||||
'name' => 'Category',
|
||||
'description' => null,
|
||||
'type' => 'category',
|
||||
'parent_id' => null,
|
||||
'position' => 1,
|
||||
]);
|
||||
$forum = \App\Models\Forum::create([
|
||||
'name' => 'Forum',
|
||||
'description' => null,
|
||||
'type' => 'forum',
|
||||
'parent_id' => $category->id,
|
||||
'position' => 1,
|
||||
]);
|
||||
$thread = \App\Models\Thread::create([
|
||||
'forum_id' => $forum->id,
|
||||
'user_id' => null,
|
||||
'title' => 'Thread',
|
||||
'body' => 'Body',
|
||||
]);
|
||||
$post = \App\Models\Post::create([
|
||||
'thread_id' => $thread->id,
|
||||
'user_id' => null,
|
||||
'body' => 'Post',
|
||||
]);
|
||||
|
||||
$image = UploadedFile::fake()->image('photo.png', 800, 600);
|
||||
$path = "attachments/posts/{$post->id}/photo.png";
|
||||
Storage::disk('local')->put($path, file_get_contents($image->getPathname()));
|
||||
|
||||
$attachment = \App\Models\Attachment::create([
|
||||
'thread_id' => null,
|
||||
'post_id' => $post->id,
|
||||
'attachment_extension_id' => null,
|
||||
'attachment_group_id' => null,
|
||||
'user_id' => null,
|
||||
'disk' => 'local',
|
||||
'path' => $path,
|
||||
'original_name' => 'photo.png',
|
||||
'extension' => 'png',
|
||||
'mime_type' => 'image/png',
|
||||
'size_bytes' => 1234,
|
||||
]);
|
||||
|
||||
$service = new AttachmentThumbnailService();
|
||||
$result = $service->createForAttachment($attachment, true);
|
||||
|
||||
expect($result)->not->toBeNull();
|
||||
expect($result['path'])->toContain("attachments/posts/{$post->id}/thumbs/");
|
||||
});
|
||||
|
||||
it('returns null when mime is unsupported even if image data is valid', function (): void {
|
||||
if (!function_exists('imagecreatetruecolor')) {
|
||||
$this->markTestSkipped('GD extension not available.');
|
||||
}
|
||||
|
||||
Storage::fake('local');
|
||||
|
||||
Setting::updateOrCreate(['key' => 'attachments.create_thumbnails'], ['value' => 'true']);
|
||||
Setting::updateOrCreate(['key' => 'attachments.thumbnail_max_width'], ['value' => '100']);
|
||||
Setting::updateOrCreate(['key' => 'attachments.thumbnail_max_height'], ['value' => '100']);
|
||||
|
||||
$category = \App\Models\Forum::create([
|
||||
'name' => 'Category',
|
||||
'description' => null,
|
||||
'type' => 'category',
|
||||
'parent_id' => null,
|
||||
'position' => 1,
|
||||
]);
|
||||
$forum = \App\Models\Forum::create([
|
||||
'name' => 'Forum',
|
||||
'description' => null,
|
||||
'type' => 'forum',
|
||||
'parent_id' => $category->id,
|
||||
'position' => 1,
|
||||
]);
|
||||
$thread = \App\Models\Thread::create([
|
||||
'forum_id' => $forum->id,
|
||||
'user_id' => null,
|
||||
'title' => 'Thread',
|
||||
'body' => 'Body',
|
||||
]);
|
||||
|
||||
$image = UploadedFile::fake()->image('photo.png', 800, 600);
|
||||
$path = "attachments/threads/{$thread->id}/photo.png";
|
||||
Storage::disk('local')->put($path, file_get_contents($image->getPathname()));
|
||||
|
||||
$attachment = \App\Models\Attachment::create([
|
||||
'thread_id' => $thread->id,
|
||||
'post_id' => null,
|
||||
'attachment_extension_id' => null,
|
||||
'attachment_group_id' => null,
|
||||
'user_id' => null,
|
||||
'disk' => 'local',
|
||||
'path' => $path,
|
||||
'original_name' => 'photo.png',
|
||||
'extension' => 'png',
|
||||
'mime_type' => 'image/avif',
|
||||
'size_bytes' => 1234,
|
||||
]);
|
||||
|
||||
$service = new AttachmentThumbnailService();
|
||||
$result = $service->createForAttachment($attachment, true);
|
||||
|
||||
expect($result)->toBeNull();
|
||||
});
|
||||
|
||||
it('creates thumbnails for gif images', function (): void {
|
||||
if (!function_exists('imagecreatetruecolor')) {
|
||||
$this->markTestSkipped('GD extension not available.');
|
||||
}
|
||||
|
||||
if (!function_exists('imagegif')) {
|
||||
$this->markTestSkipped('GIF support not available.');
|
||||
}
|
||||
|
||||
Storage::fake('local');
|
||||
|
||||
Setting::updateOrCreate(['key' => 'attachments.create_thumbnails'], ['value' => 'true']);
|
||||
Setting::updateOrCreate(['key' => 'attachments.thumbnail_max_width'], ['value' => '100']);
|
||||
Setting::updateOrCreate(['key' => 'attachments.thumbnail_max_height'], ['value' => '100']);
|
||||
|
||||
$service = new AttachmentThumbnailService();
|
||||
$file = UploadedFile::fake()->image('photo.gif', 800, 600);
|
||||
|
||||
$result = $service->createForUpload($file, 'threads/1', 'local');
|
||||
|
||||
expect($result)->not->toBeNull();
|
||||
Storage::disk('local')->assertExists($result['path']);
|
||||
});
|
||||
55
tests/Unit/AuditLogControllerUnitTest.php
Normal file
55
tests/Unit/AuditLogControllerUnitTest.php
Normal file
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
use App\Http\Controllers\AuditLogController;
|
||||
use App\Models\AuditLog;
|
||||
use App\Models\Role;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
it('returns unauthorized when no user', function (): void {
|
||||
$controller = new AuditLogController();
|
||||
$request = Request::create('/api/audit-logs', 'GET');
|
||||
$request->setUserResolver(fn () => null);
|
||||
|
||||
$response = $controller->index($request);
|
||||
|
||||
expect($response->getStatusCode())->toBe(401);
|
||||
});
|
||||
|
||||
it('returns forbidden when user is not admin', function (): void {
|
||||
$controller = new AuditLogController();
|
||||
$user = User::factory()->create();
|
||||
$request = Request::create('/api/audit-logs', 'GET');
|
||||
$request->setUserResolver(fn () => $user);
|
||||
|
||||
$response = $controller->index($request);
|
||||
|
||||
expect($response->getStatusCode())->toBe(403);
|
||||
});
|
||||
|
||||
it('returns logs for admin', function (): void {
|
||||
$controller = new AuditLogController();
|
||||
$admin = User::factory()->create();
|
||||
$role = Role::create(['name' => 'ROLE_ADMIN']);
|
||||
$admin->roles()->attach($role);
|
||||
|
||||
AuditLog::create([
|
||||
'action' => 'test.action',
|
||||
'subject_type' => 'post',
|
||||
'subject_id' => 1,
|
||||
'metadata' => ['foo' => 'bar'],
|
||||
'ip_address' => '127.0.0.1',
|
||||
'user_agent' => 'test',
|
||||
'user_id' => $admin->id,
|
||||
]);
|
||||
|
||||
$request = Request::create('/api/audit-logs', 'GET');
|
||||
$request->setUserResolver(fn () => $admin);
|
||||
|
||||
$response = $controller->index($request);
|
||||
|
||||
expect($response->getStatusCode())->toBe(200);
|
||||
$payload = $response->getData(true);
|
||||
expect($payload)->toHaveCount(1);
|
||||
expect($payload[0]['user']['roles'][0])->toBe('ROLE_ADMIN');
|
||||
});
|
||||
66
tests/Unit/AuditLoggerTest.php
Normal file
66
tests/Unit/AuditLoggerTest.php
Normal file
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
use App\Models\AuditLog;
|
||||
use App\Models\User;
|
||||
use App\Services\AuditLogger;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
it('creates audit log with actor and subject', function (): void {
|
||||
$user = User::factory()->create();
|
||||
$subject = User::factory()->create();
|
||||
|
||||
$request = Request::create('/api/test', 'POST');
|
||||
$request->headers->set('User-Agent', 'phpunit');
|
||||
$request->server->set('REMOTE_ADDR', '127.0.0.1');
|
||||
|
||||
$logger = new AuditLogger();
|
||||
$result = $logger->log(
|
||||
$request,
|
||||
'user.updated',
|
||||
$subject,
|
||||
['field' => 'name'],
|
||||
$user
|
||||
);
|
||||
|
||||
expect($result)->toBeInstanceOf(AuditLog::class);
|
||||
$this->assertDatabaseHas('audit_logs', [
|
||||
'user_id' => $user->id,
|
||||
'action' => 'user.updated',
|
||||
'subject_type' => $subject::class,
|
||||
'subject_id' => $subject->id,
|
||||
'ip_address' => '127.0.0.1',
|
||||
'user_agent' => 'phpunit',
|
||||
]);
|
||||
});
|
||||
|
||||
it('handles missing user', function (): void {
|
||||
$request = Request::create('/api/test', 'POST');
|
||||
$request->headers->set('User-Agent', 'phpunit');
|
||||
$request->server->set('REMOTE_ADDR', '127.0.0.1');
|
||||
|
||||
$logger = new AuditLogger();
|
||||
$result = $logger->log($request, 'system.ping');
|
||||
|
||||
expect($result)->toBeInstanceOf(AuditLog::class);
|
||||
$this->assertDatabaseHas('audit_logs', [
|
||||
'action' => 'system.ping',
|
||||
'user_id' => null,
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns null when logging fails', function (): void {
|
||||
AuditLog::creating(function () {
|
||||
throw new RuntimeException('fail');
|
||||
});
|
||||
|
||||
$request = Request::create('/api/test', 'POST');
|
||||
$request->headers->set('User-Agent', 'phpunit');
|
||||
$request->server->set('REMOTE_ADDR', '127.0.0.1');
|
||||
|
||||
$logger = new AuditLogger();
|
||||
$result = $logger->log($request, 'system.fail');
|
||||
|
||||
expect($result)->toBeNull();
|
||||
|
||||
AuditLog::flushEventListeners();
|
||||
});
|
||||
109
tests/Unit/BbcodeFormatterTest.php
Normal file
109
tests/Unit/BbcodeFormatterTest.php
Normal file
@@ -0,0 +1,109 @@
|
||||
<?php
|
||||
|
||||
use App\Actions\BbcodeFormatter;
|
||||
|
||||
it('returns empty string for null and empty input', function (): void {
|
||||
expect(BbcodeFormatter::format(null))->toBe('');
|
||||
expect(BbcodeFormatter::format(''))->toBe('');
|
||||
});
|
||||
|
||||
it('formats bbcode content', function (): void {
|
||||
$parserProp = new ReflectionProperty(BbcodeFormatter::class, 'parser');
|
||||
$parserProp->setAccessible(true);
|
||||
$parserProp->setValue(
|
||||
\Mockery::mock(\s9e\TextFormatter\Parser::class)
|
||||
->shouldReceive('parse')
|
||||
->andReturn('<r/>')
|
||||
->getMock()
|
||||
);
|
||||
|
||||
$rendererProp = new ReflectionProperty(BbcodeFormatter::class, 'renderer');
|
||||
$rendererProp->setAccessible(true);
|
||||
$rendererProp->setValue(
|
||||
\Mockery::mock(\s9e\TextFormatter\Renderer::class)
|
||||
->shouldReceive('render')
|
||||
->andReturn('<b>Bold</b>')
|
||||
->getMock()
|
||||
);
|
||||
|
||||
$html = BbcodeFormatter::format('[b]Bold[/b]');
|
||||
|
||||
expect($html)->toContain('<b>');
|
||||
});
|
||||
|
||||
it('initializes parser and renderer when not set', function (): void {
|
||||
$parserProp = new ReflectionProperty(BbcodeFormatter::class, 'parser');
|
||||
$parserProp->setAccessible(true);
|
||||
$parserProp->setValue(null);
|
||||
|
||||
$rendererProp = new ReflectionProperty(BbcodeFormatter::class, 'renderer');
|
||||
$rendererProp->setAccessible(true);
|
||||
$rendererProp->setValue(null);
|
||||
|
||||
$html = BbcodeFormatter::format('[b]Bold[/b]');
|
||||
|
||||
expect($html)->toBeString();
|
||||
expect($parserProp->getValue())->not->toBeNull();
|
||||
expect($rendererProp->getValue())->not->toBeNull();
|
||||
});
|
||||
|
||||
it('build returns parser and renderer', function (): void {
|
||||
putenv('BBCODE_FORCE_FAIL');
|
||||
unset($_ENV['BBCODE_FORCE_FAIL'], $_SERVER['BBCODE_FORCE_FAIL']);
|
||||
|
||||
$ref = new ReflectionMethod(BbcodeFormatter::class, 'build');
|
||||
$ref->setAccessible(true);
|
||||
|
||||
$result = $ref->invoke(null);
|
||||
|
||||
expect($result)->toBeArray();
|
||||
expect($result)->toHaveCount(2);
|
||||
expect($result[0])->toBeInstanceOf(\s9e\TextFormatter\Parser::class);
|
||||
expect($result[1])->toBeInstanceOf(\s9e\TextFormatter\Renderer::class);
|
||||
});
|
||||
|
||||
it('formats with real build when parser is reset', function (): void {
|
||||
putenv('BBCODE_FORCE_FAIL');
|
||||
unset($_ENV['BBCODE_FORCE_FAIL'], $_SERVER['BBCODE_FORCE_FAIL']);
|
||||
|
||||
$parserProp = new ReflectionProperty(BbcodeFormatter::class, 'parser');
|
||||
$parserProp->setAccessible(true);
|
||||
$parserProp->setValue(null);
|
||||
|
||||
$rendererProp = new ReflectionProperty(BbcodeFormatter::class, 'renderer');
|
||||
$rendererProp->setAccessible(true);
|
||||
$rendererProp->setValue(null);
|
||||
|
||||
$html = BbcodeFormatter::format('[b]Bold[/b]');
|
||||
expect($html)->toBeString();
|
||||
expect($parserProp->getValue())->not->toBeNull();
|
||||
expect($rendererProp->getValue())->not->toBeNull();
|
||||
});
|
||||
|
||||
it('throws when bbcode formatter cannot initialize', function (): void {
|
||||
putenv('BBCODE_FORCE_FAIL=1');
|
||||
$_ENV['BBCODE_FORCE_FAIL'] = '1';
|
||||
$_SERVER['BBCODE_FORCE_FAIL'] = '1';
|
||||
|
||||
$parserProp = new ReflectionProperty(BbcodeFormatter::class, 'parser');
|
||||
$parserProp->setAccessible(true);
|
||||
$parserProp->setValue(null);
|
||||
|
||||
$rendererProp = new ReflectionProperty(BbcodeFormatter::class, 'renderer');
|
||||
$rendererProp->setAccessible(true);
|
||||
$rendererProp->setValue(null);
|
||||
|
||||
try {
|
||||
BbcodeFormatter::format('test');
|
||||
$this->fail('Expected exception not thrown.');
|
||||
} catch (Throwable $e) {
|
||||
expect($e)->toBeInstanceOf(RuntimeException::class);
|
||||
} finally {
|
||||
putenv('BBCODE_FORCE_FAIL');
|
||||
unset($_ENV['BBCODE_FORCE_FAIL'], $_SERVER['BBCODE_FORCE_FAIL']);
|
||||
}
|
||||
});
|
||||
|
||||
afterEach(function (): void {
|
||||
\Mockery::close();
|
||||
});
|
||||
50
tests/Unit/ConsoleCommandTest.php
Normal file
50
tests/Unit/ConsoleCommandTest.php
Normal file
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Setting;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
it('version bump fails when no version', function (): void {
|
||||
Setting::where('key', 'version')->delete();
|
||||
$exitCode = \Illuminate\Support\Facades\Artisan::call('version:bump');
|
||||
expect($exitCode)->toBe(1);
|
||||
});
|
||||
|
||||
it('version bump fails when invalid version', function (): void {
|
||||
Setting::updateOrCreate(['key' => 'version'], ['value' => 'bad']);
|
||||
$exitCode = \Illuminate\Support\Facades\Artisan::call('version:bump');
|
||||
expect($exitCode)->toBe(1);
|
||||
});
|
||||
|
||||
it('version set fails when invalid version', function (): void {
|
||||
$exitCode = \Illuminate\Support\Facades\Artisan::call('version:set', ['version' => 'bad']);
|
||||
expect($exitCode)->toBe(1);
|
||||
});
|
||||
|
||||
it('version fetch fails when no version', function (): void {
|
||||
Setting::where('key', 'version')->delete();
|
||||
$exitCode = \Illuminate\Support\Facades\Artisan::call('version:fetch');
|
||||
expect($exitCode)->toBe(1);
|
||||
});
|
||||
|
||||
it('version release fails when missing config', function (): void {
|
||||
Setting::updateOrCreate(['key' => 'version'], ['value' => '1.2.3']);
|
||||
putenv('GITEA_TOKEN');
|
||||
putenv('GITEA_OWNER');
|
||||
putenv('GITEA_REPO');
|
||||
$exitCode = \Illuminate\Support\Facades\Artisan::call('version:release');
|
||||
expect($exitCode)->toBe(1);
|
||||
});
|
||||
|
||||
it('version release handles create failure', function (): void {
|
||||
Setting::updateOrCreate(['key' => 'version'], ['value' => '1.2.3']);
|
||||
putenv('GITEA_TOKEN=token');
|
||||
putenv('GITEA_OWNER=owner');
|
||||
putenv('GITEA_REPO=repo');
|
||||
|
||||
Http::fake([
|
||||
'*' => Http::response([], 500),
|
||||
]);
|
||||
|
||||
$exitCode = \Illuminate\Support\Facades\Artisan::call('version:release');
|
||||
expect($exitCode)->toBe(1);
|
||||
});
|
||||
159
tests/Unit/CronRunCommandTest.php
Normal file
159
tests/Unit/CronRunCommandTest.php
Normal file
@@ -0,0 +1,159 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Attachment;
|
||||
use App\Services\AttachmentThumbnailService;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
beforeEach(function (): void {
|
||||
Storage::fake('local');
|
||||
});
|
||||
|
||||
it('skips non-image attachments', function (): void {
|
||||
Attachment::create([
|
||||
'thread_id' => null,
|
||||
'post_id' => null,
|
||||
'attachment_extension_id' => null,
|
||||
'attachment_group_id' => null,
|
||||
'user_id' => null,
|
||||
'disk' => 'local',
|
||||
'path' => 'attachments/test.txt',
|
||||
'original_name' => 'test.txt',
|
||||
'extension' => 'txt',
|
||||
'mime_type' => 'text/plain',
|
||||
'size_bytes' => 10,
|
||||
]);
|
||||
|
||||
$exitCode = Artisan::call('speedbb:cron');
|
||||
expect($exitCode)->toBe(0);
|
||||
});
|
||||
|
||||
it('counts missing files for images', function (): void {
|
||||
Attachment::create([
|
||||
'thread_id' => null,
|
||||
'post_id' => null,
|
||||
'attachment_extension_id' => null,
|
||||
'attachment_group_id' => null,
|
||||
'user_id' => null,
|
||||
'disk' => 'local',
|
||||
'path' => 'attachments/missing.jpg',
|
||||
'original_name' => 'missing.jpg',
|
||||
'extension' => 'jpg',
|
||||
'mime_type' => 'image/jpeg',
|
||||
'size_bytes' => 10,
|
||||
]);
|
||||
|
||||
$exitCode = Artisan::call('speedbb:cron');
|
||||
expect($exitCode)->toBe(0);
|
||||
});
|
||||
|
||||
it('skips when thumbnail already exists', function (): void {
|
||||
Storage::disk('local')->put('attachments/photo.jpg', 'image');
|
||||
Storage::disk('local')->put('attachments/thumb.jpg', 'thumb');
|
||||
|
||||
Attachment::create([
|
||||
'thread_id' => null,
|
||||
'post_id' => null,
|
||||
'attachment_extension_id' => null,
|
||||
'attachment_group_id' => null,
|
||||
'user_id' => null,
|
||||
'disk' => 'local',
|
||||
'path' => 'attachments/photo.jpg',
|
||||
'thumbnail_path' => 'attachments/thumb.jpg',
|
||||
'original_name' => 'photo.jpg',
|
||||
'extension' => 'jpg',
|
||||
'mime_type' => 'image/jpeg',
|
||||
'size_bytes' => 10,
|
||||
]);
|
||||
|
||||
$exitCode = Artisan::call('speedbb:cron');
|
||||
expect($exitCode)->toBe(0);
|
||||
});
|
||||
|
||||
it('creates thumbnails in dry run mode', function (): void {
|
||||
Storage::disk('local')->put('attachments/photo.jpg', 'image');
|
||||
|
||||
Attachment::create([
|
||||
'thread_id' => null,
|
||||
'post_id' => null,
|
||||
'attachment_extension_id' => null,
|
||||
'attachment_group_id' => null,
|
||||
'user_id' => null,
|
||||
'disk' => 'local',
|
||||
'path' => 'attachments/photo.jpg',
|
||||
'original_name' => 'photo.jpg',
|
||||
'extension' => 'jpg',
|
||||
'mime_type' => 'image/jpeg',
|
||||
'size_bytes' => 10,
|
||||
]);
|
||||
|
||||
$exitCode = Artisan::call('speedbb:cron', ['--dry-run' => true]);
|
||||
expect($exitCode)->toBe(0);
|
||||
});
|
||||
|
||||
it('forces thumbnail regeneration and updates attachment when created', function (): void {
|
||||
Storage::disk('local')->put('attachments/photo.jpg', 'image');
|
||||
Storage::disk('local')->put('attachments/thumb-old.jpg', 'old');
|
||||
|
||||
$attachment = Attachment::create([
|
||||
'thread_id' => null,
|
||||
'post_id' => null,
|
||||
'attachment_extension_id' => null,
|
||||
'attachment_group_id' => null,
|
||||
'user_id' => null,
|
||||
'disk' => 'local',
|
||||
'path' => 'attachments/photo.jpg',
|
||||
'thumbnail_path' => 'attachments/thumb-old.jpg',
|
||||
'original_name' => 'photo.jpg',
|
||||
'extension' => 'jpg',
|
||||
'mime_type' => 'image/jpeg',
|
||||
'size_bytes' => 10,
|
||||
]);
|
||||
|
||||
$service = Mockery::mock(AttachmentThumbnailService::class);
|
||||
$service->shouldReceive('createForAttachment')
|
||||
->once()
|
||||
->andReturn([
|
||||
'path' => 'attachments/thumb-new.jpg',
|
||||
'mime' => 'image/jpeg',
|
||||
'size' => 123,
|
||||
]);
|
||||
|
||||
app()->instance(AttachmentThumbnailService::class, $service);
|
||||
|
||||
$exitCode = Artisan::call('speedbb:cron', ['--force' => true]);
|
||||
expect($exitCode)->toBe(0);
|
||||
|
||||
$attachment->refresh();
|
||||
expect($attachment->thumbnail_path)->toBe('attachments/thumb-new.jpg');
|
||||
expect($attachment->thumbnail_size_bytes)->toBe(123);
|
||||
});
|
||||
|
||||
it('skips when thumbnail creation fails', function (): void {
|
||||
Storage::disk('local')->put('attachments/photo.jpg', 'image');
|
||||
|
||||
Attachment::create([
|
||||
'thread_id' => null,
|
||||
'post_id' => null,
|
||||
'attachment_extension_id' => null,
|
||||
'attachment_group_id' => null,
|
||||
'user_id' => null,
|
||||
'disk' => 'local',
|
||||
'path' => 'attachments/photo.jpg',
|
||||
'original_name' => 'photo.jpg',
|
||||
'extension' => 'jpg',
|
||||
'mime_type' => 'image/jpeg',
|
||||
'size_bytes' => 10,
|
||||
]);
|
||||
|
||||
$service = Mockery::mock(AttachmentThumbnailService::class);
|
||||
$service->shouldReceive('createForAttachment')->once()->andReturnNull();
|
||||
app()->instance(AttachmentThumbnailService::class, $service);
|
||||
|
||||
$exitCode = Artisan::call('speedbb:cron');
|
||||
expect($exitCode)->toBe(0);
|
||||
});
|
||||
|
||||
afterEach(function (): void {
|
||||
Mockery::close();
|
||||
});
|
||||
34
tests/Unit/FortifyServiceProviderTest.php
Normal file
34
tests/Unit/FortifyServiceProviderTest.php
Normal file
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
use App\Providers\FortifyServiceProvider;
|
||||
use Illuminate\Cache\RateLimiting\Limit;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Session\Store;
|
||||
use Illuminate\Session\ArraySessionHandler;
|
||||
use Laravel\Fortify\Fortify;
|
||||
|
||||
it('registers rate limiters for login and two-factor', function (): void {
|
||||
(new FortifyServiceProvider(app()))->boot();
|
||||
|
||||
$loginLimiter = RateLimiter::limiter('login');
|
||||
$twoFactorLimiter = RateLimiter::limiter('two-factor');
|
||||
|
||||
$request = Request::create('/login', 'POST', [
|
||||
Fortify::username() => 'Test@Example.com',
|
||||
]);
|
||||
$request->server->set('REMOTE_ADDR', '127.0.0.1');
|
||||
$request->setLaravelSession(new Store('test', new ArraySessionHandler(60)));
|
||||
$request->session()->put('login.id', 'login-id');
|
||||
|
||||
$loginLimit = $loginLimiter($request);
|
||||
|
||||
expect($loginLimit)->toBeInstanceOf(Limit::class);
|
||||
expect($loginLimit->maxAttempts)->toBe(5);
|
||||
expect($loginLimit->key)->toBe(Str::transliterate('test@example.com|127.0.0.1'));
|
||||
|
||||
$twoFactorLimit = $twoFactorLimiter($request);
|
||||
expect($twoFactorLimit)->toBeInstanceOf(Limit::class);
|
||||
expect($twoFactorLimit->maxAttempts)->toBe(5);
|
||||
});
|
||||
114
tests/Unit/ForumControllerUnitTest.php
Normal file
114
tests/Unit/ForumControllerUnitTest.php
Normal file
@@ -0,0 +1,114 @@
|
||||
<?php
|
||||
|
||||
use App\Http\Controllers\ForumController;
|
||||
use App\Models\Forum;
|
||||
use App\Models\Post;
|
||||
use App\Models\Role;
|
||||
use App\Models\Thread;
|
||||
use App\Models\User;
|
||||
|
||||
it('parseIriId handles null and numeric', function (): void {
|
||||
$controller = new ForumController();
|
||||
$ref = new ReflectionMethod($controller, 'parseIriId');
|
||||
$ref->setAccessible(true);
|
||||
|
||||
expect($ref->invoke($controller, null))->toBeNull();
|
||||
expect($ref->invoke($controller, '/forums/12'))->toBe(12);
|
||||
expect($ref->invoke($controller, '7'))->toBe(7);
|
||||
expect($ref->invoke($controller, 'abc'))->toBeNull();
|
||||
});
|
||||
|
||||
it('loadLastPostsByForum returns empty for no ids', function (): void {
|
||||
$controller = new ForumController();
|
||||
$ref = new ReflectionMethod($controller, 'loadLastPostsByForum');
|
||||
$ref->setAccessible(true);
|
||||
|
||||
expect($ref->invoke($controller, []))->toBe([]);
|
||||
});
|
||||
|
||||
it('resolveGroupColor returns null for missing roles', function (): void {
|
||||
$controller = new ForumController();
|
||||
$user = User::factory()->create();
|
||||
$user->setRelation('roles', null);
|
||||
|
||||
$ref = new ReflectionMethod($controller, 'resolveGroupColor');
|
||||
$ref->setAccessible(true);
|
||||
|
||||
expect($ref->invoke($controller, null))->toBeNull();
|
||||
expect($ref->invoke($controller, $user))->toBeNull();
|
||||
});
|
||||
|
||||
it('resolveGroupColor returns first sorted role color', function (): void {
|
||||
$controller = new ForumController();
|
||||
$user = User::factory()->create();
|
||||
$roleB = Role::create(['name' => 'ROLE_B', 'color' => '#bbbbbb']);
|
||||
$roleA = Role::create(['name' => 'ROLE_A', 'color' => '#aaaaaa']);
|
||||
$user->roles()->attach([$roleB->id, $roleA->id]);
|
||||
$user->load('roles');
|
||||
|
||||
$ref = new ReflectionMethod($controller, 'resolveGroupColor');
|
||||
$ref->setAccessible(true);
|
||||
|
||||
expect($ref->invoke($controller, $user))->toBe('#aaaaaa');
|
||||
});
|
||||
|
||||
it('resolveGroupColor returns null when roles have no colors', function (): void {
|
||||
$controller = new ForumController();
|
||||
$user = User::factory()->create();
|
||||
$role = Role::create(['name' => 'ROLE_EMPTY', 'color' => null]);
|
||||
$user->roles()->attach($role);
|
||||
$user->load('roles');
|
||||
|
||||
$ref = new ReflectionMethod($controller, 'resolveGroupColor');
|
||||
$ref->setAccessible(true);
|
||||
|
||||
expect($ref->invoke($controller, $user))->toBeNull();
|
||||
});
|
||||
|
||||
it('loadLastPostsByForum returns latest post per forum', function (): void {
|
||||
$controller = new ForumController();
|
||||
$ref = new ReflectionMethod($controller, 'loadLastPostsByForum');
|
||||
$ref->setAccessible(true);
|
||||
|
||||
$category = Forum::create([
|
||||
'name' => 'Category U',
|
||||
'description' => null,
|
||||
'type' => 'category',
|
||||
'parent_id' => null,
|
||||
'position' => 1,
|
||||
]);
|
||||
$forum = Forum::create([
|
||||
'name' => 'Forum U',
|
||||
'description' => null,
|
||||
'type' => 'forum',
|
||||
'parent_id' => $category->id,
|
||||
'position' => 1,
|
||||
]);
|
||||
$thread = Thread::create([
|
||||
'forum_id' => $forum->id,
|
||||
'user_id' => null,
|
||||
'title' => 'Thread',
|
||||
'body' => 'Body',
|
||||
]);
|
||||
$older = Post::create([
|
||||
'thread_id' => $thread->id,
|
||||
'user_id' => null,
|
||||
'body' => 'Old',
|
||||
]);
|
||||
$newer = Post::create([
|
||||
'thread_id' => $thread->id,
|
||||
'user_id' => null,
|
||||
'body' => 'New',
|
||||
]);
|
||||
Post::whereKey($older->id)->update([
|
||||
'created_at' => now()->subDay(),
|
||||
'updated_at' => now()->subDay(),
|
||||
]);
|
||||
Post::whereKey($newer->id)->update([
|
||||
'created_at' => now()->addSeconds(10),
|
||||
'updated_at' => now()->addSeconds(10),
|
||||
]);
|
||||
|
||||
$result = $ref->invoke($controller, [$forum->id]);
|
||||
expect($result[$forum->id]->id)->toBe($newer->id);
|
||||
});
|
||||
74
tests/Unit/ForumModelTest.php
Normal file
74
tests/Unit/ForumModelTest.php
Normal file
@@ -0,0 +1,74 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Forum;
|
||||
use App\Models\Post;
|
||||
use App\Models\Thread;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Carbon;
|
||||
|
||||
it('exposes forum relationships and latest helpers', function (): void {
|
||||
$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' => 2,
|
||||
]);
|
||||
|
||||
$child = Forum::create([
|
||||
'name' => 'Child',
|
||||
'description' => null,
|
||||
'type' => 'forum',
|
||||
'parent_id' => $forum->id,
|
||||
'position' => 1,
|
||||
]);
|
||||
|
||||
$user = User::factory()->create();
|
||||
|
||||
$threadOld = Thread::create([
|
||||
'forum_id' => $forum->id,
|
||||
'user_id' => $user->id,
|
||||
'title' => 'Old Thread',
|
||||
'body' => 'Old',
|
||||
'created_at' => Carbon::now()->subDays(2),
|
||||
]);
|
||||
|
||||
$threadNew = Thread::create([
|
||||
'forum_id' => $forum->id,
|
||||
'user_id' => $user->id,
|
||||
'title' => 'New Thread',
|
||||
'body' => 'New',
|
||||
'created_at' => Carbon::now()->subDay(),
|
||||
]);
|
||||
|
||||
$postOld = Post::create([
|
||||
'thread_id' => $threadOld->id,
|
||||
'user_id' => $user->id,
|
||||
'body' => 'Old post',
|
||||
'created_at' => Carbon::now()->subDays(2),
|
||||
]);
|
||||
|
||||
$postNew = Post::create([
|
||||
'thread_id' => $threadNew->id,
|
||||
'user_id' => $user->id,
|
||||
'body' => 'New post',
|
||||
'created_at' => Carbon::now()->subDay(),
|
||||
]);
|
||||
|
||||
$forum->load(['parent', 'children', 'threads', 'posts', 'latestThread', 'latestPost']);
|
||||
|
||||
expect($forum->parent?->id)->toBe($category->id);
|
||||
expect($forum->children->first()->id)->toBe($child->id);
|
||||
expect($forum->threads)->toHaveCount(2);
|
||||
expect($forum->posts)->toHaveCount(2);
|
||||
expect($forum->latestThread?->id)->toBe($threadNew->id);
|
||||
expect($forum->latestPost?->id)->toBe($postNew->id);
|
||||
});
|
||||
146
tests/Unit/InstallerControllerTest.php
Normal file
146
tests/Unit/InstallerControllerTest.php
Normal file
@@ -0,0 +1,146 @@
|
||||
<?php
|
||||
|
||||
use App\Http\Controllers\InstallerController;
|
||||
use App\Models\Role;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
function withEnvBackup(callable $callback): void
|
||||
{
|
||||
$path = base_path('.env');
|
||||
$hadEnv = file_exists($path);
|
||||
$original = $hadEnv ? file_get_contents($path) : null;
|
||||
|
||||
try {
|
||||
$callback($path);
|
||||
} finally {
|
||||
if ($hadEnv) {
|
||||
file_put_contents($path, (string) $original);
|
||||
} elseif (file_exists($path)) {
|
||||
unlink($path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function installerRequest(array $overrides = []): Request
|
||||
{
|
||||
$db = config('database.connections.mysql');
|
||||
|
||||
$data = array_merge([
|
||||
'app_url' => 'https://example.test',
|
||||
'db_host' => $db['host'] ?? '127.0.0.1',
|
||||
'db_port' => $db['port'] ?? 3306,
|
||||
'db_database' => $db['database'] ?? 'tracer_speedBB_test',
|
||||
'db_username' => $db['username'] ?? 'root',
|
||||
'db_password' => $db['password'] ?? '',
|
||||
'admin_name' => 'Admin',
|
||||
'admin_email' => 'admin@example.com',
|
||||
'admin_password' => 'Password123!',
|
||||
], $overrides);
|
||||
|
||||
return Request::create('https://example.test/install', 'POST', $data);
|
||||
}
|
||||
|
||||
it('shows installer when env missing', function (): void {
|
||||
withEnvBackup(function (): void {
|
||||
if (file_exists(base_path('.env'))) {
|
||||
unlink(base_path('.env'));
|
||||
}
|
||||
|
||||
$controller = new InstallerController();
|
||||
$request = Request::create('https://example.test/install', 'GET');
|
||||
|
||||
$response = $controller->show($request);
|
||||
expect($response)->toBeInstanceOf(Illuminate\View\View::class);
|
||||
});
|
||||
});
|
||||
|
||||
it('redirects installer when env exists', function (): void {
|
||||
withEnvBackup(function (): void {
|
||||
file_put_contents(base_path('.env'), "APP_KEY=base64:test\n");
|
||||
|
||||
$controller = new InstallerController();
|
||||
$request = Request::create('https://example.test/install', 'GET');
|
||||
|
||||
$response = $controller->show($request);
|
||||
expect($response)->toBeInstanceOf(Illuminate\Http\RedirectResponse::class);
|
||||
});
|
||||
});
|
||||
|
||||
it('store redirects when env exists', function (): void {
|
||||
withEnvBackup(function (): void {
|
||||
file_put_contents(base_path('.env'), "APP_KEY=base64:test\n");
|
||||
|
||||
$controller = new InstallerController();
|
||||
$request = installerRequest();
|
||||
|
||||
$response = $controller->store($request);
|
||||
expect($response)->toBeInstanceOf(Illuminate\Http\RedirectResponse::class);
|
||||
});
|
||||
});
|
||||
|
||||
it('store handles db connection failure', function (): void {
|
||||
withEnvBackup(function (): void {
|
||||
if (file_exists(base_path('.env'))) {
|
||||
unlink(base_path('.env'));
|
||||
}
|
||||
|
||||
DB::shouldReceive('purge')->once();
|
||||
DB::shouldReceive('connection->getPdo')->andThrow(new RuntimeException('boom'));
|
||||
|
||||
$controller = new InstallerController();
|
||||
$request = installerRequest(['app_url' => 'https://example.test']);
|
||||
|
||||
$response = $controller->store($request);
|
||||
expect($response)->toBeInstanceOf(Illuminate\View\View::class);
|
||||
expect(file_exists(base_path('.env')))->toBeFalse();
|
||||
});
|
||||
});
|
||||
|
||||
it('store handles migration failure', function (): void {
|
||||
withEnvBackup(function (): void {
|
||||
if (file_exists(base_path('.env'))) {
|
||||
unlink(base_path('.env'));
|
||||
}
|
||||
|
||||
DB::shouldReceive('purge')->once();
|
||||
DB::shouldReceive('connection->getPdo')->andReturn(true);
|
||||
Artisan::shouldReceive('call')->andReturn(1);
|
||||
|
||||
$controller = new InstallerController();
|
||||
$request = installerRequest();
|
||||
|
||||
$response = $controller->store($request);
|
||||
expect($response)->toBeInstanceOf(Illuminate\View\View::class);
|
||||
expect(file_exists(base_path('.env')))->toBeFalse();
|
||||
});
|
||||
});
|
||||
|
||||
it('store completes installation on success', function (): void {
|
||||
withEnvBackup(function (): void {
|
||||
if (file_exists(base_path('.env'))) {
|
||||
unlink(base_path('.env'));
|
||||
}
|
||||
|
||||
DB::shouldReceive('purge')->once();
|
||||
DB::shouldReceive('connection->getPdo')->andReturn(true);
|
||||
Artisan::shouldReceive('call')->andReturn(0);
|
||||
|
||||
$controller = new InstallerController();
|
||||
$request = installerRequest(['admin_email' => 'success@example.com']);
|
||||
|
||||
$response = $controller->store($request);
|
||||
expect($response)->toBeInstanceOf(Illuminate\View\View::class);
|
||||
|
||||
$user = User::where('email', 'success@example.com')->first();
|
||||
expect($user)->not->toBeNull();
|
||||
expect(Role::where('name', 'ROLE_ADMIN')->exists())->toBeTrue();
|
||||
expect(Role::where('name', 'ROLE_FOUNDER')->exists())->toBeTrue();
|
||||
|
||||
if (file_exists(base_path('.env'))) {
|
||||
unlink(base_path('.env'));
|
||||
}
|
||||
});
|
||||
});
|
||||
353
tests/Unit/PostControllerUnitTest.php
Normal file
353
tests/Unit/PostControllerUnitTest.php
Normal file
@@ -0,0 +1,353 @@
|
||||
<?php
|
||||
|
||||
use App\Http\Controllers\PostController;
|
||||
use App\Models\Attachment;
|
||||
use App\Models\AttachmentGroup;
|
||||
use App\Models\Forum;
|
||||
use App\Models\Post;
|
||||
use App\Models\Rank;
|
||||
use App\Models\Role;
|
||||
use App\Models\Setting;
|
||||
use App\Models\Thread;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
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 makeForumForPostController(): 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('returns unauthorized on update when no user', function (): void {
|
||||
$controller = new PostController();
|
||||
$forum = makeForumForPostController();
|
||||
$thread = Thread::create([
|
||||
'forum_id' => $forum->id,
|
||||
'user_id' => null,
|
||||
'title' => 'Thread',
|
||||
'body' => 'Body',
|
||||
]);
|
||||
$post = Post::create([
|
||||
'thread_id' => $thread->id,
|
||||
'user_id' => null,
|
||||
'body' => 'Body',
|
||||
]);
|
||||
|
||||
$request = Request::create('/api/posts/'.$post->id, 'PATCH', ['body' => 'x']);
|
||||
$request->setUserResolver(fn () => null);
|
||||
|
||||
$response = $controller->update($request, $post);
|
||||
|
||||
expect($response->getStatusCode())->toBe(401);
|
||||
});
|
||||
|
||||
it('returns forbidden on update when user is not owner or admin', function (): void {
|
||||
$controller = new PostController();
|
||||
$forum = makeForumForPostController();
|
||||
$owner = User::factory()->create();
|
||||
$viewer = User::factory()->create();
|
||||
$thread = Thread::create([
|
||||
'forum_id' => $forum->id,
|
||||
'user_id' => $owner->id,
|
||||
'title' => 'Thread',
|
||||
'body' => 'Body',
|
||||
]);
|
||||
$post = Post::create([
|
||||
'thread_id' => $thread->id,
|
||||
'user_id' => $owner->id,
|
||||
'body' => 'Body',
|
||||
]);
|
||||
|
||||
$request = Request::create('/api/posts/'.$post->id, 'PATCH', ['body' => 'x']);
|
||||
$request->setUserResolver(fn () => $viewer);
|
||||
|
||||
$response = $controller->update($request, $post);
|
||||
|
||||
expect($response->getStatusCode())->toBe(403);
|
||||
});
|
||||
|
||||
it('updates post when user is owner', function (): void {
|
||||
$controller = new PostController();
|
||||
$forum = makeForumForPostController();
|
||||
$owner = User::factory()->create();
|
||||
$thread = Thread::create([
|
||||
'forum_id' => $forum->id,
|
||||
'user_id' => $owner->id,
|
||||
'title' => 'Thread',
|
||||
'body' => 'Body',
|
||||
]);
|
||||
$post = Post::create([
|
||||
'thread_id' => $thread->id,
|
||||
'user_id' => $owner->id,
|
||||
'body' => 'Body',
|
||||
]);
|
||||
|
||||
$request = Request::create('/api/posts/'.$post->id, 'PATCH', ['body' => 'Updated']);
|
||||
$request->setUserResolver(fn () => $owner);
|
||||
|
||||
$response = $controller->update($request, $post);
|
||||
|
||||
expect($response->getStatusCode())->toBe(200);
|
||||
$post->refresh();
|
||||
expect($post->body)->toBe('Updated');
|
||||
});
|
||||
|
||||
it('parseIriId handles empty and numeric values', function (): void {
|
||||
$controller = new PostController();
|
||||
$ref = new ReflectionMethod($controller, 'parseIriId');
|
||||
$ref->setAccessible(true);
|
||||
|
||||
expect($ref->invoke($controller, null))->toBeNull();
|
||||
expect($ref->invoke($controller, ''))->toBeNull();
|
||||
expect($ref->invoke($controller, '/threads/12'))->toBe(12);
|
||||
expect($ref->invoke($controller, '7'))->toBe(7);
|
||||
expect($ref->invoke($controller, 'abc'))->toBeNull();
|
||||
});
|
||||
|
||||
it('serializes posts with attachments and rank data', function (): void {
|
||||
$forum = makeForumForPostController();
|
||||
$role = Role::create(['name' => 'ROLE_MOD', 'color' => '#00ff00']);
|
||||
$rank = Rank::create(['name' => 'Gold', 'badge_image_path' => 'ranks/badge.png']);
|
||||
$user = User::factory()->create([
|
||||
'rank_id' => $rank->id,
|
||||
'avatar_path' => 'avatars/u.png',
|
||||
'location' => 'Here',
|
||||
]);
|
||||
$user->roles()->attach($role);
|
||||
|
||||
$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' => 'See [attachment]file.png[/attachment]',
|
||||
]);
|
||||
|
||||
$group = AttachmentGroup::create([
|
||||
'name' => 'Images',
|
||||
'max_size_kb' => 100,
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
$attachment = Attachment::create([
|
||||
'thread_id' => null,
|
||||
'post_id' => $post->id,
|
||||
'attachment_extension_id' => null,
|
||||
'attachment_group_id' => $group->id,
|
||||
'user_id' => $user->id,
|
||||
'disk' => 'local',
|
||||
'path' => 'attachments/posts/'.$post->id.'/file.png',
|
||||
'thumbnail_path' => 'attachments/posts/'.$post->id.'/thumb.png',
|
||||
'original_name' => 'file.png',
|
||||
'extension' => 'png',
|
||||
'mime_type' => 'image/png',
|
||||
'size_bytes' => 10,
|
||||
]);
|
||||
|
||||
$post->load(['user.rank', 'user.roles', 'attachments.group']);
|
||||
|
||||
$controller = new PostController();
|
||||
$ref = new ReflectionMethod($controller, 'serializePost');
|
||||
$ref->setAccessible(true);
|
||||
|
||||
$payload = $ref->invoke($controller, $post);
|
||||
|
||||
expect($payload['user_rank_badge_url'])->not->toBeNull();
|
||||
expect($payload['user_group_color'])->toBe('#00ff00');
|
||||
expect($payload['attachments'][0]['group']['name'])->toBe('Images');
|
||||
expect($payload['attachments'][0]['thumbnail_url'])->toContain('/thumbnail');
|
||||
});
|
||||
|
||||
it('serializes posts with null user and no attachments', function (): void {
|
||||
$forum = makeForumForPostController();
|
||||
$thread = Thread::create([
|
||||
'forum_id' => $forum->id,
|
||||
'user_id' => null,
|
||||
'title' => 'Thread',
|
||||
'body' => 'Body',
|
||||
]);
|
||||
$post = Post::create([
|
||||
'thread_id' => $thread->id,
|
||||
'user_id' => null,
|
||||
'body' => 'Body',
|
||||
]);
|
||||
|
||||
$controller = new PostController();
|
||||
$ref = new ReflectionMethod($controller, 'serializePost');
|
||||
$ref->setAccessible(true);
|
||||
|
||||
$payload = $ref->invoke($controller, $post);
|
||||
|
||||
expect($payload['user_avatar_url'])->toBeNull();
|
||||
expect($payload['user_rank_badge_url'])->toBeNull();
|
||||
expect($payload['user_group_color'])->toBeNull();
|
||||
expect($payload['attachments'])->toBe([]);
|
||||
});
|
||||
|
||||
it('replaceAttachmentTags handles inline images and links', function (): void {
|
||||
$controller = new PostController();
|
||||
$ref = new ReflectionMethod($controller, 'replaceAttachmentTags');
|
||||
$ref->setAccessible(true);
|
||||
|
||||
Setting::updateOrCreate(['key' => 'attachments.display_images_inline'], ['value' => '1']);
|
||||
|
||||
$attachment = new Attachment([
|
||||
'id' => 1,
|
||||
'original_name' => 'file.png',
|
||||
'mime_type' => 'image/png',
|
||||
'thumbnail_path' => null,
|
||||
]);
|
||||
|
||||
$body = 'See [attachment]file.png[/attachment]';
|
||||
$result = $ref->invoke($controller, $body, collect([$attachment]));
|
||||
expect($result)->toContain('[img]');
|
||||
|
||||
$attachment->thumbnail_path = 'thumb';
|
||||
$result = $ref->invoke($controller, $body, collect([$attachment]));
|
||||
expect($result)->toContain('[url=');
|
||||
|
||||
Setting::updateOrCreate(['key' => 'attachments.display_images_inline'], ['value' => '0']);
|
||||
$result = $ref->invoke($controller, $body, collect([$attachment]));
|
||||
expect($result)->toContain('[url=');
|
||||
|
||||
$result = $ref->invoke($controller, 'No match', collect([$attachment]));
|
||||
expect($result)->toContain('No match');
|
||||
});
|
||||
|
||||
it('replaceAttachmentTags returns original tag when attachment name missing in map', function (): void {
|
||||
$controller = new PostController();
|
||||
$ref = new ReflectionMethod($controller, 'replaceAttachmentTags');
|
||||
$ref->setAccessible(true);
|
||||
|
||||
Setting::updateOrCreate(['key' => 'attachments.display_images_inline'], ['value' => '1']);
|
||||
|
||||
$attachment = new Attachment([
|
||||
'id' => 2,
|
||||
'original_name' => 'actual.txt',
|
||||
'mime_type' => 'text/plain',
|
||||
]);
|
||||
|
||||
$body = 'See [attachment]missing.txt[/attachment]';
|
||||
$result = $ref->invoke($controller, $body, collect([$attachment]));
|
||||
|
||||
expect($result)->toBe($body);
|
||||
});
|
||||
|
||||
it('replaceAttachmentTags renders non-image attachments as links', function (): void {
|
||||
$controller = new PostController();
|
||||
$ref = new ReflectionMethod($controller, 'replaceAttachmentTags');
|
||||
$ref->setAccessible(true);
|
||||
|
||||
Setting::updateOrCreate(['key' => 'attachments.display_images_inline'], ['value' => 'yes']);
|
||||
|
||||
$attachment = new Attachment([
|
||||
'id' => 3,
|
||||
'original_name' => 'doc.txt',
|
||||
'mime_type' => 'text/plain',
|
||||
]);
|
||||
|
||||
$body = 'See [attachment]doc.txt[/attachment]';
|
||||
$result = $ref->invoke($controller, $body, collect([$attachment]));
|
||||
|
||||
expect($result)->toContain('[url=');
|
||||
expect($result)->toContain('doc.txt');
|
||||
});
|
||||
|
||||
it('replaceAttachmentTags returns body when no attachments or map empty', function (): void {
|
||||
$controller = new PostController();
|
||||
$ref = new ReflectionMethod($controller, 'replaceAttachmentTags');
|
||||
$ref->setAccessible(true);
|
||||
|
||||
expect($ref->invoke($controller, 'Body', []))->toBe('Body');
|
||||
|
||||
$attachment = new Attachment([
|
||||
'original_name' => '',
|
||||
]);
|
||||
expect($ref->invoke($controller, 'Body', collect([$attachment])))->toBe('Body');
|
||||
});
|
||||
|
||||
it('displayImagesInline defaults to true when missing setting', function (): void {
|
||||
Setting::where('key', 'attachments.display_images_inline')->delete();
|
||||
|
||||
$controller = new PostController();
|
||||
$ref = new ReflectionMethod($controller, 'displayImagesInline');
|
||||
$ref->setAccessible(true);
|
||||
|
||||
expect($ref->invoke($controller))->toBeTrue();
|
||||
});
|
||||
|
||||
it('displayImagesInline returns false for off values', function (): void {
|
||||
Setting::updateOrCreate(['key' => 'attachments.display_images_inline'], ['value' => 'off']);
|
||||
|
||||
$controller = new PostController();
|
||||
$ref = new ReflectionMethod($controller, 'displayImagesInline');
|
||||
$ref->setAccessible(true);
|
||||
|
||||
expect($ref->invoke($controller))->toBeFalse();
|
||||
});
|
||||
|
||||
it('resolveGroupColor returns null for missing roles', function (): void {
|
||||
$controller = new PostController();
|
||||
$user = User::factory()->create();
|
||||
$user->setRelation('roles', null);
|
||||
|
||||
$ref = new ReflectionMethod($controller, 'resolveGroupColor');
|
||||
$ref->setAccessible(true);
|
||||
|
||||
expect($ref->invoke($controller, $user))->toBeNull();
|
||||
});
|
||||
|
||||
it('resolveGroupColor returns first sorted role color', function (): void {
|
||||
$controller = new PostController();
|
||||
$user = User::factory()->create();
|
||||
$roleB = Role::create(['name' => 'ROLE_B', 'color' => '#bbbbbb']);
|
||||
$roleA = Role::create(['name' => 'ROLE_A', 'color' => '#aaaaaa']);
|
||||
$user->roles()->attach([$roleB->id, $roleA->id]);
|
||||
$user->load('roles');
|
||||
|
||||
$ref = new ReflectionMethod($controller, 'resolveGroupColor');
|
||||
$ref->setAccessible(true);
|
||||
|
||||
expect($ref->invoke($controller, $user))->toBe('#aaaaaa');
|
||||
});
|
||||
65
tests/Unit/PostModelTest.php
Normal file
65
tests/Unit/PostModelTest.php
Normal file
@@ -0,0 +1,65 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Attachment;
|
||||
use App\Models\Forum;
|
||||
use App\Models\Post;
|
||||
use App\Models\PostThank;
|
||||
use App\Models\Thread;
|
||||
use App\Models\User;
|
||||
|
||||
it('exposes post relationships', function (): void {
|
||||
$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,
|
||||
]);
|
||||
|
||||
$user = User::factory()->create();
|
||||
$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' => 'Post body',
|
||||
]);
|
||||
|
||||
$attachment = Attachment::create([
|
||||
'thread_id' => null,
|
||||
'post_id' => $post->id,
|
||||
'attachment_extension_id' => null,
|
||||
'attachment_group_id' => null,
|
||||
'user_id' => $user->id,
|
||||
'disk' => 'local',
|
||||
'path' => 'attachments/posts/'.$post->id.'/file.pdf',
|
||||
'original_name' => 'file.pdf',
|
||||
'extension' => 'pdf',
|
||||
'mime_type' => 'application/pdf',
|
||||
'size_bytes' => 10,
|
||||
]);
|
||||
|
||||
$thank = PostThank::create([
|
||||
'post_id' => $post->id,
|
||||
'user_id' => $user->id,
|
||||
]);
|
||||
|
||||
$post->load(['thread', 'user', 'attachments', 'thanks']);
|
||||
|
||||
expect($post->thread->id)->toBe($thread->id);
|
||||
expect($post->user->id)->toBe($user->id);
|
||||
expect($post->attachments->first()->id)->toBe($attachment->id);
|
||||
expect($post->thanks->first()->id)->toBe($thank->id);
|
||||
});
|
||||
36
tests/Unit/PostThankControllerUnitTest.php
Normal file
36
tests/Unit/PostThankControllerUnitTest.php
Normal file
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
use App\Http\Controllers\PostThankController;
|
||||
use App\Models\Post;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
it('returns unauthenticated when no user in store/destroy', function (): void {
|
||||
$controller = new PostThankController();
|
||||
$post = new Post([
|
||||
'id' => 1,
|
||||
]);
|
||||
$post->setRawAttributes(['id' => 1, 'thread_id' => 1, 'user_id' => null, 'body' => 'Post'], true);
|
||||
|
||||
$request = Request::create('/api/posts/'.$post->id.'/thanks', 'POST');
|
||||
$request->setUserResolver(fn () => null);
|
||||
$response = $controller->store($request, $post);
|
||||
expect($response->getStatusCode())->toBe(401);
|
||||
|
||||
$request = Request::create('/api/posts/'.$post->id.'/thanks', 'DELETE');
|
||||
$request->setUserResolver(fn () => null);
|
||||
$response = $controller->destroy($request, $post);
|
||||
expect($response->getStatusCode())->toBe(401);
|
||||
});
|
||||
|
||||
it('resolveGroupColor returns null for missing roles', function (): void {
|
||||
$controller = new PostThankController();
|
||||
$user = User::factory()->create();
|
||||
$user->setRelation('roles', null);
|
||||
|
||||
$ref = new ReflectionMethod($controller, 'resolveGroupColor');
|
||||
$ref->setAccessible(true);
|
||||
|
||||
expect($ref->invoke($controller, $user))->toBeNull();
|
||||
expect($ref->invoke($controller, null))->toBeNull();
|
||||
});
|
||||
22
tests/Unit/RankModelTest.php
Normal file
22
tests/Unit/RankModelTest.php
Normal file
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Rank;
|
||||
use App\Models\User;
|
||||
|
||||
it('relates ranks to users', function (): void {
|
||||
$rank = Rank::create([
|
||||
'name' => 'Gold',
|
||||
'badge_type' => 'text',
|
||||
'badge_text' => 'G',
|
||||
'color' => '#ffaa00',
|
||||
]);
|
||||
|
||||
$user = User::factory()->create([
|
||||
'rank_id' => $rank->id,
|
||||
]);
|
||||
|
||||
$rank->load('users');
|
||||
|
||||
expect($rank->users)->toHaveCount(1);
|
||||
expect($rank->users->first()->id)->toBe($user->id);
|
||||
});
|
||||
20
tests/Unit/ResetUserPasswordTest.php
Normal file
20
tests/Unit/ResetUserPasswordTest.php
Normal file
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
use App\Actions\Fortify\ResetUserPassword;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
|
||||
it('resets user password after validation', function (): void {
|
||||
$user = User::factory()->create([
|
||||
'password' => Hash::make('OldPass123!'),
|
||||
]);
|
||||
|
||||
$action = new ResetUserPassword();
|
||||
$action->reset($user, [
|
||||
'password' => 'NewPass123!',
|
||||
'password_confirmation' => 'NewPass123!',
|
||||
]);
|
||||
|
||||
$user->refresh();
|
||||
expect(Hash::check('NewPass123!', $user->password))->toBeTrue();
|
||||
});
|
||||
63
tests/Unit/StatsControllerUnitTest.php
Normal file
63
tests/Unit/StatsControllerUnitTest.php
Normal file
@@ -0,0 +1,63 @@
|
||||
<?php
|
||||
|
||||
use App\Http\Controllers\StatsController;
|
||||
use App\Models\Setting;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
it('returns null board version when no version is set', function (): void {
|
||||
Setting::where('key', 'version')->delete();
|
||||
Setting::where('key', 'build')->delete();
|
||||
|
||||
$controller = new StatsController();
|
||||
$response = $controller->__invoke();
|
||||
$payload = $response->getData(true);
|
||||
|
||||
expect($payload['board_version'])->toBeNull();
|
||||
});
|
||||
|
||||
it('handles stats edge cases without crashing', function (): void {
|
||||
Storage::fake('public');
|
||||
|
||||
Setting::updateOrCreate(['key' => 'version'], ['value' => '1.0.0']);
|
||||
Setting::updateOrCreate(['key' => 'build'], ['value' => '9']);
|
||||
|
||||
DB::shouldReceive('connection->getDriverName')->andReturn('sqlite');
|
||||
DB::shouldReceive('selectOne')->andThrow(new RuntimeException('db fail'));
|
||||
|
||||
$controller = new StatsController();
|
||||
$response = $controller->__invoke();
|
||||
|
||||
$payload = $response->getData(true);
|
||||
|
||||
expect($payload['database_size_bytes'])->toBeNull();
|
||||
expect($payload['database_server'])->toBeNull();
|
||||
expect($payload['board_version'])->toBe('1.0.0 (build 9)');
|
||||
expect($payload['orphan_attachments'])->toBeInt();
|
||||
});
|
||||
|
||||
it('returns null for database size and avatar size on exceptions', function (): void {
|
||||
DB::shouldReceive('connection->getDriverName')->andThrow(new RuntimeException('db fail'));
|
||||
|
||||
$controller = new StatsController();
|
||||
$refDb = new ReflectionMethod($controller, 'resolveDatabaseSize');
|
||||
$refDb->setAccessible(true);
|
||||
$refAvatar = new ReflectionMethod($controller, 'resolveAvatarDirectorySize');
|
||||
$refAvatar->setAccessible(true);
|
||||
|
||||
expect($refDb->invoke($controller))->toBeNull();
|
||||
\Illuminate\Support\Facades\Storage::shouldReceive('disk')->andThrow(new RuntimeException('disk fail'));
|
||||
expect($refAvatar->invoke($controller))->toBeNull();
|
||||
});
|
||||
|
||||
it('sums avatar directory size', function (): void {
|
||||
Storage::fake('public');
|
||||
Storage::disk('public')->put('avatars/a.png', 'a');
|
||||
Storage::disk('public')->put('avatars/b.png', 'bb');
|
||||
|
||||
$controller = new StatsController();
|
||||
$refAvatar = new ReflectionMethod($controller, 'resolveAvatarDirectorySize');
|
||||
$refAvatar->setAccessible(true);
|
||||
|
||||
expect($refAvatar->invoke($controller))->toBe(3);
|
||||
});
|
||||
225
tests/Unit/SystemStatusControllerUnitTest.php
Normal file
225
tests/Unit/SystemStatusControllerUnitTest.php
Normal file
@@ -0,0 +1,225 @@
|
||||
<?php
|
||||
|
||||
use App\Http\Controllers\SystemStatusController;
|
||||
use App\Models\Role;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
function withFakeBinForStatus(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($dir);
|
||||
} 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('returns system status for admins', function (): void {
|
||||
withFakeBinForStatus([
|
||||
'php' => "#!/bin/sh\nif [ \"$1\" = \"-r\" ]; then echo \"8.4.0\"; exit 0; fi\necho \"php\"\n",
|
||||
'composer' => "#!/bin/sh\necho \"composer 2.0.0\"\n",
|
||||
'node' => "#!/bin/sh\necho \"v20.0.0\"\n",
|
||||
'npm' => "#!/bin/sh\necho \"9.0.0\"\n",
|
||||
'tar' => "#!/bin/sh\necho \"tar 1.2.3\"\n",
|
||||
'rsync' => "#!/bin/sh\necho \"rsync 3.2.0\"\n",
|
||||
], function (string $dir): void {
|
||||
$admin = User::factory()->create();
|
||||
$role = Role::firstOrCreate(['name' => 'ROLE_ADMIN'], ['color' => '#111111']);
|
||||
$admin->roles()->attach($role);
|
||||
|
||||
$request = Request::create('/api/system/status', 'GET');
|
||||
$request->setUserResolver(fn () => $admin);
|
||||
|
||||
$controller = new SystemStatusController();
|
||||
$response = $controller->__invoke($request);
|
||||
|
||||
expect($response->getStatusCode())->toBe(200);
|
||||
$payload = $response->getData(true);
|
||||
|
||||
expect($payload)->toHaveKeys([
|
||||
'php',
|
||||
'php_default',
|
||||
'composer',
|
||||
'composer_version',
|
||||
'node',
|
||||
'node_version',
|
||||
'npm',
|
||||
'npm_version',
|
||||
'tar',
|
||||
'tar_version',
|
||||
'rsync',
|
||||
'rsync_version',
|
||||
'proc_functions',
|
||||
'storage_writable',
|
||||
'updates_writable',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
it('covers binary resolution edge cases', function (): void {
|
||||
withFakeBinForStatus([
|
||||
'sh' => "#!/bin/sh\nexit 0\n",
|
||||
], function (string $dir): void {
|
||||
$controller = new SystemStatusController();
|
||||
$refBinary = new ReflectionMethod($controller, 'resolveBinary');
|
||||
$refBinary->setAccessible(true);
|
||||
|
||||
expect($refBinary->invoke($controller, 'php'))->toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it('returns php version when available', function (): void {
|
||||
withFakeBinForStatus([
|
||||
'phpfake' => "#!/bin/sh\nif [ \"$1\" = \"-r\" ]; then echo \"8.4.1\"; exit 0; fi\nexit 0\n",
|
||||
], function (string $dir): void {
|
||||
$controller = new SystemStatusController();
|
||||
$refPhp = new ReflectionMethod($controller, 'resolvePhpVersion');
|
||||
$refPhp->setAccessible(true);
|
||||
|
||||
$path = $dir . '/phpfake';
|
||||
expect($refPhp->invoke($controller, $path))->toBe('8.4.1');
|
||||
});
|
||||
});
|
||||
|
||||
it('returns null php version when command fails', function (): void {
|
||||
withFakeBinForStatus([
|
||||
'phpfail' => "#!/bin/sh\nexit 1\n",
|
||||
], function (string $dir): void {
|
||||
$controller = new SystemStatusController();
|
||||
$refPhp = new ReflectionMethod($controller, 'resolvePhpVersion');
|
||||
$refPhp->setAccessible(true);
|
||||
|
||||
$path = $dir . '/phpfail';
|
||||
expect($refPhp->invoke($controller, $path))->toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it('returns binary version when regex matches', function (): void {
|
||||
withFakeBinForStatus([
|
||||
'tool' => "#!/bin/sh\necho \"tool v1.2.3\"\n",
|
||||
], function (string $dir): void {
|
||||
$controller = new SystemStatusController();
|
||||
$refVer = new ReflectionMethod($controller, 'resolveBinaryVersion');
|
||||
$refVer->setAccessible(true);
|
||||
|
||||
$path = $dir . '/tool';
|
||||
expect($refVer->invoke($controller, $path, ['--version']))->toBe('1.2.3');
|
||||
});
|
||||
});
|
||||
|
||||
it('returns null when binary version output is empty', function (): void {
|
||||
withFakeBinForStatus([
|
||||
'empty' => "#!/bin/sh\nexit 0\n",
|
||||
], function (string $dir): void {
|
||||
$controller = new SystemStatusController();
|
||||
$refVer = new ReflectionMethod($controller, 'resolveBinaryVersion');
|
||||
$refVer->setAccessible(true);
|
||||
|
||||
$path = $dir . '/empty';
|
||||
expect($refVer->invoke($controller, $path, ['--version']))->toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it('returns null when binary version output has no version', function (): void {
|
||||
withFakeBinForStatus([
|
||||
'noversion' => "#!/bin/sh\necho \"tool version unknown\"\n",
|
||||
], function (string $dir): void {
|
||||
$controller = new SystemStatusController();
|
||||
$refVer = new ReflectionMethod($controller, 'resolveBinaryVersion');
|
||||
$refVer->setAccessible(true);
|
||||
|
||||
$path = $dir . '/noversion';
|
||||
expect($refVer->invoke($controller, $path, ['--version']))->toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it('returns null when binary version command fails', function (): void {
|
||||
withFakeBinForStatus([
|
||||
'fail' => "#!/bin/sh\nexit 1\n",
|
||||
], function (string $dir): void {
|
||||
$controller = new SystemStatusController();
|
||||
$refVer = new ReflectionMethod($controller, 'resolveBinaryVersion');
|
||||
$refVer->setAccessible(true);
|
||||
|
||||
$path = $dir . '/fail';
|
||||
expect($refVer->invoke($controller, $path, ['--version']))->toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it('returns empty array when readJson cannot read file', function (): void {
|
||||
$controller = new SystemStatusController();
|
||||
$ref = new ReflectionMethod($controller, 'readJson');
|
||||
$ref->setAccessible(true);
|
||||
|
||||
$path = sys_get_temp_dir() . '/missing.json';
|
||||
$result = $ref->invoke($controller, $path);
|
||||
|
||||
expect($result)->toBe([]);
|
||||
});
|
||||
|
||||
it('returns empty array when readJson invalid', function (): void {
|
||||
$controller = new SystemStatusController();
|
||||
$ref = new ReflectionMethod($controller, 'readJson');
|
||||
$ref->setAccessible(true);
|
||||
|
||||
$path = sys_get_temp_dir() . '/invalid.json';
|
||||
file_put_contents($path, 'not-json');
|
||||
|
||||
$result = $ref->invoke($controller, $path);
|
||||
unlink($path);
|
||||
|
||||
expect($result)->toBe([]);
|
||||
});
|
||||
|
||||
it('returns empty array when readJson cannot read contents', function (): void {
|
||||
$controller = new SystemStatusController();
|
||||
$ref = new ReflectionMethod($controller, 'readJson');
|
||||
$ref->setAccessible(true);
|
||||
|
||||
$path = storage_path('app/unreadable.json');
|
||||
file_put_contents($path, '{"a":1}');
|
||||
chmod($path, 0000);
|
||||
|
||||
$prev = set_error_handler(static fn () => true);
|
||||
$result = $ref->invoke($controller, $path);
|
||||
restore_error_handler();
|
||||
|
||||
chmod($path, 0644);
|
||||
unlink($path);
|
||||
|
||||
expect($result)->toBe([]);
|
||||
});
|
||||
164
tests/Unit/ThreadControllerBranchesTest.php
Normal file
164
tests/Unit/ThreadControllerBranchesTest.php
Normal file
@@ -0,0 +1,164 @@
|
||||
<?php
|
||||
|
||||
use App\Http\Controllers\ThreadController;
|
||||
use App\Models\Attachment;
|
||||
use App\Models\Forum;
|
||||
use App\Models\Setting;
|
||||
use App\Models\Thread;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
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();
|
||||
});
|
||||
|
||||
it('parseIriId returns null for empty values and non numeric', function (): void {
|
||||
$controller = new ThreadController();
|
||||
$ref = new ReflectionMethod($controller, 'parseIriId');
|
||||
$ref->setAccessible(true);
|
||||
|
||||
expect($ref->invoke($controller, null))->toBeNull();
|
||||
expect($ref->invoke($controller, ''))->toBeNull();
|
||||
expect($ref->invoke($controller, 'abc'))->toBeNull();
|
||||
});
|
||||
|
||||
it('parseIriId parses forum iris and numeric values', function (): void {
|
||||
$controller = new ThreadController();
|
||||
$ref = new ReflectionMethod($controller, 'parseIriId');
|
||||
$ref->setAccessible(true);
|
||||
|
||||
expect($ref->invoke($controller, '/api/forums/123'))->toBe(123);
|
||||
expect($ref->invoke($controller, '456'))->toBe(456);
|
||||
});
|
||||
|
||||
it('serializes thread with rank badge url when present', function (): void {
|
||||
Storage::fake('public');
|
||||
|
||||
$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,
|
||||
]);
|
||||
|
||||
$rank = \App\Models\Rank::create([
|
||||
'name' => 'Rank',
|
||||
'badge_image_path' => 'ranks/badge.png',
|
||||
]);
|
||||
|
||||
$user = \App\Models\User::factory()->create([
|
||||
'rank_id' => $rank->id,
|
||||
]);
|
||||
|
||||
$thread = Thread::create([
|
||||
'forum_id' => $forum->id,
|
||||
'user_id' => $user->id,
|
||||
'title' => 'Thread',
|
||||
'body' => 'Body',
|
||||
]);
|
||||
|
||||
$thread->load(['user.rank', 'attachments']);
|
||||
|
||||
$controller = new ThreadController();
|
||||
$ref = new ReflectionMethod($controller, 'serializeThread');
|
||||
$ref->setAccessible(true);
|
||||
|
||||
$payload = $ref->invoke($controller, $thread);
|
||||
|
||||
expect($payload['user_rank_badge_url'])->not->toBeNull();
|
||||
});
|
||||
|
||||
it('replaces attachment tags with inline image without thumb', function (): void {
|
||||
Setting::updateOrCreate(['key' => 'attachments.display_images_inline'], ['value' => 'true']);
|
||||
|
||||
$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' => null,
|
||||
'title' => 'Thread',
|
||||
'body' => 'See [attachment]image.jpg[/attachment]',
|
||||
]);
|
||||
|
||||
$attachment = Attachment::create([
|
||||
'thread_id' => $thread->id,
|
||||
'post_id' => null,
|
||||
'attachment_extension_id' => null,
|
||||
'attachment_group_id' => null,
|
||||
'user_id' => null,
|
||||
'disk' => 'local',
|
||||
'path' => 'attachments/threads/'.$thread->id.'/image.jpg',
|
||||
'original_name' => 'image.jpg',
|
||||
'extension' => 'jpg',
|
||||
'mime_type' => 'image/jpeg',
|
||||
'size_bytes' => 10,
|
||||
]);
|
||||
|
||||
$controller = new ThreadController();
|
||||
$ref = new ReflectionMethod($controller, 'replaceAttachmentTags');
|
||||
$ref->setAccessible(true);
|
||||
|
||||
$result = $ref->invoke($controller, $thread->body, collect([$attachment]));
|
||||
|
||||
expect($result)->toContain('[img]');
|
||||
});
|
||||
|
||||
it('defaults to inline images when setting is missing', function (): void {
|
||||
Setting::where('key', 'attachments.display_images_inline')->delete();
|
||||
|
||||
$controller = new ThreadController();
|
||||
$ref = new ReflectionMethod($controller, 'displayImagesInline');
|
||||
$ref->setAccessible(true);
|
||||
|
||||
expect($ref->invoke($controller))->toBeTrue();
|
||||
});
|
||||
|
||||
it('returns null group color when roles relation is null', function (): void {
|
||||
$controller = new ThreadController();
|
||||
$user = \App\Models\User::factory()->create();
|
||||
$user->setRelation('roles', null);
|
||||
|
||||
$ref = new ReflectionMethod($controller, 'resolveGroupColor');
|
||||
$ref->setAccessible(true);
|
||||
|
||||
expect($ref->invoke($controller, $user))->toBeNull();
|
||||
});
|
||||
250
tests/Unit/ThreadControllerUnitTest.php
Normal file
250
tests/Unit/ThreadControllerUnitTest.php
Normal file
@@ -0,0 +1,250 @@
|
||||
<?php
|
||||
|
||||
use App\Http\Controllers\ThreadController;
|
||||
use App\Models\Attachment;
|
||||
use App\Models\AttachmentGroup;
|
||||
use App\Models\Forum;
|
||||
use App\Models\Post;
|
||||
use App\Models\Thread;
|
||||
use App\Models\User;
|
||||
use App\Models\Role;
|
||||
use App\Models\Setting;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
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 makeForumForThreadController(): 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('returns unauthorized for update when no user', function (): void {
|
||||
$controller = new ThreadController();
|
||||
$forum = makeForumForThreadController();
|
||||
$thread = Thread::create([
|
||||
'forum_id' => $forum->id,
|
||||
'user_id' => null,
|
||||
'title' => 'Thread',
|
||||
'body' => 'Body',
|
||||
]);
|
||||
|
||||
$request = Request::create("/api/threads/{$thread->id}", 'PATCH', [
|
||||
'title' => 'New',
|
||||
]);
|
||||
$request->setUserResolver(fn () => null);
|
||||
|
||||
$response = $controller->update($request, $thread);
|
||||
|
||||
expect($response->getStatusCode())->toBe(401);
|
||||
});
|
||||
|
||||
it('returns unauthorized for updateSolved when no user', function (): void {
|
||||
$controller = new ThreadController();
|
||||
$forum = makeForumForThreadController();
|
||||
$thread = Thread::create([
|
||||
'forum_id' => $forum->id,
|
||||
'user_id' => null,
|
||||
'title' => 'Thread',
|
||||
'body' => 'Body',
|
||||
]);
|
||||
|
||||
$request = Request::create("/api/threads/{$thread->id}/solved", 'PATCH', [
|
||||
'solved' => true,
|
||||
]);
|
||||
$request->setUserResolver(fn () => null);
|
||||
|
||||
$response = $controller->updateSolved($request, $thread);
|
||||
|
||||
expect($response->getStatusCode())->toBe(401);
|
||||
});
|
||||
|
||||
it('serializes threads with attachments, group colors, and inline images', function (): void {
|
||||
Storage::fake('public');
|
||||
|
||||
$forum = makeForumForThreadController();
|
||||
$role = Role::create(['name' => 'ROLE_HELPER', 'color' => '#ff0000']);
|
||||
$user = User::factory()->create([
|
||||
'avatar_path' => 'avatars/u.png',
|
||||
'location' => 'Somewhere',
|
||||
]);
|
||||
$user->roles()->attach($role);
|
||||
|
||||
Setting::updateOrCreate(['key' => 'attachments.display_images_inline'], ['value' => '1']);
|
||||
|
||||
$thread = Thread::create([
|
||||
'forum_id' => $forum->id,
|
||||
'user_id' => $user->id,
|
||||
'title' => 'Thread',
|
||||
'body' => 'See [attachment]image.png[/attachment]',
|
||||
]);
|
||||
|
||||
$post = Post::create([
|
||||
'thread_id' => $thread->id,
|
||||
'user_id' => $user->id,
|
||||
'body' => 'Reply',
|
||||
]);
|
||||
|
||||
$group = AttachmentGroup::create([
|
||||
'name' => 'Images',
|
||||
'max_size_kb' => 100,
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
$attachment = Attachment::create([
|
||||
'thread_id' => $thread->id,
|
||||
'post_id' => null,
|
||||
'attachment_extension_id' => null,
|
||||
'attachment_group_id' => $group->id,
|
||||
'user_id' => $user->id,
|
||||
'disk' => 'local',
|
||||
'path' => 'attachments/threads/'.$thread->id.'/image.png',
|
||||
'thumbnail_path' => 'attachments/threads/'.$thread->id.'/thumbs/image.png',
|
||||
'original_name' => 'image.png',
|
||||
'extension' => 'png',
|
||||
'mime_type' => 'image/png',
|
||||
'size_bytes' => 10,
|
||||
]);
|
||||
|
||||
$thread->load(['user.roles', 'attachments.group', 'latestPost']);
|
||||
|
||||
$controller = new ThreadController();
|
||||
$ref = new ReflectionMethod($controller, 'serializeThread');
|
||||
$ref->setAccessible(true);
|
||||
|
||||
$payload = $ref->invoke($controller, $thread);
|
||||
|
||||
expect($payload['user_avatar_url'])->not->toBeNull();
|
||||
expect($payload['user_group_color'])->toBe('#ff0000');
|
||||
expect($payload['attachments'][0]['group']['name'])->toBe('Images');
|
||||
expect($payload['attachments'][0]['thumbnail_url'])->toContain('/thumbnail');
|
||||
expect($payload['body_html'])->toContain('<p');
|
||||
expect($payload['last_post_id'])->toBe($post->id);
|
||||
});
|
||||
|
||||
it('replaces attachment tags with links when inline images disabled', function (): void {
|
||||
Setting::updateOrCreate(['key' => 'attachments.display_images_inline'], ['value' => '0']);
|
||||
|
||||
$forum = makeForumForThreadController();
|
||||
$thread = Thread::create([
|
||||
'forum_id' => $forum->id,
|
||||
'user_id' => null,
|
||||
'title' => 'Thread',
|
||||
'body' => 'See [attachment]doc.pdf[/attachment]',
|
||||
]);
|
||||
|
||||
$attachment = Attachment::create([
|
||||
'thread_id' => $thread->id,
|
||||
'post_id' => null,
|
||||
'attachment_extension_id' => null,
|
||||
'attachment_group_id' => null,
|
||||
'user_id' => null,
|
||||
'disk' => 'local',
|
||||
'path' => 'attachments/threads/'.$thread->id.'/doc.pdf',
|
||||
'original_name' => 'doc.pdf',
|
||||
'extension' => 'pdf',
|
||||
'mime_type' => 'application/pdf',
|
||||
'size_bytes' => 10,
|
||||
]);
|
||||
|
||||
$controller = new ThreadController();
|
||||
$ref = new ReflectionMethod($controller, 'replaceAttachmentTags');
|
||||
$ref->setAccessible(true);
|
||||
|
||||
$result = $ref->invoke($controller, $thread->body, collect([$attachment]));
|
||||
|
||||
expect($result)->toContain('[url=');
|
||||
expect($result)->toContain('doc.pdf');
|
||||
});
|
||||
|
||||
it('returns body unchanged when no attachments are present', function (): void {
|
||||
$controller = new ThreadController();
|
||||
$ref = new ReflectionMethod($controller, 'replaceAttachmentTags');
|
||||
$ref->setAccessible(true);
|
||||
|
||||
$result = $ref->invoke($controller, 'No attachments', []);
|
||||
|
||||
expect($result)->toBe('No attachments');
|
||||
});
|
||||
|
||||
it('returns original tag when attachment name does not match', function (): void {
|
||||
$forum = makeForumForThreadController();
|
||||
$thread = Thread::create([
|
||||
'forum_id' => $forum->id,
|
||||
'user_id' => null,
|
||||
'title' => 'Thread',
|
||||
'body' => 'See [attachment]missing.pdf[/attachment]',
|
||||
]);
|
||||
|
||||
$attachment = Attachment::create([
|
||||
'thread_id' => $thread->id,
|
||||
'post_id' => null,
|
||||
'attachment_extension_id' => null,
|
||||
'attachment_group_id' => null,
|
||||
'user_id' => null,
|
||||
'disk' => 'local',
|
||||
'path' => 'attachments/threads/'.$thread->id.'/doc.pdf',
|
||||
'original_name' => 'doc.pdf',
|
||||
'extension' => 'pdf',
|
||||
'mime_type' => 'application/pdf',
|
||||
'size_bytes' => 10,
|
||||
]);
|
||||
|
||||
$controller = new ThreadController();
|
||||
$ref = new ReflectionMethod($controller, 'replaceAttachmentTags');
|
||||
$ref->setAccessible(true);
|
||||
|
||||
$result = $ref->invoke($controller, $thread->body, collect([$attachment]));
|
||||
|
||||
expect($result)->toContain('[attachment]missing.pdf[/attachment]');
|
||||
});
|
||||
|
||||
it('returns body unchanged when attachment map is empty', function (): void {
|
||||
$controller = new ThreadController();
|
||||
$ref = new ReflectionMethod($controller, 'replaceAttachmentTags');
|
||||
$ref->setAccessible(true);
|
||||
|
||||
$attachment = new Attachment([
|
||||
'original_name' => '',
|
||||
]);
|
||||
|
||||
$result = $ref->invoke($controller, 'Body', collect([$attachment]));
|
||||
|
||||
expect($result)->toBe('Body');
|
||||
});
|
||||
73
tests/Unit/ThreadModelTest.php
Normal file
73
tests/Unit/ThreadModelTest.php
Normal file
@@ -0,0 +1,73 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Attachment;
|
||||
use App\Models\Forum;
|
||||
use App\Models\Post;
|
||||
use App\Models\Thread;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Carbon;
|
||||
|
||||
it('casts solved flag and exposes relationships', function (): void {
|
||||
$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,
|
||||
]);
|
||||
|
||||
$user = User::factory()->create();
|
||||
|
||||
$thread = Thread::create([
|
||||
'forum_id' => $forum->id,
|
||||
'user_id' => $user->id,
|
||||
'title' => 'Thread',
|
||||
'body' => 'Body',
|
||||
'solved' => 1,
|
||||
]);
|
||||
|
||||
$oldPost = Post::create([
|
||||
'thread_id' => $thread->id,
|
||||
'user_id' => $user->id,
|
||||
'body' => 'Old post',
|
||||
'created_at' => Carbon::now()->subDay(),
|
||||
]);
|
||||
|
||||
$newPost = Post::create([
|
||||
'thread_id' => $thread->id,
|
||||
'user_id' => $user->id,
|
||||
'body' => 'New post',
|
||||
'created_at' => Carbon::now(),
|
||||
]);
|
||||
|
||||
$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' => 10,
|
||||
]);
|
||||
|
||||
$thread->load(['forum', 'user', 'posts', 'attachments', 'latestPost']);
|
||||
|
||||
expect($thread->solved)->toBeTrue();
|
||||
expect($thread->forum->id)->toBe($forum->id);
|
||||
expect($thread->user->id)->toBe($user->id);
|
||||
expect($thread->posts)->toHaveCount(2);
|
||||
expect($thread->attachments->first()->id)->toBe($attachment->id);
|
||||
expect($thread->latestPost->id)->toBe($newPost->id);
|
||||
});
|
||||
44
tests/Unit/UpdateUserPasswordTest.php
Normal file
44
tests/Unit/UpdateUserPasswordTest.php
Normal file
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
use App\Actions\Fortify\UpdateUserPassword;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
it('updates password when current password matches', function (): void {
|
||||
$user = User::factory()->create([
|
||||
'password' => Hash::make('OldPass123!'),
|
||||
]);
|
||||
$this->actingAs($user);
|
||||
|
||||
$action = new UpdateUserPassword();
|
||||
|
||||
$action->update($user, [
|
||||
'current_password' => 'OldPass123!',
|
||||
'password' => 'NewPass123!',
|
||||
'password_confirmation' => 'NewPass123!',
|
||||
]);
|
||||
|
||||
$user->refresh();
|
||||
expect(Hash::check('NewPass123!', $user->password))->toBeTrue();
|
||||
});
|
||||
|
||||
it('rejects wrong current password', function (): void {
|
||||
$user = User::factory()->create([
|
||||
'password' => Hash::make('OldPass123!'),
|
||||
]);
|
||||
$this->actingAs($user);
|
||||
|
||||
$action = new UpdateUserPassword();
|
||||
|
||||
try {
|
||||
$action->update($user, [
|
||||
'current_password' => 'WrongPass',
|
||||
'password' => 'NewPass123!',
|
||||
'password_confirmation' => 'NewPass123!',
|
||||
]);
|
||||
$this->fail('Expected ValidationException not thrown.');
|
||||
} catch (ValidationException $e) {
|
||||
expect($e->errors())->toHaveKey('current_password');
|
||||
}
|
||||
});
|
||||
40
tests/Unit/UpdateUserProfileInformationTest.php
Normal file
40
tests/Unit/UpdateUserProfileInformationTest.php
Normal file
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
use App\Actions\Fortify\UpdateUserProfileInformation;
|
||||
use App\Models\User;
|
||||
|
||||
it('updates profile without email verification when email unchanged', function (): void {
|
||||
$user = User::factory()->create([
|
||||
'name' => 'Old',
|
||||
'email' => 'old@example.com',
|
||||
]);
|
||||
|
||||
$action = new UpdateUserProfileInformation();
|
||||
$action->update($user, [
|
||||
'name' => 'New Name',
|
||||
'email' => 'old@example.com',
|
||||
]);
|
||||
|
||||
$user->refresh();
|
||||
expect($user->name)->toBe('New Name');
|
||||
expect($user->name_canonical)->toBe('new name');
|
||||
expect($user->email)->toBe('old@example.com');
|
||||
});
|
||||
|
||||
it('resets verification and sends notification when email changes', function (): void {
|
||||
$user = User::factory()->create([
|
||||
'name' => 'Old',
|
||||
'email' => 'old@example.com',
|
||||
'email_verified_at' => now(),
|
||||
]);
|
||||
|
||||
$action = new UpdateUserProfileInformation();
|
||||
$action->update($user, [
|
||||
'name' => 'New Name',
|
||||
'email' => 'new@example.com',
|
||||
]);
|
||||
|
||||
$user->refresh();
|
||||
expect($user->email)->toBe('new@example.com');
|
||||
expect($user->email_verified_at)->toBeNull();
|
||||
});
|
||||
14
tests/Unit/UploadControllerUnitTest.php
Normal file
14
tests/Unit/UploadControllerUnitTest.php
Normal file
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
|
||||
use App\Http\Controllers\UploadController;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
it('returns unauthorized when storeAvatar has no user', function (): void {
|
||||
$controller = new UploadController();
|
||||
$request = Request::create('/api/user/avatar', 'POST');
|
||||
$request->setUserResolver(fn () => null);
|
||||
|
||||
$response = $controller->storeAvatar($request);
|
||||
|
||||
expect($response->getStatusCode())->toBe(401);
|
||||
});
|
||||
81
tests/Unit/UserControllerUnitTest.php
Normal file
81
tests/Unit/UserControllerUnitTest.php
Normal file
@@ -0,0 +1,81 @@
|
||||
<?php
|
||||
|
||||
use App\Http\Controllers\UserController;
|
||||
use App\Models\Role;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
it('returns unauthenticated for me when no user', function (): void {
|
||||
$controller = new UserController();
|
||||
$request = Request::create('/api/user/me', 'GET');
|
||||
$request->setUserResolver(fn () => null);
|
||||
|
||||
$response = $controller->me($request);
|
||||
|
||||
expect($response->getStatusCode())->toBe(401);
|
||||
});
|
||||
|
||||
it('returns unauthenticated for updateMe when no user', function (): void {
|
||||
$controller = new UserController();
|
||||
$request = Request::create('/api/user/me', 'PATCH', [
|
||||
'location' => 'Test',
|
||||
]);
|
||||
$request->setUserResolver(fn () => null);
|
||||
|
||||
$response = $controller->updateMe($request);
|
||||
|
||||
expect($response->getStatusCode())->toBe(401);
|
||||
});
|
||||
|
||||
it('trims blank location to null in updateMe', function (): void {
|
||||
$controller = new UserController();
|
||||
$user = User::factory()->create(['location' => 'Somewhere']);
|
||||
|
||||
$request = Request::create('/api/user/me', 'PATCH', [
|
||||
'location' => ' ',
|
||||
]);
|
||||
$request->setUserResolver(fn () => $user);
|
||||
|
||||
$response = $controller->updateMe($request);
|
||||
|
||||
expect($response->getStatusCode())->toBe(200);
|
||||
$user->refresh();
|
||||
expect($user->location)->toBeNull();
|
||||
});
|
||||
|
||||
it('resolves avatar urls when present', function (): void {
|
||||
Storage::fake('public');
|
||||
|
||||
$controller = new UserController();
|
||||
$user = User::factory()->create([
|
||||
'avatar_path' => 'avatars/test.png',
|
||||
]);
|
||||
|
||||
$response = $controller->profile($user);
|
||||
|
||||
expect($response->getStatusCode())->toBe(200);
|
||||
expect($response->getData(true)['avatar_url'])->not->toBeNull();
|
||||
});
|
||||
|
||||
it('returns null group color when roles relation is null', function (): void {
|
||||
$controller = new UserController();
|
||||
$user = User::factory()->create();
|
||||
$user->setRelation('roles', null);
|
||||
|
||||
$ref = new ReflectionMethod($controller, 'resolveGroupColor');
|
||||
$ref->setAccessible(true);
|
||||
|
||||
$result = $ref->invoke($controller, $user);
|
||||
|
||||
expect($result)->toBeNull();
|
||||
});
|
||||
|
||||
it('normalizes empty and raw role names', function (): void {
|
||||
$controller = new UserController();
|
||||
$ref = new ReflectionMethod($controller, 'normalizeRoleName');
|
||||
$ref->setAccessible(true);
|
||||
|
||||
expect($ref->invoke($controller, ''))->toBe('ROLE_');
|
||||
expect($ref->invoke($controller, 'moderator'))->toBe('ROLE_MODERATOR');
|
||||
});
|
||||
122
tests/Unit/VersionBumpCommandTest.php
Normal file
122
tests/Unit/VersionBumpCommandTest.php
Normal file
@@ -0,0 +1,122 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands {
|
||||
if (!function_exists(__NAMESPACE__ . '\\file_get_contents')) {
|
||||
function file_get_contents($path): string|false
|
||||
{
|
||||
if (!empty($GLOBALS['version_bump_file_get_contents_false']) && str_ends_with($path, 'composer.json')) {
|
||||
return false;
|
||||
}
|
||||
if (!empty($GLOBALS['version_fetch_file_get_contents_false']) && str_ends_with($path, 'composer.json')) {
|
||||
return false;
|
||||
}
|
||||
if (!empty($GLOBALS['version_set_file_get_contents_false']) && str_ends_with($path, 'composer.json')) {
|
||||
return false;
|
||||
}
|
||||
if (!empty($GLOBALS['version_release_file_get_contents_false']) && str_ends_with($path, 'CHANGELOG.md')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return \file_get_contents($path);
|
||||
}
|
||||
}
|
||||
|
||||
if (!function_exists(__NAMESPACE__ . '\\json_encode')) {
|
||||
function json_encode($value, int $flags = 0): string|false
|
||||
{
|
||||
if (!empty($GLOBALS['version_bump_json_encode_false'])) {
|
||||
return false;
|
||||
}
|
||||
if (!empty($GLOBALS['version_fetch_json_encode_false']) && is_array($value) && array_key_exists('build', $value)) {
|
||||
return false;
|
||||
}
|
||||
if (!empty($GLOBALS['version_set_json_encode_false'])) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return \json_encode($value, $flags);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
namespace {
|
||||
use App\Models\Setting;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
|
||||
function withComposerBackup(callable $callback): void
|
||||
{
|
||||
$path = base_path('composer.json');
|
||||
$original = file_get_contents($path);
|
||||
|
||||
try {
|
||||
$callback($path, $original);
|
||||
} finally {
|
||||
if ($original !== false) {
|
||||
file_put_contents($path, $original);
|
||||
}
|
||||
$GLOBALS['version_bump_file_get_contents_false'] = false;
|
||||
$GLOBALS['version_bump_json_encode_false'] = false;
|
||||
}
|
||||
}
|
||||
|
||||
it('bumps patch version and syncs composer metadata', function (): void {
|
||||
withComposerBackup(function (string $path): void {
|
||||
Setting::updateOrCreate(['key' => 'version'], ['value' => '1.2.09-beta']);
|
||||
|
||||
$exitCode = Artisan::call('version:bump');
|
||||
expect($exitCode)->toBe(0);
|
||||
|
||||
$setting = Setting::where('key', 'version')->value('value');
|
||||
expect($setting)->toBe('1.2.10-beta');
|
||||
|
||||
$data = json_decode((string) file_get_contents($path), true);
|
||||
expect($data['version'] ?? null)->toBe('1.2.10-beta');
|
||||
});
|
||||
});
|
||||
|
||||
it('fails when composer.json cannot be decoded', function (): void {
|
||||
withComposerBackup(function (string $path): void {
|
||||
file_put_contents($path, 'not-json');
|
||||
|
||||
Setting::updateOrCreate(['key' => 'version'], ['value' => '1.2.3']);
|
||||
|
||||
$exitCode = Artisan::call('version:bump');
|
||||
expect($exitCode)->toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
it('fails when composer.json is not readable', function (): void {
|
||||
withComposerBackup(function (string $path): void {
|
||||
chmod($path, 0000);
|
||||
|
||||
Setting::updateOrCreate(['key' => 'version'], ['value' => '1.2.3']);
|
||||
|
||||
$exitCode = Artisan::call('version:bump');
|
||||
expect($exitCode)->toBe(1);
|
||||
|
||||
chmod($path, 0644);
|
||||
});
|
||||
});
|
||||
|
||||
it('fails when file_get_contents returns false', function (): void {
|
||||
withComposerBackup(function (): void {
|
||||
$GLOBALS['version_bump_file_get_contents_false'] = true;
|
||||
|
||||
Setting::updateOrCreate(['key' => 'version'], ['value' => '1.2.3']);
|
||||
|
||||
$exitCode = Artisan::call('version:bump');
|
||||
expect($exitCode)->toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
it('fails when json_encode returns false', function (): void {
|
||||
withComposerBackup(function (): void {
|
||||
$GLOBALS['version_bump_json_encode_false'] = true;
|
||||
|
||||
Setting::updateOrCreate(['key' => 'version'], ['value' => '1.2.3']);
|
||||
|
||||
$exitCode = Artisan::call('version:bump');
|
||||
expect($exitCode)->toBe(1);
|
||||
});
|
||||
});
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user