feat: system tools and admin enhancements
This commit is contained in:
@@ -45,6 +45,17 @@ class BbcodeFormatter
|
||||
|
||||
$configurator->tags->add('BR')->template = '<br/>';
|
||||
|
||||
if (isset($configurator->tags['QUOTE'])) {
|
||||
$configurator->tags['QUOTE']->template = <<<'XSL'
|
||||
<blockquote>
|
||||
<xsl:if test="@author">
|
||||
<cite><xsl:value-of select="@author"/> wrote:</cite>
|
||||
</xsl:if>
|
||||
<div><xsl:apply-templates/></div>
|
||||
</blockquote>
|
||||
XSL;
|
||||
}
|
||||
|
||||
$bundle = $configurator->finalize();
|
||||
$parser = $bundle['parser'] ?? null;
|
||||
$renderer = $bundle['renderer'] ?? null;
|
||||
|
||||
93
app/Console/Commands/CronRun.php
Normal file
93
app/Console/Commands/CronRun.php
Normal file
@@ -0,0 +1,93 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\Attachment;
|
||||
use App\Services\AttachmentThumbnailService;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
class CronRun extends Command
|
||||
{
|
||||
protected $signature = 'speedbb:cron {--force : Recreate thumbnails even if already present} {--dry-run : Report without writing}';
|
||||
|
||||
protected $description = 'Run periodic maintenance tasks (currently: attachment thumbnail recreation).';
|
||||
|
||||
public function handle(AttachmentThumbnailService $thumbnailService): int
|
||||
{
|
||||
$force = (bool) $this->option('force');
|
||||
$dryRun = (bool) $this->option('dry-run');
|
||||
|
||||
$stats = [
|
||||
'checked' => 0,
|
||||
'created' => 0,
|
||||
'skipped' => 0,
|
||||
'missing' => 0,
|
||||
'non_image' => 0,
|
||||
];
|
||||
|
||||
$this->info('Processing attachment thumbnails...');
|
||||
|
||||
Attachment::query()
|
||||
->orderBy('id')
|
||||
->chunkById(200, function ($attachments) use ($thumbnailService, $force, $dryRun, &$stats) {
|
||||
foreach ($attachments as $attachment) {
|
||||
$stats['checked']++;
|
||||
|
||||
$mime = $attachment->mime_type ?? '';
|
||||
if (!str_starts_with($mime, 'image/')) {
|
||||
$stats['non_image']++;
|
||||
continue;
|
||||
}
|
||||
|
||||
$disk = Storage::disk($attachment->disk);
|
||||
if (!$disk->exists($attachment->path)) {
|
||||
$stats['missing']++;
|
||||
continue;
|
||||
}
|
||||
|
||||
$needsThumbnail = $force
|
||||
|| !$attachment->thumbnail_path
|
||||
|| !$disk->exists($attachment->thumbnail_path);
|
||||
|
||||
if (!$needsThumbnail) {
|
||||
$stats['skipped']++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($dryRun) {
|
||||
$stats['created']++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($force && $attachment->thumbnail_path && $disk->exists($attachment->thumbnail_path)) {
|
||||
$disk->delete($attachment->thumbnail_path);
|
||||
}
|
||||
|
||||
$payload = $thumbnailService->createForAttachment($attachment, $force);
|
||||
if (!$payload) {
|
||||
$stats['skipped']++;
|
||||
continue;
|
||||
}
|
||||
|
||||
$attachment->thumbnail_path = $payload['path'] ?? null;
|
||||
$attachment->thumbnail_mime_type = $payload['mime'] ?? null;
|
||||
$attachment->thumbnail_size_bytes = $payload['size'] ?? null;
|
||||
$attachment->save();
|
||||
|
||||
$stats['created']++;
|
||||
}
|
||||
});
|
||||
|
||||
$this->info(sprintf(
|
||||
'Checked: %d | Created: %d | Skipped: %d | Missing: %d | Non-image: %d',
|
||||
$stats['checked'],
|
||||
$stats['created'],
|
||||
$stats['skipped'],
|
||||
$stats['missing'],
|
||||
$stats['non_image']
|
||||
));
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
89
app/Console/Commands/VersionBump.php
Normal file
89
app/Console/Commands/VersionBump.php
Normal file
@@ -0,0 +1,89 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\Setting;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class VersionBump extends Command
|
||||
{
|
||||
protected $signature = 'version:bump';
|
||||
|
||||
protected $description = 'Bump the patch version (e.g. 26.0.1 -> 26.0.2).';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$current = Setting::query()->where('key', 'version')->value('value');
|
||||
if (!$current) {
|
||||
$this->error('Unable to determine current version from settings.');
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$next = $this->bumpPatch($current);
|
||||
if ($next === null) {
|
||||
$this->error('Version format must be X.Y.Z (optionally with suffix).');
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
Setting::updateOrCreate(['key' => 'version'], ['value' => $next]);
|
||||
|
||||
if (!$this->syncComposerVersion($next)) {
|
||||
$this->error('Failed to sync version to composer.json.');
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$this->info("Version bumped: {$current} -> {$next}");
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
private function bumpPatch(string $version): ?string
|
||||
{
|
||||
if (!preg_match('/^(\d+)\.(\d+)\.(\d+)(.*)?$/', $version, $matches)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$major = $matches[1];
|
||||
$minor = $matches[2];
|
||||
$patch = $matches[3];
|
||||
$suffix = $matches[4] ?? '';
|
||||
|
||||
$patchWidth = strlen($patch);
|
||||
$nextPatch = (string) ((int) $patch + 1);
|
||||
if ($patchWidth > 1) {
|
||||
$nextPatch = str_pad($nextPatch, $patchWidth, '0', STR_PAD_LEFT);
|
||||
}
|
||||
|
||||
return "{$major}.{$minor}.{$nextPatch}{$suffix}";
|
||||
}
|
||||
|
||||
private function syncComposerVersion(string $version): bool
|
||||
{
|
||||
$composerPath = base_path('composer.json');
|
||||
|
||||
if (!is_file($composerPath) || !is_readable($composerPath)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$raw = file_get_contents($composerPath);
|
||||
if ($raw === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$data = json_decode($raw, true);
|
||||
if (!is_array($data)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$data['version'] = $version;
|
||||
|
||||
$encoded = json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
|
||||
if ($encoded === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$encoded .= "\n";
|
||||
|
||||
return file_put_contents($composerPath, $encoded) !== false;
|
||||
}
|
||||
}
|
||||
113
app/Console/Commands/VersionRelease.php
Normal file
113
app/Console/Commands/VersionRelease.php
Normal file
@@ -0,0 +1,113 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\Setting;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
class VersionRelease extends Command
|
||||
{
|
||||
protected $signature = 'version:release {--prerelease : Mark this release as a prerelease} {--target= : Override target commit (defaults to env GITEA_TARGET_COMMIT or master)}';
|
||||
|
||||
protected $description = 'Create or update a Gitea release for the current version.';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$version = Setting::query()->where('key', 'version')->value('value');
|
||||
if (!$version) {
|
||||
$this->error('Unable to determine version from settings.');
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$token = env('GITEA_TOKEN');
|
||||
$owner = env('GITEA_OWNER');
|
||||
$repo = env('GITEA_REPO');
|
||||
$apiBase = rtrim((string) env('GITEA_API_BASE', 'https://git.24unix.net/api/v1'), '/');
|
||||
$target = $this->option('target') ?: env('GITEA_TARGET_COMMIT', 'master');
|
||||
$prerelease = $this->option('prerelease') || filter_var(env('GITEA_PRERELEASE', false), FILTER_VALIDATE_BOOLEAN);
|
||||
|
||||
if (!$token || !$owner || !$repo) {
|
||||
$this->error('Missing Gitea config. Set GITEA_TOKEN, GITEA_OWNER, and GITEA_REPO in .env.');
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$tag = "v{$version}";
|
||||
$body = $this->resolveChangelogBody($version);
|
||||
|
||||
$client = Http::withHeaders([
|
||||
'Authorization' => "token {$token}",
|
||||
'Accept' => 'application/json',
|
||||
]);
|
||||
|
||||
$payload = [
|
||||
'tag_name' => $tag,
|
||||
'target_commitish' => $target,
|
||||
'name' => $tag,
|
||||
'body' => $body,
|
||||
'prerelease' => (bool) $prerelease,
|
||||
];
|
||||
|
||||
$createUrl = "{$apiBase}/repos/{$owner}/{$repo}/releases";
|
||||
$response = $client->post($createUrl, $payload);
|
||||
|
||||
if ($response->successful()) {
|
||||
$this->info("Release created: {$tag}");
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
if ($response->status() === 409 || $response->status() === 422) {
|
||||
$getUrl = "{$apiBase}/repos/{$owner}/{$repo}/releases/tags/{$tag}";
|
||||
$existing = $client->get($getUrl);
|
||||
if (!$existing->successful()) {
|
||||
$this->error('Release already exists, but failed to fetch it for update.');
|
||||
return self::FAILURE;
|
||||
}
|
||||
$id = $existing->json('id');
|
||||
if (!$id) {
|
||||
$this->error('Release already exists, but no ID was returned.');
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$updateUrl = "{$apiBase}/repos/{$owner}/{$repo}/releases/{$id}";
|
||||
$updatePayload = [
|
||||
'name' => $tag,
|
||||
'body' => $body,
|
||||
'prerelease' => (bool) $prerelease,
|
||||
'target_commitish' => $target,
|
||||
];
|
||||
$updated = $client->patch($updateUrl, $updatePayload);
|
||||
if ($updated->successful()) {
|
||||
$this->info("Release updated: {$tag}");
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$this->error("Failed to update release: {$updated->status()}");
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$this->error("Failed to create release: {$response->status()}");
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
private function resolveChangelogBody(string $version): string
|
||||
{
|
||||
$path = base_path('CHANGELOG.md');
|
||||
if (!is_file($path) || !is_readable($path)) {
|
||||
return 'See commit history for details.';
|
||||
}
|
||||
|
||||
$raw = file_get_contents($path);
|
||||
if ($raw === false) {
|
||||
return 'See commit history for details.';
|
||||
}
|
||||
|
||||
$pattern = '/^##\\s+' . preg_quote($version, '/') . '\\s*\\R(.*?)(?=^##\\s+|\\z)/ms';
|
||||
if (preg_match($pattern, $raw, $matches)) {
|
||||
$body = trim($matches[1] ?? '');
|
||||
return $body !== '' ? $body : 'See commit history for details.';
|
||||
}
|
||||
|
||||
return 'See commit history for details.';
|
||||
}
|
||||
}
|
||||
73
app/Console/Commands/VersionSet.php
Normal file
73
app/Console/Commands/VersionSet.php
Normal file
@@ -0,0 +1,73 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\Setting;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class VersionSet extends Command
|
||||
{
|
||||
protected $signature = 'version:set {version}';
|
||||
|
||||
protected $description = 'Set the forum version (e.g. 26.0.1).';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$version = trim((string) $this->argument('version'));
|
||||
if (!$this->isValidVersion($version)) {
|
||||
$this->error('Version format must be X.Y or X.Y.Z (optionally with suffix).');
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$current = Setting::query()->where('key', 'version')->value('value');
|
||||
Setting::updateOrCreate(['key' => 'version'], ['value' => $version]);
|
||||
|
||||
if (!$this->syncComposerVersion($version)) {
|
||||
$this->error('Failed to sync version to composer.json.');
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
if ($current) {
|
||||
$this->info("Version updated: {$current} -> {$version}");
|
||||
} else {
|
||||
$this->info("Version set to {$version}");
|
||||
}
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
private function isValidVersion(string $version): bool
|
||||
{
|
||||
return (bool) preg_match('/^\d+\.\d+(?:\.\d+)?(?:[-._][0-9A-Za-z.-]+)?$/', $version);
|
||||
}
|
||||
|
||||
private function syncComposerVersion(string $version): bool
|
||||
{
|
||||
$composerPath = base_path('composer.json');
|
||||
|
||||
if (!is_file($composerPath) || !is_readable($composerPath)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$raw = file_get_contents($composerPath);
|
||||
if ($raw === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$data = json_decode($raw, true);
|
||||
if (!is_array($data)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$data['version'] = $version;
|
||||
|
||||
$encoded = json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
|
||||
if ($encoded === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$encoded .= "\n";
|
||||
|
||||
return file_put_contents($composerPath, $encoded) !== false;
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,8 @@ namespace App\Http\Controllers;
|
||||
use App\Models\Attachment;
|
||||
use App\Models\AttachmentExtension;
|
||||
use App\Models\Post;
|
||||
use App\Models\Setting;
|
||||
use App\Services\AttachmentThumbnailService;
|
||||
use App\Services\AuditLogger;
|
||||
use App\Models\Thread;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
@@ -115,7 +116,8 @@ class AttachmentController extends Controller
|
||||
$path = "attachments/{$scopeFolder}/{$filename}";
|
||||
Storage::disk($disk)->putFileAs("attachments/{$scopeFolder}", $file, $filename);
|
||||
|
||||
$thumbnailPayload = $this->maybeCreateThumbnail($file, $scopeFolder);
|
||||
$thumbnailPayload = app(AttachmentThumbnailService::class)
|
||||
->createForUpload($file, $scopeFolder, $disk);
|
||||
|
||||
$attachment = Attachment::create([
|
||||
'thread_id' => $threadId,
|
||||
@@ -134,6 +136,13 @@ class AttachmentController extends Controller
|
||||
'size_bytes' => (int) $file->getSize(),
|
||||
]);
|
||||
|
||||
app(AuditLogger::class)->log($request, 'attachment.created', $attachment, [
|
||||
'thread_id' => $threadId,
|
||||
'post_id' => $postId,
|
||||
'original_name' => $attachment->original_name,
|
||||
'size_bytes' => $attachment->size_bytes,
|
||||
]);
|
||||
|
||||
$attachment->loadMissing(['extension', 'group']);
|
||||
|
||||
return response()->json($this->serializeAttachment($attachment), 201);
|
||||
@@ -201,6 +210,13 @@ class AttachmentController extends Controller
|
||||
return response()->json(['message' => 'Not authorized to delete attachments.'], 403);
|
||||
}
|
||||
|
||||
app(AuditLogger::class)->log($request, 'attachment.deleted', $attachment, [
|
||||
'thread_id' => $attachment->thread_id,
|
||||
'post_id' => $attachment->post_id,
|
||||
'original_name' => $attachment->original_name,
|
||||
'size_bytes' => $attachment->size_bytes,
|
||||
]);
|
||||
|
||||
$attachment->delete();
|
||||
|
||||
return response()->json(null, 204);
|
||||
@@ -332,151 +348,4 @@ class AttachmentController extends Controller
|
||||
];
|
||||
}
|
||||
|
||||
private function maybeCreateThumbnail($file, string $scopeFolder): ?array
|
||||
{
|
||||
$enabled = $this->settingBool('attachments.create_thumbnails', true);
|
||||
if (!$enabled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$mime = $file->getMimeType() ?? '';
|
||||
if (!str_starts_with($mime, 'image/')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$maxWidth = $this->settingInt('attachments.thumbnail_max_width', 300);
|
||||
$maxHeight = $this->settingInt('attachments.thumbnail_max_height', 300);
|
||||
if ($maxWidth <= 0 || $maxHeight <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$sourcePath = $file->getPathname();
|
||||
$info = @getimagesize($sourcePath);
|
||||
if (!$info) {
|
||||
return null;
|
||||
}
|
||||
|
||||
[$width, $height] = $info;
|
||||
if ($width <= 0 || $height <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($width <= $maxWidth && $height <= $maxHeight) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$ratio = min($maxWidth / $width, $maxHeight / $height);
|
||||
$targetWidth = max(1, (int) round($width * $ratio));
|
||||
$targetHeight = max(1, (int) round($height * $ratio));
|
||||
|
||||
$sourceImage = $this->createImageFromFile($sourcePath, $mime);
|
||||
if (!$sourceImage) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$thumbImage = imagecreatetruecolor($targetWidth, $targetHeight);
|
||||
if (!$thumbImage) {
|
||||
imagedestroy($sourceImage);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (in_array($mime, ['image/png', 'image/gif'], true)) {
|
||||
imagecolortransparent($thumbImage, imagecolorallocatealpha($thumbImage, 0, 0, 0, 127));
|
||||
imagealphablending($thumbImage, false);
|
||||
imagesavealpha($thumbImage, true);
|
||||
}
|
||||
|
||||
imagecopyresampled(
|
||||
$thumbImage,
|
||||
$sourceImage,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
$targetWidth,
|
||||
$targetHeight,
|
||||
$width,
|
||||
$height
|
||||
);
|
||||
|
||||
$quality = $this->settingInt('attachments.thumbnail_quality', 85);
|
||||
$thumbBinary = $this->renderImageBinary($thumbImage, $mime, $quality);
|
||||
|
||||
imagedestroy($sourceImage);
|
||||
imagedestroy($thumbImage);
|
||||
|
||||
if ($thumbBinary === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$filename = Str::uuid()->toString();
|
||||
$extension = strtolower((string) $file->getClientOriginalExtension());
|
||||
if ($extension !== '') {
|
||||
$filename .= ".{$extension}";
|
||||
}
|
||||
|
||||
$disk = 'local';
|
||||
$thumbPath = "attachments/{$scopeFolder}/thumbs/{$filename}";
|
||||
Storage::disk($disk)->put($thumbPath, $thumbBinary);
|
||||
|
||||
return [
|
||||
'path' => $thumbPath,
|
||||
'mime' => $mime,
|
||||
'size' => strlen($thumbBinary),
|
||||
];
|
||||
}
|
||||
|
||||
private function createImageFromFile(string $path, string $mime)
|
||||
{
|
||||
return match ($mime) {
|
||||
'image/jpeg', 'image/jpg' => @imagecreatefromjpeg($path),
|
||||
'image/png' => @imagecreatefrompng($path),
|
||||
'image/gif' => @imagecreatefromgif($path),
|
||||
'image/webp' => function_exists('imagecreatefromwebp') ? @imagecreatefromwebp($path) : null,
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
|
||||
private function renderImageBinary($image, string $mime, int $quality): ?string
|
||||
{
|
||||
ob_start();
|
||||
$success = false;
|
||||
|
||||
if (in_array($mime, ['image/jpeg', 'image/jpg'], true)) {
|
||||
$success = imagejpeg($image, null, max(10, min(95, $quality)));
|
||||
} elseif ($mime === 'image/png') {
|
||||
$compression = (int) round(9 - (max(10, min(95, $quality)) / 100) * 9);
|
||||
$success = imagepng($image, null, $compression);
|
||||
} elseif ($mime === 'image/gif') {
|
||||
$success = imagegif($image);
|
||||
} elseif ($mime === 'image/webp' && function_exists('imagewebp')) {
|
||||
$success = imagewebp($image, null, max(10, min(95, $quality)));
|
||||
}
|
||||
|
||||
$data = ob_get_clean();
|
||||
|
||||
if (!$success) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $data !== false ? $data : null;
|
||||
}
|
||||
|
||||
private function settingBool(string $key, bool $default): bool
|
||||
{
|
||||
$value = Setting::query()->where('key', $key)->value('value');
|
||||
if ($value === null) {
|
||||
return $default;
|
||||
}
|
||||
return in_array(strtolower($value), ['1', 'true', 'yes', 'on'], true);
|
||||
}
|
||||
|
||||
private function settingInt(string $key, int $default): int
|
||||
{
|
||||
$value = Setting::query()->where('key', $key)->value('value');
|
||||
if ($value === null) {
|
||||
return $default;
|
||||
}
|
||||
return (int) $value;
|
||||
}
|
||||
}
|
||||
|
||||
55
app/Http/Controllers/AuditLogController.php
Normal file
55
app/Http/Controllers/AuditLogController.php
Normal file
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\AuditLog;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class AuditLogController extends Controller
|
||||
{
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
$user = $request->user();
|
||||
if (!$user) {
|
||||
return response()->json(['message' => 'Unauthorized.'], 401);
|
||||
}
|
||||
|
||||
$isAdmin = $user->roles()->where('name', 'ROLE_ADMIN')->exists();
|
||||
if (!$isAdmin) {
|
||||
return response()->json(['message' => 'Not authorized.'], 403);
|
||||
}
|
||||
|
||||
$limit = (int) $request->query('limit', 200);
|
||||
$limit = max(1, min(500, $limit));
|
||||
|
||||
$logs = AuditLog::query()
|
||||
->with(['user.roles'])
|
||||
->latest('created_at')
|
||||
->limit($limit)
|
||||
->get()
|
||||
->map(fn (AuditLog $log) => $this->serializeLog($log));
|
||||
|
||||
return response()->json($logs);
|
||||
}
|
||||
|
||||
private function serializeLog(AuditLog $log): array
|
||||
{
|
||||
return [
|
||||
'id' => $log->id,
|
||||
'action' => $log->action,
|
||||
'subject_type' => $log->subject_type,
|
||||
'subject_id' => $log->subject_id,
|
||||
'metadata' => $log->metadata,
|
||||
'ip_address' => $log->ip_address,
|
||||
'user_agent' => $log->user_agent,
|
||||
'created_at' => $log->created_at?->toIso8601String(),
|
||||
'user' => $log->user ? [
|
||||
'id' => $log->user->id,
|
||||
'name' => $log->user->name,
|
||||
'email' => $log->user->email,
|
||||
'roles' => $log->user->roles?->pluck('name')->values(),
|
||||
] : null,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ namespace App\Http\Controllers;
|
||||
use App\Actions\Fortify\CreateNewUser;
|
||||
use App\Actions\Fortify\PasswordValidationRules;
|
||||
use App\Models\User;
|
||||
use App\Services\AuditLogger;
|
||||
use Illuminate\Auth\Events\Verified;
|
||||
use Illuminate\Auth\Events\PasswordReset;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
@@ -32,6 +33,9 @@ class AuthController extends Controller
|
||||
$user = $creator->create(input: $input);
|
||||
|
||||
$user->sendEmailVerificationNotification();
|
||||
app(AuditLogger::class)->log($request, 'user.registered', $user, [
|
||||
'email' => $user->email,
|
||||
], $user);
|
||||
|
||||
return response()->json(data: [
|
||||
'user_id' => $user->id,
|
||||
@@ -77,6 +81,10 @@ class AuthController extends Controller
|
||||
|
||||
$token = $user->createToken(name: 'api')->plainTextToken;
|
||||
|
||||
app(AuditLogger::class)->log($request, 'user.login', $user, [
|
||||
'login' => $login,
|
||||
], $user);
|
||||
|
||||
return response()->json(data: [
|
||||
'token' => $token,
|
||||
'user_id' => $user->id,
|
||||
@@ -130,13 +138,14 @@ class AuthController extends Controller
|
||||
|
||||
$status = Password::reset(
|
||||
$request->only('email', 'password', 'password_confirmation', 'token'),
|
||||
function (User $user, string $password) {
|
||||
function (User $user, string $password) use ($request) {
|
||||
$user->forceFill(attributes: [
|
||||
'password' => Hash::make(value: $password),
|
||||
'remember_token' => Str::random(length: 60),
|
||||
])->save();
|
||||
|
||||
event(new PasswordReset(user: $user));
|
||||
app(AuditLogger::class)->log($request, 'user.password_reset', $user, [], $user);
|
||||
}
|
||||
);
|
||||
|
||||
@@ -169,11 +178,14 @@ class AuthController extends Controller
|
||||
'remember_token' => Str::random(length: 60),
|
||||
])->save();
|
||||
|
||||
app(AuditLogger::class)->log($request, 'user.password_changed', $user, [], $user);
|
||||
|
||||
return response()->json(data: ['message' => 'Password updated.']);
|
||||
}
|
||||
|
||||
public function logout(Request $request): JsonResponse
|
||||
{
|
||||
app(AuditLogger::class)->log($request, 'user.logout', $request->user());
|
||||
$request->user()?->currentAccessToken()?->delete();
|
||||
|
||||
return response()->json(data: null, status: 204);
|
||||
|
||||
@@ -6,6 +6,7 @@ use App\Actions\BbcodeFormatter;
|
||||
use App\Models\Post;
|
||||
use App\Models\Thread;
|
||||
use App\Models\Setting;
|
||||
use App\Services\AuditLogger;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
@@ -54,6 +55,10 @@ class PostController extends Controller
|
||||
'body' => $data['body'],
|
||||
]);
|
||||
|
||||
app(AuditLogger::class)->log($request, 'post.created', $post, [
|
||||
'thread_id' => $thread->id,
|
||||
]);
|
||||
|
||||
$post->loadMissing([
|
||||
'user' => fn ($query) => $query
|
||||
->withCount(['posts', 'threads', 'thanksGiven', 'thanksReceived'])
|
||||
@@ -67,6 +72,13 @@ class PostController extends Controller
|
||||
|
||||
public function destroy(Request $request, Post $post): JsonResponse
|
||||
{
|
||||
$reason = $request->input('reason');
|
||||
$reasonText = $request->input('reason_text');
|
||||
app(AuditLogger::class)->log($request, 'post.deleted', $post, [
|
||||
'thread_id' => $post->thread_id,
|
||||
'reason' => $reason,
|
||||
'reason_text' => $reasonText,
|
||||
]);
|
||||
$post->deleted_by = $request->user()?->id;
|
||||
$post->save();
|
||||
$post->delete();
|
||||
@@ -74,6 +86,41 @@ class PostController extends Controller
|
||||
return response()->json(null, 204);
|
||||
}
|
||||
|
||||
public function update(Request $request, Post $post): JsonResponse
|
||||
{
|
||||
$user = $request->user();
|
||||
if (!$user) {
|
||||
return response()->json(['message' => 'Unauthorized.'], 401);
|
||||
}
|
||||
|
||||
$isAdmin = $user->roles()->where('name', 'ROLE_ADMIN')->exists();
|
||||
if (!$isAdmin && $post->user_id !== $user->id) {
|
||||
return response()->json(['message' => 'Not authorized to edit posts.'], 403);
|
||||
}
|
||||
|
||||
$data = $request->validate([
|
||||
'body' => ['required', 'string'],
|
||||
]);
|
||||
|
||||
$post->body = $data['body'];
|
||||
$post->save();
|
||||
$post->refresh();
|
||||
|
||||
app(AuditLogger::class)->log($request, 'post.edited', $post, [
|
||||
'thread_id' => $post->thread_id,
|
||||
]);
|
||||
|
||||
$post->loadMissing([
|
||||
'user' => fn ($query) => $query
|
||||
->withCount(['posts', 'threads', 'thanksGiven', 'thanksReceived'])
|
||||
->with(['rank', 'roles']),
|
||||
'attachments.extension',
|
||||
'attachments.group',
|
||||
]);
|
||||
|
||||
return response()->json($this->serializePost($post));
|
||||
}
|
||||
|
||||
private function parseIriId(?string $value): ?int
|
||||
{
|
||||
if (!$value) {
|
||||
@@ -163,6 +210,9 @@ class PostController extends Controller
|
||||
$map[$name] = [
|
||||
'url' => "/api/attachments/{$attachment->id}/download",
|
||||
'mime' => $attachment->mime_type ?? '',
|
||||
'thumb' => $attachment->thumbnail_path
|
||||
? "/api/attachments/{$attachment->id}/thumbnail"
|
||||
: null,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -181,6 +231,10 @@ class PostController extends Controller
|
||||
$url = $entry['url'];
|
||||
$mime = $entry['mime'] ?? '';
|
||||
if (str_starts_with($mime, 'image/') && $this->displayImagesInline()) {
|
||||
if (!empty($entry['thumb'])) {
|
||||
$thumb = $entry['thumb'];
|
||||
return "[url={$url}][img]{$thumb}[/img][/url]";
|
||||
}
|
||||
return "[img]{$url}[/img]";
|
||||
}
|
||||
return "[url={$url}]{$rawName}[/url]";
|
||||
|
||||
@@ -5,17 +5,157 @@ namespace App\Http\Controllers;
|
||||
use App\Models\Post;
|
||||
use App\Models\Thread;
|
||||
use App\Models\User;
|
||||
use App\Models\Attachment;
|
||||
use App\Models\Setting;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
|
||||
class StatsController extends Controller
|
||||
{
|
||||
public function __invoke(): JsonResponse
|
||||
{
|
||||
$threadsCount = Thread::query()->withoutTrashed()->count();
|
||||
$postsCount = Post::query()->withoutTrashed()->count();
|
||||
$usersCount = User::query()->count();
|
||||
$attachmentsCount = Attachment::query()->withoutTrashed()->count();
|
||||
$attachmentsSizeBytes = (int) Attachment::query()->withoutTrashed()->sum('size_bytes');
|
||||
|
||||
$boardStartedAt = $this->resolveBoardStartedAt();
|
||||
$daysSinceStart = $boardStartedAt
|
||||
? max(1, Carbon::parse($boardStartedAt)->diffInSeconds(now()) / 86400)
|
||||
: null;
|
||||
|
||||
$dbSizeBytes = $this->resolveDatabaseSize();
|
||||
$dbServer = $this->resolveDatabaseServer();
|
||||
$avatarSizeBytes = $this->resolveAvatarDirectorySize();
|
||||
$orphanAttachments = $this->resolveOrphanAttachments();
|
||||
|
||||
$version = Setting::query()->where('key', 'version')->value('value');
|
||||
$build = Setting::query()->where('key', 'build')->value('value');
|
||||
$boardVersion = $version
|
||||
? ($build ? "{$version} (build {$build})" : $version)
|
||||
: null;
|
||||
|
||||
return response()->json([
|
||||
'threads' => Thread::query()->withoutTrashed()->count(),
|
||||
'posts' => Post::query()->withoutTrashed()->count()
|
||||
+ Thread::query()->withoutTrashed()->count(),
|
||||
'users' => User::query()->count(),
|
||||
'threads' => $threadsCount,
|
||||
'posts' => $postsCount + $threadsCount,
|
||||
'users' => $usersCount,
|
||||
'attachments' => $attachmentsCount,
|
||||
'board_started_at' => $boardStartedAt,
|
||||
'attachments_size_bytes' => $attachmentsSizeBytes,
|
||||
'avatar_directory_size_bytes' => $avatarSizeBytes,
|
||||
'database_size_bytes' => $dbSizeBytes,
|
||||
'database_server' => $dbServer,
|
||||
'gzip_compression' => $this->resolveGzipCompression(),
|
||||
'php_version' => PHP_VERSION,
|
||||
'orphan_attachments' => $orphanAttachments,
|
||||
'board_version' => $boardVersion,
|
||||
'posts_per_day' => $daysSinceStart ? ($postsCount + $threadsCount) / $daysSinceStart : null,
|
||||
'topics_per_day' => $daysSinceStart ? $threadsCount / $daysSinceStart : null,
|
||||
'users_per_day' => $daysSinceStart ? $usersCount / $daysSinceStart : null,
|
||||
'attachments_per_day' => $daysSinceStart ? $attachmentsCount / $daysSinceStart : null,
|
||||
]);
|
||||
}
|
||||
|
||||
private function resolveBoardStartedAt(): ?string
|
||||
{
|
||||
$timestamps = [
|
||||
User::query()->min('created_at'),
|
||||
Thread::query()->min('created_at'),
|
||||
Post::query()->min('created_at'),
|
||||
];
|
||||
|
||||
$min = null;
|
||||
foreach ($timestamps as $value) {
|
||||
if (!$value) {
|
||||
continue;
|
||||
}
|
||||
$time = Carbon::parse($value)->timestamp;
|
||||
if ($min === null || $time < $min) {
|
||||
$min = $time;
|
||||
}
|
||||
}
|
||||
|
||||
return $min !== null ? Carbon::createFromTimestamp($min)->toIso8601String() : null;
|
||||
}
|
||||
|
||||
private function resolveDatabaseSize(): ?int
|
||||
{
|
||||
try {
|
||||
$driver = DB::connection()->getDriverName();
|
||||
if ($driver === 'mysql') {
|
||||
$row = DB::selectOne('SELECT SUM(data_length + index_length) AS size FROM information_schema.tables WHERE table_schema = DATABASE()');
|
||||
return $row && isset($row->size) ? (int) $row->size : null;
|
||||
}
|
||||
} catch (\Throwable) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function resolveDatabaseServer(): ?string
|
||||
{
|
||||
try {
|
||||
$row = DB::selectOne('SELECT VERSION() AS version');
|
||||
return $row && isset($row->version) ? (string) $row->version : null;
|
||||
} catch (\Throwable) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private function resolveAvatarDirectorySize(): ?int
|
||||
{
|
||||
try {
|
||||
$disk = Storage::disk('public');
|
||||
$files = $disk->allFiles('avatars');
|
||||
$total = 0;
|
||||
foreach ($files as $file) {
|
||||
$total += $disk->size($file);
|
||||
}
|
||||
return $total;
|
||||
} catch (\Throwable) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private function resolveOrphanAttachments(): int
|
||||
{
|
||||
try {
|
||||
return (int) DB::table('attachments')
|
||||
->leftJoin('threads', 'attachments.thread_id', '=', 'threads.id')
|
||||
->leftJoin('posts', 'attachments.post_id', '=', 'posts.id')
|
||||
->whereNull('attachments.deleted_at')
|
||||
->where(function ($query) {
|
||||
$query
|
||||
->whereNull('attachments.thread_id')
|
||||
->whereNull('attachments.post_id')
|
||||
->orWhere(function ($inner) {
|
||||
$inner->whereNotNull('attachments.thread_id')
|
||||
->where(function ($inner2) {
|
||||
$inner2->whereNull('threads.id')
|
||||
->orWhereNotNull('threads.deleted_at');
|
||||
});
|
||||
})
|
||||
->orWhere(function ($inner) {
|
||||
$inner->whereNotNull('attachments.post_id')
|
||||
->where(function ($inner2) {
|
||||
$inner2->whereNull('posts.id')
|
||||
->orWhereNotNull('posts.deleted_at');
|
||||
});
|
||||
});
|
||||
})
|
||||
->count();
|
||||
} catch (\Throwable) {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
private function resolveGzipCompression(): bool
|
||||
{
|
||||
$value = ini_get('zlib.output_compression');
|
||||
return in_array(strtolower((string) $value), ['1', 'on', 'true'], true);
|
||||
}
|
||||
}
|
||||
|
||||
150
app/Http/Controllers/SystemStatusController.php
Normal file
150
app/Http/Controllers/SystemStatusController.php
Normal 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 : [];
|
||||
}
|
||||
}
|
||||
199
app/Http/Controllers/SystemUpdateController.php
Normal file
199
app/Http/Controllers/SystemUpdateController.php
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ use App\Models\Forum;
|
||||
use App\Models\Thread;
|
||||
use App\Actions\BbcodeFormatter;
|
||||
use App\Models\Setting;
|
||||
use App\Services\AuditLogger;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
@@ -81,6 +82,11 @@ class ThreadController extends Controller
|
||||
'body' => $data['body'],
|
||||
]);
|
||||
|
||||
app(AuditLogger::class)->log($request, 'thread.created', $thread, [
|
||||
'forum_id' => $forum->id,
|
||||
'title' => $thread->title,
|
||||
]);
|
||||
|
||||
$thread->loadMissing([
|
||||
'user' => fn ($query) => $query
|
||||
->withCount(['posts', 'threads', 'thanksGiven', 'thanksReceived'])
|
||||
@@ -96,6 +102,14 @@ class ThreadController extends Controller
|
||||
|
||||
public function destroy(Request $request, Thread $thread): JsonResponse
|
||||
{
|
||||
$reason = $request->input('reason');
|
||||
$reasonText = $request->input('reason_text');
|
||||
app(AuditLogger::class)->log($request, 'thread.deleted', $thread, [
|
||||
'forum_id' => $thread->forum_id,
|
||||
'title' => $thread->title,
|
||||
'reason' => $reason,
|
||||
'reason_text' => $reasonText,
|
||||
]);
|
||||
$thread->deleted_by = $request->user()?->id;
|
||||
$thread->save();
|
||||
$thread->delete();
|
||||
@@ -103,6 +117,51 @@ class ThreadController extends Controller
|
||||
return response()->json(null, 204);
|
||||
}
|
||||
|
||||
public function update(Request $request, Thread $thread): JsonResponse
|
||||
{
|
||||
$user = $request->user();
|
||||
if (!$user) {
|
||||
return response()->json(['message' => 'Unauthorized.'], 401);
|
||||
}
|
||||
|
||||
$isAdmin = $user->roles()->where('name', 'ROLE_ADMIN')->exists();
|
||||
if (!$isAdmin && $thread->user_id !== $user->id) {
|
||||
return response()->json(['message' => 'Not authorized to edit threads.'], 403);
|
||||
}
|
||||
|
||||
$data = $request->validate([
|
||||
'title' => ['sometimes', 'required', 'string'],
|
||||
'body' => ['sometimes', 'required', 'string'],
|
||||
]);
|
||||
|
||||
if (array_key_exists('title', $data)) {
|
||||
$thread->title = $data['title'];
|
||||
}
|
||||
if (array_key_exists('body', $data)) {
|
||||
$thread->body = $data['body'];
|
||||
}
|
||||
|
||||
$thread->save();
|
||||
$thread->refresh();
|
||||
|
||||
app(AuditLogger::class)->log($request, 'thread.edited', $thread, [
|
||||
'forum_id' => $thread->forum_id,
|
||||
'title' => $thread->title,
|
||||
]);
|
||||
|
||||
$thread->loadMissing([
|
||||
'user' => fn ($query) => $query
|
||||
->withCount(['posts', 'threads', 'thanksGiven', 'thanksReceived'])
|
||||
->with(['rank', 'roles']),
|
||||
'attachments.extension',
|
||||
'attachments.group',
|
||||
'latestPost.user.rank',
|
||||
'latestPost.user.roles',
|
||||
])->loadCount('posts');
|
||||
|
||||
return response()->json($this->serializeThread($thread));
|
||||
}
|
||||
|
||||
public function updateSolved(Request $request, Thread $thread): JsonResponse
|
||||
{
|
||||
$user = $request->user();
|
||||
@@ -121,6 +180,9 @@ class ThreadController extends Controller
|
||||
|
||||
$thread->solved = $data['solved'];
|
||||
$thread->save();
|
||||
app(AuditLogger::class)->log($request, 'thread.solved_updated', $thread, [
|
||||
'solved' => $thread->solved,
|
||||
]);
|
||||
$thread->refresh();
|
||||
$thread->loadMissing([
|
||||
'user' => fn ($query) => $query
|
||||
@@ -238,6 +300,9 @@ class ThreadController extends Controller
|
||||
$map[$name] = [
|
||||
'url' => "/api/attachments/{$attachment->id}/download",
|
||||
'mime' => $attachment->mime_type ?? '',
|
||||
'thumb' => $attachment->thumbnail_path
|
||||
? "/api/attachments/{$attachment->id}/thumbnail"
|
||||
: null,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -256,6 +321,10 @@ class ThreadController extends Controller
|
||||
$url = $entry['url'];
|
||||
$mime = $entry['mime'] ?? '';
|
||||
if (str_starts_with($mime, 'image/') && $this->displayImagesInline()) {
|
||||
if (!empty($entry['thumb'])) {
|
||||
$thumb = $entry['thumb'];
|
||||
return "[url={$url}][img]{$thumb}[/img][/url]";
|
||||
}
|
||||
return "[img]{$url}[/img]";
|
||||
}
|
||||
return "[url={$url}]{$rawName}[/url]";
|
||||
|
||||
72
app/Http/Controllers/VersionCheckController.php
Normal file
72
app/Http/Controllers/VersionCheckController.php
Normal file
@@ -0,0 +1,72 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Setting;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
class VersionCheckController extends Controller
|
||||
{
|
||||
public function __invoke(): JsonResponse
|
||||
{
|
||||
$current = Setting::query()->where('key', 'version')->value('value');
|
||||
$build = Setting::query()->where('key', 'build')->value('value');
|
||||
|
||||
$owner = env('GITEA_OWNER');
|
||||
$repo = env('GITEA_REPO');
|
||||
$apiBase = rtrim((string) env('GITEA_API_BASE', 'https://git.24unix.net/api/v1'), '/');
|
||||
$token = env('GITEA_TOKEN');
|
||||
|
||||
if (!$owner || !$repo) {
|
||||
return response()->json([
|
||||
'current_version' => $current,
|
||||
'current_build' => $build !== null ? (int) $build : null,
|
||||
'latest_tag' => null,
|
||||
'latest_version' => null,
|
||||
'is_latest' => null,
|
||||
'error' => 'Missing GITEA_OWNER/GITEA_REPO configuration.',
|
||||
]);
|
||||
}
|
||||
|
||||
try {
|
||||
$client = Http::acceptJson();
|
||||
if ($token) {
|
||||
$client = $client->withHeaders(['Authorization' => "token {$token}"]);
|
||||
}
|
||||
|
||||
$response = $client->get("{$apiBase}/repos/{$owner}/{$repo}/releases/latest");
|
||||
if (!$response->successful()) {
|
||||
return response()->json([
|
||||
'current_version' => $current,
|
||||
'current_build' => $build !== null ? (int) $build : null,
|
||||
'latest_tag' => null,
|
||||
'latest_version' => null,
|
||||
'is_latest' => null,
|
||||
'error' => "Release check failed: {$response->status()}",
|
||||
]);
|
||||
}
|
||||
|
||||
$tag = (string) ($response->json('tag_name') ?? '');
|
||||
$latestVersion = ltrim($tag, 'v');
|
||||
$isLatest = $current && $latestVersion ? $current === $latestVersion : null;
|
||||
|
||||
return response()->json([
|
||||
'current_version' => $current,
|
||||
'current_build' => $build !== null ? (int) $build : null,
|
||||
'latest_tag' => $tag ?: null,
|
||||
'latest_version' => $latestVersion ?: null,
|
||||
'is_latest' => $isLatest,
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
return response()->json([
|
||||
'current_version' => $current,
|
||||
'current_build' => $build !== null ? (int) $build : null,
|
||||
'latest_tag' => null,
|
||||
'latest_version' => null,
|
||||
'is_latest' => null,
|
||||
'error' => 'Version check failed.',
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
41
app/Models/AuditLog.php
Normal file
41
app/Models/AuditLog.php
Normal file
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
/**
|
||||
* @property int $id
|
||||
* @property int|null $user_id
|
||||
* @property string $action
|
||||
* @property string|null $subject_type
|
||||
* @property int|null $subject_id
|
||||
* @property array|null $metadata
|
||||
* @property string|null $ip_address
|
||||
* @property string|null $user_agent
|
||||
* @property \Illuminate\Support\Carbon|null $created_at
|
||||
* @property \Illuminate\Support\Carbon|null $updated_at
|
||||
* @mixin \Eloquent
|
||||
*/
|
||||
class AuditLog extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'user_id',
|
||||
'action',
|
||||
'subject_type',
|
||||
'subject_id',
|
||||
'metadata',
|
||||
'ip_address',
|
||||
'user_agent',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'metadata' => 'array',
|
||||
];
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
}
|
||||
208
app/Services/AttachmentThumbnailService.php
Normal file
208
app/Services/AttachmentThumbnailService.php
Normal file
@@ -0,0 +1,208 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Attachment;
|
||||
use App\Models\Setting;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class AttachmentThumbnailService
|
||||
{
|
||||
public function createForUpload(UploadedFile $file, string $scopeFolder, string $disk = 'local'): ?array
|
||||
{
|
||||
$mime = $file->getMimeType() ?? '';
|
||||
if (!str_starts_with($mime, 'image/')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$sourcePath = $file->getPathname();
|
||||
$extension = strtolower((string) $file->getClientOriginalExtension());
|
||||
|
||||
return $this->createThumbnail($sourcePath, $mime, $extension, $scopeFolder, $disk);
|
||||
}
|
||||
|
||||
public function createForAttachment(Attachment $attachment, bool $force = false): ?array
|
||||
{
|
||||
if (!$force && $attachment->thumbnail_path) {
|
||||
$thumbDisk = Storage::disk($attachment->disk);
|
||||
if ($thumbDisk->exists($attachment->thumbnail_path)) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
$mime = $attachment->mime_type ?? '';
|
||||
if (!str_starts_with($mime, 'image/')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$disk = Storage::disk($attachment->disk);
|
||||
if (!$disk->exists($attachment->path)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$sourcePath = $disk->path($attachment->path);
|
||||
$scopeFolder = $this->resolveScopeFolder($attachment);
|
||||
$extension = strtolower((string) ($attachment->extension ?? ''));
|
||||
|
||||
return $this->createThumbnail($sourcePath, $mime, $extension, $scopeFolder, $attachment->disk);
|
||||
}
|
||||
|
||||
private function resolveScopeFolder(Attachment $attachment): string
|
||||
{
|
||||
if ($attachment->thread_id) {
|
||||
return "threads/{$attachment->thread_id}";
|
||||
}
|
||||
|
||||
if ($attachment->post_id) {
|
||||
return "posts/{$attachment->post_id}";
|
||||
}
|
||||
|
||||
return 'misc';
|
||||
}
|
||||
|
||||
private function createThumbnail(
|
||||
string $sourcePath,
|
||||
string $mime,
|
||||
string $extension,
|
||||
string $scopeFolder,
|
||||
string $diskName
|
||||
): ?array {
|
||||
if (!$this->settingBool('attachments.create_thumbnails', true)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$maxWidth = $this->settingInt('attachments.thumbnail_max_width', 300);
|
||||
$maxHeight = $this->settingInt('attachments.thumbnail_max_height', 300);
|
||||
if ($maxWidth <= 0 || $maxHeight <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$info = @getimagesize($sourcePath);
|
||||
if (!$info) {
|
||||
return null;
|
||||
}
|
||||
|
||||
[$width, $height] = $info;
|
||||
if ($width <= 0 || $height <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($width <= $maxWidth && $height <= $maxHeight) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$ratio = min($maxWidth / $width, $maxHeight / $height);
|
||||
$targetWidth = max(1, (int) round($width * $ratio));
|
||||
$targetHeight = max(1, (int) round($height * $ratio));
|
||||
|
||||
$sourceImage = $this->createImageFromFile($sourcePath, $mime);
|
||||
if (!$sourceImage) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$thumbImage = imagecreatetruecolor($targetWidth, $targetHeight);
|
||||
if (!$thumbImage) {
|
||||
imagedestroy($sourceImage);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (in_array($mime, ['image/png', 'image/gif'], true)) {
|
||||
imagecolortransparent($thumbImage, imagecolorallocatealpha($thumbImage, 0, 0, 0, 127));
|
||||
imagealphablending($thumbImage, false);
|
||||
imagesavealpha($thumbImage, true);
|
||||
}
|
||||
|
||||
imagecopyresampled(
|
||||
$thumbImage,
|
||||
$sourceImage,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
$targetWidth,
|
||||
$targetHeight,
|
||||
$width,
|
||||
$height
|
||||
);
|
||||
|
||||
$quality = $this->settingInt('attachments.thumbnail_quality', 85);
|
||||
$thumbBinary = $this->renderImageBinary($thumbImage, $mime, $quality);
|
||||
|
||||
imagedestroy($sourceImage);
|
||||
imagedestroy($thumbImage);
|
||||
|
||||
if ($thumbBinary === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$filename = Str::uuid()->toString();
|
||||
if ($extension !== '') {
|
||||
$filename .= ".{$extension}";
|
||||
}
|
||||
|
||||
$thumbPath = "attachments/{$scopeFolder}/thumbs/{$filename}";
|
||||
Storage::disk($diskName)->put($thumbPath, $thumbBinary);
|
||||
|
||||
return [
|
||||
'path' => $thumbPath,
|
||||
'mime' => $mime,
|
||||
'size' => strlen($thumbBinary),
|
||||
];
|
||||
}
|
||||
|
||||
private function createImageFromFile(string $path, string $mime)
|
||||
{
|
||||
return match ($mime) {
|
||||
'image/jpeg', 'image/jpg' => @imagecreatefromjpeg($path),
|
||||
'image/png' => @imagecreatefrompng($path),
|
||||
'image/gif' => @imagecreatefromgif($path),
|
||||
'image/webp' => function_exists('imagecreatefromwebp') ? @imagecreatefromwebp($path) : null,
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
|
||||
private function renderImageBinary($image, string $mime, int $quality): ?string
|
||||
{
|
||||
ob_start();
|
||||
$success = false;
|
||||
|
||||
if (in_array($mime, ['image/jpeg', 'image/jpg'], true)) {
|
||||
$success = imagejpeg($image, null, max(10, min(95, $quality)));
|
||||
} elseif ($mime === 'image/png') {
|
||||
$compression = (int) round(9 - (max(10, min(95, $quality)) / 100) * 9);
|
||||
$success = imagepng($image, null, $compression);
|
||||
} elseif ($mime === 'image/gif') {
|
||||
$success = imagegif($image);
|
||||
} elseif ($mime === 'image/webp' && function_exists('imagewebp')) {
|
||||
$success = imagewebp($image, null, max(10, min(95, $quality)));
|
||||
}
|
||||
|
||||
$data = ob_get_clean();
|
||||
|
||||
if (!$success) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $data !== false ? $data : null;
|
||||
}
|
||||
|
||||
private function settingBool(string $key, bool $default): bool
|
||||
{
|
||||
$value = Setting::query()->where('key', $key)->value('value');
|
||||
if ($value === null) {
|
||||
return $default;
|
||||
}
|
||||
return in_array(strtolower($value), ['1', 'true', 'yes', 'on'], true);
|
||||
}
|
||||
|
||||
private function settingInt(string $key, int $default): int
|
||||
{
|
||||
$value = Setting::query()->where('key', $key)->value('value');
|
||||
if ($value === null) {
|
||||
return $default;
|
||||
}
|
||||
return (int) $value;
|
||||
}
|
||||
}
|
||||
34
app/Services/AuditLogger.php
Normal file
34
app/Services/AuditLogger.php
Normal file
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\AuditLog;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class AuditLogger
|
||||
{
|
||||
public function log(
|
||||
Request $request,
|
||||
string $action,
|
||||
?Model $subject = null,
|
||||
array $metadata = [],
|
||||
?Model $actor = null
|
||||
): ?AuditLog {
|
||||
try {
|
||||
$actorUser = $actor ?? $request->user();
|
||||
|
||||
return AuditLog::create([
|
||||
'user_id' => $actorUser?->id,
|
||||
'action' => $action,
|
||||
'subject_type' => $subject ? get_class($subject) : null,
|
||||
'subject_id' => $subject?->getKey(),
|
||||
'metadata' => $metadata ?: null,
|
||||
'ip_address' => $request->ip(),
|
||||
'user_agent' => $request->userAgent(),
|
||||
]);
|
||||
} catch (\Throwable) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user