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(); $composer = $this->readComposerMetadata(); $this->syncVersionBuildSettings($composer); $version = $composer['version'] ?? Setting::query()->where('key', 'version')->value('value'); $build = $composer['build'] ?? Setting::query()->where('key', 'build')->value('value'); $boardVersion = $version ? ($build ? "{$version} (build {$build})" : $version) : null; return response()->json([ '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); } private function readComposerMetadata(): array { $path = base_path('composer.json'); if (!is_file($path) || !is_readable($path)) { return []; } $raw = file_get_contents($path); if ($raw === false) { return []; } $data = json_decode($raw, true); if (!is_array($data)) { return []; } $version = trim((string) ($data['version'] ?? '')); $build = trim((string) ($data['build'] ?? '')); return [ 'version' => $version !== '' ? $version : null, 'build' => ctype_digit($build) ? (int) $build : null, ]; } private function syncVersionBuildSettings(array $composer): void { $version = $composer['version'] ?? null; $build = $composer['build'] ?? null; if ($version === null && $build === null) { return; } try { if ($version !== null) { $currentVersion = Setting::query()->where('key', 'version')->value('value'); if ((string) $currentVersion !== (string) $version) { Setting::updateOrCreate(['key' => 'version'], ['value' => (string) $version]); } } if ($build !== null) { $buildString = (string) $build; $currentBuild = Setting::query()->where('key', 'build')->value('value'); if ((string) $currentBuild !== $buildString) { Setting::updateOrCreate(['key' => 'build'], ['value' => $buildString]); } } } catch (\Throwable) { // Stats endpoint should remain readable even if settings sync fails. } } }