Fixes
This commit is contained in:
@@ -8,10 +8,17 @@ use App\Enums\UserRole;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\AuthAuditLog;
|
||||
use App\Models\Artwork;
|
||||
use App\Models\Report;
|
||||
use App\Models\Story;
|
||||
use App\Models\Upload;
|
||||
use App\Models\User;
|
||||
use App\Support\Moderation\ReportTargetResolver;
|
||||
use Illuminate\Database\Query\Builder;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
@@ -33,6 +40,252 @@ final class AdminController extends Controller
|
||||
]);
|
||||
}
|
||||
|
||||
public function dailyActivity(Request $request, ReportTargetResolver $reportTargets): Response
|
||||
{
|
||||
$selectedDate = $this->resolveActivityDate($request);
|
||||
$periodStart = $selectedDate->copy()->startOfDay();
|
||||
$periodEnd = $selectedDate->copy()->endOfDay();
|
||||
|
||||
$users = User::query()
|
||||
->select('id', 'name', 'username', 'email', 'role', 'created_at')
|
||||
->whereBetween('created_at', [$periodStart, $periodEnd])
|
||||
->orderByDesc('created_at')
|
||||
->limit(25)
|
||||
->get()
|
||||
->map(fn (User $user): array => [
|
||||
'id' => (int) $user->id,
|
||||
'name' => (string) $user->name,
|
||||
'username' => $user->username,
|
||||
'email' => (string) $user->email,
|
||||
'role' => (string) $user->role,
|
||||
'created_at' => optional($user->created_at)?->toISOString(),
|
||||
])
|
||||
->values();
|
||||
|
||||
$artworks = Artwork::query()
|
||||
->with('user:id,name,username')
|
||||
->select('id', 'title', 'artwork_status', 'created_at', 'user_id', 'hash', 'thumb_ext')
|
||||
->whereBetween('created_at', [$periodStart, $periodEnd])
|
||||
->orderByDesc('created_at')
|
||||
->limit(25)
|
||||
->get()
|
||||
->map(fn (Artwork $artwork): array => [
|
||||
'id' => (int) $artwork->id,
|
||||
'title' => (string) ($artwork->title ?? 'Untitled artwork'),
|
||||
'status' => (string) ($artwork->artwork_status ?? 'unknown'),
|
||||
'thumb' => $artwork->thumbUrl('sm') ?? null,
|
||||
'created_at' => optional($artwork->created_at)?->toISOString(),
|
||||
'user' => $artwork->user ? [
|
||||
'id' => (int) $artwork->user->id,
|
||||
'name' => (string) $artwork->user->name,
|
||||
'username' => $artwork->user->username,
|
||||
] : null,
|
||||
])
|
||||
->values();
|
||||
|
||||
$stories = Story::query()
|
||||
->with('creator:id,name,username')
|
||||
->select('id', 'title', 'status', 'created_at', 'published_at', 'creator_id')
|
||||
->whereBetween('created_at', [$periodStart, $periodEnd])
|
||||
->orderByDesc('created_at')
|
||||
->limit(25)
|
||||
->get()
|
||||
->map(fn (Story $story): array => [
|
||||
'id' => (int) $story->id,
|
||||
'title' => (string) ($story->title ?? 'Untitled story'),
|
||||
'status' => (string) ($story->status ?? 'draft'),
|
||||
'created_at' => optional($story->created_at)?->toISOString(),
|
||||
'published_at' => optional($story->published_at)?->toISOString(),
|
||||
'creator' => $story->creator ? [
|
||||
'id' => (int) $story->creator->id,
|
||||
'name' => (string) $story->creator->name,
|
||||
'username' => $story->creator->username,
|
||||
] : null,
|
||||
])
|
||||
->values();
|
||||
|
||||
$uploads = Schema::hasTable('uploads')
|
||||
? Upload::query()
|
||||
->select('id', 'user_id', 'type', 'status', 'processing_state', 'title', 'created_at', 'moderation_status', 'moderated_at', 'moderated_by', 'moderation_note')
|
||||
->where(function ($query) use ($periodStart, $periodEnd): void {
|
||||
$query->whereBetween('created_at', [$periodStart, $periodEnd])
|
||||
->orWhereBetween('moderated_at', [$periodStart, $periodEnd]);
|
||||
})
|
||||
->orderByDesc('created_at')
|
||||
->limit(40)
|
||||
->get()
|
||||
->map(fn (Upload $upload): array => [
|
||||
'id' => (string) $upload->id,
|
||||
'user_id' => $upload->user_id !== null ? (int) $upload->user_id : null,
|
||||
'title' => (string) ($upload->title ?? 'Untitled upload'),
|
||||
'type' => (string) ($upload->type ?? 'unknown'),
|
||||
'status' => (string) ($upload->status ?? 'unknown'),
|
||||
'processing_state' => (string) ($upload->processing_state ?? 'unknown'),
|
||||
'moderation_status' => (string) ($upload->moderation_status ?? 'unknown'),
|
||||
'created_at' => optional($upload->created_at)?->toISOString(),
|
||||
'moderated_at' => optional($upload->moderated_at)?->toISOString(),
|
||||
'moderated_by' => $upload->moderated_by !== null ? (int) $upload->moderated_by : null,
|
||||
'moderation_note' => $upload->moderation_note,
|
||||
])
|
||||
->values()
|
||||
: collect();
|
||||
|
||||
$reports = Schema::hasTable('reports')
|
||||
? Report::query()
|
||||
->with(['reporter:id,username', 'lastModeratedBy:id,username'])
|
||||
->where(function ($query) use ($periodStart, $periodEnd): void {
|
||||
$query->whereBetween('created_at', [$periodStart, $periodEnd])
|
||||
->orWhereBetween('last_moderated_at', [$periodStart, $periodEnd]);
|
||||
})
|
||||
->orderByDesc('created_at')
|
||||
->limit(30)
|
||||
->get()
|
||||
->map(fn (Report $report): array => [
|
||||
'id' => (int) $report->id,
|
||||
'status' => (string) $report->status,
|
||||
'reason' => (string) $report->reason,
|
||||
'target_type' => (string) $report->target_type,
|
||||
'target_id' => (int) $report->target_id,
|
||||
'created_at' => optional($report->created_at)?->toISOString(),
|
||||
'last_moderated_at' => optional($report->last_moderated_at)?->toISOString(),
|
||||
'moderator_note' => $report->moderator_note,
|
||||
'reporter' => $report->reporter ? [
|
||||
'id' => (int) $report->reporter->id,
|
||||
'username' => (string) $report->reporter->username,
|
||||
] : null,
|
||||
'last_moderated_by' => $report->lastModeratedBy ? [
|
||||
'id' => (int) $report->lastModeratedBy->id,
|
||||
'username' => (string) $report->lastModeratedBy->username,
|
||||
] : null,
|
||||
'target' => $reportTargets->summarize($report),
|
||||
])
|
||||
->values()
|
||||
: collect();
|
||||
|
||||
$usernameRequests = Schema::hasTable('username_approval_requests')
|
||||
? (function () use ($periodStart, $periodEnd) {
|
||||
$requestColumns = Schema::getColumnListing('username_approval_requests');
|
||||
$selects = [
|
||||
'requests.id',
|
||||
'requests.user_id',
|
||||
'requests.requested_username',
|
||||
'requests.status',
|
||||
'requests.created_at',
|
||||
'users.username as current_username',
|
||||
'users.name as current_name',
|
||||
];
|
||||
|
||||
if (in_array('context', $requestColumns, true)) {
|
||||
$selects[] = 'requests.context';
|
||||
}
|
||||
|
||||
if (in_array('similar_to', $requestColumns, true)) {
|
||||
$selects[] = 'requests.similar_to';
|
||||
}
|
||||
|
||||
if (in_array('review_note', $requestColumns, true)) {
|
||||
$selects[] = 'requests.review_note';
|
||||
}
|
||||
|
||||
if (in_array('reviewed_at', $requestColumns, true)) {
|
||||
$selects[] = 'requests.reviewed_at';
|
||||
}
|
||||
|
||||
$query = DB::table('username_approval_requests as requests')
|
||||
->leftJoin('users', 'users.id', '=', 'requests.user_id')
|
||||
->select($selects)
|
||||
->where(function (Builder $query) use ($periodStart, $periodEnd, $requestColumns): void {
|
||||
$query->whereBetween('requests.created_at', [$periodStart, $periodEnd]);
|
||||
|
||||
if (in_array('reviewed_at', $requestColumns, true)) {
|
||||
$query->orWhereBetween('requests.reviewed_at', [$periodStart, $periodEnd]);
|
||||
}
|
||||
})
|
||||
->orderByDesc('requests.created_at')
|
||||
->limit(30);
|
||||
|
||||
return $query
|
||||
->get()
|
||||
->map(fn ($row): array => [
|
||||
'id' => (int) $row->id,
|
||||
'user_id' => $row->user_id !== null ? (int) $row->user_id : null,
|
||||
'requested_username' => (string) $row->requested_username,
|
||||
'status' => (string) ($row->status ?? 'pending'),
|
||||
'context' => $row->context ?? null,
|
||||
'similar_to' => $row->similar_to ?? null,
|
||||
'reason' => $row->review_note ?? null,
|
||||
'created_at' => $this->serializeDatabaseTimestamp($row->created_at),
|
||||
'reviewed_at' => $this->serializeDatabaseTimestamp($row->reviewed_at ?? null),
|
||||
'current_username' => $row->current_username,
|
||||
'current_name' => $row->current_name,
|
||||
])
|
||||
->values();
|
||||
})()
|
||||
: collect();
|
||||
|
||||
$authEvents = Schema::hasTable('auth_audit_logs')
|
||||
? AuthAuditLog::query()
|
||||
->with('user:id,name,username,email,role')
|
||||
->select('id', 'user_id', 'event_type', 'identifier', 'status', 'reason', 'ip', 'created_at')
|
||||
->whereBetween('created_at', [$periodStart, $periodEnd])
|
||||
->orderByDesc('created_at')
|
||||
->limit(30)
|
||||
->get()
|
||||
->map(fn (AuthAuditLog $log): array => [
|
||||
'id' => (int) $log->id,
|
||||
'event_type' => (string) $log->event_type,
|
||||
'identifier' => $log->identifier,
|
||||
'status' => (string) $log->status,
|
||||
'reason' => $log->reason,
|
||||
'ip' => $log->ip,
|
||||
'created_at' => optional($log->created_at)?->toISOString(),
|
||||
'user' => $log->user ? [
|
||||
'id' => (int) $log->user->id,
|
||||
'name' => (string) $log->user->name,
|
||||
'username' => $log->user->username,
|
||||
'email' => (string) $log->user->email,
|
||||
'role' => (string) $log->user->role,
|
||||
] : null,
|
||||
])
|
||||
->values()
|
||||
: collect();
|
||||
|
||||
return Inertia::render('Admin/DailyActivity', [
|
||||
'selectedDate' => $selectedDate->toDateString(),
|
||||
'summary' => [
|
||||
'new_users' => $users->count(),
|
||||
'new_artworks' => $artworks->count(),
|
||||
'new_stories' => $stories->count(),
|
||||
'upload_events' => $uploads->count(),
|
||||
'report_events' => $reports->count(),
|
||||
'username_events' => $usernameRequests->count(),
|
||||
'auth_events' => $authEvents->count(),
|
||||
'moderated_uploads' => $uploads->filter(fn (array $upload): bool => ! empty($upload['moderated_at']))->count(),
|
||||
'moderated_reports' => $reports->filter(fn (array $report): bool => ! empty($report['last_moderated_at']))->count(),
|
||||
],
|
||||
'queues' => [
|
||||
'pending_uploads' => Schema::hasTable('uploads')
|
||||
? Upload::query()->where('status', 'draft')->where('moderation_status', 'pending')->count()
|
||||
: 0,
|
||||
'open_reports' => Schema::hasTable('reports')
|
||||
? Report::query()->where('status', 'open')->count()
|
||||
: 0,
|
||||
'pending_username_requests' => Schema::hasTable('username_approval_requests')
|
||||
? DB::table('username_approval_requests')->where('status', 'pending')->count()
|
||||
: 0,
|
||||
],
|
||||
'sections' => [
|
||||
'users' => $users,
|
||||
'artworks' => $artworks,
|
||||
'stories' => $stories,
|
||||
'uploads' => $uploads,
|
||||
'reports' => $reports,
|
||||
'username_requests' => $usernameRequests,
|
||||
'auth_events' => $authEvents,
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
// ── Users ─────────────────────────────────────────────────────────────────
|
||||
|
||||
public function users(Request $request): Response
|
||||
@@ -237,4 +490,36 @@ final class AdminController extends Controller
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
private function resolveActivityDate(Request $request): Carbon
|
||||
{
|
||||
$date = $request->string('date')->trim()->toString();
|
||||
|
||||
if ($date === '') {
|
||||
return today();
|
||||
}
|
||||
|
||||
try {
|
||||
return Carbon::createFromFormat('Y-m-d', $date)->startOfDay();
|
||||
} catch (\Throwable) {
|
||||
return today();
|
||||
}
|
||||
}
|
||||
|
||||
private function serializeDatabaseTimestamp(mixed $value): ?string
|
||||
{
|
||||
if ($value === null || $value === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($value instanceof Carbon) {
|
||||
return $value->toISOString();
|
||||
}
|
||||
|
||||
try {
|
||||
return Carbon::parse((string) $value)->toISOString();
|
||||
} catch (\Throwable) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -751,24 +751,42 @@ class StoryController extends Controller
|
||||
], 422);
|
||||
}
|
||||
|
||||
$disk = Storage::disk('public');
|
||||
$base = 'stories/' . now()->format('Y/m') . '/' . Str::uuid();
|
||||
$extension = strtolower((string) ($file->guessExtension() ?: $file->extension() ?: 'jpg'));
|
||||
$originalPath = $base . '/original.' . $extension;
|
||||
$thumbnailPath = $base . '/thumbnail.webp';
|
||||
$mediumPath = $base . '/medium.webp';
|
||||
try {
|
||||
$this->assertStoryMediaStorageIsAllowed();
|
||||
} catch (\RuntimeException $e) {
|
||||
return response()->json([
|
||||
'message' => $e->getMessage(),
|
||||
], 422);
|
||||
}
|
||||
|
||||
$stream = fopen($sourcePath, 'rb');
|
||||
if ($stream === false) {
|
||||
$raw = file_get_contents($sourcePath);
|
||||
if ($raw === false || $raw === '') {
|
||||
return response()->json([
|
||||
'message' => 'Unable to process uploaded image. Please try again.',
|
||||
], 422);
|
||||
}
|
||||
|
||||
try {
|
||||
$disk->put($originalPath, $stream);
|
||||
} finally {
|
||||
fclose($stream);
|
||||
$hash = hash('sha256', $raw);
|
||||
$originalExtension = strtolower((string) ($file->guessExtension() ?: $file->extension() ?: 'jpg'));
|
||||
if ($originalExtension === 'jpeg') {
|
||||
$originalExtension = 'jpg';
|
||||
}
|
||||
|
||||
$originalPath = $this->storyMediaPath('original', $hash, $hash . '.' . $originalExtension);
|
||||
$thumbnailPath = $this->storyMediaPath('sm', $hash);
|
||||
$mediumPath = $this->storyMediaPath('md', $hash);
|
||||
$disk = Storage::disk($this->storyMediaDiskName());
|
||||
|
||||
$written = $disk->put($originalPath, $raw, [
|
||||
'visibility' => 'public',
|
||||
'CacheControl' => 'public, max-age=31536000, immutable',
|
||||
'ContentType' => (string) ($file->getMimeType() ?: 'application/octet-stream'),
|
||||
]);
|
||||
|
||||
if ($written !== true) {
|
||||
return response()->json([
|
||||
'message' => 'Unable to store uploaded image. Please try again.',
|
||||
], 500);
|
||||
}
|
||||
|
||||
$storedThumbnails = false;
|
||||
@@ -781,10 +799,20 @@ class StoryController extends Controller
|
||||
$image = $manager->read($sourcePath);
|
||||
|
||||
$thumb = $image->scaleDown(width: 420);
|
||||
$disk->put($thumbnailPath, (string) $thumb->encode(new WebpEncoder(82)));
|
||||
$thumbEncoded = (string) $thumb->encode(new WebpEncoder(82));
|
||||
$disk->put($thumbnailPath, $thumbEncoded, [
|
||||
'visibility' => 'public',
|
||||
'CacheControl' => 'public, max-age=31536000, immutable',
|
||||
'ContentType' => 'image/webp',
|
||||
]);
|
||||
|
||||
$medium = $image->scaleDown(width: 1200);
|
||||
$disk->put($mediumPath, (string) $medium->encode(new WebpEncoder(85)));
|
||||
$mediumEncoded = (string) $medium->encode(new WebpEncoder(85));
|
||||
$disk->put($mediumPath, $mediumEncoded, [
|
||||
'visibility' => 'public',
|
||||
'CacheControl' => 'public, max-age=31536000, immutable',
|
||||
'ContentType' => 'image/webp',
|
||||
]);
|
||||
$storedThumbnails = true;
|
||||
} catch (\Throwable) {
|
||||
$storedThumbnails = false;
|
||||
@@ -792,17 +820,62 @@ class StoryController extends Controller
|
||||
}
|
||||
|
||||
if (! $storedThumbnails) {
|
||||
$disk->copy($originalPath, $thumbnailPath);
|
||||
$disk->copy($originalPath, $mediumPath);
|
||||
$disk->put($thumbnailPath, $raw, [
|
||||
'visibility' => 'public',
|
||||
'CacheControl' => 'public, max-age=31536000, immutable',
|
||||
'ContentType' => (string) ($file->getMimeType() ?: 'application/octet-stream'),
|
||||
]);
|
||||
$disk->put($mediumPath, $raw, [
|
||||
'visibility' => 'public',
|
||||
'CacheControl' => 'public, max-age=31536000, immutable',
|
||||
'ContentType' => (string) ($file->getMimeType() ?: 'application/octet-stream'),
|
||||
]);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'thumbnail_url' => $disk->url($thumbnailPath),
|
||||
'medium_url' => $disk->url($mediumPath),
|
||||
'original_url' => $disk->url($originalPath),
|
||||
'thumbnail_url' => $this->storyMediaPublicUrl($thumbnailPath),
|
||||
'medium_url' => $this->storyMediaPublicUrl($mediumPath),
|
||||
'original_url' => $this->storyMediaPublicUrl($originalPath),
|
||||
]);
|
||||
}
|
||||
|
||||
private function storyMediaDiskName(): string
|
||||
{
|
||||
return (string) config('uploads.object_storage.disk', 's3');
|
||||
}
|
||||
|
||||
private function storyMediaPath(string $variant, string $hash, ?string $filename = null): string
|
||||
{
|
||||
$cleanVariant = trim($variant, '/');
|
||||
$cleanHash = strtolower((string) preg_replace('/[^a-f0-9]/', '', $hash));
|
||||
$file = $filename ?? ($cleanHash . '.webp');
|
||||
|
||||
return sprintf(
|
||||
'stories/%s/%s/%s/%s',
|
||||
$cleanVariant,
|
||||
substr($cleanHash, 0, 2),
|
||||
substr($cleanHash, 2, 2),
|
||||
ltrim($file, '/')
|
||||
);
|
||||
}
|
||||
|
||||
private function storyMediaPublicUrl(string $path): string
|
||||
{
|
||||
return rtrim((string) config('cdn.files_url', 'https://files.skinbase.org'), '/') . '/' . ltrim($path, '/');
|
||||
}
|
||||
|
||||
private function assertStoryMediaStorageIsAllowed(): void
|
||||
{
|
||||
if (! app()->environment('production')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$diskName = $this->storyMediaDiskName();
|
||||
if (in_array($diskName, ['local', 'public'], true)) {
|
||||
throw new \RuntimeException('Production story media storage must use object storage, not local/public disks.');
|
||||
}
|
||||
}
|
||||
|
||||
public function tag(string $tag): View
|
||||
{
|
||||
$storyTag = StoryTag::query()->where('slug', $tag)->firstOrFail();
|
||||
|
||||
@@ -13,6 +13,7 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
/**
|
||||
* Creator Story model.
|
||||
@@ -143,11 +144,12 @@ class Story extends Model
|
||||
|
||||
public function getCoverUrlAttribute(): ?string
|
||||
{
|
||||
if (! $this->cover_image) {
|
||||
return null;
|
||||
}
|
||||
return $this->resolveStoryMediaUrl($this->cover_image);
|
||||
}
|
||||
|
||||
return str_starts_with($this->cover_image, 'http') ? $this->cover_image : asset($this->cover_image);
|
||||
public function getOgImageUrlAttribute(): ?string
|
||||
{
|
||||
return $this->resolveStoryMediaUrl($this->og_image);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -174,4 +176,30 @@ class Story extends Model
|
||||
|
||||
return \Illuminate\Support\Str::limit($text, 160);
|
||||
}
|
||||
|
||||
private function resolveStoryMediaUrl(?string $value): ?string
|
||||
{
|
||||
if (! $value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (str_starts_with($value, 'http')) {
|
||||
return $value;
|
||||
}
|
||||
|
||||
$path = ltrim($value, '/');
|
||||
if (str_starts_with($path, 'storage/')) {
|
||||
$path = substr($path, strlen('storage/'));
|
||||
}
|
||||
|
||||
if (preg_match('#^stories/(sm|md|original)/[a-f0-9]{2}/[a-f0-9]{2}/[a-f0-9]+\.(webp|jpg|jpeg|png)$#', $path) === 1) {
|
||||
$cdnBase = rtrim((string) config('cdn.files_url', ''), '/');
|
||||
|
||||
return $cdnBase !== ''
|
||||
? $cdnBase . '/' . $path
|
||||
: Storage::disk('public')->url($path);
|
||||
}
|
||||
|
||||
return asset($value);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user