Repair: copy legacy joinDate into new user's created_at when creating users from legacy wallz

This commit is contained in:
2026-03-22 09:13:39 +01:00
parent e8b5edf5d2
commit 2608be7420
80 changed files with 3991 additions and 723 deletions

View File

@@ -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));

View File

@@ -0,0 +1,184 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Models\Artwork;
use App\Models\Category;
use App\Services\Vision\ArtworkVisionImageUrl;
use App\Services\Vision\VectorGatewayClient;
use Illuminate\Console\Command;
final class IndexArtworkVectorsCommand extends Command
{
protected $signature = 'artworks:vectors-index
{--start-id=0 : Start from this artwork id (inclusive)}
{--after-id=0 : Resume after this artwork id}
{--batch=100 : Batch size per iteration}
{--limit=0 : Maximum artworks to process in this run}
{--public-only : Index only public, approved, published artworks}
{--dry-run : Preview requests without sending them}';
protected $description = 'Send artwork image URLs to the vector gateway for indexing';
public function handle(VectorGatewayClient $client, ArtworkVisionImageUrl $imageUrl): int
{
$dryRun = (bool) $this->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<string, string> $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;
}
}

View File

@@ -0,0 +1,301 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Support\UsernamePolicy;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str;
use Carbon\Carbon;
class RepairLegacyWallzUsersCommand extends Command
{
protected $signature = 'skinbase:repair-legacy-wallz-users
{--chunk=500 : Number of legacy wallz rows to scan per batch}
{--legacy-connection=legacy : Legacy database connection name}
{--legacy-table=wallz : Legacy table to update}
{--artworks-table=artworks : Current DB artworks table name}
{--fix-artworks : Backfill `artworks.user_id` from legacy `wallz.user_id` for rows where user_id = 0}
{--dry-run : Preview matches and inserts without writing changes}';
protected $description = 'Backfill legacy wallz.user_id from uname by matching or creating users in the new users table';
public function handle(): int
{
$chunk = max(1, (int) $this->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']
: '<new-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';
}
}

View File

@@ -0,0 +1,135 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Support\UsernamePolicy;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
class RepairTemporaryUsernamesCommand extends Command
{
protected $signature = 'skinbase:repair-temp-usernames
{--chunk=500 : Number of users to process per batch}
{--dry-run : Preview username changes without writing them}';
protected $description = 'Replace current users.username values like tmpu% using the users.name field';
public function handle(): int
{
$chunk = max(1, (int) $this->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();
}
}

View File

@@ -0,0 +1,118 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Models\Artwork;
use App\Models\Category;
use App\Services\Vision\ArtworkVisionImageUrl;
use App\Services\Vision\VectorGatewayClient;
use Illuminate\Console\Command;
final class SearchArtworkVectorsCommand extends Command
{
protected $signature = 'artworks:vectors-search
{artwork_id : Source artwork id}
{--limit=5 : Number of similar artworks to return}';
protected $description = 'Search similar artworks through the vector gateway using an artwork image URL';
public function handle(VectorGatewayClient $client, ArtworkVisionImageUrl $imageUrl): int
{
if (! $client->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;
}
}

View File

@@ -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,

View File

@@ -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),
],
];
}
}
}

View File

@@ -48,4 +48,4 @@ class MessageCreated implements ShouldBroadcast
'message' => app(MessagingPayloadFactory::class)->message($this->message, (int) $this->message->sender_id),
];
}
}
}

View File

@@ -42,4 +42,4 @@ class MessageDeleted implements ShouldBroadcast
'deleted_at' => optional($this->message->deleted_at ?? now())?->toIso8601String(),
];
}
}
}

View File

@@ -48,4 +48,4 @@ class MessageRead implements ShouldBroadcast
'last_read_at' => optional($this->participant->last_read_at)?->toIso8601String(),
];
}
}
}

View File

@@ -41,4 +41,4 @@ class MessageUpdated implements ShouldBroadcast
'message' => app(MessagingPayloadFactory::class)->message($this->message),
];
}
}
}

View File

@@ -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 = [];

View File

@@ -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()),
]);
}

View File

@@ -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

View File

@@ -0,0 +1,33 @@
<?php
namespace App\Http\Controllers\Api\Messaging;
use App\Http\Controllers\Controller;
use App\Models\Conversation;
use App\Services\Messaging\MessagingPresenceService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class PresenceController extends Controller
{
public function __construct(
private readonly MessagingPresenceService $presence,
) {}
public function heartbeat(Request $request): JsonResponse
{
$conversationId = $request->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,
]);
}
}

View File

@@ -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),
];
}

View File

@@ -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,
];

View File

@@ -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(),
];

View File

@@ -17,4 +17,4 @@ class ManageConversationParticipantRequest extends FormRequest
'user_id' => 'required|integer|exists:users,id',
];
}
}
}

View File

@@ -17,4 +17,4 @@ class RenameConversationRequest extends FormRequest
'title' => 'required|string|max:120',
];
}
}
}

View File

@@ -23,4 +23,4 @@ class StoreConversationRequest extends FormRequest
'client_temp_id' => 'nullable|string|max:120',
];
}
}
}

View File

@@ -21,4 +21,4 @@ class StoreMessageRequest extends FormRequest
'reply_to_message_id' => 'nullable|integer|exists:messages,id',
];
}
}
}

View File

@@ -17,4 +17,4 @@ class ToggleMessageReactionRequest extends FormRequest
'reaction' => 'required|string|max:32',
];
}
}
}

View File

@@ -17,4 +17,4 @@ class UpdateMessageRequest extends FormRequest
'body' => 'required|string|max:5000',
];
}
}
}

View File

@@ -1,6 +1,7 @@
<?php
namespace App\Http\Resources;
use App\Services\ContentSanitizer;
use App\Services\ThumbnailPresenter;
use Illuminate\Http\Resources\Json\JsonResource;
use Illuminate\Support\Facades\DB;
@@ -100,6 +101,7 @@ class ArtworkResource extends JsonResource
'slug' => (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';
}
}

View File

@@ -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;

View File

@@ -31,4 +31,4 @@ class MessageRead extends Model
{
return $this->belongsTo(User::class);
}
}
}

View File

@@ -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",
];
}
}

View File

@@ -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",
];
}
}

View File

@@ -44,4 +44,4 @@ class ConversationPolicy
->whereNull('left_at')
->first();
}
}
}

View File

@@ -26,4 +26,4 @@ class MessagePolicy
{
return $message->sender_id === $user->id || $user->isAdmin();
}
}
}

View File

@@ -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

View File

@@ -0,0 +1,34 @@
<?php
namespace App\Providers;
use Illuminate\Support\Facades\Gate;
use Laravel\Horizon\HorizonApplicationServiceProvider;
class HorizonServiceProvider extends HorizonApplicationServiceProvider
{
/**
* Bootstrap any application services.
*/
public function boot(): void
{
parent::boot();
// Horizon::routeSmsNotificationsTo('15556667777');
// Horizon::routeMailNotificationsTo('example@example.com');
// Horizon::routeSlackNotificationsTo('slack-webhook-url', '#channel');
}
/**
* Register the Horizon gate.
*
* This gate determines who can access Horizon in non-local environments.
*/
protected function gate(): void
{
Gate::define('viewHorizon', function ($user = null) {
return app()->environment('local')
|| (is_object($user) && method_exists($user, 'isAdmin') && $user->isAdmin());
});
}
}

View File

@@ -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']);

View File

@@ -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);
}
}
}

View File

@@ -0,0 +1,31 @@
<?php
namespace App\Services\Messaging;
use App\Models\Conversation;
use App\Models\Message;
use App\Models\User;
use Illuminate\Support\Collection;
class ConversationDeltaService
{
public function __construct(
private readonly MessagingPayloadFactory $payloadFactory,
) {}
public function messagesAfter(Conversation $conversation, User $viewer, int $afterMessageId, ?int $limit = null): Collection
{
$maxMessages = max(1, (int) config('messaging.recovery.max_messages', 100));
$effectiveLimit = min($limit ?? $maxMessages, $maxMessages);
return Message::withTrashed()
->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();
}
}

View File

@@ -0,0 +1,76 @@
<?php
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\DB;
class ConversationReadService
{
public function __construct(
private readonly ConversationStateService $conversationState,
) {}
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->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']);
}
}

View File

@@ -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']);
}
}
}

View File

@@ -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()

View File

@@ -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;
}
}
}

View File

@@ -0,0 +1,69 @@
<?php
namespace App\Services\Messaging;
use App\Models\User;
use Illuminate\Cache\Repository;
use Illuminate\Support\Facades\Cache;
class MessagingPresenceService
{
public function touch(User|int $user, ?int $conversationId = null): void
{
$userId = $user instanceof User ? (int) $user->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();
}
}
}

View File

@@ -123,4 +123,4 @@ class SendMessageAction
'created_at' => now(),
]);
}
}
}

View File

@@ -0,0 +1,81 @@
<?php
namespace App\Services\Messaging;
use App\Models\Conversation;
use App\Models\ConversationParticipant;
use App\Models\Message;
use App\Models\User;
use Illuminate\Database\Eloquent\Builder;
class UnreadCounterService
{
public function applyUnreadCountSelect(Builder $query, User|int $user, string $participantAlias = 'cp_me'): Builder
{
$userId = $user instanceof User ? (int) $user->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));
}
}

View File

@@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace App\Services\Vision;
use App\Models\Artwork;
use App\Services\ThumbnailService;
final class ArtworkVisionImageUrl
{
public function fromArtwork(Artwork $artwork): ?string
{
return $this->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);
}
}

View File

@@ -0,0 +1,213 @@
<?php
declare(strict_types=1);
namespace App\Services\Vision;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Http\Client\Response;
use Illuminate\Support\Facades\Http;
use RuntimeException;
final class VectorGatewayClient
{
public function isConfigured(): bool
{
return (bool) config('vision.vector_gateway.enabled', true)
&& $this->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<array{id: int|string, score: float, metadata: array<string, mixed>}>
*/
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<string, mixed> $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<array{id: int|string, score: float, metadata: array<string, mixed>}>
*/
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<mixed> $json
* @return array<int, mixed>
*/
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 : [];
}
}