diff --git a/.env.example b/.env.example index 8e37d6af..c32b6d65 100644 --- a/.env.example +++ b/.env.example @@ -47,7 +47,17 @@ QUEUE_CONNECTION=redis MESSAGING_REALTIME=true MESSAGING_BROADCAST_QUEUE=broadcasts +MESSAGING_TYPING_TTL=8 MESSAGING_TYPING_CACHE_STORE=redis +MESSAGING_PRESENCE_TTL=90 +MESSAGING_CONVERSATION_PRESENCE_TTL=45 +MESSAGING_PRESENCE_CACHE_STORE=redis +MESSAGING_RECOVERY_MAX_MESSAGES=100 +MESSAGING_OFFLINE_FALLBACK_ONLY=true + +HORIZON_NAME=skinbase-nova +HORIZON_PATH=horizon +HORIZON_PREFIX=skinbase_nova_horizon: REVERB_APP_ID=skinbase-local REVERB_APP_KEY=skinbase-local-key @@ -77,6 +87,14 @@ SKINBASE_DUPLICATE_HASH_POLICY=block VISION_ENABLED=true VISION_QUEUE=default VISION_IMAGE_VARIANT=md +VISION_VECTOR_GATEWAY_ENABLED=true +VISION_VECTOR_GATEWAY_URL= +VISION_VECTOR_GATEWAY_API_KEY= +VISION_VECTOR_GATEWAY_COLLECTION=images +VISION_VECTOR_GATEWAY_TIMEOUT=20 +VISION_VECTOR_GATEWAY_CONNECT_TIMEOUT=5 +VISION_VECTOR_GATEWAY_RETRIES=1 +VISION_VECTOR_GATEWAY_RETRY_DELAY_MS=250 # CLIP service (set base URL to enable CLIP calls) CLIP_BASE_URL= @@ -101,6 +119,8 @@ RECOMMENDATIONS_AB_ALGO_VERSIONS=clip-cosine-v1 RECOMMENDATIONS_MIN_DIM=64 RECOMMENDATIONS_MAX_DIM=4096 RECOMMENDATIONS_BACKFILL_BATCH=200 +SIMILARITY_VECTOR_ENABLED=false +SIMILARITY_VECTOR_ADAPTER=pgvector # Personalized discovery foundation (Phase 8) DISCOVERY_QUEUE=${RECOMMENDATIONS_QUEUE} diff --git a/app/Console/Commands/ImportLegacyUsers.php b/app/Console/Commands/ImportLegacyUsers.php index 10c77f23..e10d8704 100644 --- a/app/Console/Commands/ImportLegacyUsers.php +++ b/app/Console/Commands/ImportLegacyUsers.php @@ -11,7 +11,7 @@ use Illuminate\Support\Str; class ImportLegacyUsers extends Command { - protected $signature = 'skinbase:import-legacy-users {--chunk=200 : Chunk size for processing} {--force-reset-all : Force reset passwords for all imported users} {--dry-run : Preview which users would be skipped/deleted without making changes}'; + protected $signature = 'skinbase:import-legacy-users {--chunk=200 : Chunk size for processing} {--force-reset-all : Force reset passwords for all imported users} {--restore-temp-usernames : Restore legacy usernames for existing users still using tmpu12345-style placeholders} {--dry-run : Preview which users would be skipped/deleted without making changes}'; protected $description = 'Import legacy users into the new auth schema per legacy_users_migration spec'; protected string $migrationLogPath; @@ -20,7 +20,7 @@ class ImportLegacyUsers extends Command public function handle(): int { - $this->migrationLogPath = storage_path('logs/username_migration.log'); + $this->migrationLogPath = (string) storage_path('logs/username_migration.log'); @file_put_contents($this->migrationLogPath, '['.now()."] Starting legacy username policy migration\n", FILE_APPEND); // Build the set of legacy user IDs that have any meaningful activity. @@ -134,8 +134,14 @@ class ImportLegacyUsers extends Command { $legacyId = (int) $row->user_id; - // Use legacy username as-is (sanitized only, no numeric suffixing — was unique in old DB). - $username = $this->sanitizeUsername((string) ($row->uname ?: ('user' . $legacyId))); + // Use legacy username as-is by default. Placeholder tmp usernames can be + // restored explicitly with --restore-temp-usernames using safe uniqueness rules. + $existingUser = DB::table('users') + ->select(['id', 'username']) + ->where('id', $legacyId) + ->first(); + + $username = $this->resolveImportUsername($row, $legacyId, $existingUser?->username ?? null); $normalizedLegacy = UsernamePolicy::normalize((string) ($row->uname ?? '')); if ($normalizedLegacy !== $username) { @@ -173,7 +179,12 @@ class ImportLegacyUsers extends Command DB::transaction(function () use ($legacyId, $username, $email, $passwordHash, $row, $uploads, $downloads, $pageviews, $awards) { $now = now(); - $alreadyExists = DB::table('users')->where('id', $legacyId)->exists(); + $existingUser = DB::table('users') + ->select(['id', 'username']) + ->where('id', $legacyId) + ->first(); + $alreadyExists = $existingUser !== null; + $previousUsername = (string) ($existingUser?->username ?? ''); // All fields synced from legacy on every run $sharedFields = [ @@ -212,7 +223,7 @@ class ImportLegacyUsers extends Command 'country_code' => $row->country_code ? substr($row->country_code, 0, 2) : null, 'language' => $row->lang ?: null, 'birthdate' => $row->birth ?: null, - 'gender' => $row->gender ?: 'X', + 'gender' => $this->normalizeLegacyGender($row->gender ?? null), 'website' => $row->web ?: null, 'updated_at' => $now, ] @@ -232,7 +243,7 @@ class ImportLegacyUsers extends Command ); if (Schema::hasTable('username_redirects')) { - $old = UsernamePolicy::normalize((string) ($row->uname ?? '')); + $old = $this->usernameRedirectKey((string) ($row->uname ?? '')); if ($old !== '' && $old !== $username) { DB::table('username_redirects')->updateOrInsert( ['old_username' => $old], @@ -244,10 +255,50 @@ class ImportLegacyUsers extends Command ] ); } + + if ($this->shouldRestoreTemporaryUsername($previousUsername) && $previousUsername !== $username) { + DB::table('username_redirects')->updateOrInsert( + ['old_username' => $this->usernameRedirectKey($previousUsername)], + [ + 'new_username' => $username, + 'user_id' => $legacyId, + 'created_at' => $now, + 'updated_at' => $now, + ] + ); + } } }); } + protected function resolveImportUsername(object $row, int $legacyId, ?string $existingUsername = null): string + { + $legacyUsername = $this->sanitizeUsername((string) ($row->uname ?: ('user' . $legacyId))); + + if (! $this->option('restore-temp-usernames')) { + return $legacyUsername; + } + + if ($existingUsername === null || $existingUsername === '') { + return $legacyUsername; + } + + if (! $this->shouldRestoreTemporaryUsername($existingUsername)) { + return $existingUsername; + } + + return UsernamePolicy::uniqueCandidate((string) ($row->uname ?: ('user' . $legacyId)), $legacyId); + } + + protected function shouldRestoreTemporaryUsername(?string $username): bool + { + if (! is_string($username) || trim($username) === '') { + return false; + } + + return preg_match('/^tmpu\d+$/i', trim($username)) === 1; + } + /** * Ensure statistic values are safe for unsigned DB columns. */ @@ -265,6 +316,24 @@ class ImportLegacyUsers extends Command return UsernamePolicy::sanitizeLegacy($username); } + protected function usernameRedirectKey(?string $username): string + { + $value = $this->sanitizeUsername((string) ($username ?? '')); + + return $value === 'user' && trim((string) ($username ?? '')) === '' ? '' : $value; + } + + protected function normalizeLegacyGender(mixed $value): ?string + { + $normalized = strtoupper(trim((string) ($value ?? ''))); + + return match ($normalized) { + 'M', 'MALE', 'MAN', 'BOY' => 'M', + 'F', 'FEMALE', 'WOMAN', 'GIRL' => 'F', + default => null, + }; + } + protected function sanitizeEmailLocal(string $value): string { $local = strtolower(trim($value)); diff --git a/app/Console/Commands/IndexArtworkVectorsCommand.php b/app/Console/Commands/IndexArtworkVectorsCommand.php new file mode 100644 index 00000000..d8dc1abb --- /dev/null +++ b/app/Console/Commands/IndexArtworkVectorsCommand.php @@ -0,0 +1,184 @@ +option('dry-run'); + if (! $dryRun && ! $client->isConfigured()) { + $this->error('Vision vector gateway is not configured. Set VISION_VECTOR_GATEWAY_URL and VISION_VECTOR_GATEWAY_API_KEY.'); + return self::FAILURE; + } + + $startId = max(0, (int) $this->option('start-id')); + $afterId = max(0, (int) $this->option('after-id')); + $batch = max(1, min((int) $this->option('batch'), 1000)); + $limit = max(0, (int) $this->option('limit')); + $publicOnly = (bool) $this->option('public-only'); + $nextId = $startId > 0 ? $startId : max(1, $afterId + 1); + + $processed = 0; + $indexed = 0; + $skipped = 0; + $failed = 0; + $lastId = $afterId; + + if ($startId > 0 && $afterId > 0) { + $this->warn(sprintf( + 'Both --start-id=%d and --after-id=%d were provided. Using --start-id and ignoring --after-id.', + $startId, + $afterId + )); + } + + $this->info(sprintf( + 'Starting vector index: start_id=%d after_id=%d next_id=%d batch=%d limit=%s public_only=%s dry_run=%s', + $startId, + $afterId, + $nextId, + $batch, + $limit > 0 ? (string) $limit : 'all', + $publicOnly ? 'yes' : 'no', + $dryRun ? 'yes' : 'no' + )); + + while (true) { + $remaining = $limit > 0 ? max(0, $limit - $processed) : $batch; + if ($limit > 0 && $remaining === 0) { + break; + } + + $take = $limit > 0 ? min($batch, $remaining) : $batch; + + $query = Artwork::query() + ->with(['categories' => fn ($categories) => $categories->with('contentType')->orderBy('sort_order')->orderBy('name')]) + ->where('id', '>=', $nextId) + ->whereNotNull('hash') + ->orderBy('id') + ->limit($take); + + if ($publicOnly) { + $query->public()->published(); + } + + $artworks = $query->get(); + if ($artworks->isEmpty()) { + $this->line('No more artworks matched the current query window.'); + break; + } + + $this->line(sprintf( + 'Fetched batch: count=%d first_id=%d last_id=%d', + $artworks->count(), + (int) $artworks->first()->id, + (int) $artworks->last()->id + )); + + foreach ($artworks as $artwork) { + $processed++; + $lastId = (int) $artwork->id; + $nextId = $lastId + 1; + + $url = $imageUrl->fromArtwork($artwork); + if ($url === null) { + $skipped++; + $this->warn("Skipped artwork {$artwork->id}: no vision image URL could be generated."); + continue; + } + + $metadata = $this->metadataForArtwork($artwork); + $this->line(sprintf( + 'Processing artwork=%d hash=%s thumb_ext=%s url=%s metadata=%s', + (int) $artwork->id, + (string) ($artwork->hash ?? ''), + (string) ($artwork->thumb_ext ?? ''), + $url, + $this->json($metadata) + )); + + if ($dryRun) { + $indexed++; + $this->line(sprintf( + '[dry] artwork=%d indexed=%d/%d', + (int) $artwork->id, + $indexed, + $processed + )); + continue; + } + + try { + $client->upsertByUrl($url, (int) $artwork->id, $metadata); + $indexed++; + $this->info(sprintf( + 'Indexed artwork %d successfully. totals: processed=%d indexed=%d skipped=%d failed=%d', + (int) $artwork->id, + $processed, + $indexed, + $skipped, + $failed + )); + } catch (\Throwable $e) { + $failed++; + $this->warn("Failed artwork {$artwork->id}: {$e->getMessage()}"); + } + } + } + + $this->info("Vector index finished. processed={$processed} indexed={$indexed} skipped={$skipped} failed={$failed} last_id={$lastId} next_id={$nextId}"); + + return $failed > 0 ? self::FAILURE : self::SUCCESS; + } + + /** + * @param array $payload + */ + private function json(array $payload): string + { + $json = json_encode($payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); + + return is_string($json) ? $json : '{}'; + } + + /** + * @return array{content_type: string, category: string, user_id: string} + */ + private function metadataForArtwork(Artwork $artwork): array + { + $category = $this->primaryCategory($artwork); + + return [ + 'content_type' => (string) ($category?->contentType?->name ?? ''), + 'category' => (string) ($category?->name ?? ''), + 'user_id' => (string) ($artwork->user_id ?? ''), + ]; + } + + private function primaryCategory(Artwork $artwork): ?Category + { + /** @var Category|null $category */ + $category = $artwork->categories->sortBy('sort_order')->first(); + + return $category; + } +} diff --git a/app/Console/Commands/RepairLegacyWallzUsersCommand.php b/app/Console/Commands/RepairLegacyWallzUsersCommand.php new file mode 100644 index 00000000..06ace14d --- /dev/null +++ b/app/Console/Commands/RepairLegacyWallzUsersCommand.php @@ -0,0 +1,301 @@ +option('chunk')); + $legacyConnection = (string) $this->option('legacy-connection'); + $legacyTable = (string) $this->option('legacy-table'); + $artworksTable = (string) $this->option('artworks-table'); + $fixArtworks = (bool) $this->option('fix-artworks'); + $dryRun = (bool) $this->option('dry-run'); + + if (! $this->legacyTableExists($legacyConnection, $legacyTable)) { + $this->error("Legacy table {$legacyConnection}.{$legacyTable} does not exist or the connection is unavailable."); + + return self::FAILURE; + } + + if ($dryRun) { + $this->warn('[DRY RUN] No changes will be written.'); + } + + if ($fixArtworks) { + $this->handleFixArtworks($chunk, $legacyConnection, $legacyTable, $artworksTable, $dryRun); + } + + $total = (int) DB::connection($legacyConnection) + ->table($legacyTable) + ->where('user_id', 0) + ->count(); + + if ($total === 0) { + if (! $fixArtworks) { + $this->info('No legacy wallz rows with user_id = 0 were found.'); + } + + return self::SUCCESS; + } + + $this->info("Scanning {$total} legacy rows in {$legacyConnection}.{$legacyTable}."); + + $processed = 0; + $updatedRows = 0; + $matchedUsers = 0; + $createdUsers = 0; + $skippedRows = 0; + $usernameMap = []; + + DB::connection($legacyConnection) + ->table($legacyTable) + ->select(['id', 'uname']) + ->where('user_id', 0) + ->orderBy('id') + ->chunkById($chunk, function ($rows) use ( + &$processed, + &$updatedRows, + &$matchedUsers, + &$createdUsers, + &$skippedRows, + &$usernameMap, + $dryRun, + $legacyConnection, + $legacyTable + ) { + foreach ($rows as $row) { + $processed++; + + $rawUsername = trim((string) ($row->uname ?? '')); + if ($rawUsername === '') { + $skippedRows++; + $this->warn("Skipping wallz id={$row->id}: uname is empty."); + continue; + } + + $lookupKey = UsernamePolicy::normalize($rawUsername); + if ($lookupKey === '') { + $skippedRows++; + $this->warn("Skipping wallz id={$row->id}: uname normalizes to empty."); + continue; + } + + if (! array_key_exists($lookupKey, $usernameMap)) { + $existingUser = $this->findUserByUsername($lookupKey); + + if ($existingUser !== null) { + $usernameMap[$lookupKey] = [ + 'user_id' => (int) $existingUser->id, + 'created' => false, + ]; + } else { + $usernameMap[$lookupKey] = [ + 'user_id' => $dryRun + ? 0 + : $this->createUserForLegacyUsername($rawUsername, $legacyConnection), + 'created' => true, + ]; + } + } + + $resolved = $usernameMap[$lookupKey]; + + if ($resolved['created']) { + $createdUsers++; + $usernameMap[$lookupKey]['created'] = false; + $resolved['created'] = false; + $this->line($dryRun + ? "[dry] Would create user for uname='{$rawUsername}'" + : "[create] Created user_id={$usernameMap[$lookupKey]['user_id']} for uname='{$rawUsername}'"); + } else { + $matchedUsers++; + } + + if ($dryRun) { + $targetUser = $usernameMap[$lookupKey]['user_id'] > 0 + ? (string) $usernameMap[$lookupKey]['user_id'] + : ''; + $this->line("[dry] Would update wallz id={$row->id} to user_id={$targetUser} using uname='{$rawUsername}'"); + $updatedRows++; + continue; + } + + $affected = DB::connection($legacyConnection) + ->table($legacyTable) + ->where('id', $row->id) + ->where('user_id', 0) + ->update([ + 'user_id' => $usernameMap[$lookupKey]['user_id'], + ]); + + if ($affected > 0) { + $updatedRows += $affected; + } + } + }, 'id'); + + $this->info(sprintf( + 'Finished. processed=%d updated=%d matched=%d created=%d skipped=%d', + $processed, + $updatedRows, + $matchedUsers, + $createdUsers, + $skippedRows + )); + + return self::SUCCESS; + } + + private function handleFixArtworks(int $chunk, string $legacyConnection, string $legacyTable, string $artworksTable, bool $dryRun): void + { + $this->info("\nAttempting to backfill `{$artworksTable}.user_id` from legacy {$legacyConnection}.{$legacyTable} where user_id = 0"); + + $total = (int) DB::table($artworksTable)->where('user_id', 0)->count(); + $this->info("Found {$total} rows in {$artworksTable} with user_id = 0. Chunk size: {$chunk}."); + + $processed = 0; + $updated = 0; + DB::table($artworksTable) + ->select(['id']) + ->where('user_id', 0) + ->orderBy('id') + ->chunkById($chunk, function ($rows) use (&$processed, &$updated, $legacyConnection, $legacyTable, $artworksTable, $dryRun) { + foreach ($rows as $row) { + $processed++; + + $legacyUser = DB::connection($legacyConnection) + ->table($legacyTable) + ->where('id', $row->id) + ->value('user_id'); + + $legacyUser = (int) ($legacyUser ?? 0); + if ($legacyUser <= 0) { + continue; + } + + if ($dryRun) { + $this->line("[dry] Would update {$artworksTable} id={$row->id} to user_id={$legacyUser}"); + $updated++; + continue; + } + + $affected = DB::table($artworksTable) + ->where('id', $row->id) + ->where('user_id', 0) + ->update(['user_id' => $legacyUser]); + + if ($affected > 0) { + $updated += $affected; + } + } + }, 'id'); + + $this->info(sprintf('Artworks backfill complete. processed=%d updated=%d', $processed, $updated)); + } + + private function legacyTableExists(string $connection, string $table): bool + { + try { + return DB::connection($connection)->getSchemaBuilder()->hasTable($table); + } catch (\Throwable) { + return false; + } + } + + private function findUserByUsername(string $normalizedUsername): ?object + { + return DB::table('users') + ->select(['id', 'username']) + ->whereRaw('LOWER(username) = ?', [$normalizedUsername]) + ->first(); + } + + private function createUserForLegacyUsername(string $legacyUsername, string $legacyConnection): int + { + $username = UsernamePolicy::uniqueCandidate($legacyUsername); + $emailLocal = $this->sanitizeEmailLocal($username); + $email = $this->uniqueEmailCandidate($emailLocal . '@users.skinbase.org'); + $now = now(); + + // Attempt to copy legacy joinDate from the legacy `users` table when available. + $legacyJoin = null; + try { + $legacyJoin = DB::connection($legacyConnection) + ->table('users') + ->whereRaw('LOWER(uname) = ?', [strtolower((string) $legacyUsername)]) + ->value('joinDate'); + } catch (\Throwable) { + $legacyJoin = null; + } + + $createdAt = $now; + if (! empty($legacyJoin) && strpos((string) $legacyJoin, '0000') !== 0) { + try { + $createdAt = Carbon::parse($legacyJoin); + } catch (\Throwable) { + $createdAt = $now; + } + } + + $userId = (int) DB::table('users')->insertGetId([ + 'username' => $username, + 'username_changed_at' => $now, + 'name' => $legacyUsername, + 'email' => $email, + 'password' => Hash::make(Str::random(64)), + 'is_active' => true, + 'needs_password_reset' => true, + 'role' => 'user', + 'legacy_password_algo' => null, + 'created_at' => $createdAt, + 'updated_at' => $now, + ]); + + return $userId; + } + + private function uniqueEmailCandidate(string $email): string + { + $candidate = strtolower(trim($email)); + $suffix = 1; + + while (DB::table('users')->whereRaw('LOWER(email) = ?', [$candidate])->exists()) { + $parts = explode('@', $email, 2); + $local = $parts[0] ?? 'user'; + $domain = $parts[1] ?? 'users.skinbase.org'; + $candidate = $local . '+' . $suffix . '@' . $domain; + $suffix++; + } + + return $candidate; + } + + private function sanitizeEmailLocal(string $value): string + { + $local = strtolower(trim($value)); + $local = preg_replace('/[^a-z0-9._-]/', '-', $local) ?: 'user'; + + return trim($local, '.-') ?: 'user'; + } +} diff --git a/app/Console/Commands/RepairTemporaryUsernamesCommand.php b/app/Console/Commands/RepairTemporaryUsernamesCommand.php new file mode 100644 index 00000000..162cb6dd --- /dev/null +++ b/app/Console/Commands/RepairTemporaryUsernamesCommand.php @@ -0,0 +1,135 @@ +option('chunk')); + $dryRun = (bool) $this->option('dry-run'); + + if ($dryRun) { + $this->warn('[DRY RUN] No changes will be written.'); + } + + $total = (int) DB::table('users') + ->where('username', 'like', 'tmpu%') + ->count(); + + if ($total === 0) { + $this->info('No users with temporary tmpu% usernames were found.'); + + return self::SUCCESS; + } + + $this->info("Found {$total} users with temporary tmpu% usernames."); + + $processed = 0; + $updated = 0; + $skipped = 0; + + DB::table('users') + ->select(['id', 'name', 'username']) + ->where('username', 'like', 'tmpu%') + ->chunkById($chunk, function ($rows) use (&$processed, &$updated, &$skipped, $dryRun) { + foreach ($rows as $row) { + $processed++; + + $sourceName = trim((string) ($row->name ?? '')); + if ($sourceName === '') { + $skipped++; + $this->warn("Skipping user id={$row->id}: name is empty."); + continue; + } + + $candidate = $this->resolveCandidate($sourceName, (int) $row->id); + + if ($candidate === null || strcasecmp($candidate, (string) $row->username) === 0) { + $skipped++; + $this->warn("Skipping user id={$row->id}: unable to resolve a better username from name='{$sourceName}'."); + continue; + } + + if ($dryRun) { + $this->line("[dry] Would update user id={$row->id} username '{$row->username}' => '{$candidate}'"); + $updated++; + continue; + } + + $affected = DB::table('users') + ->where('id', (int) $row->id) + ->where('username', 'like', 'tmpu%') + ->update([ + 'username' => $candidate, + 'username_changed_at' => now(), + 'updated_at' => now(), + ]); + + if ($affected > 0) { + $updated += $affected; + $this->line("[update] user id={$row->id} username '{$row->username}' => '{$candidate}'"); + } + } + }, 'id'); + + $this->info(sprintf('Finished. processed=%d updated=%d skipped=%d', $processed, $updated, $skipped)); + + return self::SUCCESS; + } + + private function resolveCandidate(string $sourceName, int $userId): ?string + { + $base = UsernamePolicy::sanitizeLegacy($sourceName); + $min = UsernamePolicy::min(); + $max = UsernamePolicy::max(); + + if ($base === '') { + return null; + } + + if (preg_match('/^tmpu\d+$/i', $base) === 1) { + $base = 'user' . $userId; + } + + if (strlen($base) < $min) { + $base = substr($base . $userId, 0, $max); + } + + if ($base === '' || $base === 'user') { + $base = 'user' . $userId; + } + + $candidate = substr($base, 0, $max); + $suffix = 1; + + while ($this->usernameExists($candidate, $userId) || UsernamePolicy::isReserved($candidate)) { + $suffixValue = (string) $suffix; + $prefixLen = max(1, $max - strlen($suffixValue)); + $candidate = substr($base, 0, $prefixLen) . $suffixValue; + $suffix++; + } + + return $candidate; + } + + private function usernameExists(string $username, int $ignoreUserId): bool + { + return DB::table('users') + ->whereRaw('LOWER(username) = ?', [strtolower($username)]) + ->where('id', '!=', $ignoreUserId) + ->exists(); + } +} diff --git a/app/Console/Commands/SearchArtworkVectorsCommand.php b/app/Console/Commands/SearchArtworkVectorsCommand.php new file mode 100644 index 00000000..862fae5e --- /dev/null +++ b/app/Console/Commands/SearchArtworkVectorsCommand.php @@ -0,0 +1,118 @@ +isConfigured()) { + $this->error('Vision vector gateway is not configured. Set VISION_VECTOR_GATEWAY_URL and VISION_VECTOR_GATEWAY_API_KEY.'); + return self::FAILURE; + } + + $artworkId = max(1, (int) $this->argument('artwork_id')); + $limit = max(1, min((int) $this->option('limit'), 100)); + + $artwork = Artwork::query() + ->with(['categories' => fn ($categories) => $categories->with('contentType')->orderBy('sort_order')->orderBy('name')]) + ->find($artworkId); + + if (! $artwork) { + $this->error("Artwork {$artworkId} was not found."); + return self::FAILURE; + } + + $url = $imageUrl->fromArtwork($artwork); + if ($url === null) { + $this->error("Artwork {$artworkId} does not have a usable CDN image URL."); + return self::FAILURE; + } + + try { + $matches = $client->searchByUrl($url, $limit + 1); + } catch (\Throwable $e) { + $this->error('Vector search failed: ' . $e->getMessage()); + return self::FAILURE; + } + + $ids = collect($matches) + ->map(fn (array $match): int => (int) $match['id']) + ->filter(fn (int $id): bool => $id > 0 && $id !== $artworkId) + ->unique() + ->take($limit) + ->values() + ->all(); + + if ($ids === []) { + $this->warn('No similar artworks were returned by the vector gateway.'); + return self::SUCCESS; + } + + $artworks = Artwork::query() + ->with(['categories' => fn ($categories) => $categories->with('contentType')->orderBy('sort_order')->orderBy('name')]) + ->whereIn('id', $ids) + ->public() + ->published() + ->get() + ->keyBy('id'); + + $rows = []; + foreach ($matches as $match) { + $matchId = (int) ($match['id'] ?? 0); + if ($matchId <= 0 || $matchId === $artworkId) { + continue; + } + + /** @var Artwork|null $matchedArtwork */ + $matchedArtwork = $artworks->get($matchId); + if (! $matchedArtwork) { + continue; + } + + $category = $this->primaryCategory($matchedArtwork); + $rows[] = [ + 'id' => $matchId, + 'score' => number_format((float) ($match['score'] ?? 0.0), 4, '.', ''), + 'title' => (string) $matchedArtwork->title, + 'content_type' => (string) ($category?->contentType?->name ?? ''), + 'category' => (string) ($category?->name ?? ''), + ]; + + if (count($rows) >= $limit) { + break; + } + } + + if ($rows === []) { + $this->warn('The vector gateway returned matches, but none resolved to public published artworks.'); + return self::SUCCESS; + } + + $this->table(['ID', 'Score', 'Title', 'Content Type', 'Category'], $rows); + + return self::SUCCESS; + } + + private function primaryCategory(Artwork $artwork): ?Category + { + /** @var Category|null $category */ + $category = $artwork->categories->sortBy('sort_order')->first(); + + return $category; + } +} diff --git a/app/Console/Kernel.php b/app/Console/Kernel.php index 4bba1ae8..8c65eb9d 100644 --- a/app/Console/Kernel.php +++ b/app/Console/Kernel.php @@ -7,6 +7,8 @@ use App\Console\Commands\ImportLegacyUsers; use App\Console\Commands\ImportCategories; use App\Console\Commands\MigrateFeaturedWorks; use App\Console\Commands\BackfillArtworkEmbeddingsCommand; +use App\Console\Commands\IndexArtworkVectorsCommand; +use App\Console\Commands\SearchArtworkVectorsCommand; use App\Console\Commands\AggregateSimilarArtworkAnalyticsCommand; use App\Console\Commands\AggregateFeedAnalyticsCommand; use App\Console\Commands\AggregateTagInteractionAnalyticsCommand; @@ -43,6 +45,8 @@ class Kernel extends ConsoleKernel CleanupUploadsCommand::class, PublishScheduledArtworksCommand::class, BackfillArtworkEmbeddingsCommand::class, + IndexArtworkVectorsCommand::class, + SearchArtworkVectorsCommand::class, AggregateSimilarArtworkAnalyticsCommand::class, AggregateFeedAnalyticsCommand::class, AggregateTagInteractionAnalyticsCommand::class, diff --git a/app/Events/ConversationUpdated.php b/app/Events/ConversationUpdated.php index 1b730dbc..3a2055a8 100644 --- a/app/Events/ConversationUpdated.php +++ b/app/Events/ConversationUpdated.php @@ -4,6 +4,7 @@ namespace App\Events; use App\Models\Conversation; use App\Services\Messaging\MessagingPayloadFactory; +use App\Services\Messaging\UnreadCounterService; use Illuminate\Broadcasting\InteractsWithSockets; use Illuminate\Broadcasting\PrivateChannel; use Illuminate\Contracts\Broadcasting\ShouldBroadcast; @@ -41,6 +42,9 @@ class ConversationUpdated implements ShouldBroadcast 'event' => 'conversation.updated', 'reason' => $this->reason, 'conversation' => app(MessagingPayloadFactory::class)->conversationSummary($this->conversation, $this->userId), + 'summary' => [ + 'unread_total' => app(UnreadCounterService::class)->totalUnreadForUser($this->userId), + ], ]; } -} \ No newline at end of file +} diff --git a/app/Events/MessageCreated.php b/app/Events/MessageCreated.php index 8c47dd2e..2b7bbb3b 100644 --- a/app/Events/MessageCreated.php +++ b/app/Events/MessageCreated.php @@ -48,4 +48,4 @@ class MessageCreated implements ShouldBroadcast 'message' => app(MessagingPayloadFactory::class)->message($this->message, (int) $this->message->sender_id), ]; } -} \ No newline at end of file +} diff --git a/app/Events/MessageDeleted.php b/app/Events/MessageDeleted.php index 03f35cf5..48c35584 100644 --- a/app/Events/MessageDeleted.php +++ b/app/Events/MessageDeleted.php @@ -42,4 +42,4 @@ class MessageDeleted implements ShouldBroadcast 'deleted_at' => optional($this->message->deleted_at ?? now())?->toIso8601String(), ]; } -} \ No newline at end of file +} diff --git a/app/Events/MessageRead.php b/app/Events/MessageRead.php index 4bea4063..4b0bc9e5 100644 --- a/app/Events/MessageRead.php +++ b/app/Events/MessageRead.php @@ -48,4 +48,4 @@ class MessageRead implements ShouldBroadcast 'last_read_at' => optional($this->participant->last_read_at)?->toIso8601String(), ]; } -} \ No newline at end of file +} diff --git a/app/Events/MessageUpdated.php b/app/Events/MessageUpdated.php index 246adc4c..a80775ad 100644 --- a/app/Events/MessageUpdated.php +++ b/app/Events/MessageUpdated.php @@ -41,4 +41,4 @@ class MessageUpdated implements ShouldBroadcast 'message' => app(MessagingPayloadFactory::class)->message($this->message), ]; } -} \ No newline at end of file +} diff --git a/app/Http/Controllers/Api/ArtworkCommentController.php b/app/Http/Controllers/Api/ArtworkCommentController.php index db6a8161..c88f6410 100644 --- a/app/Http/Controllers/Api/ArtworkCommentController.php +++ b/app/Http/Controllers/Api/ArtworkCommentController.php @@ -197,7 +197,7 @@ class ArtworkCommentController extends Controller 'id' => $c->id, 'parent_id' => $c->parent_id, 'raw_content' => $c->raw_content ?? $c->content, - 'rendered_content' => $c->rendered_content ?? e(strip_tags($c->content ?? '')), + 'rendered_content' => $this->renderCommentContent($c), 'created_at' => $c->created_at?->toIso8601String(), 'time_ago' => $c->created_at ? Carbon::parse($c->created_at)->diffForHumans() : null, 'can_edit' => $currentUserId === $userId, @@ -224,6 +224,31 @@ class ArtworkCommentController extends Controller return $data; } + private function renderCommentContent(ArtworkComment $comment): string + { + $rawContent = (string) ($comment->raw_content ?? $comment->content ?? ''); + $renderedContent = $comment->rendered_content; + + if (! is_string($renderedContent) || trim($renderedContent) === '') { + $renderedContent = $rawContent !== '' + ? ContentSanitizer::render($rawContent) + : nl2br(e(strip_tags((string) ($comment->content ?? '')))); + } + + return ContentSanitizer::sanitizeRenderedHtml( + $renderedContent, + $this->commentAuthorCanPublishLinks($comment) + ); + } + + private function commentAuthorCanPublishLinks(ArtworkComment $comment): bool + { + $level = (int) ($comment->user?->level ?? 1); + $rank = strtolower((string) ($comment->user?->rank ?? 'Newbie')); + + return $level > 1 && $rank !== 'newbie'; + } + private function notifyRecipients(Artwork $artwork, ArtworkComment $comment, User $actor, ?int $parentId): void { $notifiedUserIds = []; diff --git a/app/Http/Controllers/Api/Messaging/ConversationController.php b/app/Http/Controllers/Api/Messaging/ConversationController.php index 64c10b72..9083f260 100644 --- a/app/Http/Controllers/Api/Messaging/ConversationController.php +++ b/app/Http/Controllers/Api/Messaging/ConversationController.php @@ -9,10 +9,11 @@ use App\Http\Requests\Messaging\RenameConversationRequest; use App\Http\Requests\Messaging\StoreConversationRequest; use App\Models\Conversation; use App\Models\ConversationParticipant; -use App\Models\Message; use App\Models\User; +use App\Services\Messaging\ConversationReadService; use App\Services\Messaging\ConversationStateService; use App\Services\Messaging\SendMessageAction; +use App\Services\Messaging\UnreadCounterService; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\Cache; @@ -23,7 +24,9 @@ class ConversationController extends Controller { public function __construct( private readonly ConversationStateService $conversationState, + private readonly ConversationReadService $conversationReads, private readonly SendMessageAction $sendMessage, + private readonly UnreadCounterService $unreadCounters, ) {} // ── GET /api/messages/conversations ───────────────────────────────────── @@ -36,26 +39,13 @@ class ConversationController extends Controller $cacheKey = $this->conversationListCacheKey($user->id, $page, $cacheVersion); $conversations = Cache::remember($cacheKey, now()->addSeconds(20), function () use ($user, $page) { - return Conversation::query() + $query = Conversation::query() ->select('conversations.*') ->join('conversation_participants as cp_me', function ($join) use ($user) { $join->on('cp_me.conversation_id', '=', 'conversations.id') ->where('cp_me.user_id', '=', $user->id) ->whereNull('cp_me.left_at'); }) - ->addSelect([ - 'unread_count' => Message::query() - ->selectRaw('count(*)') - ->whereColumn('messages.conversation_id', 'conversations.id') - ->where('messages.sender_id', '!=', $user->id) - ->whereNull('messages.deleted_at') - ->where(function ($query) { - $query->whereNull('cp_me.last_read_message_id') - ->whereNull('cp_me.last_read_at') - ->orWhereColumn('messages.id', '>', 'cp_me.last_read_message_id') - ->orWhereColumn('messages.created_at', '>', 'cp_me.last_read_at'); - }), - ]) ->where('conversations.is_active', true) ->with([ 'allParticipants' => fn ($q) => $q->whereNull('left_at')->with(['user:id,username']), @@ -64,8 +54,11 @@ class ConversationController extends Controller ->orderByDesc('cp_me.is_pinned') ->orderByDesc('cp_me.pinned_at') ->orderByDesc('last_message_at') - ->orderByDesc('conversations.id') - ->paginate(20, ['conversations.*'], 'page', $page); + ->orderByDesc('conversations.id'); + + $this->unreadCounters->applyUnreadCountSelect($query, $user, 'cp_me'); + + return $query->paginate(20, ['conversations.*'], 'page', $page); }); $conversations->through(function ($conv) use ($user) { @@ -74,7 +67,12 @@ class ConversationController extends Controller return $conv; }); - return response()->json($conversations); + return response()->json([ + ...$conversations->toArray(), + 'summary' => [ + 'unread_total' => $this->unreadCounters->totalUnreadForUser($user), + ], + ]); } // ── GET /api/messages/conversation/{id} ───────────────────────────────── @@ -110,7 +108,7 @@ class ConversationController extends Controller public function markRead(Request $request, int $id): JsonResponse { $conversation = $this->findAuthorized($request, $id); - $participant = $this->conversationState->markConversationRead( + $participant = $this->conversationReads->markConversationRead( $conversation, $request->user(), $request->integer('message_id') ?: null, @@ -120,6 +118,7 @@ class ConversationController extends Controller 'ok' => true, 'last_read_at' => optional($participant->last_read_at)?->toIso8601String(), 'last_read_message_id' => $participant->last_read_message_id, + 'unread_total' => $this->unreadCounters->totalUnreadForUser($request->user()), ]); } diff --git a/app/Http/Controllers/Api/Messaging/MessageController.php b/app/Http/Controllers/Api/Messaging/MessageController.php index e6f2719b..4df18218 100644 --- a/app/Http/Controllers/Api/Messaging/MessageController.php +++ b/app/Http/Controllers/Api/Messaging/MessageController.php @@ -13,6 +13,7 @@ use App\Models\Conversation; use App\Models\ConversationParticipant; use App\Models\Message; use App\Models\MessageReaction; +use App\Services\Messaging\ConversationDeltaService; use App\Services\Messaging\ConversationStateService; use App\Services\Messaging\MessagingPayloadFactory; use App\Services\Messaging\MessageSearchIndexer; @@ -26,6 +27,7 @@ class MessageController extends Controller private const PAGE_SIZE = 30; public function __construct( + private readonly ConversationDeltaService $conversationDelta, private readonly ConversationStateService $conversationState, private readonly MessagingPayloadFactory $payloadFactory, private readonly SendMessageAction $sendMessage, @@ -40,15 +42,7 @@ class MessageController extends Controller $afterId = $request->integer('after_id'); if ($afterId) { - $messages = Message::withTrashed() - ->where('conversation_id', $conversationId) - ->with(['sender:id,username', 'reactions', 'attachments']) - ->where('id', '>', $afterId) - ->orderBy('id') - ->limit(100) - ->get() - ->map(fn (Message $message) => $this->payloadFactory->message($message, (int) $request->user()->id)) - ->values(); + $messages = $this->conversationDelta->messagesAfter($conversation, $request->user(), $afterId); return response()->json([ 'data' => $messages, @@ -77,6 +71,18 @@ class MessageController extends Controller ]); } + public function delta(Request $request, int $conversationId): JsonResponse + { + $conversation = $this->findConversationOrFail($conversationId); + $afterMessageId = max(0, (int) $request->integer('after_message_id')); + + abort_if($afterMessageId < 1, 422, 'after_message_id is required.'); + + return response()->json([ + 'data' => $this->conversationDelta->messagesAfter($conversation, $request->user(), $afterMessageId), + ]); + } + // ── POST /api/messages/{conversation_id} ───────────────────────────────── public function store(StoreMessageRequest $request, int $conversationId): JsonResponse diff --git a/app/Http/Controllers/Api/Messaging/PresenceController.php b/app/Http/Controllers/Api/Messaging/PresenceController.php new file mode 100644 index 00000000..974d2e6e --- /dev/null +++ b/app/Http/Controllers/Api/Messaging/PresenceController.php @@ -0,0 +1,33 @@ +integer('conversation_id') ?: null; + + if ($conversationId) { + $conversation = Conversation::query()->findOrFail($conversationId); + $this->authorize('view', $conversation); + } + + $this->presence->touch($request->user(), $conversationId); + + return response()->json([ + 'ok' => true, + 'conversation_id' => $conversationId, + ]); + } +} diff --git a/app/Http/Controllers/Api/ProfileApiController.php b/app/Http/Controllers/Api/ProfileApiController.php index 56676110..97118ca5 100644 --- a/app/Http/Controllers/Api/ProfileApiController.php +++ b/app/Http/Controllers/Api/ProfileApiController.php @@ -37,7 +37,14 @@ final class ProfileApiController extends Controller $isOwner = Auth::check() && Auth::id() === $user->id; $sort = $request->input('sort', 'latest'); - $query = Artwork::with('user:id,name,username') + $query = Artwork::with([ + 'user:id,name,username,level,rank', + 'stats:artwork_id,views,downloads,favorites', + 'categories' => function ($query) { + $query->select('categories.id', 'categories.content_type_id', 'categories.parent_id', 'categories.name', 'categories.slug', 'categories.sort_order') + ->with(['contentType:id,slug,name']); + }, + ]) ->where('user_id', $user->id) ->whereNull('deleted_at'); @@ -106,7 +113,14 @@ final class ProfileApiController extends Controller return response()->json(['data' => [], 'next_cursor' => null, 'has_more' => false]); } - $indexed = Artwork::with('user:id,name,username') + $indexed = Artwork::with([ + 'user:id,name,username,level,rank', + 'stats:artwork_id,views,downloads,favorites', + 'categories' => function ($query) { + $query->select('categories.id', 'categories.content_type_id', 'categories.parent_id', 'categories.name', 'categories.slug', 'categories.sort_order') + ->with(['contentType:id,slug,name']); + }, + ]) ->whereIn('id', $favIds) ->get() ->keyBy('id'); @@ -173,6 +187,9 @@ final class ProfileApiController extends Controller private function mapArtworkCardPayload(Artwork $art): array { $present = ThumbnailPresenter::present($art, 'md'); + $category = $art->categories->first(); + $contentType = $category?->contentType; + $stats = $art->stats; return [ 'id' => $art->id, @@ -183,6 +200,13 @@ final class ProfileApiController extends Controller 'height' => $art->height, 'username' => $art->user->username ?? null, 'uname' => $art->user->username ?? $art->user->name ?? 'Skinbase', + 'content_type' => $contentType?->name, + 'content_type_slug' => $contentType?->slug, + 'category' => $category?->name, + 'category_slug' => $category?->slug, + 'views' => (int) ($stats?->views ?? $art->view_count ?? 0), + 'downloads' => (int) ($stats?->downloads ?? 0), + 'likes' => (int) ($stats?->favorites ?? $art->favourite_count ?? 0), 'published_at' => $this->formatIsoDate($art->published_at), ]; } diff --git a/app/Http/Controllers/User/ProfileController.php b/app/Http/Controllers/User/ProfileController.php index 241395de..1b9a4922 100644 --- a/app/Http/Controllers/User/ProfileController.php +++ b/app/Http/Controllers/User/ProfileController.php @@ -49,6 +49,18 @@ use Inertia\Inertia; class ProfileController extends Controller { + private const PROFILE_TABS = [ + 'posts', + 'artworks', + 'stories', + 'achievements', + 'collections', + 'about', + 'stats', + 'favourites', + 'activity', + ]; + public function __construct( private readonly ArtworkService $artworkService, private readonly UsernameApprovalService $usernameApprovalService, @@ -84,7 +96,12 @@ class ProfileController extends Controller return redirect()->route('profile.show', ['username' => strtolower((string) $user->username)], 301); } - return $this->renderProfilePage($request, $user); + $tab = $this->normalizeProfileTab($request->query('tab')); + if ($tab !== null) { + return $this->redirectToProfileTab($request, (string) $user->username, $tab); + } + + return $this->renderProfilePage($request, $user, 'Profile/ProfileShow', false, 'posts'); } public function showGalleryByUsername(Request $request, string $username) @@ -111,6 +128,45 @@ class ProfileController extends Controller return $this->renderProfilePage($request, $user, 'Profile/ProfileGallery', true); } + public function showTabByUsername(Request $request, string $username, string $tab) + { + $normalized = UsernamePolicy::normalize($username); + $user = User::query()->whereRaw('LOWER(username) = ?', [$normalized])->first(); + $normalizedTab = $this->normalizeProfileTab($tab); + + if ($normalizedTab === null) { + abort(404); + } + + if (! $user) { + $redirect = DB::table('username_redirects') + ->whereRaw('LOWER(old_username) = ?', [$normalized]) + ->value('new_username'); + + if ($redirect) { + return redirect()->route('profile.tab', [ + 'username' => strtolower((string) $redirect), + 'tab' => $normalizedTab, + ], 301); + } + + abort(404); + } + + if ($username !== strtolower((string) $user->username)) { + return redirect()->route('profile.tab', [ + 'username' => strtolower((string) $user->username), + 'tab' => $normalizedTab, + ], 301); + } + + if ($request->query->has('tab')) { + return $this->redirectToProfileTab($request, (string) $user->username, $normalizedTab); + } + + return $this->renderProfilePage($request, $user, 'Profile/ProfileShow', false, $normalizedTab); + } + public function legacyById(Request $request, int $id, ?string $username = null) { $user = User::query()->findOrFail($id); @@ -836,7 +892,13 @@ class ProfileController extends Controller return Redirect::route('dashboard.profile')->with('status', 'password-updated'); } - private function renderProfilePage(Request $request, User $user, string $component = 'Profile/ProfileShow', bool $galleryOnly = false) + private function renderProfilePage( + Request $request, + User $user, + string $component = 'Profile/ProfileShow', + bool $galleryOnly = false, + ?string $initialTab = null, + ) { $isOwner = Auth::check() && Auth::id() === $user->id; $viewer = Auth::user(); @@ -1088,8 +1150,19 @@ class ProfileController extends Controller $usernameSlug = strtolower((string) ($user->username ?? '')); $canonical = url('/@' . $usernameSlug); $galleryUrl = url('/@' . $usernameSlug . '/gallery'); + $profileTabUrls = collect(self::PROFILE_TABS) + ->mapWithKeys(fn (string $tab) => [$tab => url('/@' . $usernameSlug . '/' . $tab)]) + ->all(); $achievementSummary = $this->achievements->summary((int) $user->id); $leaderboardRank = $this->leaderboards->creatorRankSummary((int) $user->id); + $resolvedInitialTab = $this->normalizeProfileTab($initialTab); + $isTabLanding = ! $galleryOnly && $resolvedInitialTab !== null; + $activeProfileUrl = $resolvedInitialTab !== null + ? ($profileTabUrls[$resolvedInitialTab] ?? $canonical) + : $canonical; + $tabMetaLabel = $resolvedInitialTab !== null + ? ucfirst($resolvedInitialTab) + : null; return Inertia::render($component, [ 'user' => [ @@ -1133,20 +1206,51 @@ class ProfileController extends Controller 'countryName' => $countryName, 'isOwner' => $isOwner, 'auth' => $authData, + 'initialTab' => $resolvedInitialTab, 'profileUrl' => $canonical, 'galleryUrl' => $galleryUrl, + 'profileTabUrls' => $profileTabUrls, ])->withViewData([ 'page_title' => $galleryOnly ? (($user->username ?? $user->name ?? 'User') . ' Gallery on Skinbase') - : (($user->username ?? $user->name ?? 'User') . ' on Skinbase'), - 'page_canonical' => $galleryOnly ? $galleryUrl : $canonical, + : ($isTabLanding + ? (($user->username ?? $user->name ?? 'User') . ' ' . $tabMetaLabel . ' on Skinbase') + : (($user->username ?? $user->name ?? 'User') . ' on Skinbase')), + 'page_canonical' => $galleryOnly ? $galleryUrl : $activeProfileUrl, 'page_meta_description' => $galleryOnly ? ('Browse the public gallery of ' . ($user->username ?? $user->name) . ' on Skinbase.') - : ('View the profile of ' . ($user->username ?? $user->name) . ' on Skinbase.org — artworks, favourites and more.'), + : ($isTabLanding + ? ('Explore the ' . strtolower((string) $tabMetaLabel) . ' section for ' . ($user->username ?? $user->name) . ' on Skinbase.') + : ('View the profile of ' . ($user->username ?? $user->name) . ' on Skinbase.org — artworks, favourites and more.')), 'og_image' => $avatarUrl, ]); } + private function normalizeProfileTab(mixed $tab): ?string + { + if (! is_string($tab)) { + return null; + } + + $normalized = strtolower(trim($tab)); + + return in_array($normalized, self::PROFILE_TABS, true) ? $normalized : null; + } + + private function redirectToProfileTab(Request $request, string $username, string $tab): RedirectResponse + { + $baseUrl = url('/@' . strtolower($username) . '/' . $tab); + + $query = $request->query(); + unset($query['tab']); + + if ($query !== []) { + $baseUrl .= '?' . http_build_query($query); + } + + return redirect()->to($baseUrl, 301); + } + private function resolveFavouriteTable(): ?string { foreach (['artwork_favourites', 'user_favorites', 'artworks_favourites', 'favourites'] as $table) { @@ -1164,6 +1268,9 @@ class ProfileController extends Controller private function mapArtworkCardPayload(Artwork $art): array { $present = ThumbnailPresenter::present($art, 'md'); + $category = $art->categories->first(); + $contentType = $category?->contentType; + $stats = $art->stats; return [ 'id' => $art->id, @@ -1178,6 +1285,13 @@ class ProfileController extends Controller 'user_id' => $art->user_id, 'author_level' => (int) ($art->user?->level ?? 1), 'author_rank' => (string) ($art->user?->rank ?? 'Newbie'), + 'content_type' => $contentType?->name, + 'content_type_slug' => $contentType?->slug, + 'category' => $category?->name, + 'category_slug' => $category?->slug, + 'views' => (int) ($stats?->views ?? $art->view_count ?? 0), + 'downloads' => (int) ($stats?->downloads ?? 0), + 'likes' => (int) ($stats?->favorites ?? $art->favourite_count ?? 0), 'width' => $art->width, 'height' => $art->height, ]; diff --git a/app/Http/Controllers/Web/ArtworkPageController.php b/app/Http/Controllers/Web/ArtworkPageController.php index 97b95206..22ec4cf5 100644 --- a/app/Http/Controllers/Web/ArtworkPageController.php +++ b/app/Http/Controllers/Web/ArtworkPageController.php @@ -8,8 +8,11 @@ use App\Http\Controllers\Controller; use App\Http\Resources\ArtworkResource; use App\Models\Artwork; use App\Models\ArtworkComment; +use App\Services\ContentSanitizer; use App\Services\ThumbnailPresenter; use App\Services\ErrorSuggestionService; +use App\Support\AvatarUrl; +use Illuminate\Support\Carbon; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Illuminate\Http\Response; @@ -167,23 +170,38 @@ final class ArtworkPageController extends Controller // Recursive helper to format a comment and its nested replies $formatComment = null; - $formatComment = function(ArtworkComment $c) use (&$formatComment) { + $formatComment = function (ArtworkComment $c) use (&$formatComment): array { $replies = $c->relationLoaded('approvedReplies') ? $c->approvedReplies : collect(); + $user = $c->user; + $userId = (int) ($c->user_id ?? 0); + $avatarHash = $user?->profile?->avatar_hash ?? null; + $canPublishLinks = (int) ($user?->level ?? 1) > 1 && strtolower((string) ($user?->rank ?? 'Newbie')) !== 'newbie'; + $rawContent = (string) ($c->raw_content ?? $c->content ?? ''); + $renderedContent = $c->rendered_content; + + if (! is_string($renderedContent) || trim($renderedContent) === '') { + $renderedContent = $rawContent !== '' + ? ContentSanitizer::render($rawContent) + : nl2br(e(strip_tags((string) ($c->content ?? '')))); + } return [ 'id' => $c->id, 'parent_id' => $c->parent_id, 'content' => html_entity_decode((string) $c->content, ENT_QUOTES | ENT_HTML5, 'UTF-8'), 'raw_content' => $c->raw_content ?? $c->content, - 'rendered_content' => $c->rendered_content, - 'created_at' => $c->created_at?->toIsoString(), + 'rendered_content' => ContentSanitizer::sanitizeRenderedHtml($renderedContent, $canPublishLinks), + 'created_at' => $c->created_at?->toIso8601String(), + 'time_ago' => $c->created_at ? Carbon::parse($c->created_at)->diffForHumans() : null, 'user' => [ - 'id' => $c->user?->id, - 'name' => $c->user?->name, - 'username' => $c->user?->username, - 'display' => $c->user?->username ?? $c->user?->name ?? 'User', - 'profile_url' => $c->user?->username ? '/@' . $c->user->username : null, - 'avatar_url' => $c->user?->profile?->avatar_url, + 'id' => $userId, + 'name' => $user?->name, + 'username' => $user?->username, + 'display' => $user?->username ?? $user?->name ?? 'User', + 'profile_url' => $user?->username ? '/@' . $user->username : ($userId > 0 ? '/profile/' . $userId : null), + 'avatar_url' => AvatarUrl::forUser($userId, $avatarHash, 64), + 'level' => (int) ($user?->level ?? 1), + 'rank' => (string) ($user?->rank ?? 'Newbie'), ], 'replies' => $replies->map($formatComment)->values()->all(), ]; diff --git a/app/Http/Requests/Messaging/ManageConversationParticipantRequest.php b/app/Http/Requests/Messaging/ManageConversationParticipantRequest.php index 7c0227e7..e97e55d1 100644 --- a/app/Http/Requests/Messaging/ManageConversationParticipantRequest.php +++ b/app/Http/Requests/Messaging/ManageConversationParticipantRequest.php @@ -17,4 +17,4 @@ class ManageConversationParticipantRequest extends FormRequest 'user_id' => 'required|integer|exists:users,id', ]; } -} \ No newline at end of file +} diff --git a/app/Http/Requests/Messaging/RenameConversationRequest.php b/app/Http/Requests/Messaging/RenameConversationRequest.php index d5764b7a..27f643c8 100644 --- a/app/Http/Requests/Messaging/RenameConversationRequest.php +++ b/app/Http/Requests/Messaging/RenameConversationRequest.php @@ -17,4 +17,4 @@ class RenameConversationRequest extends FormRequest 'title' => 'required|string|max:120', ]; } -} \ No newline at end of file +} diff --git a/app/Http/Requests/Messaging/StoreConversationRequest.php b/app/Http/Requests/Messaging/StoreConversationRequest.php index 91e9e6bf..25a86b24 100644 --- a/app/Http/Requests/Messaging/StoreConversationRequest.php +++ b/app/Http/Requests/Messaging/StoreConversationRequest.php @@ -23,4 +23,4 @@ class StoreConversationRequest extends FormRequest 'client_temp_id' => 'nullable|string|max:120', ]; } -} \ No newline at end of file +} diff --git a/app/Http/Requests/Messaging/StoreMessageRequest.php b/app/Http/Requests/Messaging/StoreMessageRequest.php index 1fcf1502..3bff85f7 100644 --- a/app/Http/Requests/Messaging/StoreMessageRequest.php +++ b/app/Http/Requests/Messaging/StoreMessageRequest.php @@ -21,4 +21,4 @@ class StoreMessageRequest extends FormRequest 'reply_to_message_id' => 'nullable|integer|exists:messages,id', ]; } -} \ No newline at end of file +} diff --git a/app/Http/Requests/Messaging/ToggleMessageReactionRequest.php b/app/Http/Requests/Messaging/ToggleMessageReactionRequest.php index a06d087e..cc8aaa83 100644 --- a/app/Http/Requests/Messaging/ToggleMessageReactionRequest.php +++ b/app/Http/Requests/Messaging/ToggleMessageReactionRequest.php @@ -17,4 +17,4 @@ class ToggleMessageReactionRequest extends FormRequest 'reaction' => 'required|string|max:32', ]; } -} \ No newline at end of file +} diff --git a/app/Http/Requests/Messaging/UpdateMessageRequest.php b/app/Http/Requests/Messaging/UpdateMessageRequest.php index 918749e2..500a6fce 100644 --- a/app/Http/Requests/Messaging/UpdateMessageRequest.php +++ b/app/Http/Requests/Messaging/UpdateMessageRequest.php @@ -17,4 +17,4 @@ class UpdateMessageRequest extends FormRequest 'body' => 'required|string|max:5000', ]; } -} \ No newline at end of file +} diff --git a/app/Http/Resources/ArtworkResource.php b/app/Http/Resources/ArtworkResource.php index 371b05cd..764c5a2d 100644 --- a/app/Http/Resources/ArtworkResource.php +++ b/app/Http/Resources/ArtworkResource.php @@ -1,6 +1,7 @@ (string) $this->slug, 'title' => $decode($this->title), 'description' => $decode($this->description), + 'description_html' => $this->renderDescriptionHtml(), 'dimensions' => [ 'width' => (int) ($this->width ?? 0), 'height' => (int) ($this->height ?? 0), @@ -123,6 +125,8 @@ class ArtworkResource extends JsonResource 'username' => (string) ($this->user?->username ?? ''), 'profile_url' => $this->user?->username ? '/@' . $this->user->username : null, 'avatar_url' => $this->user?->profile?->avatar_url, + 'level' => (int) ($this->user?->level ?? 1), + 'rank' => (string) ($this->user?->rank ?? 'Newbie'), 'followers_count' => $followerCount, ], 'viewer' => [ @@ -168,4 +172,27 @@ class ArtworkResource extends JsonResource ])->values(), ]; } + + private function renderDescriptionHtml(): string + { + $rawDescription = (string) ($this->description ?? ''); + + if (trim($rawDescription) === '') { + return ''; + } + + if (! $this->authorCanPublishLinks()) { + return nl2br(e(ContentSanitizer::stripToPlain($rawDescription))); + } + + return ContentSanitizer::render($rawDescription); + } + + private function authorCanPublishLinks(): bool + { + $level = (int) ($this->user?->level ?? 1); + $rank = strtolower((string) ($this->user?->rank ?? 'Newbie')); + + return $level > 1 && $rank !== 'newbie'; + } } diff --git a/app/Jobs/GenerateArtworkEmbeddingJob.php b/app/Jobs/GenerateArtworkEmbeddingJob.php index a787dd4e..edfb98c9 100644 --- a/app/Jobs/GenerateArtworkEmbeddingJob.php +++ b/app/Jobs/GenerateArtworkEmbeddingJob.php @@ -7,6 +7,7 @@ namespace App\Jobs; use App\Models\Artwork; use App\Models\ArtworkEmbedding; use App\Services\Vision\ArtworkEmbeddingClient; +use App\Services\Vision\ArtworkVisionImageUrl; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Bus\Dispatchable; @@ -41,7 +42,7 @@ final class GenerateArtworkEmbeddingJob implements ShouldQueue return [2, 10, 30]; } - public function handle(ArtworkEmbeddingClient $client): void + public function handle(ArtworkEmbeddingClient $client, ArtworkVisionImageUrl $imageUrlBuilder): void { if (! (bool) config('recommendations.embedding.enabled', true)) { return; @@ -79,7 +80,7 @@ final class GenerateArtworkEmbeddingJob implements ShouldQueue } try { - $imageUrl = $this->buildImageUrl($sourceHash); + $imageUrl = $imageUrlBuilder->fromHash($sourceHash, (string) ($artwork->thumb_ext ?: 'webp')); if ($imageUrl === null) { return; } @@ -134,21 +135,6 @@ final class GenerateArtworkEmbeddingJob implements ShouldQueue return array_map(static fn (float $value): float => $value / $norm, $vector); } - private function buildImageUrl(string $hash): ?string - { - $base = rtrim((string) config('cdn.files_url', ''), '/'); - if ($base === '') { - return null; - } - - $variant = (string) config('vision.image_variant', 'md'); - $clean = strtolower((string) preg_replace('/[^a-z0-9]/', '', $hash)); - $clean = str_pad($clean, 6, '0'); - $segments = [substr($clean, 0, 2) ?: '00', substr($clean, 2, 2) ?: '00', substr($clean, 4, 2) ?: '00']; - - return $base . '/img/' . implode('/', $segments) . '/' . $variant . '.webp'; - } - private function lockKey(int $artworkId, string $model, string $version): string { return 'artwork-embedding:lock:' . $artworkId . ':' . $model . ':' . $version; diff --git a/app/Models/MessageRead.php b/app/Models/MessageRead.php index 80f55083..f00a44c9 100644 --- a/app/Models/MessageRead.php +++ b/app/Models/MessageRead.php @@ -31,4 +31,4 @@ class MessageRead extends Model { return $this->belongsTo(User::class); } -} \ No newline at end of file +} diff --git a/app/Notifications/ArtworkSharedNotification.php b/app/Notifications/ArtworkSharedNotification.php index 9d813ce9..1a4164a2 100644 --- a/app/Notifications/ArtworkSharedNotification.php +++ b/app/Notifications/ArtworkSharedNotification.php @@ -40,7 +40,7 @@ class ArtworkSharedNotification extends Notification implements ShouldQueue 'sharer_name' => $this->sharer->name, 'sharer_username' => $this->sharer->username, 'message' => $this->sharer->name . ' shared your artwork "' . $this->artwork->title . '"', - 'url' => "/@{$this->sharer->username}?tab=posts", + 'url' => "/@{$this->sharer->username}/posts", ]; } } diff --git a/app/Notifications/PostCommentedNotification.php b/app/Notifications/PostCommentedNotification.php index a6bf29f2..0a7edc51 100644 --- a/app/Notifications/PostCommentedNotification.php +++ b/app/Notifications/PostCommentedNotification.php @@ -39,7 +39,7 @@ class PostCommentedNotification extends Notification implements ShouldQueue 'commenter_name' => $this->commenter->name, 'commenter_username' => $this->commenter->username, 'message' => "{$this->commenter->name} commented on your post", - 'url' => "/@{$this->post->user->username}?tab=posts", + 'url' => "/@{$this->post->user->username}/posts", ]; } } diff --git a/app/Policies/ConversationPolicy.php b/app/Policies/ConversationPolicy.php index e33600a8..8c1606a0 100644 --- a/app/Policies/ConversationPolicy.php +++ b/app/Policies/ConversationPolicy.php @@ -44,4 +44,4 @@ class ConversationPolicy ->whereNull('left_at') ->first(); } -} \ No newline at end of file +} diff --git a/app/Policies/MessagePolicy.php b/app/Policies/MessagePolicy.php index aa75fe39..f8745d35 100644 --- a/app/Policies/MessagePolicy.php +++ b/app/Policies/MessagePolicy.php @@ -26,4 +26,4 @@ class MessagePolicy { return $message->sender_id === $user->id || $user->isAdmin(); } -} \ No newline at end of file +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index b38f25aa..1ee1cb83 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -290,6 +290,44 @@ class AppServiceProvider extends ServiceProvider Limit::perMinute(120)->by('messages:react:ip:' . $request->ip()), ]; }); + + RateLimiter::for('messages-read', function (Request $request): array { + $userId = $request->user()?->id ?? 'guest'; + + return [ + Limit::perMinute(120)->by('messages:read:user:' . $userId), + Limit::perMinute(240)->by('messages:read:ip:' . $request->ip()), + ]; + }); + + RateLimiter::for('messages-typing', function (Request $request): array { + $userId = $request->user()?->id ?? 'guest'; + $conversationId = (int) $request->route('conversation_id'); + + return [ + Limit::perMinute(90)->by('messages:typing:user:' . $userId . ':conv:' . $conversationId), + Limit::perMinute(180)->by('messages:typing:ip:' . $request->ip()), + ]; + }); + + RateLimiter::for('messages-recovery', function (Request $request): array { + $userId = $request->user()?->id ?? 'guest'; + $conversationId = (int) $request->route('conversation_id'); + + return [ + Limit::perMinute(30)->by('messages:recovery:user:' . $userId . ':conv:' . $conversationId), + Limit::perMinute(60)->by('messages:recovery:ip:' . $request->ip()), + ]; + }); + + RateLimiter::for('messages-presence', function (Request $request): array { + $userId = $request->user()?->id ?? 'guest'; + + return [ + Limit::perMinute(180)->by('messages:presence:user:' . $userId), + Limit::perMinute(300)->by('messages:presence:ip:' . $request->ip()), + ]; + }); } private function configureDownloadRateLimiter(): void diff --git a/app/Providers/HorizonServiceProvider.php b/app/Providers/HorizonServiceProvider.php new file mode 100644 index 00000000..bc7c0dbc --- /dev/null +++ b/app/Providers/HorizonServiceProvider.php @@ -0,0 +1,34 @@ +environment('local') + || (is_object($user) && method_exists($user, 'isAdmin') && $user->isAdmin()); + }); + } +} diff --git a/app/Services/ArtworkService.php b/app/Services/ArtworkService.php index 822c5823..21232e98 100644 --- a/app/Services/ArtworkService.php +++ b/app/Services/ArtworkService.php @@ -301,7 +301,8 @@ class ArtworkService { $query = Artwork::where('user_id', $userId) ->with([ - 'user:id,name,username', + 'user:id,name,username,level,rank', + 'stats:artwork_id,views,downloads,favorites', 'categories' => function ($q) { $q->select('categories.id', 'categories.content_type_id', 'categories.parent_id', 'categories.name', 'categories.slug', 'categories.sort_order') ->with(['parent:id,parent_id,content_type_id,name,slug', 'contentType:id,slug,name']); diff --git a/app/Services/ContentSanitizer.php b/app/Services/ContentSanitizer.php index de6a6ff5..9d9311f0 100644 --- a/app/Services/ContentSanitizer.php +++ b/app/Services/ContentSanitizer.php @@ -72,6 +72,20 @@ class ContentSanitizer return $html; } + /** + * Normalize previously rendered HTML for display-time policy changes. + * This is useful when stored HTML predates current link attributes or + * when display rules depend on the author rather than the raw content. + */ + public static function sanitizeRenderedHtml(?string $html, bool $allowLinks = true): string + { + if ($html === null || trim($html) === '') { + return ''; + } + + return static::sanitizeHtml($html, $allowLinks); + } + /** * Strip ALL HTML from input, returning plain text with newlines preserved. */ @@ -190,7 +204,7 @@ class ContentSanitizer * Whitelist-based HTML sanitizer. * Removes all tags not in ALLOWED_TAGS, and strips disallowed attributes. */ - private static function sanitizeHtml(string $html): string + private static function sanitizeHtml(string $html, bool $allowLinks = true): string { // Parse with DOMDocument $doc = new \DOMDocument('1.0', 'UTF-8'); @@ -202,7 +216,7 @@ class ContentSanitizer ); libxml_clear_errors(); - static::cleanNode($doc->getElementsByTagName('body')->item(0)); + static::cleanNode($doc->getElementsByTagName('body')->item(0), $allowLinks); // Serialize back, removing the wrapping html/body $body = $doc->getElementsByTagName('body')->item(0); @@ -218,13 +232,17 @@ class ContentSanitizer /** * Recursively clean a DOMNode — strip forbidden tags/attributes. */ - private static function cleanNode(\DOMNode $node): void + private static function cleanNode(\DOMNode $node, bool $allowLinks = true): void { $toRemove = []; $toUnwrap = []; foreach ($node->childNodes as $child) { if ($child->nodeType === XML_ELEMENT_NODE) { + if (! $child instanceof \DOMElement) { + continue; + } + $tag = strtolower($child->nodeName); if (! in_array($tag, self::ALLOWED_TAGS, true)) { @@ -245,17 +263,22 @@ class ContentSanitizer // Force external links to be safe if ($tag === 'a') { + if (! $allowLinks) { + $toUnwrap[] = $child; + continue; + } + $href = $child->getAttribute('href'); if ($href && ! static::isSafeUrl($href)) { $toUnwrap[] = $child; continue; } - $child->setAttribute('rel', 'noopener noreferrer nofollow'); + $child->setAttribute('rel', 'noopener noreferrer nofollow ugc'); $child->setAttribute('target', '_blank'); } // Recurse - static::cleanNode($child); + static::cleanNode($child, $allowLinks); } } } diff --git a/app/Services/Messaging/ConversationDeltaService.php b/app/Services/Messaging/ConversationDeltaService.php new file mode 100644 index 00000000..014c02e7 --- /dev/null +++ b/app/Services/Messaging/ConversationDeltaService.php @@ -0,0 +1,31 @@ +where('conversation_id', $conversation->id) + ->where('id', '>', $afterMessageId) + ->with(['sender:id,username,name', 'reactions', 'attachments']) + ->orderBy('id') + ->limit($effectiveLimit) + ->get() + ->map(fn (Message $message) => $this->payloadFactory->message($message, (int) $viewer->id)) + ->values(); + } +} diff --git a/app/Services/Messaging/ConversationReadService.php b/app/Services/Messaging/ConversationReadService.php new file mode 100644 index 00000000..99e4065d --- /dev/null +++ b/app/Services/Messaging/ConversationReadService.php @@ -0,0 +1,76 @@ +where('conversation_id', $conversation->id) + ->where('user_id', $user->id) + ->whereNull('left_at') + ->firstOrFail(); + + $lastReadableMessage = Message::query() + ->where('conversation_id', $conversation->id) + ->whereNull('deleted_at') + ->where('sender_id', '!=', $user->id) + ->when($messageId, fn ($query) => $query->where('id', '<=', $messageId)) + ->orderByDesc('id') + ->first(); + + $readAt = now(); + + $participant->forceFill([ + 'last_read_at' => $readAt, + 'last_read_message_id' => $lastReadableMessage?->id, + ])->save(); + + if ($lastReadableMessage) { + $messageReads = Message::query() + ->select(['id']) + ->where('conversation_id', $conversation->id) + ->whereNull('deleted_at') + ->where('sender_id', '!=', $user->id) + ->where('id', '<=', $lastReadableMessage->id) + ->get() + ->map(fn (Message $message) => [ + 'message_id' => $message->id, + 'user_id' => $user->id, + 'read_at' => $readAt, + ]) + ->all(); + + if (! empty($messageReads)) { + DB::table('message_reads')->upsert($messageReads, ['message_id', 'user_id'], ['read_at']); + } + } + + $participantIds = $this->conversationState->activeParticipantIds($conversation); + $this->conversationState->touchConversationCachesForUsers($participantIds); + + DB::afterCommit(function () use ($conversation, $participant, $user, $participantIds): void { + event(new MessageRead($conversation, $participant, $user)); + + foreach ($participantIds as $participantId) { + event(new ConversationUpdated($participantId, $conversation, 'message.read')); + } + }); + + return $participant->fresh(['user']); + } +} diff --git a/app/Services/Messaging/ConversationStateService.php b/app/Services/Messaging/ConversationStateService.php index 2619aedc..1314806d 100644 --- a/app/Services/Messaging/ConversationStateService.php +++ b/app/Services/Messaging/ConversationStateService.php @@ -2,14 +2,9 @@ namespace App\Services\Messaging; -use App\Events\ConversationUpdated; -use App\Events\MessageRead; use App\Models\Conversation; use App\Models\ConversationParticipant; -use App\Models\Message; -use App\Models\User; use Illuminate\Support\Facades\Cache; -use Illuminate\Support\Facades\DB; class ConversationStateService { @@ -37,62 +32,4 @@ class ConversationStateService Cache::increment($versionKey); } } - - public function markConversationRead(Conversation $conversation, User $user, ?int $messageId = null): ConversationParticipant - { - /** @var ConversationParticipant $participant */ - $participant = ConversationParticipant::query() - ->where('conversation_id', $conversation->id) - ->where('user_id', $user->id) - ->whereNull('left_at') - ->firstOrFail(); - - $lastReadableMessage = Message::query() - ->where('conversation_id', $conversation->id) - ->whereNull('deleted_at') - ->where('sender_id', '!=', $user->id) - ->when($messageId, fn ($query) => $query->where('id', '<=', $messageId)) - ->orderByDesc('id') - ->first(); - - $readAt = now(); - - $participant->update([ - 'last_read_at' => $readAt, - 'last_read_message_id' => $lastReadableMessage?->id, - ]); - - if ($lastReadableMessage) { - $messageReads = Message::query() - ->select(['id']) - ->where('conversation_id', $conversation->id) - ->whereNull('deleted_at') - ->where('sender_id', '!=', $user->id) - ->where('id', '<=', $lastReadableMessage->id) - ->get() - ->map(fn (Message $message) => [ - 'message_id' => $message->id, - 'user_id' => $user->id, - 'read_at' => $readAt, - ]) - ->all(); - - if (! empty($messageReads)) { - DB::table('message_reads')->upsert($messageReads, ['message_id', 'user_id'], ['read_at']); - } - } - - $participantIds = $this->activeParticipantIds($conversation); - $this->touchConversationCachesForUsers($participantIds); - - DB::afterCommit(function () use ($conversation, $participant, $user): void { - event(new MessageRead($conversation, $participant, $user)); - - foreach ($this->activeParticipantIds($conversation) as $participantId) { - event(new ConversationUpdated($participantId, $conversation, 'message.read')); - } - }); - - return $participant->fresh(['user']); - } -} \ No newline at end of file +} diff --git a/app/Services/Messaging/MessageNotificationService.php b/app/Services/Messaging/MessageNotificationService.php index 61ba75e7..ec449803 100644 --- a/app/Services/Messaging/MessageNotificationService.php +++ b/app/Services/Messaging/MessageNotificationService.php @@ -13,6 +13,10 @@ use Illuminate\Support\Str; class MessageNotificationService { + public function __construct( + private readonly MessagingPresenceService $presence, + ) {} + public function notifyNewMessage(Conversation $conversation, Message $message, User $sender): void { if (! DB::getSchemaBuilder()->hasTable('notifications')) { @@ -36,6 +40,13 @@ class MessageNotificationService ->whereIn('id', $recipientIds) ->get() ->filter(fn (User $recipient) => $recipient->allowsMessagesFrom($sender)) + ->filter(function (User $recipient): bool { + if (! (bool) config('messaging.notifications.offline_fallback_only', true)) { + return true; + } + + return ! $this->presence->isUserOnline((int) $recipient->id); + }) ->pluck('id') ->map(fn ($id) => (int) $id) ->values() diff --git a/app/Services/Messaging/MessagingPayloadFactory.php b/app/Services/Messaging/MessagingPayloadFactory.php index 119015b2..dcff26bc 100644 --- a/app/Services/Messaging/MessagingPayloadFactory.php +++ b/app/Services/Messaging/MessagingPayloadFactory.php @@ -56,7 +56,7 @@ class MessagingPayloadFactory 'title' => $conversation->title, 'is_active' => (bool) ($conversation->is_active ?? true), 'last_message_at' => optional($conversation->last_message_at)?->toIso8601String(), - 'unread_count' => $conversation->unreadCountFor($viewerId), + 'unread_count' => app(UnreadCounterService::class)->unreadCountForConversation($conversation, $viewerId), 'my_participant' => $myParticipant ? $this->participant($myParticipant) : null, 'all_participants' => $conversation->allParticipants ->whereNull('left_at') @@ -149,4 +149,4 @@ class MessagingPayloadFactory return $counts; } -} \ No newline at end of file +} diff --git a/app/Services/Messaging/MessagingPresenceService.php b/app/Services/Messaging/MessagingPresenceService.php new file mode 100644 index 00000000..ff8d85fe --- /dev/null +++ b/app/Services/Messaging/MessagingPresenceService.php @@ -0,0 +1,69 @@ +id : (int) $user; + $store = $this->store(); + $onlineKey = $this->onlineKey($userId); + $existing = $store->get($onlineKey, []); + $previousConversationId = (int) ($existing['conversation_id'] ?? 0) ?: null; + $onlineTtl = max(30, (int) config('messaging.presence.ttl_seconds', 90)); + $conversationTtl = max(15, (int) config('messaging.presence.conversation_ttl_seconds', 45)); + + if ($previousConversationId && $previousConversationId !== $conversationId) { + $store->forget($this->conversationKey($previousConversationId, $userId)); + } + + $store->put($onlineKey, [ + 'conversation_id' => $conversationId, + 'seen_at' => now()->toIso8601String(), + ], now()->addSeconds($onlineTtl)); + + if ($conversationId) { + $store->put($this->conversationKey($conversationId, $userId), now()->toIso8601String(), now()->addSeconds($conversationTtl)); + } + } + + public function isUserOnline(int $userId): bool + { + return $this->store()->has($this->onlineKey($userId)); + } + + public function isViewingConversation(int $conversationId, int $userId): bool + { + return $this->store()->has($this->conversationKey($conversationId, $userId)); + } + + private function onlineKey(int $userId): string + { + return 'messages:presence:user:' . $userId; + } + + private function conversationKey(int $conversationId, int $userId): string + { + return 'messages:presence:conversation:' . $conversationId . ':user:' . $userId; + } + + private function store(): Repository + { + $store = (string) config('messaging.presence.cache_store', 'redis'); + + if ($store === 'redis' && ! class_exists('Redis')) { + return Cache::store(); + } + + try { + return Cache::store($store); + } catch (\Throwable) { + return Cache::store(); + } + } +} diff --git a/app/Services/Messaging/SendMessageAction.php b/app/Services/Messaging/SendMessageAction.php index aa808647..d138ca38 100644 --- a/app/Services/Messaging/SendMessageAction.php +++ b/app/Services/Messaging/SendMessageAction.php @@ -123,4 +123,4 @@ class SendMessageAction 'created_at' => now(), ]); } -} \ No newline at end of file +} diff --git a/app/Services/Messaging/UnreadCounterService.php b/app/Services/Messaging/UnreadCounterService.php new file mode 100644 index 00000000..9fe36cea --- /dev/null +++ b/app/Services/Messaging/UnreadCounterService.php @@ -0,0 +1,81 @@ +id : (int) $user; + + return $query->addSelect([ + 'unread_count' => Message::query() + ->selectRaw('count(*)') + ->whereColumn('messages.conversation_id', 'conversations.id') + ->where('messages.sender_id', '!=', $userId) + ->whereNull('messages.deleted_at') + ->where(function ($nested) use ($participantAlias) { + $nested->where(function ($group) use ($participantAlias) { + $group->whereNull($participantAlias . '.last_read_message_id') + ->whereNull($participantAlias . '.last_read_at'); + })->orWhereColumn('messages.id', '>', $participantAlias . '.last_read_message_id') + ->orWhereColumn('messages.created_at', '>', $participantAlias . '.last_read_at'); + }), + ]); + } + + public function unreadCountForConversation(Conversation $conversation, User|int $user): int + { + $userId = $user instanceof User ? (int) $user->id : (int) $user; + + $participant = ConversationParticipant::query() + ->where('conversation_id', $conversation->id) + ->where('user_id', $userId) + ->whereNull('left_at') + ->first(); + + if (! $participant) { + return 0; + } + + return $this->unreadCountForParticipant($participant); + } + + public function unreadCountForParticipant(ConversationParticipant $participant): int + { + $query = Message::query() + ->where('conversation_id', $participant->conversation_id) + ->where('sender_id', '!=', $participant->user_id) + ->whereNull('deleted_at'); + + if ($participant->last_read_message_id) { + $query->where('id', '>', $participant->last_read_message_id); + } elseif ($participant->last_read_at) { + $query->where('created_at', '>', $participant->last_read_at); + } + + return (int) $query->count(); + } + + public function totalUnreadForUser(User|int $user): int + { + $userId = $user instanceof User ? (int) $user->id : (int) $user; + + return (int) Conversation::query() + ->select('conversations.id') + ->join('conversation_participants as cp_me', function ($join) use ($userId) { + $join->on('cp_me.conversation_id', '=', 'conversations.id') + ->where('cp_me.user_id', '=', $userId) + ->whereNull('cp_me.left_at'); + }) + ->where('conversations.is_active', true) + ->get() + ->sum(fn (Conversation $conversation) => $this->unreadCountForConversation($conversation, $userId)); + } +} diff --git a/app/Services/Vision/ArtworkVisionImageUrl.php b/app/Services/Vision/ArtworkVisionImageUrl.php new file mode 100644 index 00000000..5a376a95 --- /dev/null +++ b/app/Services/Vision/ArtworkVisionImageUrl.php @@ -0,0 +1,29 @@ +fromHash( + (string) ($artwork->hash ?? ''), + (string) ($artwork->thumb_ext ?: 'webp') + ); + } + + public function fromHash(?string $hash, ?string $ext = 'webp', string $size = 'md'): ?string + { + $clean = strtolower((string) preg_replace('/[^a-z0-9]/', '', (string) $hash)); + if ($clean === '') { + return null; + } + + return ThumbnailService::fromHash($clean, $ext, $size); + } +} diff --git a/app/Services/Vision/VectorGatewayClient.php b/app/Services/Vision/VectorGatewayClient.php new file mode 100644 index 00000000..94438422 --- /dev/null +++ b/app/Services/Vision/VectorGatewayClient.php @@ -0,0 +1,213 @@ +baseUrl() !== '' + && $this->apiKey() !== ''; + } + + public function upsertByUrl(string $imageUrl, int|string $id, array $metadata = []): array + { + $response = $this->postJson( + $this->url((string) config('vision.vector_gateway.upsert_endpoint', '/vectors/upsert')), + [ + 'url' => $imageUrl, + 'id' => (string) $id, + 'metadata' => $metadata, + ] + ); + + if ($response->failed()) { + throw new RuntimeException($this->failureMessage('Vector upsert', $response)); + } + + $json = $response->json(); + + return is_array($json) ? $json : []; + } + + /** + * @return list}> + */ + public function searchByUrl(string $imageUrl, int $limit = 5): array + { + $response = $this->postJson( + $this->url((string) config('vision.vector_gateway.search_endpoint', '/vectors/search')), + [ + 'url' => $imageUrl, + 'limit' => max(1, $limit), + ] + ); + + if ($response->failed()) { + throw new RuntimeException($this->failureMessage('Vector search', $response)); + } + + return $this->extractMatches($response->json()); + } + + public function deleteByIds(array $ids): array + { + $response = $this->postJson( + $this->url((string) config('vision.vector_gateway.delete_endpoint', '/vectors/delete')), + [ + 'ids' => array_values(array_map(static fn (int|string $id): string => (string) $id, $ids)), + ] + ); + + if ($response->failed()) { + throw new RuntimeException($this->failureMessage('Vector delete', $response)); + } + + $json = $response->json(); + + return is_array($json) ? $json : []; + } + + private function request(): PendingRequest + { + if (! $this->isConfigured()) { + throw new RuntimeException('Vision vector gateway is not configured. Set VISION_VECTOR_GATEWAY_URL and VISION_VECTOR_GATEWAY_API_KEY.'); + } + + return Http::acceptJson() + ->withHeaders([ + 'X-API-Key' => $this->apiKey(), + ]) + ->connectTimeout(max(1, (int) config('vision.vector_gateway.connect_timeout_seconds', 5))) + ->timeout(max(1, (int) config('vision.vector_gateway.timeout_seconds', 20))) + ->retry( + max(0, (int) config('vision.vector_gateway.retries', 1)), + max(0, (int) config('vision.vector_gateway.retry_delay_ms', 250)), + throw: false, + ); + } + + /** + * @param array $payload + */ + private function postJson(string $url, array $payload): Response + { + $response = $this->request()->post($url, $payload); + + if (! $response instanceof Response) { + throw new RuntimeException('Vector gateway request did not return an HTTP response.'); + } + + return $response; + } + + private function baseUrl(): string + { + return rtrim((string) config('vision.vector_gateway.base_url', ''), '/'); + } + + private function apiKey(): string + { + return trim((string) config('vision.vector_gateway.api_key', '')); + } + + private function url(string $path): string + { + return $this->baseUrl() . '/' . ltrim($path, '/'); + } + + private function failureMessage(string $operation, Response $response): string + { + $body = trim($response->body()); + + if ($body === '') { + return $operation . ' failed with HTTP ' . $response->status() . '.'; + } + + return $operation . ' failed with HTTP ' . $response->status() . ': ' . $body; + } + + /** + * @param mixed $json + * @return list}> + */ + private function extractMatches(mixed $json): array + { + $candidates = []; + + if (is_array($json)) { + $candidates = $this->extractCandidateRows($json); + } + + $results = []; + foreach ($candidates as $candidate) { + if (! is_array($candidate)) { + continue; + } + + $id = $candidate['id'] + ?? $candidate['point_id'] + ?? $candidate['payload']['id'] + ?? $candidate['metadata']['id'] + ?? null; + + if (! is_int($id) && ! is_string($id)) { + continue; + } + + $score = $candidate['score'] + ?? $candidate['similarity'] + ?? $candidate['distance'] + ?? 0.0; + + $metadata = $candidate['metadata'] ?? $candidate['payload'] ?? []; + if (! is_array($metadata)) { + $metadata = []; + } + + $results[] = [ + 'id' => $id, + 'score' => (float) $score, + 'metadata' => $metadata, + ]; + } + + return $results; + } + + /** + * @param array $json + * @return array + */ + private function extractCandidateRows(array $json): array + { + $keys = ['results', 'matches', 'points', 'data']; + + foreach ($keys as $key) { + if (! isset($json[$key]) || ! is_array($json[$key])) { + continue; + } + + $value = $json[$key]; + if (array_is_list($value)) { + return $value; + } + + foreach (['results', 'matches', 'points', 'items'] as $nestedKey) { + if (isset($value[$nestedKey]) && is_array($value[$nestedKey]) && array_is_list($value[$nestedKey])) { + return $value[$nestedKey]; + } + } + } + + return array_is_list($json) ? $json : []; + } +} diff --git a/bootstrap/providers.php b/bootstrap/providers.php index f6d9de70..fe679b48 100644 --- a/bootstrap/providers.php +++ b/bootstrap/providers.php @@ -3,9 +3,10 @@ return [ App\Providers\AppServiceProvider::class, App\Providers\AuthServiceProvider::class, + App\Providers\HorizonServiceProvider::class, Klevze\ControlPanel\ServiceProvider::class, cPad\Plugins\Artworks\ServiceProvider::class, - cPad\Plugins\News\ServiceProvider::class, cPad\Plugins\Forum\ServiceProvider::class, + cPad\Plugins\News\ServiceProvider::class, cPad\Plugins\Site\ServiceProvider::class, ]; diff --git a/composer.json b/composer.json index 99cf26b0..442ba7b3 100644 --- a/composer.json +++ b/composer.json @@ -17,6 +17,7 @@ "intervention/image": "^3.11", "jenssegers/agent": "*", "laravel/framework": "^12.0", + "laravel/horizon": "^5.45", "laravel/reverb": "^1.0", "laravel/scout": "^10.24", "laravel/socialite": "^5.24", diff --git a/composer.lock b/composer.lock index 095e5891..eb42a8fb 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "e1ededa537b256c2936370d7e28a4bd5", + "content-hash": "7310d1d07635e290193ccbe4539b1397", "packages": [ { "name": "alexusmai/laravel-file-manager", @@ -2209,6 +2209,86 @@ }, "time": "2026-02-24T14:35:15+00:00" }, + { + "name": "laravel/horizon", + "version": "v5.45.4", + "source": { + "type": "git", + "url": "https://github.com/laravel/horizon.git", + "reference": "b2b32e3f6013081e0176307e9081cd085f0ad4d6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/horizon/zipball/b2b32e3f6013081e0176307e9081cd085f0ad4d6", + "reference": "b2b32e3f6013081e0176307e9081cd085f0ad4d6", + "shasum": "" + }, + "require": { + "ext-json": "*", + "ext-pcntl": "*", + "ext-posix": "*", + "illuminate/contracts": "^9.21|^10.0|^11.0|^12.0|^13.0", + "illuminate/queue": "^9.21|^10.0|^11.0|^12.0|^13.0", + "illuminate/support": "^9.21|^10.0|^11.0|^12.0|^13.0", + "laravel/sentinel": "^1.0", + "nesbot/carbon": "^2.17|^3.0", + "php": "^8.0", + "ramsey/uuid": "^4.0", + "symfony/console": "^6.0|^7.0|^8.0", + "symfony/error-handler": "^6.0|^7.0|^8.0", + "symfony/polyfill-php83": "^1.28", + "symfony/process": "^6.0|^7.0|^8.0" + }, + "require-dev": { + "mockery/mockery": "^1.0", + "orchestra/testbench": "^7.56|^8.37|^9.16|^10.9|^11.0", + "phpstan/phpstan": "^1.10|^2.0", + "predis/predis": "^1.1|^2.0|^3.0" + }, + "suggest": { + "ext-redis": "Required to use the Redis PHP driver.", + "predis/predis": "Required when not using the Redis PHP driver (^1.1|^2.0|^3.0)." + }, + "type": "library", + "extra": { + "laravel": { + "aliases": { + "Horizon": "Laravel\\Horizon\\Horizon" + }, + "providers": [ + "Laravel\\Horizon\\HorizonServiceProvider" + ] + }, + "branch-alias": { + "dev-master": "6.x-dev" + } + }, + "autoload": { + "psr-4": { + "Laravel\\Horizon\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "Dashboard and code-driven configuration for Laravel queues.", + "keywords": [ + "laravel", + "queue" + ], + "support": { + "issues": "https://github.com/laravel/horizon/issues", + "source": "https://github.com/laravel/horizon/tree/v5.45.4" + }, + "time": "2026-03-18T14:14:59+00:00" + }, { "name": "laravel/prompts", "version": "v0.3.13", @@ -2427,6 +2507,65 @@ }, "time": "2026-02-10T18:44:39+00:00" }, + { + "name": "laravel/sentinel", + "version": "v1.0.1", + "source": { + "type": "git", + "url": "https://github.com/laravel/sentinel.git", + "reference": "7a98db53e0d9d6f61387f3141c07477f97425603" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/sentinel/zipball/7a98db53e0d9d6f61387f3141c07477f97425603", + "reference": "7a98db53e0d9d6f61387f3141c07477f97425603", + "shasum": "" + }, + "require": { + "ext-json": "*", + "illuminate/container": "^8.37|^9.0|^10.0|^11.0|^12.0|^13.0", + "php": "^8.0" + }, + "require-dev": { + "laravel/pint": "^1.27", + "orchestra/testbench": "^6.47.1|^7.56|^8.37|^9.16|^10.9|^11.0", + "phpstan/phpstan": "^2.1.33" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Laravel\\Sentinel\\SentinelServiceProvider" + ] + }, + "branch-alias": { + "dev-main": "1.x-dev" + } + }, + "autoload": { + "psr-4": { + "Laravel\\Sentinel\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + }, + { + "name": "Mior Muhammad Zaki", + "email": "mior@laravel.com" + } + ], + "support": { + "source": "https://github.com/laravel/sentinel/tree/v1.0.1" + }, + "time": "2026-02-12T13:32:54+00:00" + }, { "name": "laravel/serializable-closure", "version": "v2.0.10", diff --git a/config/horizon.php b/config/horizon.php new file mode 100644 index 00000000..a8ce79da --- /dev/null +++ b/config/horizon.php @@ -0,0 +1,277 @@ + env('HORIZON_NAME'), + + /* + |-------------------------------------------------------------------------- + | Horizon Domain + |-------------------------------------------------------------------------- + | + | This is the subdomain where Horizon will be accessible from. If this + | setting is null, Horizon will reside under the same domain as the + | application. Otherwise, this value will serve as the subdomain. + | + */ + + 'domain' => env('HORIZON_DOMAIN'), + + /* + |-------------------------------------------------------------------------- + | Horizon Path + |-------------------------------------------------------------------------- + | + | This is the URI path where Horizon will be accessible from. Feel free + | to change this path to anything you like. Note that the URI will not + | affect the paths of its internal API that aren't exposed to users. + | + */ + + 'path' => env('HORIZON_PATH', 'horizon'), + + /* + |-------------------------------------------------------------------------- + | Horizon Redis Connection + |-------------------------------------------------------------------------- + | + | This is the name of the Redis connection where Horizon will store the + | meta information required for it to function. It includes the list + | of supervisors, failed jobs, job metrics, and other information. + | + */ + + 'use' => 'default', + + /* + |-------------------------------------------------------------------------- + | Horizon Redis Prefix + |-------------------------------------------------------------------------- + | + | This prefix will be used when storing all Horizon data in Redis. You + | may modify the prefix when you are running multiple installations + | of Horizon on the same server so that they don't have problems. + | + */ + + 'prefix' => env( + 'HORIZON_PREFIX', + Str::slug(env('APP_NAME', 'laravel'), '_').'_horizon:' + ), + + /* + |-------------------------------------------------------------------------- + | Horizon Route Middleware + |-------------------------------------------------------------------------- + | + | These middleware will get attached onto each Horizon route, giving you + | the chance to add your own middleware to this list or change any of + | the existing middleware. Or, you can simply stick with this list. + | + */ + + 'middleware' => ['web'], + + /* + |-------------------------------------------------------------------------- + | Queue Wait Time Thresholds + |-------------------------------------------------------------------------- + | + | This option allows you to configure when the LongWaitDetected event + | will be fired. Every connection / queue combination may have its + | own, unique threshold (in seconds) before this event is fired. + | + */ + + 'waits' => [ + 'redis:broadcasts' => 15, + 'redis:default' => 60, + 'redis:notifications' => 90, + ], + + /* + |-------------------------------------------------------------------------- + | Job Trimming Times + |-------------------------------------------------------------------------- + | + | Here you can configure for how long (in minutes) you desire Horizon to + | persist the recent and failed jobs. Typically, recent jobs are kept + | for one hour while all failed jobs are stored for an entire week. + | + */ + + 'trim' => [ + 'recent' => 60, + 'pending' => 60, + 'completed' => 60, + 'recent_failed' => 10080, + 'failed' => 10080, + 'monitored' => 10080, + ], + + /* + |-------------------------------------------------------------------------- + | Silenced Jobs + |-------------------------------------------------------------------------- + | + | Silencing a job will instruct Horizon to not place the job in the list + | of completed jobs within the Horizon dashboard. This setting may be + | used to fully remove any noisy jobs from the completed jobs list. + | + */ + + 'silenced' => [ + // App\Jobs\ExampleJob::class, + ], + + 'silenced_tags' => [ + // 'notifications', + ], + + /* + |-------------------------------------------------------------------------- + | Metrics + |-------------------------------------------------------------------------- + | + | Here you can configure how many snapshots should be kept to display in + | the metrics graph. This will get used in combination with Horizon's + | `horizon:snapshot` schedule to define how long to retain metrics. + | + */ + + 'metrics' => [ + 'trim_snapshots' => [ + 'job' => 24, + 'queue' => 24, + ], + ], + + /* + |-------------------------------------------------------------------------- + | Fast Termination + |-------------------------------------------------------------------------- + | + | When this option is enabled, Horizon's "terminate" command will not + | wait on all of the workers to terminate unless the --wait option + | is provided. Fast termination can shorten deployment delay by + | allowing a new instance of Horizon to start while the last + | instance will continue to terminate each of its workers. + | + */ + + 'fast_termination' => false, + + /* + |-------------------------------------------------------------------------- + | Memory Limit (MB) + |-------------------------------------------------------------------------- + | + | This value describes the maximum amount of memory the Horizon master + | supervisor may consume before it is terminated and restarted. For + | configuring these limits on your workers, see the next section. + | + */ + + 'memory_limit' => 64, + + /* + |-------------------------------------------------------------------------- + | Queue Worker Configuration + |-------------------------------------------------------------------------- + | + | Here you may define the queue worker settings used by your application + | in all environments. These supervisors and settings handle all your + | queued jobs and will be provisioned by Horizon during deployment. + | + */ + + 'defaults' => [ + 'supervisor-default' => [ + 'connection' => 'redis', + 'queue' => ['default'], + 'balance' => 'auto', + 'autoScalingStrategy' => 'time', + 'maxProcesses' => 1, + 'maxTime' => 0, + 'maxJobs' => 0, + 'memory' => 128, + 'tries' => 1, + 'timeout' => 60, + 'nice' => 0, + ], + 'supervisor-messaging' => [ + 'connection' => 'redis', + 'queue' => ['broadcasts', 'notifications'], + 'balance' => 'auto', + 'autoScalingStrategy' => 'time', + 'maxProcesses' => 2, + 'maxTime' => 0, + 'maxJobs' => 0, + 'memory' => 128, + 'tries' => 1, + 'timeout' => 90, + 'nice' => 0, + ], + ], + + 'environments' => [ + 'production' => [ + 'supervisor-default' => [ + 'maxProcesses' => 10, + 'balanceMaxShift' => 1, + 'balanceCooldown' => 3, + ], + 'supervisor-messaging' => [ + 'maxProcesses' => 6, + 'balanceMaxShift' => 1, + 'balanceCooldown' => 3, + ], + ], + + 'local' => [ + 'supervisor-default' => [ + 'maxProcesses' => 3, + ], + 'supervisor-messaging' => [ + 'maxProcesses' => 2, + ], + ], + ], + + /* + |-------------------------------------------------------------------------- + | File Watcher Configuration + |-------------------------------------------------------------------------- + | + | The following list of directories and files will be watched when using + | the `horizon:listen` command. Whenever any directories or files are + | changed, Horizon will automatically restart to apply all changes. + | + */ + + 'watch' => [ + 'app', + 'bootstrap', + 'config/**/*.php', + 'database/**/*.php', + 'public/**/*.php', + 'resources/**/*.php', + 'routes', + 'composer.lock', + 'composer.json', + '.env', + ], +]; diff --git a/config/messaging.php b/config/messaging.php index 54f57627..ef9ad252 100644 --- a/config/messaging.php +++ b/config/messaging.php @@ -12,6 +12,20 @@ return [ 'cache_store' => env('MESSAGING_TYPING_CACHE_STORE', 'redis'), ], + 'presence' => [ + 'ttl_seconds' => (int) env('MESSAGING_PRESENCE_TTL', 90), + 'conversation_ttl_seconds' => (int) env('MESSAGING_CONVERSATION_PRESENCE_TTL', 45), + 'cache_store' => env('MESSAGING_PRESENCE_CACHE_STORE', env('MESSAGING_TYPING_CACHE_STORE', 'redis')), + ], + + 'recovery' => [ + 'max_messages' => (int) env('MESSAGING_RECOVERY_MAX_MESSAGES', 100), + ], + + 'notifications' => [ + 'offline_fallback_only' => (bool) env('MESSAGING_OFFLINE_FALLBACK_ONLY', true), + ], + 'search' => [ 'index' => env('MESSAGING_MEILI_INDEX', 'messages'), 'page_size' => (int) env('MESSAGING_SEARCH_PAGE_SIZE', 20), diff --git a/config/vision.php b/config/vision.php index 4d5dafaf..bb49d252 100644 --- a/config/vision.php +++ b/config/vision.php @@ -44,6 +44,21 @@ return [ 'connect_timeout_seconds'=> (int) env('VISION_GATEWAY_CONNECT_TIMEOUT', 3), ], + 'vector_gateway' => [ + 'enabled' => env('VISION_VECTOR_GATEWAY_ENABLED', true), + 'base_url' => env('VISION_VECTOR_GATEWAY_URL', ''), + 'api_key' => env('VISION_VECTOR_GATEWAY_API_KEY', ''), + 'collection' => env('VISION_VECTOR_GATEWAY_COLLECTION', 'images'), + 'timeout_seconds' => (int) env('VISION_VECTOR_GATEWAY_TIMEOUT', 20), + 'connect_timeout_seconds' => (int) env('VISION_VECTOR_GATEWAY_CONNECT_TIMEOUT', 5), + 'retries' => (int) env('VISION_VECTOR_GATEWAY_RETRIES', 1), + 'retry_delay_ms' => (int) env('VISION_VECTOR_GATEWAY_RETRY_DELAY_MS', 250), + 'upsert_endpoint' => env('VISION_VECTOR_GATEWAY_UPSERT_ENDPOINT', '/vectors/upsert'), + 'search_endpoint' => env('VISION_VECTOR_GATEWAY_SEARCH_ENDPOINT', '/vectors/search'), + 'delete_endpoint' => env('VISION_VECTOR_GATEWAY_DELETE_ENDPOINT', '/vectors/delete'), + 'collections_endpoint' => env('VISION_VECTOR_GATEWAY_COLLECTIONS_ENDPOINT', '/vectors/collections'), + ], + /* |-------------------------------------------------------------------------- | LM Studio – local multimodal inference (tag generation) diff --git a/database/migrations/2026_03_21_000100_add_realtime_fields_to_messaging_tables.php b/database/migrations/2026_03_21_000100_add_realtime_fields_to_messaging_tables.php index 6b21b830..c3e4c79d 100644 --- a/database/migrations/2026_03_21_000100_add_realtime_fields_to_messaging_tables.php +++ b/database/migrations/2026_03_21_000100_add_realtime_fields_to_messaging_tables.php @@ -189,4 +189,4 @@ return new class extends Migration } }); } -}; \ No newline at end of file +}; diff --git a/docs/realtime-messaging.md b/docs/realtime-messaging.md index 82463614..da9fd9f7 100644 --- a/docs/realtime-messaging.md +++ b/docs/realtime-messaging.md @@ -1,26 +1,44 @@ # Realtime Messaging -Skinbase Nova messaging now uses Laravel Reverb, Laravel Broadcasting, Laravel Echo, and Redis-backed queues. +Skinbase Nova messaging now uses Laravel Reverb, Laravel Broadcasting, Laravel Echo, Redis-backed queues, and Laravel Horizon for queue visibility. + +## v2 capabilities + +- Presence is exposed through a global `presence-messaging` channel for inbox-level online state. +- Conversation presence still uses the per-thread presence channel so the header can show who is actively viewing the room. +- Typing indicators remain ephemeral and Redis-backed. +- Read markers are stored on conversation participants and expanded into `message_reads` for durable receipts. +- The conversation list response now includes `summary.unread_total` for global badge consumers. +- Reconnect recovery uses `GET /api/messages/{conversation_id}/delta?after_message_id=...`. +- Presence heartbeats use `POST /api/messages/presence/heartbeat` and are intended only to support offline fallback notification logic plus server-side presence awareness. ## Local setup -1. Set the Reverb and Redis values in `.env`. +1. Set the Reverb, Redis, messaging, and Horizon values in `.env`. 2. Run `php artisan migrate`. 3. Run `npm install` if dependencies are not installed. 4. Start the websocket server with `php artisan reverb:start --host=0.0.0.0 --port=8080`. -5. Start queue workers with `php artisan queue:work redis --queue=broadcasts,default,notifications --tries=1`. +5. Start queue workers with `php artisan queue:work redis --queue=broadcasts,notifications,default --tries=1`. 6. Start the frontend with `npm run dev` or build assets with `npm run build`. +## Horizon + +- Horizon is installed for production queue monitoring and uses dedicated supervisors for `broadcasts` and `notifications` alongside the default queue. +- The scheduler now runs `php artisan horizon:snapshot` every five minutes so the dashboard records queue metrics. +- On Windows development machines, Horizon itself cannot run because PHP lacks `ext-pcntl` and `ext-posix`; that limitation does not affect Linux production deployments. +- Use `php artisan horizon` on Linux-based environments and keep the dashboard behind the `viewHorizon` gate. + ## Production notes - Use `BROADCAST_CONNECTION=reverb` and `QUEUE_CONNECTION=redis`. - Keep `MESSAGING_REALTIME=true` only when Reverb is configured and reachable from the browser. - Terminate TLS in Nginx and proxy websocket traffic to the Reverb process. -- Run both `php artisan reverb:start` and `php artisan queue:work redis --queue=broadcasts,default,notifications --tries=1` under Supervisor or systemd. +- Run `php artisan reverb:start` and `php artisan horizon` under Supervisor or systemd. - The chat UI falls back to HTTP polling only when realtime is disabled in config. +- Database notification fallback now only runs for recipients who are not marked online in messaging presence. ## Reconnect model - The conversation view loads once via HTTP. -- Live message, read, and typing updates arrive over websocket channels. -- When the socket reconnects, the client requests message deltas with `after_id` to merge missed messages idempotently. +- Live message, read, typing, and conversation summary updates arrive over websocket channels. +- When the socket reconnects, the client requests deltas from the explicit `delta` endpoint and merges them idempotently by message id, UUID, and client temp id. diff --git a/playwright-report/index.html b/playwright-report/index.html index 1650ea00..44950592 100644 --- a/playwright-report/index.html +++ b/playwright-report/index.html @@ -82,4 +82,4 @@ Error generating stack: `+a.message+`
- \ No newline at end of file + \ No newline at end of file diff --git a/resources/js/Pages/Messages/Index.jsx b/resources/js/Pages/Messages/Index.jsx index b1475984..23c53177 100644 --- a/resources/js/Pages/Messages/Index.jsx +++ b/resources/js/Pages/Messages/Index.jsx @@ -61,10 +61,12 @@ function buildSearchPreview(item) { function MessagesPage({ userId, username, activeConversationId: initialId }) { const [conversations, setConversations] = useState([]) + const [unreadTotal, setUnreadTotal] = useState(null) const [loadingConvs, setLoadingConvs] = useState(true) const [activeId, setActiveId] = useState(initialId ?? null) const [realtimeEnabled, setRealtimeEnabled] = useState(false) const [realtimeStatus, setRealtimeStatus] = useState('offline') + const [onlineUserIds, setOnlineUserIds] = useState([]) const [typingByConversation, setTypingByConversation] = useState({}) const [showNewModal, setShowNewModal] = useState(false) const [searchQuery, setSearchQuery] = useState('') @@ -75,6 +77,7 @@ function MessagesPage({ userId, username, activeConversationId: initialId }) { try { const data = await apiFetch('/api/messages/conversations') setConversations(data.data ?? []) + setUnreadTotal(Number.isFinite(Number(data?.summary?.unread_total)) ? Number(data.summary.unread_total) : null) } catch (e) { console.error('Failed to load conversations', e) } finally { @@ -173,6 +176,11 @@ function MessagesPage({ userId, username, activeConversationId: initialId }) { } setConversations((prev) => mergeConversationSummary(prev, nextConversation)) + + const nextUnreadTotal = Number(payload?.summary?.unread_total) + if (Number.isFinite(nextUnreadTotal)) { + setUnreadTotal(nextUnreadTotal) + } } channel.listen('.conversation.updated', handleConversationUpdated) @@ -192,6 +200,79 @@ function MessagesPage({ userId, username, activeConversationId: initialId }) { } }, [realtimeEnabled, userId]) + useEffect(() => { + if (!realtimeEnabled || !userId) { + setOnlineUserIds([]) + return undefined + } + + const echo = getEcho() + if (!echo) { + setOnlineUserIds([]) + return undefined + } + + const setMembers = (users) => { + const nextIds = (users ?? []) + .map((user) => Number(user?.id)) + .filter((id) => Number.isFinite(id) && id !== Number(userId)) + + setOnlineUserIds(Array.from(new Set(nextIds))) + } + + const channel = echo.join('messaging') + channel + .here(setMembers) + .joining((user) => setOnlineUserIds((prev) => ( + prev.includes(Number(user?.id)) || Number(user?.id) === Number(userId) + ? prev + : [...prev, Number(user.id)] + ))) + .leaving((user) => setOnlineUserIds((prev) => prev.filter((id) => id !== Number(user?.id)))) + + return () => { + echo.leave('messaging') + } + }, [realtimeEnabled, userId]) + + useEffect(() => { + if (!userId) { + return undefined + } + + let intervalId = null + + const sendHeartbeat = () => { + if (document.visibilityState === 'hidden') { + return + } + + apiFetch('/api/messages/presence/heartbeat', { + method: 'POST', + body: JSON.stringify(activeId ? { conversation_id: activeId } : {}), + }).catch(() => {}) + } + + const handleVisibilitySync = () => { + if (document.visibilityState === 'visible') { + sendHeartbeat() + } + } + + sendHeartbeat() + intervalId = window.setInterval(sendHeartbeat, 25000) + window.addEventListener('focus', sendHeartbeat) + document.addEventListener('visibilitychange', handleVisibilitySync) + + return () => { + if (intervalId) { + window.clearInterval(intervalId) + } + window.removeEventListener('focus', sendHeartbeat) + document.removeEventListener('visibilitychange', handleVisibilitySync) + } + }, [activeId, userId]) + useEffect(() => { if (!realtimeEnabled) { setTypingByConversation({}) @@ -310,12 +391,16 @@ function MessagesPage({ userId, username, activeConversationId: initialId }) { history.replaceState(null, '', `/messages/${conv.id}`) }, [loadConversations]) - const handleMarkRead = useCallback((conversationId) => { + const handleMarkRead = useCallback((conversationId, nextUnreadTotal = null) => { setConversations((prev) => prev.map((conversation) => ( conversation.id === conversationId ? { ...conversation, unread_count: 0 } : conversation ))) + + if (Number.isFinite(Number(nextUnreadTotal))) { + setUnreadTotal(Number(nextUnreadTotal)) + } }, []) const handleConversationPatched = useCallback((patch) => { @@ -369,7 +454,9 @@ function MessagesPage({ userId, username, activeConversationId: initialId }) { }, []) const activeConversation = conversations.find((conversation) => conversation.id === activeId) ?? null - const unreadCount = conversations.reduce((sum, conversation) => sum + Number(conversation.unread_count || 0), 0) + const unreadCount = Number.isFinite(Number(unreadTotal)) + ? Number(unreadTotal) + : conversations.reduce((sum, conversation) => sum + Number(conversation.unread_count || 0), 0) const pinnedCount = conversations.reduce((sum, conversation) => { const me = conversation.my_participant ?? conversation.all_participants?.find((participant) => participant.user_id === userId) return sum + (me?.is_pinned ? 1 : 0) @@ -475,6 +562,7 @@ function MessagesPage({ userId, username, activeConversationId: initialId }) { loading={loadingConvs} activeId={activeId} currentUserId={userId} + onlineUserIds={onlineUserIds} typingByConversation={typingByConversation} onSelect={handleSelectConversation} /> @@ -490,6 +578,7 @@ function MessagesPage({ userId, username, activeConversationId: initialId }) { realtimeStatus={realtimeStatus} currentUserId={userId} currentUsername={username} + onlineUserIds={onlineUserIds} apiFetch={apiFetch} onBack={() => { setActiveId(null) diff --git a/resources/js/Pages/Profile/ProfileGallery.jsx b/resources/js/Pages/Profile/ProfileGallery.jsx index 077a89ef..cff880b1 100644 --- a/resources/js/Pages/Profile/ProfileGallery.jsx +++ b/resources/js/Pages/Profile/ProfileGallery.jsx @@ -66,10 +66,9 @@ export default function ProfileGallery() { -
+
diff --git a/resources/js/Pages/Profile/ProfileShow.jsx b/resources/js/Pages/Profile/ProfileShow.jsx index f19bf529..86594e22 100644 --- a/resources/js/Pages/Profile/ProfileShow.jsx +++ b/resources/js/Pages/Profile/ProfileShow.jsx @@ -1,7 +1,6 @@ import React, { useState, useEffect, useCallback } from 'react' import { usePage } from '@inertiajs/react' import ProfileHero from '../../components/profile/ProfileHero' -import ProfileStatsRow from '../../components/profile/ProfileStatsRow' import ProfileTabs from '../../components/profile/ProfileTabs' import TabArtworks from '../../components/profile/tabs/TabArtworks' import TabAchievements from '../../components/profile/tabs/TabAchievements' @@ -13,16 +12,26 @@ import TabActivity from '../../components/profile/tabs/TabActivity' import TabPosts from '../../components/profile/tabs/TabPosts' import TabStories from '../../components/profile/tabs/TabStories' -const VALID_TABS = ['artworks', 'stories', 'achievements', 'posts', 'collections', 'about', 'stats', 'favourites', 'activity'] +const VALID_TABS = ['posts', 'artworks', 'stories', 'achievements', 'collections', 'about', 'stats', 'favourites', 'activity'] -function getInitialTab() { - try { - const sp = new URLSearchParams(window.location.search) - const t = sp.get('tab') - return VALID_TABS.includes(t) ? t : 'artworks' - } catch { - return 'artworks' +function getInitialTab(initialTab = 'posts') { + if (typeof window === 'undefined') { + return VALID_TABS.includes(initialTab) ? initialTab : 'posts' } + + try { + const pathname = window.location.pathname.replace(/\/+$/, '') + const segments = pathname.split('/').filter(Boolean) + const lastSegment = segments.at(-1) + + if (VALID_TABS.includes(lastSegment)) { + return lastSegment + } + } catch { + return VALID_TABS.includes(initialTab) ? initialTab : 'posts' + } + + return VALID_TABS.includes(initialTab) ? initialTab : 'posts' } /** @@ -52,34 +61,37 @@ export default function ProfileShow() { countryName, isOwner, auth, + initialTab, profileUrl, galleryUrl, + profileTabUrls, } = props - const [activeTab, setActiveTab] = useState(getInitialTab) + const [activeTab, setActiveTab] = useState(() => getInitialTab(initialTab)) const handleTabChange = useCallback((tab) => { if (!VALID_TABS.includes(tab)) return setActiveTab(tab) - // Update URL query param without full navigation try { - const url = new URL(window.location.href) - if (tab === 'artworks') { - url.searchParams.delete('tab') - } else { - url.searchParams.set('tab', tab) - } - window.history.pushState({}, '', url.toString()) - } catch (_) {} - }, []) + const currentUrl = new URL(window.location.href) + const targetBase = profileTabUrls?.[tab] || `${profileUrl || `${window.location.origin}`}/${tab}` + const nextUrl = new URL(targetBase, window.location.origin) + const sharedPostId = currentUrl.searchParams.get('post') + + if (sharedPostId) { + nextUrl.searchParams.set('post', sharedPostId) + } + + window.history.pushState({}, '', nextUrl.toString()) + } catch (_) {} + }, [profileTabUrls, profileUrl]) - // Handle browser back/forward useEffect(() => { - const onPop = () => setActiveTab(getInitialTab()) + const onPop = () => setActiveTab(getInitialTab(initialTab)) window.addEventListener('popstate', onPop) return () => window.removeEventListener('popstate', onPop) - }, []) + }, [initialTab]) const isLoggedIn = !!(auth?.user) @@ -98,9 +110,27 @@ export default function ProfileShow() { ? socialLinks.reduce((acc, l) => { acc[l.platform] = l; return acc }, {}) : (socialLinks ?? {}) + const contentShellClassName = activeTab === 'artworks' + ? 'w-full px-4 md:px-6' + : activeTab === 'posts' + ? 'mx-auto max-w-7xl px-4 md:px-6' + : 'max-w-6xl mx-auto px-4' + return ( -
- {/* Hero section */} +
+