diff --git a/.gitignore b/.gitignore index 05e2aa1..5c2cf59 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ .env .env.backup .env.production +.env.test .env.*.local .phpactor.json .phpunit.result.cache diff --git a/NOTES.md b/NOTES.md new file mode 100644 index 0000000..3f4af37 --- /dev/null +++ b/NOTES.md @@ -0,0 +1 @@ +TODO: Remove remaining IIFEs in ACP UI; prefer plain components/helpers. diff --git a/app/Actions/BbcodeFormatter.php b/app/Actions/BbcodeFormatter.php index 36e4a38..cd13b42 100644 --- a/app/Actions/BbcodeFormatter.php +++ b/app/Actions/BbcodeFormatter.php @@ -45,6 +45,17 @@ class BbcodeFormatter $configurator->tags->add('BR')->template = '
'; + if (isset($configurator->tags['QUOTE'])) { + $configurator->tags['QUOTE']->template = <<<'XSL' +
+ + wrote: + +
+
+XSL; + } + $bundle = $configurator->finalize(); $parser = $bundle['parser'] ?? null; $renderer = $bundle['renderer'] ?? null; diff --git a/app/Console/Commands/CronRun.php b/app/Console/Commands/CronRun.php new file mode 100644 index 0000000..4746d40 --- /dev/null +++ b/app/Console/Commands/CronRun.php @@ -0,0 +1,93 @@ +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; + } +} diff --git a/app/Console/Commands/VersionBump.php b/app/Console/Commands/VersionBump.php new file mode 100644 index 0000000..81b2768 --- /dev/null +++ b/app/Console/Commands/VersionBump.php @@ -0,0 +1,89 @@ + 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; + } +} diff --git a/app/Console/Commands/VersionRelease.php b/app/Console/Commands/VersionRelease.php new file mode 100644 index 0000000..86723bc --- /dev/null +++ b/app/Console/Commands/VersionRelease.php @@ -0,0 +1,113 @@ +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.'; + } +} diff --git a/app/Console/Commands/VersionSet.php b/app/Console/Commands/VersionSet.php new file mode 100644 index 0000000..5db7e37 --- /dev/null +++ b/app/Console/Commands/VersionSet.php @@ -0,0 +1,73 @@ +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; + } +} diff --git a/app/Http/Controllers/AttachmentController.php b/app/Http/Controllers/AttachmentController.php index 3593d32..3e30c09 100644 --- a/app/Http/Controllers/AttachmentController.php +++ b/app/Http/Controllers/AttachmentController.php @@ -5,7 +5,8 @@ namespace App\Http\Controllers; use App\Models\Attachment; use App\Models\AttachmentExtension; use App\Models\Post; -use App\Models\Setting; +use App\Services\AttachmentThumbnailService; +use App\Services\AuditLogger; use App\Models\Thread; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; @@ -115,7 +116,8 @@ class AttachmentController extends Controller $path = "attachments/{$scopeFolder}/{$filename}"; Storage::disk($disk)->putFileAs("attachments/{$scopeFolder}", $file, $filename); - $thumbnailPayload = $this->maybeCreateThumbnail($file, $scopeFolder); + $thumbnailPayload = app(AttachmentThumbnailService::class) + ->createForUpload($file, $scopeFolder, $disk); $attachment = Attachment::create([ 'thread_id' => $threadId, @@ -134,6 +136,13 @@ class AttachmentController extends Controller 'size_bytes' => (int) $file->getSize(), ]); + app(AuditLogger::class)->log($request, 'attachment.created', $attachment, [ + 'thread_id' => $threadId, + 'post_id' => $postId, + 'original_name' => $attachment->original_name, + 'size_bytes' => $attachment->size_bytes, + ]); + $attachment->loadMissing(['extension', 'group']); return response()->json($this->serializeAttachment($attachment), 201); @@ -201,6 +210,13 @@ class AttachmentController extends Controller return response()->json(['message' => 'Not authorized to delete attachments.'], 403); } + app(AuditLogger::class)->log($request, 'attachment.deleted', $attachment, [ + 'thread_id' => $attachment->thread_id, + 'post_id' => $attachment->post_id, + 'original_name' => $attachment->original_name, + 'size_bytes' => $attachment->size_bytes, + ]); + $attachment->delete(); return response()->json(null, 204); @@ -332,151 +348,4 @@ class AttachmentController extends Controller ]; } - private function maybeCreateThumbnail($file, string $scopeFolder): ?array - { - $enabled = $this->settingBool('attachments.create_thumbnails', true); - if (!$enabled) { - return null; - } - - $mime = $file->getMimeType() ?? ''; - if (!str_starts_with($mime, 'image/')) { - return null; - } - - $maxWidth = $this->settingInt('attachments.thumbnail_max_width', 300); - $maxHeight = $this->settingInt('attachments.thumbnail_max_height', 300); - if ($maxWidth <= 0 || $maxHeight <= 0) { - return null; - } - - $sourcePath = $file->getPathname(); - $info = @getimagesize($sourcePath); - if (!$info) { - return null; - } - - [$width, $height] = $info; - if ($width <= 0 || $height <= 0) { - return null; - } - - if ($width <= $maxWidth && $height <= $maxHeight) { - return null; - } - - $ratio = min($maxWidth / $width, $maxHeight / $height); - $targetWidth = max(1, (int) round($width * $ratio)); - $targetHeight = max(1, (int) round($height * $ratio)); - - $sourceImage = $this->createImageFromFile($sourcePath, $mime); - if (!$sourceImage) { - return null; - } - - $thumbImage = imagecreatetruecolor($targetWidth, $targetHeight); - if (!$thumbImage) { - imagedestroy($sourceImage); - return null; - } - - if (in_array($mime, ['image/png', 'image/gif'], true)) { - imagecolortransparent($thumbImage, imagecolorallocatealpha($thumbImage, 0, 0, 0, 127)); - imagealphablending($thumbImage, false); - imagesavealpha($thumbImage, true); - } - - imagecopyresampled( - $thumbImage, - $sourceImage, - 0, - 0, - 0, - 0, - $targetWidth, - $targetHeight, - $width, - $height - ); - - $quality = $this->settingInt('attachments.thumbnail_quality', 85); - $thumbBinary = $this->renderImageBinary($thumbImage, $mime, $quality); - - imagedestroy($sourceImage); - imagedestroy($thumbImage); - - if ($thumbBinary === null) { - return null; - } - - $filename = Str::uuid()->toString(); - $extension = strtolower((string) $file->getClientOriginalExtension()); - if ($extension !== '') { - $filename .= ".{$extension}"; - } - - $disk = 'local'; - $thumbPath = "attachments/{$scopeFolder}/thumbs/{$filename}"; - Storage::disk($disk)->put($thumbPath, $thumbBinary); - - return [ - 'path' => $thumbPath, - 'mime' => $mime, - 'size' => strlen($thumbBinary), - ]; - } - - private function createImageFromFile(string $path, string $mime) - { - return match ($mime) { - 'image/jpeg', 'image/jpg' => @imagecreatefromjpeg($path), - 'image/png' => @imagecreatefrompng($path), - 'image/gif' => @imagecreatefromgif($path), - 'image/webp' => function_exists('imagecreatefromwebp') ? @imagecreatefromwebp($path) : null, - default => null, - }; - } - - private function renderImageBinary($image, string $mime, int $quality): ?string - { - ob_start(); - $success = false; - - if (in_array($mime, ['image/jpeg', 'image/jpg'], true)) { - $success = imagejpeg($image, null, max(10, min(95, $quality))); - } elseif ($mime === 'image/png') { - $compression = (int) round(9 - (max(10, min(95, $quality)) / 100) * 9); - $success = imagepng($image, null, $compression); - } elseif ($mime === 'image/gif') { - $success = imagegif($image); - } elseif ($mime === 'image/webp' && function_exists('imagewebp')) { - $success = imagewebp($image, null, max(10, min(95, $quality))); - } - - $data = ob_get_clean(); - - if (!$success) { - return null; - } - - return $data !== false ? $data : null; - } - - private function settingBool(string $key, bool $default): bool - { - $value = Setting::query()->where('key', $key)->value('value'); - if ($value === null) { - return $default; - } - return in_array(strtolower($value), ['1', 'true', 'yes', 'on'], true); - } - - private function settingInt(string $key, int $default): int - { - $value = Setting::query()->where('key', $key)->value('value'); - if ($value === null) { - return $default; - } - return (int) $value; - } } diff --git a/app/Http/Controllers/AuditLogController.php b/app/Http/Controllers/AuditLogController.php new file mode 100644 index 0000000..c3611e2 --- /dev/null +++ b/app/Http/Controllers/AuditLogController.php @@ -0,0 +1,55 @@ +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, + ]; + } +} diff --git a/app/Http/Controllers/AuthController.php b/app/Http/Controllers/AuthController.php index a579ded..cf7b85f 100644 --- a/app/Http/Controllers/AuthController.php +++ b/app/Http/Controllers/AuthController.php @@ -5,6 +5,7 @@ namespace App\Http\Controllers; use App\Actions\Fortify\CreateNewUser; use App\Actions\Fortify\PasswordValidationRules; use App\Models\User; +use App\Services\AuditLogger; use Illuminate\Auth\Events\Verified; use Illuminate\Auth\Events\PasswordReset; use Illuminate\Http\JsonResponse; @@ -32,6 +33,9 @@ class AuthController extends Controller $user = $creator->create(input: $input); $user->sendEmailVerificationNotification(); + app(AuditLogger::class)->log($request, 'user.registered', $user, [ + 'email' => $user->email, + ], $user); return response()->json(data: [ 'user_id' => $user->id, @@ -77,6 +81,10 @@ class AuthController extends Controller $token = $user->createToken(name: 'api')->plainTextToken; + app(AuditLogger::class)->log($request, 'user.login', $user, [ + 'login' => $login, + ], $user); + return response()->json(data: [ 'token' => $token, 'user_id' => $user->id, @@ -130,13 +138,14 @@ class AuthController extends Controller $status = Password::reset( $request->only('email', 'password', 'password_confirmation', 'token'), - function (User $user, string $password) { + function (User $user, string $password) use ($request) { $user->forceFill(attributes: [ 'password' => Hash::make(value: $password), 'remember_token' => Str::random(length: 60), ])->save(); event(new PasswordReset(user: $user)); + app(AuditLogger::class)->log($request, 'user.password_reset', $user, [], $user); } ); @@ -169,11 +178,14 @@ class AuthController extends Controller 'remember_token' => Str::random(length: 60), ])->save(); + app(AuditLogger::class)->log($request, 'user.password_changed', $user, [], $user); + return response()->json(data: ['message' => 'Password updated.']); } public function logout(Request $request): JsonResponse { + app(AuditLogger::class)->log($request, 'user.logout', $request->user()); $request->user()?->currentAccessToken()?->delete(); return response()->json(data: null, status: 204); diff --git a/app/Http/Controllers/PostController.php b/app/Http/Controllers/PostController.php index 32e9b60..6cf1a56 100644 --- a/app/Http/Controllers/PostController.php +++ b/app/Http/Controllers/PostController.php @@ -6,6 +6,7 @@ use App\Actions\BbcodeFormatter; use App\Models\Post; use App\Models\Thread; use App\Models\Setting; +use App\Services\AuditLogger; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\Storage; @@ -54,6 +55,10 @@ class PostController extends Controller 'body' => $data['body'], ]); + app(AuditLogger::class)->log($request, 'post.created', $post, [ + 'thread_id' => $thread->id, + ]); + $post->loadMissing([ 'user' => fn ($query) => $query ->withCount(['posts', 'threads', 'thanksGiven', 'thanksReceived']) @@ -67,6 +72,13 @@ class PostController extends Controller public function destroy(Request $request, Post $post): JsonResponse { + $reason = $request->input('reason'); + $reasonText = $request->input('reason_text'); + app(AuditLogger::class)->log($request, 'post.deleted', $post, [ + 'thread_id' => $post->thread_id, + 'reason' => $reason, + 'reason_text' => $reasonText, + ]); $post->deleted_by = $request->user()?->id; $post->save(); $post->delete(); @@ -74,6 +86,41 @@ class PostController extends Controller return response()->json(null, 204); } + public function update(Request $request, Post $post): JsonResponse + { + $user = $request->user(); + if (!$user) { + return response()->json(['message' => 'Unauthorized.'], 401); + } + + $isAdmin = $user->roles()->where('name', 'ROLE_ADMIN')->exists(); + if (!$isAdmin && $post->user_id !== $user->id) { + return response()->json(['message' => 'Not authorized to edit posts.'], 403); + } + + $data = $request->validate([ + 'body' => ['required', 'string'], + ]); + + $post->body = $data['body']; + $post->save(); + $post->refresh(); + + app(AuditLogger::class)->log($request, 'post.edited', $post, [ + 'thread_id' => $post->thread_id, + ]); + + $post->loadMissing([ + 'user' => fn ($query) => $query + ->withCount(['posts', 'threads', 'thanksGiven', 'thanksReceived']) + ->with(['rank', 'roles']), + 'attachments.extension', + 'attachments.group', + ]); + + return response()->json($this->serializePost($post)); + } + private function parseIriId(?string $value): ?int { if (!$value) { @@ -163,6 +210,9 @@ class PostController extends Controller $map[$name] = [ 'url' => "/api/attachments/{$attachment->id}/download", 'mime' => $attachment->mime_type ?? '', + 'thumb' => $attachment->thumbnail_path + ? "/api/attachments/{$attachment->id}/thumbnail" + : null, ]; } } @@ -181,6 +231,10 @@ class PostController extends Controller $url = $entry['url']; $mime = $entry['mime'] ?? ''; if (str_starts_with($mime, 'image/') && $this->displayImagesInline()) { + if (!empty($entry['thumb'])) { + $thumb = $entry['thumb']; + return "[url={$url}][img]{$thumb}[/img][/url]"; + } return "[img]{$url}[/img]"; } return "[url={$url}]{$rawName}[/url]"; diff --git a/app/Http/Controllers/StatsController.php b/app/Http/Controllers/StatsController.php index 158cb2a..6c14047 100644 --- a/app/Http/Controllers/StatsController.php +++ b/app/Http/Controllers/StatsController.php @@ -5,17 +5,157 @@ namespace App\Http\Controllers; use App\Models\Post; use App\Models\Thread; use App\Models\User; +use App\Models\Attachment; +use App\Models\Setting; +use Illuminate\Support\Carbon; +use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\Storage; use Illuminate\Http\JsonResponse; class StatsController extends Controller { public function __invoke(): JsonResponse { + $threadsCount = Thread::query()->withoutTrashed()->count(); + $postsCount = Post::query()->withoutTrashed()->count(); + $usersCount = User::query()->count(); + $attachmentsCount = Attachment::query()->withoutTrashed()->count(); + $attachmentsSizeBytes = (int) Attachment::query()->withoutTrashed()->sum('size_bytes'); + + $boardStartedAt = $this->resolveBoardStartedAt(); + $daysSinceStart = $boardStartedAt + ? max(1, Carbon::parse($boardStartedAt)->diffInSeconds(now()) / 86400) + : null; + + $dbSizeBytes = $this->resolveDatabaseSize(); + $dbServer = $this->resolveDatabaseServer(); + $avatarSizeBytes = $this->resolveAvatarDirectorySize(); + $orphanAttachments = $this->resolveOrphanAttachments(); + + $version = Setting::query()->where('key', 'version')->value('value'); + $build = Setting::query()->where('key', 'build')->value('value'); + $boardVersion = $version + ? ($build ? "{$version} (build {$build})" : $version) + : null; + return response()->json([ - 'threads' => Thread::query()->withoutTrashed()->count(), - 'posts' => Post::query()->withoutTrashed()->count() - + Thread::query()->withoutTrashed()->count(), - 'users' => User::query()->count(), + 'threads' => $threadsCount, + 'posts' => $postsCount + $threadsCount, + 'users' => $usersCount, + 'attachments' => $attachmentsCount, + 'board_started_at' => $boardStartedAt, + 'attachments_size_bytes' => $attachmentsSizeBytes, + 'avatar_directory_size_bytes' => $avatarSizeBytes, + 'database_size_bytes' => $dbSizeBytes, + 'database_server' => $dbServer, + 'gzip_compression' => $this->resolveGzipCompression(), + 'php_version' => PHP_VERSION, + 'orphan_attachments' => $orphanAttachments, + 'board_version' => $boardVersion, + 'posts_per_day' => $daysSinceStart ? ($postsCount + $threadsCount) / $daysSinceStart : null, + 'topics_per_day' => $daysSinceStart ? $threadsCount / $daysSinceStart : null, + 'users_per_day' => $daysSinceStart ? $usersCount / $daysSinceStart : null, + 'attachments_per_day' => $daysSinceStart ? $attachmentsCount / $daysSinceStart : null, ]); } + + private function resolveBoardStartedAt(): ?string + { + $timestamps = [ + User::query()->min('created_at'), + Thread::query()->min('created_at'), + Post::query()->min('created_at'), + ]; + + $min = null; + foreach ($timestamps as $value) { + if (!$value) { + continue; + } + $time = Carbon::parse($value)->timestamp; + if ($min === null || $time < $min) { + $min = $time; + } + } + + return $min !== null ? Carbon::createFromTimestamp($min)->toIso8601String() : null; + } + + private function resolveDatabaseSize(): ?int + { + try { + $driver = DB::connection()->getDriverName(); + if ($driver === 'mysql') { + $row = DB::selectOne('SELECT SUM(data_length + index_length) AS size FROM information_schema.tables WHERE table_schema = DATABASE()'); + return $row && isset($row->size) ? (int) $row->size : null; + } + } catch (\Throwable) { + return null; + } + + return null; + } + + private function resolveDatabaseServer(): ?string + { + try { + $row = DB::selectOne('SELECT VERSION() AS version'); + return $row && isset($row->version) ? (string) $row->version : null; + } catch (\Throwable) { + return null; + } + } + + private function resolveAvatarDirectorySize(): ?int + { + try { + $disk = Storage::disk('public'); + $files = $disk->allFiles('avatars'); + $total = 0; + foreach ($files as $file) { + $total += $disk->size($file); + } + return $total; + } catch (\Throwable) { + return null; + } + } + + private function resolveOrphanAttachments(): int + { + try { + return (int) DB::table('attachments') + ->leftJoin('threads', 'attachments.thread_id', '=', 'threads.id') + ->leftJoin('posts', 'attachments.post_id', '=', 'posts.id') + ->whereNull('attachments.deleted_at') + ->where(function ($query) { + $query + ->whereNull('attachments.thread_id') + ->whereNull('attachments.post_id') + ->orWhere(function ($inner) { + $inner->whereNotNull('attachments.thread_id') + ->where(function ($inner2) { + $inner2->whereNull('threads.id') + ->orWhereNotNull('threads.deleted_at'); + }); + }) + ->orWhere(function ($inner) { + $inner->whereNotNull('attachments.post_id') + ->where(function ($inner2) { + $inner2->whereNull('posts.id') + ->orWhereNotNull('posts.deleted_at'); + }); + }); + }) + ->count(); + } catch (\Throwable) { + return 0; + } + } + + private function resolveGzipCompression(): bool + { + $value = ini_get('zlib.output_compression'); + return in_array(strtolower((string) $value), ['1', 'on', 'true'], true); + } } diff --git a/app/Http/Controllers/SystemStatusController.php b/app/Http/Controllers/SystemStatusController.php new file mode 100644 index 0000000..9045e91 --- /dev/null +++ b/app/Http/Controllers/SystemStatusController.php @@ -0,0 +1,150 @@ +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 : []; + } +} diff --git a/app/Http/Controllers/SystemUpdateController.php b/app/Http/Controllers/SystemUpdateController.php new file mode 100644 index 0000000..a96e131 --- /dev/null +++ b/app/Http/Controllers/SystemUpdateController.php @@ -0,0 +1,199 @@ +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); + } + } +} diff --git a/app/Http/Controllers/ThreadController.php b/app/Http/Controllers/ThreadController.php index f5963d4..08c969f 100644 --- a/app/Http/Controllers/ThreadController.php +++ b/app/Http/Controllers/ThreadController.php @@ -6,6 +6,7 @@ use App\Models\Forum; use App\Models\Thread; use App\Actions\BbcodeFormatter; use App\Models\Setting; +use App\Services\AuditLogger; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\DB; @@ -81,6 +82,11 @@ class ThreadController extends Controller 'body' => $data['body'], ]); + app(AuditLogger::class)->log($request, 'thread.created', $thread, [ + 'forum_id' => $forum->id, + 'title' => $thread->title, + ]); + $thread->loadMissing([ 'user' => fn ($query) => $query ->withCount(['posts', 'threads', 'thanksGiven', 'thanksReceived']) @@ -96,6 +102,14 @@ class ThreadController extends Controller public function destroy(Request $request, Thread $thread): JsonResponse { + $reason = $request->input('reason'); + $reasonText = $request->input('reason_text'); + app(AuditLogger::class)->log($request, 'thread.deleted', $thread, [ + 'forum_id' => $thread->forum_id, + 'title' => $thread->title, + 'reason' => $reason, + 'reason_text' => $reasonText, + ]); $thread->deleted_by = $request->user()?->id; $thread->save(); $thread->delete(); @@ -103,6 +117,51 @@ class ThreadController extends Controller return response()->json(null, 204); } + public function update(Request $request, Thread $thread): JsonResponse + { + $user = $request->user(); + if (!$user) { + return response()->json(['message' => 'Unauthorized.'], 401); + } + + $isAdmin = $user->roles()->where('name', 'ROLE_ADMIN')->exists(); + if (!$isAdmin && $thread->user_id !== $user->id) { + return response()->json(['message' => 'Not authorized to edit threads.'], 403); + } + + $data = $request->validate([ + 'title' => ['sometimes', 'required', 'string'], + 'body' => ['sometimes', 'required', 'string'], + ]); + + if (array_key_exists('title', $data)) { + $thread->title = $data['title']; + } + if (array_key_exists('body', $data)) { + $thread->body = $data['body']; + } + + $thread->save(); + $thread->refresh(); + + app(AuditLogger::class)->log($request, 'thread.edited', $thread, [ + 'forum_id' => $thread->forum_id, + 'title' => $thread->title, + ]); + + $thread->loadMissing([ + 'user' => fn ($query) => $query + ->withCount(['posts', 'threads', 'thanksGiven', 'thanksReceived']) + ->with(['rank', 'roles']), + 'attachments.extension', + 'attachments.group', + 'latestPost.user.rank', + 'latestPost.user.roles', + ])->loadCount('posts'); + + return response()->json($this->serializeThread($thread)); + } + public function updateSolved(Request $request, Thread $thread): JsonResponse { $user = $request->user(); @@ -121,6 +180,9 @@ class ThreadController extends Controller $thread->solved = $data['solved']; $thread->save(); + app(AuditLogger::class)->log($request, 'thread.solved_updated', $thread, [ + 'solved' => $thread->solved, + ]); $thread->refresh(); $thread->loadMissing([ 'user' => fn ($query) => $query @@ -238,6 +300,9 @@ class ThreadController extends Controller $map[$name] = [ 'url' => "/api/attachments/{$attachment->id}/download", 'mime' => $attachment->mime_type ?? '', + 'thumb' => $attachment->thumbnail_path + ? "/api/attachments/{$attachment->id}/thumbnail" + : null, ]; } } @@ -256,6 +321,10 @@ class ThreadController extends Controller $url = $entry['url']; $mime = $entry['mime'] ?? ''; if (str_starts_with($mime, 'image/') && $this->displayImagesInline()) { + if (!empty($entry['thumb'])) { + $thumb = $entry['thumb']; + return "[url={$url}][img]{$thumb}[/img][/url]"; + } return "[img]{$url}[/img]"; } return "[url={$url}]{$rawName}[/url]"; diff --git a/app/Http/Controllers/VersionCheckController.php b/app/Http/Controllers/VersionCheckController.php new file mode 100644 index 0000000..c65a1dd --- /dev/null +++ b/app/Http/Controllers/VersionCheckController.php @@ -0,0 +1,72 @@ +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.', + ]); + } + } +} diff --git a/app/Models/AuditLog.php b/app/Models/AuditLog.php new file mode 100644 index 0000000..f007ff0 --- /dev/null +++ b/app/Models/AuditLog.php @@ -0,0 +1,41 @@ + 'array', + ]; + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } +} diff --git a/app/Services/AttachmentThumbnailService.php b/app/Services/AttachmentThumbnailService.php new file mode 100644 index 0000000..3f49651 --- /dev/null +++ b/app/Services/AttachmentThumbnailService.php @@ -0,0 +1,208 @@ +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; + } +} diff --git a/app/Services/AuditLogger.php b/app/Services/AuditLogger.php new file mode 100644 index 0000000..2570c91 --- /dev/null +++ b/app/Services/AuditLogger.php @@ -0,0 +1,34 @@ +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; + } + } +} diff --git a/composer.json b/composer.json index d5a935b..f235878 100644 --- a/composer.json +++ b/composer.json @@ -3,7 +3,10 @@ "name": "laravel/laravel", "type": "project", "description": "The skeleton application for the Laravel framework.", - "keywords": ["laravel", "framework"], + "keywords": [ + "laravel", + "framework" + ], "license": "MIT", "require": { "php": "^8.4", @@ -12,6 +15,7 @@ "laravel/sanctum": "*", "laravel/tinker": "^2.10.1", "s9e/text-formatter": "^2.5", + "composer-runtime-api": "^2.2", "ext-pdo": "*" }, "require-dev": { @@ -89,5 +93,6 @@ } }, "minimum-stability": "stable", - "prefer-stable": true + "prefer-stable": true, + "version": "26.0.1" } diff --git a/database/migrations/2026_01_31_000000_create_audit_logs_table.php b/database/migrations/2026_01_31_000000_create_audit_logs_table.php new file mode 100644 index 0000000..c670bb0 --- /dev/null +++ b/database/migrations/2026_01_31_000000_create_audit_logs_table.php @@ -0,0 +1,31 @@ +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'); + } +}; diff --git a/package.json b/package.json index ebcd7ab..65022a1 100644 --- a/package.json +++ b/package.json @@ -2,6 +2,10 @@ "$schema": "https://www.schemastore.org/package.json", "private": true, "type": "module", + "engines": { + "node": ">=20", + "npm": ">=10" + }, "scripts": { "build": "vite build", "dev": "vite", diff --git a/resources/js/App.jsx b/resources/js/App.jsx index 51d0d01..99520a3 100644 --- a/resources/js/App.jsx +++ b/resources/js/App.jsx @@ -7,7 +7,7 @@ import ForumView from './pages/ForumView' import ThreadView from './pages/ThreadView' import Login from './pages/Login' import Register from './pages/Register' -import Acp from './pages/Acp' +import { Acp } from './pages/Acp' import BoardIndex from './pages/BoardIndex' import Ucp from './pages/Ucp' import Profile from './pages/Profile' diff --git a/resources/js/api/client.js b/resources/js/api/client.js index b6655d8..a3d31c2 100644 --- a/resources/js/api/client.js +++ b/resources/js/api/client.js @@ -62,6 +62,12 @@ export async function registerUser({ email, username, plainPassword }) { }) } +export async function logoutUser() { + return apiFetch('/logout', { + method: 'POST', + }) +} + export async function listRootForums() { return getCollection('/forums?parent[exists]=false') } @@ -109,6 +115,20 @@ export async function fetchVersion() { return apiFetch('/version') } +export async function fetchVersionCheck() { + return apiFetch('/version/check') +} + +export async function runSystemUpdate() { + return apiFetch('/system/update', { + method: 'POST', + }) +} + +export async function fetchSystemStatus() { + return apiFetch('/system/status') +} + export async function fetchStats() { return apiFetch('/stats') } @@ -253,6 +273,23 @@ export async function getThread(id) { return apiFetch(`/threads/${id}`) } +export async function deleteThread(id, payload = null) { + return apiFetch(`/threads/${id}`, { + method: 'DELETE', + ...(payload ? { body: JSON.stringify(payload) } : {}), + }) +} + +export async function updateThread(id, payload) { + return apiFetch(`/threads/${id}`, { + method: 'PATCH', + headers: { + 'Content-Type': 'application/merge-patch+json', + }, + body: JSON.stringify(payload), + }) +} + export async function updateThreadSolved(threadId, solved) { return apiFetch(`/threads/${threadId}/solved`, { method: 'PATCH', @@ -351,10 +388,32 @@ export async function listPostsByThread(threadId) { return getCollection(`/posts?thread=/api/threads/${threadId}`) } +export async function updatePost(id, payload) { + return apiFetch(`/posts/${id}`, { + method: 'PATCH', + headers: { + 'Content-Type': 'application/merge-patch+json', + }, + body: JSON.stringify(payload), + }) +} + +export async function deletePost(id, payload = null) { + return apiFetch(`/posts/${id}`, { + method: 'DELETE', + ...(payload ? { body: JSON.stringify(payload) } : {}), + }) +} + export async function listUsers() { return getCollection('/users') } +export async function listAuditLogs(limit = 200) { + const query = Number.isFinite(limit) ? `?limit=${limit}` : '' + return getCollection(`/audit-logs${query}`) +} + export async function listRanks() { return getCollection('/ranks') } diff --git a/resources/js/context/AuthContext.jsx b/resources/js/context/AuthContext.jsx index 523a48d..4aed220 100644 --- a/resources/js/context/AuthContext.jsx +++ b/resources/js/context/AuthContext.jsx @@ -1,5 +1,5 @@ import { createContext, useContext, useMemo, useState, useEffect } from 'react' -import { login as apiLogin } from '../api/client' +import { login as apiLogin, logoutUser } from '../api/client' const AuthContext = createContext(null) @@ -46,7 +46,12 @@ export function AuthProvider({ children }) { setToken(data.token) setEmail(data.email || loginInput) }, - logout() { + async logout() { + try { + await logoutUser() + } catch { + // Ignore logout failures; client state is cleared regardless. + } localStorage.removeItem('speedbb_token') localStorage.removeItem('speedbb_email') localStorage.removeItem('speedbb_user_id') diff --git a/resources/js/index.css b/resources/js/index.css index 73ebc11..3678519 100644 --- a/resources/js/index.css +++ b/resources/js/index.css @@ -216,6 +216,39 @@ a { overflow-y: auto; } +.bb-lightbox-modal { + max-width: min(96vw, 1200px); +} + +.bb-lightbox-modal .modal-content { + background: rgba(12, 16, 24, 0.98); +} + +.bb-lightbox-body { + position: relative; + min-height: 200px; +} + +.bb-lightbox-controls { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 0.75rem; + pointer-events: none; +} + +.bb-lightbox-btn { + pointer-events: auto; + border-radius: 999px; + width: 42px; + height: 42px; + display: inline-flex; + align-items: center; + justify-content: center; +} + .bb-attachment-list { display: flex; flex-direction: column; @@ -740,6 +773,27 @@ a { flex: 1 1 auto; } +.bb-post-body blockquote { + margin: 1rem 0; + padding: 0.75rem 1rem; + border-left: 3px solid var(--bb-accent, #f29b3f); + background: rgba(255, 255, 255, 0.04); + color: var(--bb-ink-muted); + border-radius: 8px; +} + +.bb-post-body blockquote > cite { + display: block; + font-style: normal; + font-weight: 600; + color: var(--bb-ink); + margin-bottom: 0.35rem; +} + +.bb-post-body blockquote > div { + color: var(--bb-ink); +} + .bb-post-footer { display: flex; justify-content: flex-end; @@ -2229,6 +2283,166 @@ a { padding: 1rem; } +.bb-acp-stats-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); + gap: 1.5rem; +} + +.bb-acp-stats-table { + width: 100%; + border-radius: 10px; + border: 1px solid rgba(255, 255, 255, 0.08); + background: rgba(12, 16, 24, 0.6); + overflow: hidden; + border-collapse: collapse; +} + +.bb-acp-stats-table th { + text-align: left; + padding: 0.55rem 0.75rem; + font-size: 0.72rem; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--bb-ink-muted); + background: rgba(255, 255, 255, 0.04); +} + +.bb-acp-stats-table td { + padding: 0.6rem 0.75rem; + border-top: 1px solid rgba(255, 255, 255, 0.06); + color: var(--bb-ink); +} + +.bb-acp-stats-table tbody tr:nth-child(even) { + background: rgba(255, 255, 255, 0.03); +} + +.bb-acp-stats-value { + text-align: right; + font-weight: 600; +} + +.bb-acp-version-inline { + display: inline-flex; + align-items: center; + gap: 0.75rem; + flex-wrap: wrap; +} + +.bb-acp-version-link { + color: var(--bb-accent, #f29b3f); + text-decoration: none; + font-weight: 600; +} + +.bb-acp-version-link:hover { + text-decoration: underline; +} + +.bb-acp-version-meta { + color: var(--bb-ink-muted); + font-size: 0.85rem; +} + +.bb-acp-update-log { + max-height: 240px; + overflow: auto; + background: rgba(12, 16, 24, 0.7); + border-radius: 8px; + padding: 0.75rem; + border: 1px solid rgba(255, 255, 255, 0.08); + color: var(--bb-ink); + font-size: 0.85rem; +} + +.bb-status-icon { + display: inline-flex; + align-items: center; + justify-content: center; + font-size: 1rem; +} + +.bb-status-icon.is-ok { + color: #22c55e; +} + +.bb-status-icon.is-bad { + color: #ef4444; +} + +.bb-status-icon.is-warn { + color: #f59e0b; +} + +[data-bs-theme="light"] .bb-acp-stats-table { + background: #ffffff; + border-color: rgba(14, 18, 27, 0.12); +} + +[data-bs-theme="light"] .bb-acp-stats-table th { + background: rgba(14, 18, 27, 0.04); + color: #5b6678; +} + +[data-bs-theme="light"] .bb-acp-stats-table td { + border-top-color: rgba(14, 18, 27, 0.06); +} + +[data-bs-theme="light"] .bb-acp-stats-table tbody tr:nth-child(even) { + background: rgba(14, 18, 27, 0.02); +} + +.bb-acp-admin-log__table { + width: 100%; + border-collapse: collapse; + border-radius: 10px; + overflow: hidden; + border: 1px solid rgba(255, 255, 255, 0.08); + background: rgba(12, 16, 24, 0.6); +} + +.bb-acp-admin-log__table th { + text-align: left; + padding: 0.55rem 0.75rem; + font-size: 0.72rem; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--bb-ink-muted); + background: rgba(255, 255, 255, 0.04); +} + +.bb-acp-admin-log__table td { + padding: 0.6rem 0.75rem; + border-top: 1px solid rgba(255, 255, 255, 0.06); + color: var(--bb-ink); +} + +.bb-acp-admin-log__table tbody tr:nth-child(even) { + background: rgba(255, 255, 255, 0.03); +} + +.bb-acp-admin-log__table tfoot td { + text-align: right; +} + +[data-bs-theme="light"] .bb-acp-admin-log__table { + background: #ffffff; + border-color: rgba(14, 18, 27, 0.12); +} + +[data-bs-theme="light"] .bb-acp-admin-log__table th { + background: rgba(14, 18, 27, 0.04); + color: #5b6678; +} + +[data-bs-theme="light"] .bb-acp-admin-log__table td { + border-top-color: rgba(14, 18, 27, 0.06); +} + +[data-bs-theme="light"] .bb-acp-admin-log__table tbody tr:nth-child(even) { + background: rgba(14, 18, 27, 0.02); +} .bb-icon { width: 44px; height: 44px; @@ -2587,6 +2801,10 @@ a { max-width: 320px; } +.bb-audit-limit { + max-width: 120px; +} + .bb-sort-label { display: flex; align-items: center; diff --git a/resources/js/pages/Acp.jsx b/resources/js/pages/Acp.jsx index a9f4ff8..44af204 100644 --- a/resources/js/pages/Acp.jsx +++ b/resources/js/pages/Acp.jsx @@ -1,5 +1,5 @@ -import { useEffect, useMemo, useRef, useState } from 'react' -import { Accordion, Button, ButtonGroup, Col, Container, Form, Modal, Row, Tab, Tabs } from 'react-bootstrap' +import { useEffect, useMemo, useRef, useState, useId } from 'react' +import { Accordion, Button, ButtonGroup, Col, Container, Form, Modal, Row, Tab, Tabs, OverlayTrigger, Tooltip } from 'react-bootstrap' import DataTable, { createTheme } from 'react-data-table-component' import { useTranslation } from 'react-i18next' import { useDropzone } from 'react-dropzone' @@ -8,10 +8,15 @@ import { createForum, deleteForum, fetchSettings, + fetchStats, + fetchVersionCheck, + runSystemUpdate, + fetchSystemStatus, listAllForums, listRanks, listRoles, listUsers, + listAuditLogs, reorderForums, saveSetting, saveSettings, @@ -38,7 +43,26 @@ import { deleteAttachmentExtension, } from '../api/client' -export default function Acp({ isAdmin }) { +const StatusIcon = ({ status = 'bad', tooltip }) => { + const id = useId() + const iconClass = + status === 'ok' ? 'bi-check-circle-fill' : status === 'warn' ? 'bi-question-circle-fill' : 'bi-x-circle-fill' + const content = ( + +