feat: system tools and admin enhancements
All checks were successful
CI/CD Pipeline / test (push) Successful in 3s
CI/CD Pipeline / deploy (push) Successful in 20s

This commit is contained in:
2026-01-31 20:12:09 +01:00
parent 64244567c0
commit 9c60a8944e
31 changed files with 3088 additions and 173 deletions

1
.gitignore vendored
View File

@@ -4,6 +4,7 @@
.env .env
.env.backup .env.backup
.env.production .env.production
.env.test
.env.*.local .env.*.local
.phpactor.json .phpactor.json
.phpunit.result.cache .phpunit.result.cache

1
NOTES.md Normal file
View File

@@ -0,0 +1 @@
TODO: Remove remaining IIFEs in ACP UI; prefer plain components/helpers.

View File

@@ -45,6 +45,17 @@ class BbcodeFormatter
$configurator->tags->add('BR')->template = '<br/>'; $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(); $bundle = $configurator->finalize();
$parser = $bundle['parser'] ?? null; $parser = $bundle['parser'] ?? null;
$renderer = $bundle['renderer'] ?? null; $renderer = $bundle['renderer'] ?? null;

View File

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

View File

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

View File

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

View File

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

View File

@@ -5,7 +5,8 @@ namespace App\Http\Controllers;
use App\Models\Attachment; use App\Models\Attachment;
use App\Models\AttachmentExtension; use App\Models\AttachmentExtension;
use App\Models\Post; use App\Models\Post;
use App\Models\Setting; use App\Services\AttachmentThumbnailService;
use App\Services\AuditLogger;
use App\Models\Thread; use App\Models\Thread;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
@@ -115,7 +116,8 @@ class AttachmentController extends Controller
$path = "attachments/{$scopeFolder}/{$filename}"; $path = "attachments/{$scopeFolder}/{$filename}";
Storage::disk($disk)->putFileAs("attachments/{$scopeFolder}", $file, $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([ $attachment = Attachment::create([
'thread_id' => $threadId, 'thread_id' => $threadId,
@@ -134,6 +136,13 @@ class AttachmentController extends Controller
'size_bytes' => (int) $file->getSize(), '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']); $attachment->loadMissing(['extension', 'group']);
return response()->json($this->serializeAttachment($attachment), 201); 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); 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(); $attachment->delete();
return response()->json(null, 204); 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;
}
} }

View File

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

View File

@@ -5,6 +5,7 @@ namespace App\Http\Controllers;
use App\Actions\Fortify\CreateNewUser; use App\Actions\Fortify\CreateNewUser;
use App\Actions\Fortify\PasswordValidationRules; use App\Actions\Fortify\PasswordValidationRules;
use App\Models\User; use App\Models\User;
use App\Services\AuditLogger;
use Illuminate\Auth\Events\Verified; use Illuminate\Auth\Events\Verified;
use Illuminate\Auth\Events\PasswordReset; use Illuminate\Auth\Events\PasswordReset;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
@@ -32,6 +33,9 @@ class AuthController extends Controller
$user = $creator->create(input: $input); $user = $creator->create(input: $input);
$user->sendEmailVerificationNotification(); $user->sendEmailVerificationNotification();
app(AuditLogger::class)->log($request, 'user.registered', $user, [
'email' => $user->email,
], $user);
return response()->json(data: [ return response()->json(data: [
'user_id' => $user->id, 'user_id' => $user->id,
@@ -77,6 +81,10 @@ class AuthController extends Controller
$token = $user->createToken(name: 'api')->plainTextToken; $token = $user->createToken(name: 'api')->plainTextToken;
app(AuditLogger::class)->log($request, 'user.login', $user, [
'login' => $login,
], $user);
return response()->json(data: [ return response()->json(data: [
'token' => $token, 'token' => $token,
'user_id' => $user->id, 'user_id' => $user->id,
@@ -130,13 +138,14 @@ class AuthController extends Controller
$status = Password::reset( $status = Password::reset(
$request->only('email', 'password', 'password_confirmation', 'token'), $request->only('email', 'password', 'password_confirmation', 'token'),
function (User $user, string $password) { function (User $user, string $password) use ($request) {
$user->forceFill(attributes: [ $user->forceFill(attributes: [
'password' => Hash::make(value: $password), 'password' => Hash::make(value: $password),
'remember_token' => Str::random(length: 60), 'remember_token' => Str::random(length: 60),
])->save(); ])->save();
event(new PasswordReset(user: $user)); 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), 'remember_token' => Str::random(length: 60),
])->save(); ])->save();
app(AuditLogger::class)->log($request, 'user.password_changed', $user, [], $user);
return response()->json(data: ['message' => 'Password updated.']); return response()->json(data: ['message' => 'Password updated.']);
} }
public function logout(Request $request): JsonResponse public function logout(Request $request): JsonResponse
{ {
app(AuditLogger::class)->log($request, 'user.logout', $request->user());
$request->user()?->currentAccessToken()?->delete(); $request->user()?->currentAccessToken()?->delete();
return response()->json(data: null, status: 204); return response()->json(data: null, status: 204);

View File

@@ -6,6 +6,7 @@ use App\Actions\BbcodeFormatter;
use App\Models\Post; use App\Models\Post;
use App\Models\Thread; use App\Models\Thread;
use App\Models\Setting; use App\Models\Setting;
use App\Services\AuditLogger;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Storage;
@@ -54,6 +55,10 @@ class PostController extends Controller
'body' => $data['body'], 'body' => $data['body'],
]); ]);
app(AuditLogger::class)->log($request, 'post.created', $post, [
'thread_id' => $thread->id,
]);
$post->loadMissing([ $post->loadMissing([
'user' => fn ($query) => $query 'user' => fn ($query) => $query
->withCount(['posts', 'threads', 'thanksGiven', 'thanksReceived']) ->withCount(['posts', 'threads', 'thanksGiven', 'thanksReceived'])
@@ -67,6 +72,13 @@ class PostController extends Controller
public function destroy(Request $request, Post $post): JsonResponse public function destroy(Request $request, Post $post): JsonResponse
{ {
$reason = $request->input('reason');
$reasonText = $request->input('reason_text');
app(AuditLogger::class)->log($request, 'post.deleted', $post, [
'thread_id' => $post->thread_id,
'reason' => $reason,
'reason_text' => $reasonText,
]);
$post->deleted_by = $request->user()?->id; $post->deleted_by = $request->user()?->id;
$post->save(); $post->save();
$post->delete(); $post->delete();
@@ -74,6 +86,41 @@ class PostController extends Controller
return response()->json(null, 204); return response()->json(null, 204);
} }
public function update(Request $request, Post $post): JsonResponse
{
$user = $request->user();
if (!$user) {
return response()->json(['message' => 'Unauthorized.'], 401);
}
$isAdmin = $user->roles()->where('name', 'ROLE_ADMIN')->exists();
if (!$isAdmin && $post->user_id !== $user->id) {
return response()->json(['message' => 'Not authorized to edit posts.'], 403);
}
$data = $request->validate([
'body' => ['required', 'string'],
]);
$post->body = $data['body'];
$post->save();
$post->refresh();
app(AuditLogger::class)->log($request, 'post.edited', $post, [
'thread_id' => $post->thread_id,
]);
$post->loadMissing([
'user' => fn ($query) => $query
->withCount(['posts', 'threads', 'thanksGiven', 'thanksReceived'])
->with(['rank', 'roles']),
'attachments.extension',
'attachments.group',
]);
return response()->json($this->serializePost($post));
}
private function parseIriId(?string $value): ?int private function parseIriId(?string $value): ?int
{ {
if (!$value) { if (!$value) {
@@ -163,6 +210,9 @@ class PostController extends Controller
$map[$name] = [ $map[$name] = [
'url' => "/api/attachments/{$attachment->id}/download", 'url' => "/api/attachments/{$attachment->id}/download",
'mime' => $attachment->mime_type ?? '', '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']; $url = $entry['url'];
$mime = $entry['mime'] ?? ''; $mime = $entry['mime'] ?? '';
if (str_starts_with($mime, 'image/') && $this->displayImagesInline()) { 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 "[img]{$url}[/img]";
} }
return "[url={$url}]{$rawName}[/url]"; return "[url={$url}]{$rawName}[/url]";

View File

@@ -5,17 +5,157 @@ namespace App\Http\Controllers;
use App\Models\Post; use App\Models\Post;
use App\Models\Thread; use App\Models\Thread;
use App\Models\User; use App\Models\User;
use App\Models\Attachment;
use App\Models\Setting;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
class StatsController extends Controller class StatsController extends Controller
{ {
public function __invoke(): JsonResponse public function __invoke(): JsonResponse
{ {
$threadsCount = Thread::query()->withoutTrashed()->count();
$postsCount = Post::query()->withoutTrashed()->count();
$usersCount = User::query()->count();
$attachmentsCount = Attachment::query()->withoutTrashed()->count();
$attachmentsSizeBytes = (int) Attachment::query()->withoutTrashed()->sum('size_bytes');
$boardStartedAt = $this->resolveBoardStartedAt();
$daysSinceStart = $boardStartedAt
? max(1, Carbon::parse($boardStartedAt)->diffInSeconds(now()) / 86400)
: null;
$dbSizeBytes = $this->resolveDatabaseSize();
$dbServer = $this->resolveDatabaseServer();
$avatarSizeBytes = $this->resolveAvatarDirectorySize();
$orphanAttachments = $this->resolveOrphanAttachments();
$version = Setting::query()->where('key', 'version')->value('value');
$build = Setting::query()->where('key', 'build')->value('value');
$boardVersion = $version
? ($build ? "{$version} (build {$build})" : $version)
: null;
return response()->json([ return response()->json([
'threads' => Thread::query()->withoutTrashed()->count(), 'threads' => $threadsCount,
'posts' => Post::query()->withoutTrashed()->count() 'posts' => $postsCount + $threadsCount,
+ Thread::query()->withoutTrashed()->count(), 'users' => $usersCount,
'users' => User::query()->count(), 'attachments' => $attachmentsCount,
'board_started_at' => $boardStartedAt,
'attachments_size_bytes' => $attachmentsSizeBytes,
'avatar_directory_size_bytes' => $avatarSizeBytes,
'database_size_bytes' => $dbSizeBytes,
'database_server' => $dbServer,
'gzip_compression' => $this->resolveGzipCompression(),
'php_version' => PHP_VERSION,
'orphan_attachments' => $orphanAttachments,
'board_version' => $boardVersion,
'posts_per_day' => $daysSinceStart ? ($postsCount + $threadsCount) / $daysSinceStart : null,
'topics_per_day' => $daysSinceStart ? $threadsCount / $daysSinceStart : null,
'users_per_day' => $daysSinceStart ? $usersCount / $daysSinceStart : null,
'attachments_per_day' => $daysSinceStart ? $attachmentsCount / $daysSinceStart : null,
]); ]);
} }
private function resolveBoardStartedAt(): ?string
{
$timestamps = [
User::query()->min('created_at'),
Thread::query()->min('created_at'),
Post::query()->min('created_at'),
];
$min = null;
foreach ($timestamps as $value) {
if (!$value) {
continue;
}
$time = Carbon::parse($value)->timestamp;
if ($min === null || $time < $min) {
$min = $time;
}
}
return $min !== null ? Carbon::createFromTimestamp($min)->toIso8601String() : null;
}
private function resolveDatabaseSize(): ?int
{
try {
$driver = DB::connection()->getDriverName();
if ($driver === 'mysql') {
$row = DB::selectOne('SELECT SUM(data_length + index_length) AS size FROM information_schema.tables WHERE table_schema = DATABASE()');
return $row && isset($row->size) ? (int) $row->size : null;
}
} catch (\Throwable) {
return null;
}
return null;
}
private function resolveDatabaseServer(): ?string
{
try {
$row = DB::selectOne('SELECT VERSION() AS version');
return $row && isset($row->version) ? (string) $row->version : null;
} catch (\Throwable) {
return null;
}
}
private function resolveAvatarDirectorySize(): ?int
{
try {
$disk = Storage::disk('public');
$files = $disk->allFiles('avatars');
$total = 0;
foreach ($files as $file) {
$total += $disk->size($file);
}
return $total;
} catch (\Throwable) {
return null;
}
}
private function resolveOrphanAttachments(): int
{
try {
return (int) DB::table('attachments')
->leftJoin('threads', 'attachments.thread_id', '=', 'threads.id')
->leftJoin('posts', 'attachments.post_id', '=', 'posts.id')
->whereNull('attachments.deleted_at')
->where(function ($query) {
$query
->whereNull('attachments.thread_id')
->whereNull('attachments.post_id')
->orWhere(function ($inner) {
$inner->whereNotNull('attachments.thread_id')
->where(function ($inner2) {
$inner2->whereNull('threads.id')
->orWhereNotNull('threads.deleted_at');
});
})
->orWhere(function ($inner) {
$inner->whereNotNull('attachments.post_id')
->where(function ($inner2) {
$inner2->whereNull('posts.id')
->orWhereNotNull('posts.deleted_at');
});
});
})
->count();
} catch (\Throwable) {
return 0;
}
}
private function resolveGzipCompression(): bool
{
$value = ini_get('zlib.output_compression');
return in_array(strtolower((string) $value), ['1', 'on', 'true'], true);
}
} }

View File

@@ -0,0 +1,150 @@
<?php
namespace App\Http\Controllers;
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');
$phpSelectedPath = PHP_BINARY ?: $phpDefaultPath;
$phpSelectedOk = (bool) $phpSelectedPath;
$phpSelectedVersion = 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_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 : [];
}
}

View File

@@ -0,0 +1,199 @@
<?php
namespace App\Http\Controllers;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Str;
use Symfony\Component\Process\Process;
class SystemUpdateController extends Controller
{
public function __invoke(Request $request): JsonResponse
{
$user = $request->user();
if (!$user || !$user->roles()->where('name', 'ROLE_ADMIN')->exists()) {
return response()->json(['message' => 'Forbidden'], 403);
}
set_time_limit(0);
$owner = env('GITEA_OWNER');
$repo = env('GITEA_REPO');
$apiBase = rtrim((string) env('GITEA_API_BASE', 'https://git.24unix.net/api/v1'), '/');
$token = env('GITEA_TOKEN');
if (!$owner || !$repo) {
return response()->json(['message' => 'Missing Gitea configuration.'], 422);
}
$log = [];
$append = function (string $line) use (&$log) {
$log[] = $line;
};
try {
$client = Http::acceptJson();
if ($token) {
$client = $client->withHeaders(['Authorization' => "token {$token}"]);
}
$append('Fetching latest release...');
$response = $client->get("{$apiBase}/repos/{$owner}/{$repo}/releases/latest");
if (!$response->successful()) {
return response()->json([
'message' => "Release check failed: {$response->status()}",
'log' => $log,
], 500);
}
$tag = (string) ($response->json('tag_name') ?? '');
if ($tag === '') {
return response()->json([
'message' => 'Release tag not found.',
'log' => $log,
], 500);
}
$tarballUrl = (string) ($response->json('tarball_url') ?? '');
if ($tarballUrl === '') {
$tarballUrl = env('GITEA_TGZ_URL_TEMPLATE');
if ($tarballUrl) {
$tarballUrl = str_replace('{{TAG}}', $tag, $tarballUrl);
$tarballUrl = str_replace('{{VERSION}}', ltrim($tag, 'v'), $tarballUrl);
}
}
if ($tarballUrl === '') {
return response()->json([
'message' => 'No tarball URL available.',
'log' => $log,
], 500);
}
$append("Downloading {$tag}...");
$archivePath = storage_path('app/updates/' . $tag . '.tar.gz');
File::ensureDirectoryExists(dirname($archivePath));
$download = $client->withOptions(['stream' => true])->get($tarballUrl);
if (!$download->successful()) {
return response()->json([
'message' => "Download failed: {$download->status()}",
'log' => $log,
], 500);
}
File::put($archivePath, $download->body());
$extractDir = storage_path('app/updates/extract-' . Str::random(8));
File::ensureDirectoryExists($extractDir);
$append('Extracting archive...');
$tar = new Process(['tar', '-xzf', $archivePath, '-C', $extractDir]);
$tar->setTimeout(300);
$tar->run();
if (!$tar->isSuccessful()) {
return response()->json([
'message' => 'Failed to extract archive.',
'log' => array_merge($log, [$tar->getErrorOutput()]),
], 500);
}
$entries = collect(File::directories($extractDir))->values();
if ($entries->isEmpty()) {
return response()->json([
'message' => 'No extracted folder found.',
'log' => $log,
], 500);
}
$sourceDir = $entries->first();
$append('Syncing files...');
$usedRsync = false;
$rsyncPath = trim((string) shell_exec('command -v rsync'));
if ($rsyncPath !== '') {
$usedRsync = true;
$rsync = new Process([
'rsync',
'-a',
'--delete',
'--exclude=.env',
'--exclude=storage',
'--exclude=public/storage',
$sourceDir . '/',
base_path() . '/',
]);
$rsync->setTimeout(600);
$rsync->run();
if (!$rsync->isSuccessful()) {
return response()->json([
'message' => 'rsync failed.',
'log' => array_merge($log, [$rsync->getErrorOutput()]),
], 500);
}
} else {
File::copyDirectory($sourceDir, base_path());
}
$append('Installing composer dependencies...');
$composer = new Process(['composer', 'install', '--no-dev', '--optimize-autoloader'], base_path());
$composer->setTimeout(600);
$composer->run();
if (!$composer->isSuccessful()) {
return response()->json([
'message' => 'Composer install failed.',
'log' => array_merge($log, [$composer->getErrorOutput()]),
], 500);
}
$append('Installing npm dependencies...');
$npmInstall = new Process(['npm', 'install'], base_path());
$npmInstall->setTimeout(600);
$npmInstall->run();
if (!$npmInstall->isSuccessful()) {
return response()->json([
'message' => 'npm install failed.',
'log' => array_merge($log, [$npmInstall->getErrorOutput()]),
], 500);
}
$append('Building assets...');
$npmBuild = new Process(['npm', 'run', 'build'], base_path());
$npmBuild->setTimeout(900);
$npmBuild->run();
if (!$npmBuild->isSuccessful()) {
return response()->json([
'message' => 'npm run build failed.',
'log' => array_merge($log, [$npmBuild->getErrorOutput()]),
], 500);
}
$phpBinary = 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);
}
}
}

View File

@@ -6,6 +6,7 @@ use App\Models\Forum;
use App\Models\Thread; use App\Models\Thread;
use App\Actions\BbcodeFormatter; use App\Actions\BbcodeFormatter;
use App\Models\Setting; use App\Models\Setting;
use App\Services\AuditLogger;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
@@ -81,6 +82,11 @@ class ThreadController extends Controller
'body' => $data['body'], 'body' => $data['body'],
]); ]);
app(AuditLogger::class)->log($request, 'thread.created', $thread, [
'forum_id' => $forum->id,
'title' => $thread->title,
]);
$thread->loadMissing([ $thread->loadMissing([
'user' => fn ($query) => $query 'user' => fn ($query) => $query
->withCount(['posts', 'threads', 'thanksGiven', 'thanksReceived']) ->withCount(['posts', 'threads', 'thanksGiven', 'thanksReceived'])
@@ -96,6 +102,14 @@ class ThreadController extends Controller
public function destroy(Request $request, Thread $thread): JsonResponse public function destroy(Request $request, Thread $thread): JsonResponse
{ {
$reason = $request->input('reason');
$reasonText = $request->input('reason_text');
app(AuditLogger::class)->log($request, 'thread.deleted', $thread, [
'forum_id' => $thread->forum_id,
'title' => $thread->title,
'reason' => $reason,
'reason_text' => $reasonText,
]);
$thread->deleted_by = $request->user()?->id; $thread->deleted_by = $request->user()?->id;
$thread->save(); $thread->save();
$thread->delete(); $thread->delete();
@@ -103,6 +117,51 @@ class ThreadController extends Controller
return response()->json(null, 204); return response()->json(null, 204);
} }
public function update(Request $request, Thread $thread): JsonResponse
{
$user = $request->user();
if (!$user) {
return response()->json(['message' => 'Unauthorized.'], 401);
}
$isAdmin = $user->roles()->where('name', 'ROLE_ADMIN')->exists();
if (!$isAdmin && $thread->user_id !== $user->id) {
return response()->json(['message' => 'Not authorized to edit threads.'], 403);
}
$data = $request->validate([
'title' => ['sometimes', 'required', 'string'],
'body' => ['sometimes', 'required', 'string'],
]);
if (array_key_exists('title', $data)) {
$thread->title = $data['title'];
}
if (array_key_exists('body', $data)) {
$thread->body = $data['body'];
}
$thread->save();
$thread->refresh();
app(AuditLogger::class)->log($request, 'thread.edited', $thread, [
'forum_id' => $thread->forum_id,
'title' => $thread->title,
]);
$thread->loadMissing([
'user' => fn ($query) => $query
->withCount(['posts', 'threads', 'thanksGiven', 'thanksReceived'])
->with(['rank', 'roles']),
'attachments.extension',
'attachments.group',
'latestPost.user.rank',
'latestPost.user.roles',
])->loadCount('posts');
return response()->json($this->serializeThread($thread));
}
public function updateSolved(Request $request, Thread $thread): JsonResponse public function updateSolved(Request $request, Thread $thread): JsonResponse
{ {
$user = $request->user(); $user = $request->user();
@@ -121,6 +180,9 @@ class ThreadController extends Controller
$thread->solved = $data['solved']; $thread->solved = $data['solved'];
$thread->save(); $thread->save();
app(AuditLogger::class)->log($request, 'thread.solved_updated', $thread, [
'solved' => $thread->solved,
]);
$thread->refresh(); $thread->refresh();
$thread->loadMissing([ $thread->loadMissing([
'user' => fn ($query) => $query 'user' => fn ($query) => $query
@@ -238,6 +300,9 @@ class ThreadController extends Controller
$map[$name] = [ $map[$name] = [
'url' => "/api/attachments/{$attachment->id}/download", 'url' => "/api/attachments/{$attachment->id}/download",
'mime' => $attachment->mime_type ?? '', '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']; $url = $entry['url'];
$mime = $entry['mime'] ?? ''; $mime = $entry['mime'] ?? '';
if (str_starts_with($mime, 'image/') && $this->displayImagesInline()) { 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 "[img]{$url}[/img]";
} }
return "[url={$url}]{$rawName}[/url]"; return "[url={$url}]{$rawName}[/url]";

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

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

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

View File

@@ -3,7 +3,10 @@
"name": "laravel/laravel", "name": "laravel/laravel",
"type": "project", "type": "project",
"description": "The skeleton application for the Laravel framework.", "description": "The skeleton application for the Laravel framework.",
"keywords": ["laravel", "framework"], "keywords": [
"laravel",
"framework"
],
"license": "MIT", "license": "MIT",
"require": { "require": {
"php": "^8.4", "php": "^8.4",
@@ -12,6 +15,7 @@
"laravel/sanctum": "*", "laravel/sanctum": "*",
"laravel/tinker": "^2.10.1", "laravel/tinker": "^2.10.1",
"s9e/text-formatter": "^2.5", "s9e/text-formatter": "^2.5",
"composer-runtime-api": "^2.2",
"ext-pdo": "*" "ext-pdo": "*"
}, },
"require-dev": { "require-dev": {
@@ -89,5 +93,6 @@
} }
}, },
"minimum-stability": "stable", "minimum-stability": "stable",
"prefer-stable": true "prefer-stable": true,
"version": "26.0.1"
} }

View File

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

View File

@@ -2,6 +2,10 @@
"$schema": "https://www.schemastore.org/package.json", "$schema": "https://www.schemastore.org/package.json",
"private": true, "private": true,
"type": "module", "type": "module",
"engines": {
"node": ">=20",
"npm": ">=10"
},
"scripts": { "scripts": {
"build": "vite build", "build": "vite build",
"dev": "vite", "dev": "vite",

View File

@@ -7,7 +7,7 @@ import ForumView from './pages/ForumView'
import ThreadView from './pages/ThreadView' import ThreadView from './pages/ThreadView'
import Login from './pages/Login' import Login from './pages/Login'
import Register from './pages/Register' import Register from './pages/Register'
import Acp from './pages/Acp' import { Acp } from './pages/Acp'
import BoardIndex from './pages/BoardIndex' import BoardIndex from './pages/BoardIndex'
import Ucp from './pages/Ucp' import Ucp from './pages/Ucp'
import Profile from './pages/Profile' import Profile from './pages/Profile'

View File

@@ -62,6 +62,12 @@ export async function registerUser({ email, username, plainPassword }) {
}) })
} }
export async function logoutUser() {
return apiFetch('/logout', {
method: 'POST',
})
}
export async function listRootForums() { export async function listRootForums() {
return getCollection('/forums?parent[exists]=false') return getCollection('/forums?parent[exists]=false')
} }
@@ -109,6 +115,20 @@ export async function fetchVersion() {
return apiFetch('/version') return apiFetch('/version')
} }
export async function fetchVersionCheck() {
return apiFetch('/version/check')
}
export async function runSystemUpdate() {
return apiFetch('/system/update', {
method: 'POST',
})
}
export async function fetchSystemStatus() {
return apiFetch('/system/status')
}
export async function fetchStats() { export async function fetchStats() {
return apiFetch('/stats') return apiFetch('/stats')
} }
@@ -253,6 +273,23 @@ export async function getThread(id) {
return apiFetch(`/threads/${id}`) return apiFetch(`/threads/${id}`)
} }
export async function deleteThread(id, payload = null) {
return apiFetch(`/threads/${id}`, {
method: 'DELETE',
...(payload ? { body: JSON.stringify(payload) } : {}),
})
}
export async function updateThread(id, payload) {
return apiFetch(`/threads/${id}`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/merge-patch+json',
},
body: JSON.stringify(payload),
})
}
export async function updateThreadSolved(threadId, solved) { export async function updateThreadSolved(threadId, solved) {
return apiFetch(`/threads/${threadId}/solved`, { return apiFetch(`/threads/${threadId}/solved`, {
method: 'PATCH', method: 'PATCH',
@@ -351,10 +388,32 @@ export async function listPostsByThread(threadId) {
return getCollection(`/posts?thread=/api/threads/${threadId}`) return getCollection(`/posts?thread=/api/threads/${threadId}`)
} }
export async function updatePost(id, payload) {
return apiFetch(`/posts/${id}`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/merge-patch+json',
},
body: JSON.stringify(payload),
})
}
export async function deletePost(id, payload = null) {
return apiFetch(`/posts/${id}`, {
method: 'DELETE',
...(payload ? { body: JSON.stringify(payload) } : {}),
})
}
export async function listUsers() { export async function listUsers() {
return getCollection('/users') return getCollection('/users')
} }
export async function listAuditLogs(limit = 200) {
const query = Number.isFinite(limit) ? `?limit=${limit}` : ''
return getCollection(`/audit-logs${query}`)
}
export async function listRanks() { export async function listRanks() {
return getCollection('/ranks') return getCollection('/ranks')
} }

View File

@@ -1,5 +1,5 @@
import { createContext, useContext, useMemo, useState, useEffect } from 'react' 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) const AuthContext = createContext(null)
@@ -46,7 +46,12 @@ export function AuthProvider({ children }) {
setToken(data.token) setToken(data.token)
setEmail(data.email || loginInput) 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_token')
localStorage.removeItem('speedbb_email') localStorage.removeItem('speedbb_email')
localStorage.removeItem('speedbb_user_id') localStorage.removeItem('speedbb_user_id')

View File

@@ -216,6 +216,39 @@ a {
overflow-y: auto; 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 { .bb-attachment-list {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -740,6 +773,27 @@ a {
flex: 1 1 auto; flex: 1 1 auto;
} }
.bb-post-body blockquote {
margin: 1rem 0;
padding: 0.75rem 1rem;
border-left: 3px solid var(--bb-accent, #f29b3f);
background: rgba(255, 255, 255, 0.04);
color: var(--bb-ink-muted);
border-radius: 8px;
}
.bb-post-body blockquote > cite {
display: block;
font-style: normal;
font-weight: 600;
color: var(--bb-ink);
margin-bottom: 0.35rem;
}
.bb-post-body blockquote > div {
color: var(--bb-ink);
}
.bb-post-footer { .bb-post-footer {
display: flex; display: flex;
justify-content: flex-end; justify-content: flex-end;
@@ -2229,6 +2283,166 @@ a {
padding: 1rem; padding: 1rem;
} }
.bb-acp-stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 1.5rem;
}
.bb-acp-stats-table {
width: 100%;
border-radius: 10px;
border: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(12, 16, 24, 0.6);
overflow: hidden;
border-collapse: collapse;
}
.bb-acp-stats-table th {
text-align: left;
padding: 0.55rem 0.75rem;
font-size: 0.72rem;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--bb-ink-muted);
background: rgba(255, 255, 255, 0.04);
}
.bb-acp-stats-table td {
padding: 0.6rem 0.75rem;
border-top: 1px solid rgba(255, 255, 255, 0.06);
color: var(--bb-ink);
}
.bb-acp-stats-table tbody tr:nth-child(even) {
background: rgba(255, 255, 255, 0.03);
}
.bb-acp-stats-value {
text-align: right;
font-weight: 600;
}
.bb-acp-version-inline {
display: inline-flex;
align-items: center;
gap: 0.75rem;
flex-wrap: wrap;
}
.bb-acp-version-link {
color: var(--bb-accent, #f29b3f);
text-decoration: none;
font-weight: 600;
}
.bb-acp-version-link:hover {
text-decoration: underline;
}
.bb-acp-version-meta {
color: var(--bb-ink-muted);
font-size: 0.85rem;
}
.bb-acp-update-log {
max-height: 240px;
overflow: auto;
background: rgba(12, 16, 24, 0.7);
border-radius: 8px;
padding: 0.75rem;
border: 1px solid rgba(255, 255, 255, 0.08);
color: var(--bb-ink);
font-size: 0.85rem;
}
.bb-status-icon {
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 1rem;
}
.bb-status-icon.is-ok {
color: #22c55e;
}
.bb-status-icon.is-bad {
color: #ef4444;
}
.bb-status-icon.is-warn {
color: #f59e0b;
}
[data-bs-theme="light"] .bb-acp-stats-table {
background: #ffffff;
border-color: rgba(14, 18, 27, 0.12);
}
[data-bs-theme="light"] .bb-acp-stats-table th {
background: rgba(14, 18, 27, 0.04);
color: #5b6678;
}
[data-bs-theme="light"] .bb-acp-stats-table td {
border-top-color: rgba(14, 18, 27, 0.06);
}
[data-bs-theme="light"] .bb-acp-stats-table tbody tr:nth-child(even) {
background: rgba(14, 18, 27, 0.02);
}
.bb-acp-admin-log__table {
width: 100%;
border-collapse: collapse;
border-radius: 10px;
overflow: hidden;
border: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(12, 16, 24, 0.6);
}
.bb-acp-admin-log__table th {
text-align: left;
padding: 0.55rem 0.75rem;
font-size: 0.72rem;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--bb-ink-muted);
background: rgba(255, 255, 255, 0.04);
}
.bb-acp-admin-log__table td {
padding: 0.6rem 0.75rem;
border-top: 1px solid rgba(255, 255, 255, 0.06);
color: var(--bb-ink);
}
.bb-acp-admin-log__table tbody tr:nth-child(even) {
background: rgba(255, 255, 255, 0.03);
}
.bb-acp-admin-log__table tfoot td {
text-align: right;
}
[data-bs-theme="light"] .bb-acp-admin-log__table {
background: #ffffff;
border-color: rgba(14, 18, 27, 0.12);
}
[data-bs-theme="light"] .bb-acp-admin-log__table th {
background: rgba(14, 18, 27, 0.04);
color: #5b6678;
}
[data-bs-theme="light"] .bb-acp-admin-log__table td {
border-top-color: rgba(14, 18, 27, 0.06);
}
[data-bs-theme="light"] .bb-acp-admin-log__table tbody tr:nth-child(even) {
background: rgba(14, 18, 27, 0.02);
}
.bb-icon { .bb-icon {
width: 44px; width: 44px;
height: 44px; height: 44px;
@@ -2587,6 +2801,10 @@ a {
max-width: 320px; max-width: 320px;
} }
.bb-audit-limit {
max-width: 120px;
}
.bb-sort-label { .bb-sort-label {
display: flex; display: flex;
align-items: center; align-items: center;

View File

@@ -1,5 +1,5 @@
import { useEffect, useMemo, useRef, useState } from 'react' import { useEffect, useMemo, useRef, useState, useId } from 'react'
import { Accordion, Button, ButtonGroup, Col, Container, Form, Modal, Row, Tab, Tabs } from 'react-bootstrap' 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 DataTable, { createTheme } from 'react-data-table-component'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { useDropzone } from 'react-dropzone' import { useDropzone } from 'react-dropzone'
@@ -8,10 +8,15 @@ import {
createForum, createForum,
deleteForum, deleteForum,
fetchSettings, fetchSettings,
fetchStats,
fetchVersionCheck,
runSystemUpdate,
fetchSystemStatus,
listAllForums, listAllForums,
listRanks, listRanks,
listRoles, listRoles,
listUsers, listUsers,
listAuditLogs,
reorderForums, reorderForums,
saveSetting, saveSetting,
saveSettings, saveSettings,
@@ -38,7 +43,26 @@ import {
deleteAttachmentExtension, deleteAttachmentExtension,
} from '../api/client' } 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 { t } = useTranslation()
const { roles: authRoles } = useAuth() const { roles: authRoles } = useAuth()
const canManageFounder = authRoles.includes('ROLE_FOUNDER') const canManageFounder = authRoles.includes('ROLE_FOUNDER')
@@ -54,6 +78,24 @@ export default function Acp({ isAdmin }) {
const [userSearch, setUserSearch] = useState('') const [userSearch, setUserSearch] = useState('')
const [usersLoading, setUsersLoading] = useState(false) const [usersLoading, setUsersLoading] = useState(false)
const [usersError, setUsersError] = useState('') 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 [usersPage, setUsersPage] = useState(1) const [usersPage, setUsersPage] = useState(1)
const [usersPerPage, setUsersPerPage] = useState(10) const [usersPerPage, setUsersPerPage] = useState(10)
const [userSort, setUserSort] = useState({ columnId: 'name', direction: 'asc' }) const [userSort, setUserSort] = useState({ columnId: 'name', direction: 'asc' })
@@ -656,6 +698,249 @@ export default function Acp({ isAdmin }) {
[themeMode] [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)
}
}
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 UsersPagination = ({ rowsPerPage, rowCount, onChangePage }) => {
const totalPages = Math.max(1, Math.ceil(rowCount / rowsPerPage)) const totalPages = Math.max(1, Math.ceil(rowCount / rowsPerPage))
const current = Math.min(usersPage, totalPages) const current = Math.min(usersPage, totalPages)
@@ -763,6 +1048,44 @@ export default function Acp({ isAdmin }) {
} }
}, [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(() => { useEffect(() => {
if (!roleMenuOpen) return if (!roleMenuOpen) return
const handleClick = (event) => { const handleClick = (event) => {
@@ -2260,6 +2583,62 @@ export default function Acp({ isAdmin }) {
<p className="bb-muted mb-0">{t('acp.general_hint')}</p> <p className="bb-muted mb-0">{t('acp.general_hint')}</p>
</div> </div>
</div> </div>
<div className="bb-acp-panel mb-4">
<div className="bb-acp-panel-header">
<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>} {generalError && <p className="text-danger">{generalError}</p>}
<div className="bb-acp-panel"> <div className="bb-acp-panel">
<div className="bb-acp-panel-header"> <div className="bb-acp-panel-header">
@@ -2560,6 +2939,71 @@ export default function Acp({ isAdmin }) {
</Form> </Form>
</div> </div>
</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> </Col>
</Row> </Row>
</Tab> </Tab>
@@ -3011,6 +3455,308 @@ export default function Acp({ isAdmin }) {
</div> </div>
</div> </div>
</Tab> </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 && systemStatus && (
<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">
<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>
<tr>
<td>PHP</td>
<td className="bb-acp-stats-value">{systemStatus.php_selected_path || '—'}</td>
<td className="bb-acp-stats-value">
{systemStatus.min_versions?.php || '—'}
</td>
<td className="bb-acp-stats-value">
{systemStatus.php_selected_version || '—'}
</td>
<td className="bb-acp-stats-value">
<StatusIcon
status={systemStatus.php_selected_ok ? 'ok' : 'bad'}
/>
</td>
<td className="bb-acp-stats-value">
<Button
size="sm"
variant="dark"
onClick={loadSystemStatus}
disabled={systemLoading}
>
{t('system.recheck')}
</Button>
</td>
</tr>
<tr>
<td>Composer</td>
<td className="bb-acp-stats-value">
{systemStatus.composer || t('system.not_found')}
</td>
<td className="bb-acp-stats-value">
{systemStatus.min_versions?.composer || '—'}
</td>
<td className="bb-acp-stats-value">
{systemStatus.composer_version || '—'}
</td>
<td className="bb-acp-stats-value">
<StatusIcon status={systemStatus.composer ? 'ok' : 'bad'} />
</td>
<td className="bb-acp-stats-value">
<Button
size="sm"
variant="dark"
onClick={loadSystemStatus}
disabled={systemLoading}
>
{t('system.recheck')}
</Button>
</td>
</tr>
<tr>
<td>Node</td>
<td className="bb-acp-stats-value">
{systemStatus.node || t('system.not_found')}
</td>
<td className="bb-acp-stats-value">
{systemStatus.min_versions?.node || '—'}
</td>
<td className="bb-acp-stats-value">
{systemStatus.node_version || '—'}
</td>
<td className="bb-acp-stats-value">
<StatusIcon status={systemStatus.node ? 'ok' : 'bad'} />
</td>
<td className="bb-acp-stats-value">
<Button
size="sm"
variant="dark"
onClick={loadSystemStatus}
disabled={systemLoading}
>
{t('system.recheck')}
</Button>
</td>
</tr>
<tr>
<td>npm</td>
<td className="bb-acp-stats-value">
{systemStatus.npm || t('system.not_found')}
</td>
<td className="bb-acp-stats-value">
{systemStatus.min_versions?.npm || '—'}
</td>
<td className="bb-acp-stats-value">
{systemStatus.npm_version || '—'}
</td>
<td className="bb-acp-stats-value">
<StatusIcon status={systemStatus.npm ? 'ok' : 'bad'} />
</td>
<td className="bb-acp-stats-value">
<Button
size="sm"
variant="dark"
onClick={loadSystemStatus}
disabled={systemLoading}
>
{t('system.recheck')}
</Button>
</td>
</tr>
<tr>
<td>tar</td>
<td className="bb-acp-stats-value">
{systemStatus.tar || t('system.not_found')}
</td>
<td className="bb-acp-stats-value"></td>
<td className="bb-acp-stats-value">
{systemStatus.tar_version || '—'}
</td>
<td className="bb-acp-stats-value">
<StatusIcon status={systemStatus.tar ? 'ok' : 'bad'} />
</td>
<td className="bb-acp-stats-value">
<Button
size="sm"
variant="dark"
onClick={loadSystemStatus}
disabled={systemLoading}
>
{t('system.recheck')}
</Button>
</td>
</tr>
<tr>
<td>rsync</td>
<td className="bb-acp-stats-value">
{systemStatus.rsync || t('system.not_found')}
</td>
<td className="bb-acp-stats-value"></td>
<td className="bb-acp-stats-value">
{systemStatus.rsync_version || '—'}
</td>
<td className="bb-acp-stats-value">
<StatusIcon status={systemStatus.rsync ? 'ok' : 'bad'} />
</td>
<td className="bb-acp-stats-value">
<Button
size="sm"
variant="dark"
onClick={loadSystemStatus}
disabled={systemLoading}
>
{t('system.recheck')}
</Button>
</td>
</tr>
<tr>
<td>proc_* functions</td>
<td className="bb-acp-stats-value" colSpan={3}>
{systemStatus.proc_functions
? Object.entries(systemStatus.proc_functions)
.filter(([, ok]) => !ok)
.map(([name]) => name)
.join(', ')
: '—'}
</td>
<td className="bb-acp-stats-value">
<StatusIcon
status={
Boolean(systemStatus.proc_functions) &&
Object.values(systemStatus.proc_functions).every(Boolean)
? 'ok'
: 'bad'
}
/>
</td>
<td className="bb-acp-stats-value">
<Button
size="sm"
variant="dark"
onClick={loadSystemStatus}
disabled={systemLoading}
>
{t('system.recheck')}
</Button>
</td>
</tr>
<tr>
<td>{t('system.storage_writable')}</td>
<td className="bb-acp-stats-value">storage/</td>
<td className="bb-acp-stats-value"></td>
<td className="bb-acp-stats-value"></td>
<td className="bb-acp-stats-value">
<StatusIcon status={systemStatus.storage_writable ? 'ok' : 'bad'} />
</td>
<td className="bb-acp-stats-value">
<Button
size="sm"
variant="dark"
onClick={loadSystemStatus}
disabled={systemLoading}
>
{t('system.recheck')}
</Button>
</td>
</tr>
<tr>
<td>{t('system.updates_writable')}</td>
<td className="bb-acp-stats-value">storage/app/updates</td>
<td className="bb-acp-stats-value"></td>
<td className="bb-acp-stats-value"></td>
<td className="bb-acp-stats-value">
<StatusIcon status={systemStatus.updates_writable ? 'ok' : 'bad'} />
</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>
)}
</Tab>
</Tabs> </Tabs>
<Modal show={showModal} onHide={handleReset} centered size="lg"> <Modal show={showModal} onHide={handleReset} centered size="lg">
<Modal.Header closeButton closeVariant="white"> <Modal.Header closeButton closeVariant="white">
@@ -3625,6 +4371,36 @@ export default function Acp({ isAdmin }) {
</Form> </Form>
</Modal.Body> </Modal.Body>
</Modal> </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 <Modal
show={showRankCreate} show={showRankCreate}
onHide={() => setShowRankCreate(false)} onHide={() => setShowRankCreate(false)}
@@ -3961,3 +4737,6 @@ export default function Acp({ isAdmin }) {
</Container> </Container>
) )
} }
export { Acp }
export default Acp

View File

@@ -3,9 +3,13 @@ import { Button, Container, Form, Modal } from 'react-bootstrap'
import { useParams } from 'react-router-dom' import { useParams } from 'react-router-dom'
import { import {
createPost, createPost,
deleteThread,
getThread, getThread,
listPostsByThread, listPostsByThread,
updateThreadSolved, updateThreadSolved,
updateThread,
updatePost,
deletePost,
uploadAttachment, uploadAttachment,
listAttachmentExtensionsPublic, listAttachmentExtensionsPublic,
previewBbcode, previewBbcode,
@@ -34,7 +38,16 @@ export default function ThreadView() {
const [previewHtml, setPreviewHtml] = useState('') const [previewHtml, setPreviewHtml] = useState('')
const [previewLoading, setPreviewLoading] = useState(false) const [previewLoading, setPreviewLoading] = useState(false)
const [previewUrls, setPreviewUrls] = useState([]) 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 [replyAttachmentTab, setReplyAttachmentTab] = useState('options')
const [replyAttachmentOptions, setReplyAttachmentOptions] = useState({ const [replyAttachmentOptions, setReplyAttachmentOptions] = useState({
disableBbcode: false, disableBbcode: false,
@@ -458,7 +471,32 @@ export default function ThreadView() {
key={attachment.id} key={attachment.id}
type="button" type="button"
className="bb-attachment-item border-0 text-start" 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 <img
src={attachment.thumbnail_url || attachment.download_url} src={attachment.thumbnail_url || attachment.download_url}
@@ -516,6 +554,145 @@ export default function ThreadView() {
return [rootPost, ...posts] return [rootPost, ...posts]
}, [posts, thread]) }, [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 = () => { const handleJumpToReply = () => {
replyRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' }) replyRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' })
} }
@@ -725,10 +902,24 @@ export default function ThreadView() {
)} )}
</div> </div>
<div className="bb-post-actions"> <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" /> <i className="bi bi-pencil" aria-hidden="true" />
</button> </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" /> <i className="bi bi-x-lg" aria-hidden="true" />
</button> </button>
<button type="button" className="bb-post-action" aria-label="Report post"> <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"> <button type="button" className="bb-post-action" aria-label="Post info">
<i className="bi bi-info-lg" aria-hidden="true" /> <i className="bi bi-info-lg" aria-hidden="true" />
</button> </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" /> <i className="bi bi-quote" aria-hidden="true" />
</button> </button>
{canThank && ( {canThank && (
@@ -752,7 +949,17 @@ export default function ThreadView() {
onClick={(event) => { onClick={(event) => {
if (event.target?.tagName === 'IMG') { if (event.target?.tagName === 'IMG') {
event.preventDefault() 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 }} dangerouslySetInnerHTML={{ __html: post.body_html || post.body }}
@@ -881,15 +1088,176 @@ export default function ThreadView() {
</Modal.Body> </Modal.Body>
</Modal> </Modal>
<Modal <Modal
show={Boolean(lightboxImage)} show={Boolean(editPost)}
onHide={() => setLightboxImage('')} onHide={() => setEditPost(null)}
centered centered
size="lg" size="lg"
> >
<Modal.Body className="text-center"> <Modal.Header closeButton>
{lightboxImage && ( <Modal.Title>{t('thread.edit')}</Modal.Title>
<img src={lightboxImage} alt="" className="img-fluid rounded" /> </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.Body>
</Modal> </Modal>
</Container> </Container>

View File

@@ -69,8 +69,14 @@
"acp.refresh": "Aktualisieren", "acp.refresh": "Aktualisieren",
"acp.reset": "Zurücksetzen", "acp.reset": "Zurücksetzen",
"acp.save": "Speichern", "acp.save": "Speichern",
"acp.statistics": "Statistik",
"acp.title": "Administrationsbereich", "acp.title": "Administrationsbereich",
"acp.users": "Benutzer", "acp.users": "Benutzer",
"acp.audit_logs": "Audit-Log",
"acp.system": "System",
"acp.admin_log_title": "Administrator-Aktionen",
"acp.admin_log_hint": "Uebersicht der letzten Administrator-Aktionen. Vollstaendiges Log unten.",
"acp.view_admin_log": "Administrator-Log anzeigen",
"auth.login_hint": "Melde dich an, um neue Threads zu starten und zu antworten.", "auth.login_hint": "Melde dich an, um neue Threads zu starten und zu antworten.",
"auth.login_title": "Anmelden", "auth.login_title": "Anmelden",
"auth.login_identifier": "E-Mail oder Benutzername", "auth.login_identifier": "E-Mail oder Benutzername",
@@ -100,6 +106,18 @@
"form.thread_title_placeholder": "Thema", "form.thread_title_placeholder": "Thema",
"form.title": "Titel", "form.title": "Titel",
"form.username": "Benutzername", "form.username": "Benutzername",
"thread.title": "Titel",
"thread.edit": "Bearbeiten",
"thread.delete": "Löschen",
"thread.quote": "Zitieren",
"thread.delete_confirm": "Diesen Thread löschen?",
"thread.delete_post_confirm": "Diesen Beitrag löschen?",
"thread.delete_reason": "Loeschgrund",
"thread.delete_reason_obsolete": "Obsolet",
"thread.delete_reason_double": "Doppelpost",
"thread.delete_reason_other": "Andere",
"thread.delete_reason_other_label": "Begruendung",
"thread.delete_reason_other_placeholder": "Kurze Begruendung...",
"forum.children": "Unterforen", "forum.children": "Unterforen",
"forum.empty_children": "Noch keine Unterforen vorhanden.", "forum.empty_children": "Noch keine Unterforen vorhanden.",
"forum.empty_threads": "Noch keine Threads vorhanden. Starte unten einen.", "forum.empty_threads": "Noch keine Threads vorhanden. Starte unten einen.",
@@ -118,6 +136,64 @@
"user.email": "E-Mail", "user.email": "E-Mail",
"user.rank": "Rang", "user.rank": "Rang",
"user.rank_unassigned": "Nicht zugewiesen", "user.rank_unassigned": "Nicht zugewiesen",
"audit.user": "Benutzer",
"audit.action": "Aktion",
"audit.subject": "Objekt",
"audit.ip": "IP",
"audit.created_at": "Zeitpunkt",
"audit.search": "Audit-Logs durchsuchen...",
"audit.empty": "Noch keine Audit-Einträge.",
"admin_log.username": "Benutzername",
"admin_log.user_ip": "Benutzer-IP",
"admin_log.time": "Zeit",
"admin_log.action": "Aktion",
"admin_log.empty": "Noch keine Administrator-Aktionen.",
"stats.statistic": "Statistik",
"stats.value": "Wert",
"stats.board_started": "Board gestartet",
"stats.avatar_directory_size": "Avatar-Verzeichnisgröße",
"stats.database_size": "Datenbankgröße",
"stats.attachments_size": "Groesse der Anhänge",
"stats.database_server": "Datenbankserver",
"stats.gzip_compression": "GZip-Kompression",
"stats.php_version": "PHP-Version",
"stats.orphan_attachments": "Verwaiste Anhänge",
"stats.board_version": "Board-Version",
"stats.posts": "Anzahl der Beiträge",
"stats.posts_per_day": "Beiträge pro Tag",
"stats.topics": "Anzahl der Themen",
"stats.topics_per_day": "Themen pro Tag",
"stats.users": "Anzahl der Benutzer",
"stats.users_per_day": "Benutzer pro Tag",
"stats.attachments": "Anzahl der Anhänge",
"stats.attachments_per_day": "Anhänge pro Tag",
"stats.on": "An",
"stats.off": "Aus",
"version.recheck": "Version prüfen",
"version.checking": "Prüfe…",
"version.up_to_date": "Aktuell",
"version.update_available": "Update verfügbar (v{{version}})",
"version.update_available_short": "Update verfügbar",
"version.unknown": "Version unbekannt",
"version.update_now": "Jetzt aktualisieren",
"version.update_title": "System aktualisieren",
"version.update_hint": "Letzte Version herunterladen, installieren, Migrationen ausführen und Assets neu bauen.",
"version.updating": "Aktualisiere…",
"system.requirements": "Systemanforderungen",
"system.check": "Prüfung",
"system.path": "Pfad",
"system.min_version": "Mindestversion",
"system.current_version": "Aktuelle Version",
"system.status": "Status",
"system.recheck": "Neu prüfen",
"system.none": "Keine",
"system.not_found": "Nicht gefunden",
"system.storage_writable": "Storage beschreibbar",
"system.updates_writable": "Updates beschreibbar",
"system.ok": "OK",
"system.not_ok": "Nicht OK",
"lightbox.prev": "Vorheriges Bild",
"lightbox.next": "Nächstes Bild",
"user.edit_title": "Benutzer bearbeiten", "user.edit_title": "Benutzer bearbeiten",
"user.search": "Benutzer suchen...", "user.search": "Benutzer suchen...",
"rank.name": "Rangname", "rank.name": "Rangname",

View File

@@ -69,8 +69,14 @@
"acp.refresh": "Refresh", "acp.refresh": "Refresh",
"acp.reset": "Reset", "acp.reset": "Reset",
"acp.save": "Save", "acp.save": "Save",
"acp.statistics": "Statistics",
"acp.title": "Admin control panel", "acp.title": "Admin control panel",
"acp.users": "Users", "acp.users": "Users",
"acp.audit_logs": "Audit log",
"acp.system": "System",
"acp.admin_log_title": "Logged administrator actions",
"acp.admin_log_hint": "Overview of the latest administrator actions. Full log is available below.",
"acp.view_admin_log": "View administrator log",
"auth.login_hint": "Access your account to start new threads and reply.", "auth.login_hint": "Access your account to start new threads and reply.",
"auth.login_title": "Log in", "auth.login_title": "Log in",
"auth.login_identifier": "Email or username", "auth.login_identifier": "Email or username",
@@ -100,6 +106,18 @@
"form.thread_title_placeholder": "Topic headline", "form.thread_title_placeholder": "Topic headline",
"form.title": "Title", "form.title": "Title",
"form.username": "Username", "form.username": "Username",
"thread.title": "Title",
"thread.edit": "Edit",
"thread.delete": "Delete",
"thread.quote": "Quote",
"thread.delete_confirm": "Delete this thread?",
"thread.delete_post_confirm": "Delete this post?",
"thread.delete_reason": "Delete reason",
"thread.delete_reason_obsolete": "Obsolete",
"thread.delete_reason_double": "Double post",
"thread.delete_reason_other": "Other",
"thread.delete_reason_other_label": "Reason details",
"thread.delete_reason_other_placeholder": "Add a short reason...",
"forum.children": "Sub-forums", "forum.children": "Sub-forums",
"forum.empty_children": "No sub-forums yet.", "forum.empty_children": "No sub-forums yet.",
"forum.empty_threads": "No threads here yet. Start one below.", "forum.empty_threads": "No threads here yet. Start one below.",
@@ -120,6 +138,64 @@
"user.rank_unassigned": "Unassigned", "user.rank_unassigned": "Unassigned",
"user.edit_title": "Edit user", "user.edit_title": "Edit user",
"user.search": "Search users...", "user.search": "Search users...",
"stats.statistic": "Statistic",
"stats.value": "Value",
"stats.board_started": "Board started",
"stats.avatar_directory_size": "Avatar directory size",
"stats.database_size": "Database size",
"stats.attachments_size": "Size of posted attachments",
"stats.database_server": "Database server",
"stats.gzip_compression": "GZip compression",
"stats.php_version": "PHP version",
"stats.orphan_attachments": "Orphan attachments",
"stats.board_version": "Board version",
"stats.posts": "Number of posts",
"stats.posts_per_day": "Posts per day",
"stats.topics": "Number of topics",
"stats.topics_per_day": "Topics per day",
"stats.users": "Number of users",
"stats.users_per_day": "Users per day",
"stats.attachments": "Number of attachments",
"stats.attachments_per_day": "Attachments per day",
"stats.on": "On",
"stats.off": "Off",
"version.recheck": "Re-check version",
"version.checking": "Checking…",
"version.up_to_date": "Up to date",
"version.update_available": "Update available (v{{version}})",
"version.update_available_short": "Update available",
"version.unknown": "Version unknown",
"version.update_now": "Update now",
"version.update_title": "Update system",
"version.update_hint": "Download and install the latest release, then run migrations and rebuild assets.",
"version.updating": "Updating…",
"system.requirements": "System requirements",
"system.check": "Check",
"system.path": "Path",
"system.min_version": "Min version",
"system.current_version": "Current version",
"system.status": "Status",
"system.recheck": "Re-check",
"system.none": "None",
"system.not_found": "Not found",
"system.storage_writable": "Storage writable",
"system.updates_writable": "Updates writable",
"system.ok": "OK",
"system.not_ok": "Not OK",
"lightbox.prev": "Previous image",
"lightbox.next": "Next image",
"audit.user": "User",
"audit.action": "Action",
"audit.subject": "Subject",
"audit.ip": "IP",
"audit.created_at": "When",
"audit.search": "Search audit logs...",
"audit.empty": "No audit events yet.",
"admin_log.username": "Username",
"admin_log.user_ip": "User IP",
"admin_log.time": "Time",
"admin_log.action": "Action",
"admin_log.empty": "No administrator actions logged yet.",
"rank.name": "Rank name", "rank.name": "Rank name",
"rank.name_placeholder": "e.g. Operator", "rank.name_placeholder": "e.g. Operator",
"rank.create": "Create rank", "rank.create": "Create rank",

View File

@@ -3,6 +3,7 @@
use App\Http\Controllers\AttachmentController; use App\Http\Controllers\AttachmentController;
use App\Http\Controllers\AttachmentExtensionController; use App\Http\Controllers\AttachmentExtensionController;
use App\Http\Controllers\AttachmentGroupController; use App\Http\Controllers\AttachmentGroupController;
use App\Http\Controllers\AuditLogController;
use App\Http\Controllers\AuthController; use App\Http\Controllers\AuthController;
use App\Http\Controllers\ForumController; use App\Http\Controllers\ForumController;
use App\Http\Controllers\I18nController; use App\Http\Controllers\I18nController;
@@ -17,6 +18,9 @@ use App\Http\Controllers\UploadController;
use App\Http\Controllers\UserSettingController; use App\Http\Controllers\UserSettingController;
use App\Http\Controllers\UserController; use App\Http\Controllers\UserController;
use App\Http\Controllers\VersionController; use App\Http\Controllers\VersionController;
use App\Http\Controllers\VersionCheckController;
use App\Http\Controllers\SystemUpdateController;
use App\Http\Controllers\SystemStatusController;
use App\Http\Controllers\RankController; use App\Http\Controllers\RankController;
use App\Http\Controllers\RoleController; use App\Http\Controllers\RoleController;
use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Route;
@@ -32,11 +36,15 @@ Route::post('/logout', [AuthController::class, 'logout'])->middleware('auth:sanc
Route::post('/user/password', [AuthController::class, 'updatePassword'])->middleware('auth:sanctum'); Route::post('/user/password', [AuthController::class, 'updatePassword'])->middleware('auth:sanctum');
Route::get('/version', VersionController::class); Route::get('/version', VersionController::class);
Route::get('/version/check', VersionCheckController::class);
Route::post('/system/update', SystemUpdateController::class)->middleware('auth:sanctum');
Route::get('/system/status', SystemStatusController::class)->middleware('auth:sanctum');
Route::get('/portal/summary', PortalController::class); Route::get('/portal/summary', PortalController::class);
Route::get('/stats', StatsController::class); Route::get('/stats', StatsController::class);
Route::get('/settings', [SettingController::class, 'index']); Route::get('/settings', [SettingController::class, 'index']);
Route::post('/settings', [SettingController::class, 'store'])->middleware('auth:sanctum'); Route::post('/settings', [SettingController::class, 'store'])->middleware('auth:sanctum');
Route::post('/settings/bulk', [SettingController::class, 'bulkStore'])->middleware('auth:sanctum'); Route::post('/settings/bulk', [SettingController::class, 'bulkStore'])->middleware('auth:sanctum');
Route::get('/audit-logs', [AuditLogController::class, 'index'])->middleware('auth:sanctum');
Route::get('/user-settings', [UserSettingController::class, 'index'])->middleware('auth:sanctum'); Route::get('/user-settings', [UserSettingController::class, 'index'])->middleware('auth:sanctum');
Route::post('/user-settings', [UserSettingController::class, 'store'])->middleware('auth:sanctum'); Route::post('/user-settings', [UserSettingController::class, 'store'])->middleware('auth:sanctum');
Route::post('/uploads/logo', [UploadController::class, 'storeLogo'])->middleware('auth:sanctum'); Route::post('/uploads/logo', [UploadController::class, 'storeLogo'])->middleware('auth:sanctum');
@@ -90,6 +98,7 @@ Route::delete('/forums/{forum}', [ForumController::class, 'destroy'])->middlewar
Route::get('/threads', [ThreadController::class, 'index']); Route::get('/threads', [ThreadController::class, 'index']);
Route::get('/threads/{thread}', [ThreadController::class, 'show']); Route::get('/threads/{thread}', [ThreadController::class, 'show']);
Route::post('/threads', [ThreadController::class, 'store'])->middleware('auth:sanctum'); Route::post('/threads', [ThreadController::class, 'store'])->middleware('auth:sanctum');
Route::patch('/threads/{thread}', [ThreadController::class, 'update'])->middleware('auth:sanctum');
Route::patch('/threads/{thread}/solved', [ThreadController::class, 'updateSolved'])->middleware('auth:sanctum'); Route::patch('/threads/{thread}/solved', [ThreadController::class, 'updateSolved'])->middleware('auth:sanctum');
Route::delete('/threads/{thread}', [ThreadController::class, 'destroy'])->middleware('auth:sanctum'); Route::delete('/threads/{thread}', [ThreadController::class, 'destroy'])->middleware('auth:sanctum');
@@ -97,5 +106,6 @@ Route::get('/posts', [PostController::class, 'index']);
Route::post('/posts', [PostController::class, 'store'])->middleware('auth:sanctum'); Route::post('/posts', [PostController::class, 'store'])->middleware('auth:sanctum');
Route::post('/posts/{post}/thanks', [PostThankController::class, 'store'])->middleware('auth:sanctum'); Route::post('/posts/{post}/thanks', [PostThankController::class, 'store'])->middleware('auth:sanctum');
Route::delete('/posts/{post}/thanks', [PostThankController::class, 'destroy'])->middleware('auth:sanctum'); Route::delete('/posts/{post}/thanks', [PostThankController::class, 'destroy'])->middleware('auth:sanctum');
Route::patch('/posts/{post}', [PostController::class, 'update'])->middleware('auth:sanctum');
Route::delete('/posts/{post}', [PostController::class, 'destroy'])->middleware('auth:sanctum'); Route::delete('/posts/{post}', [PostController::class, 'destroy'])->middleware('auth:sanctum');
Route::post('/preview', [PreviewController::class, 'preview'])->middleware('auth:sanctum'); Route::post('/preview', [PreviewController::class, 'preview'])->middleware('auth:sanctum');