Compare commits
19
Commits
58e5b3f648
...
develop
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
69edffdfee | ||
|
|
fe1054aa32 | ||
|
|
a206981914 | ||
|
|
02861b5a2f | ||
|
|
1b8853b0c9 | ||
|
|
12da3c5081 | ||
|
|
0b6f3a95e3 | ||
|
|
5d703b8da4 | ||
|
|
6b00652a20 | ||
|
|
ce0f278bac | ||
|
|
04a60a981e | ||
|
|
822b96e92a | ||
|
|
a5e51617a6 | ||
|
|
1053a38eee | ||
|
|
04b8160d9e | ||
|
|
0f52803b05 | ||
|
|
31c87e4977 | ||
|
|
586c44ba74 | ||
|
|
37bacc1334 |
@@ -4,6 +4,9 @@ APP_KEY=
|
||||
APP_DEBUG=true
|
||||
APP_URL=http://localhost
|
||||
|
||||
# Maximum secondary artwork categories in addition to the primary category.
|
||||
CATEGORIES_MAX_SECONDARY=5
|
||||
|
||||
# Artwork original downloads: PHP fallback unless nginx X-Accel is configured.
|
||||
# Leave false until the internal location exists and has been verified.
|
||||
DOWNLOAD_ACCEL_ENABLED=false
|
||||
@@ -71,6 +74,26 @@ SKINBASE_SESSION_DEBUG_HEADER=false
|
||||
BROADCAST_CONNECTION=reverb
|
||||
FILESYSTEM_DISK=local
|
||||
QUEUE_CONNECTION=redis
|
||||
|
||||
# Comment spam protection: observe records scores without changing visibility.
|
||||
COMMENT_SPAM_ENABLED=true
|
||||
COMMENT_SPAM_MODE=enforce
|
||||
COMMENT_SPAM_AI_ENABLED=true
|
||||
COMMENT_SPAM_AI_PROVIDER=together
|
||||
# Supported: Prism-ML/Ternary-Bonsai-27B or meta-llama/Llama-3.2-3B-Instruct-Turbo
|
||||
COMMENT_SPAM_AI_MODEL=meta-llama/Llama-3.2-3B-Instruct-Turbo
|
||||
COMMENT_SPAM_AI_TIMEOUT=5
|
||||
TOGETHER_API_KEY=
|
||||
TOGETHER_API_BASE_URL=https://api.together.xyz/v1
|
||||
COMMENT_SPAM_LOCAL_SAFE_MAX=29
|
||||
COMMENT_SPAM_LOCAL_SPAM_MIN=70
|
||||
COMMENT_SPAM_AI_SPAM_MIN=70
|
||||
COMMENT_SPAM_SIGNATURE_CACHE_MINUTES=1440
|
||||
COMMENT_TURNSTILE_ENABLED=false
|
||||
COMMENT_TURNSTILE_SITE_KEY=
|
||||
COMMENT_TURNSTILE_SECRET_KEY=
|
||||
COMMENT_TURNSTILE_RISK_THRESHOLD=40
|
||||
COMMENT_TURNSTILE_FAIL_OPEN=false
|
||||
# Must exceed Horizon supervisor-default timeout (960s). Production used 90s
|
||||
# and nightly RecComputeSimilar* jobs failed with MaxAttemptsExceeded.
|
||||
REDIS_QUEUE_RETRY_AFTER=1080
|
||||
@@ -133,6 +156,13 @@ UPLOAD_CHUNK_REQUEST_TIMEOUT_MS=45000
|
||||
UPLOAD_RATE_CHUNK_USER=180
|
||||
UPLOAD_RATE_CHUNK_IP=360
|
||||
|
||||
# New accounts without this many moderator/legacy published artworks go to review.
|
||||
# Queued uploads and trusted_auto publishes do not count, so a new user's 6th upload stays hidden.
|
||||
UPLOAD_MIN_APPROVED_UPLOADS=5
|
||||
UPLOAD_MIN_ACCOUNT_AGE_DAYS=7
|
||||
UPLOAD_MAX_UNTRUSTED_LEVEL=1
|
||||
UPLOAD_BOT_RISK_REVIEW_THRESHOLD=40
|
||||
|
||||
# Draft abuse prevention controls
|
||||
SKINBASE_MAX_DRAFTS=10
|
||||
SKINBASE_MAX_DRAFT_STORAGE_MB=1024
|
||||
@@ -445,6 +475,13 @@ GOOGLE_CLIENT_ID=
|
||||
GOOGLE_CLIENT_SECRET=
|
||||
GOOGLE_REDIRECT_URI=/auth/google/callback
|
||||
|
||||
# Google AdSense Management API (admin analytics, read-only)
|
||||
# Redirect URI must match the Google Cloud OAuth client exactly.
|
||||
ADSENSE_CLIENT_ID=
|
||||
ADSENSE_CLIENT_SECRET=
|
||||
ADSENSE_REDIRECT_URI=https://skinbase.org/admin/adsense/oauth/callback
|
||||
ADSENSE_SYNC_ENABLED=true
|
||||
|
||||
# Optional light theme feature
|
||||
# Set LIGHT_THEME_ENABLED=true to allow a light theme in the client-side toggle.
|
||||
# Set LIGHT_THEME_SHOW_SWITCH=true to display the theme switch in the toolbar.
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\AdsenseConnection;
|
||||
use App\Services\Adsense\AdsenseSyncResult;
|
||||
use App\Services\Adsense\AdsenseSyncService;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Carbon;
|
||||
|
||||
final class AdsenseSyncCommand extends Command
|
||||
{
|
||||
protected $signature = 'adsense:sync
|
||||
{--days= : Number of trailing days to synchronize, including today}
|
||||
{--from= : Inclusive start date (YYYY-MM-DD)}
|
||||
{--to= : Inclusive end date (YYYY-MM-DD)}
|
||||
{--entities-only : Synchronize ad units and custom channels without reports}
|
||||
{--force : Run even when ADSENSE_SYNC_ENABLED is false}';
|
||||
|
||||
protected $description = 'Synchronize Google AdSense entities and daily reporting into Skinbase';
|
||||
|
||||
public function handle(AdsenseSyncService $sync): int
|
||||
{
|
||||
if (! (bool) config('adsense.sync_enabled', true) && ! $this->option('force')) {
|
||||
$this->warn('AdSense synchronization is disabled. Use --force to run anyway.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
try {
|
||||
[$from, $to] = $this->resolveRange();
|
||||
} catch (\InvalidArgumentException $exception) {
|
||||
$this->error($exception->getMessage());
|
||||
|
||||
return self::INVALID;
|
||||
}
|
||||
|
||||
$connection = AdsenseConnection::current();
|
||||
if ($connection === null || ! $connection->hasRefreshToken()) {
|
||||
$this->error('Google AdSense is not connected.');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
if ($connection->status === AdsenseConnection::STATUS_RECONNECT_REQUIRED) {
|
||||
$this->error('AdSense authorization expired or was revoked. Reconnect Google AdSense.');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
if ((string) $connection->account_resource_name === '') {
|
||||
$this->error('No AdSense account is selected.');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$entitiesOnly = (bool) $this->option('entities-only');
|
||||
$result = $sync->sync($connection, $from, $to, $entitiesOnly);
|
||||
|
||||
$this->renderResult($result, $entitiesOnly);
|
||||
|
||||
if ($result->reconnectRequired) {
|
||||
$this->error('AdSense authorization expired or was revoked. Reconnect Google AdSense.');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
if ($result->failedReports !== []) {
|
||||
$this->warn('Synchronization completed with partial report failures: '.implode(', ', $result->failedReports));
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$this->info('Synchronization completed successfully.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{0: Carbon, 1: Carbon}
|
||||
*/
|
||||
private function resolveRange(): array
|
||||
{
|
||||
$days = $this->option('days');
|
||||
$from = $this->option('from');
|
||||
$to = $this->option('to');
|
||||
|
||||
if ($days !== null && ($from !== null || $to !== null)) {
|
||||
throw new \InvalidArgumentException('The --days option cannot be combined with --from or --to.');
|
||||
}
|
||||
|
||||
if (($from !== null) !== ($to !== null)) {
|
||||
throw new \InvalidArgumentException('Both --from and --to are required for a custom range.');
|
||||
}
|
||||
|
||||
if (is_string($from) && is_string($to)) {
|
||||
$start = Carbon::parse($from)->startOfDay();
|
||||
$end = Carbon::parse($to)->startOfDay();
|
||||
if ($end->lt($start)) {
|
||||
throw new \InvalidArgumentException('The --to date must be on or after --from.');
|
||||
}
|
||||
|
||||
return [$start, $end];
|
||||
}
|
||||
|
||||
$trailingDays = $days !== null ? max(1, (int) $days) : 3;
|
||||
$end = now()->startOfDay();
|
||||
$start = $end->copy()->subDays($trailingDays - 1);
|
||||
|
||||
return [$start, $end];
|
||||
}
|
||||
|
||||
private function renderResult(AdsenseSyncResult $result, bool $entitiesOnly): void
|
||||
{
|
||||
$this->line('AdSense account: '.($result->accountDisplayName !== '' ? $result->accountDisplayName : 'unknown'));
|
||||
$this->line('Range: '.$result->from.' -> '.$result->to);
|
||||
$this->newLine();
|
||||
$this->line('Entities:');
|
||||
$this->line(' Ad units: '.$result->adUnits);
|
||||
$this->line(' Custom channels: '.$result->customChannels);
|
||||
|
||||
if ($entitiesOnly) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->newLine();
|
||||
$this->line('Reports:');
|
||||
$this->line(' Total: '.$result->totalRows);
|
||||
$this->line(' Ad units: '.$result->adUnitRows);
|
||||
$this->line(' Custom channels: '.$result->customChannelRows);
|
||||
$this->line(' Platform: '.$result->platformRows);
|
||||
$this->line(' Countries: '.$result->countryRows);
|
||||
$this->newLine();
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,9 @@ final class AuditArtworkDownloadFilesCommand extends Command
|
||||
{--id= : Audit only this artwork ID}
|
||||
{--limit= : Stop after processing this many artworks}
|
||||
{--chunk=500 : Number of artworks to scan per batch}
|
||||
{--status= : Only inspect records with this persisted download status}
|
||||
{--missing-only : Only report unresolved records}
|
||||
{--mark-status : Persist the resolved availability status (explicit write)}
|
||||
{--restore-missing : Copy missing local originals from object storage when available}';
|
||||
|
||||
protected $description = 'Scan artworks in descending ID order and report missing local download files with full URLs.';
|
||||
@@ -28,6 +31,8 @@ final class AuditArtworkDownloadFilesCommand extends Command
|
||||
$limit = $this->option('limit') !== null ? max(1, (int) $this->option('limit')) : null;
|
||||
$chunkSize = max(1, min((int) $this->option('chunk'), 2000));
|
||||
$restoreMissing = (bool) $this->option('restore-missing');
|
||||
$markStatus = (bool) $this->option('mark-status');
|
||||
$statusFilter = $this->option('status');
|
||||
|
||||
$this->info(sprintf(
|
||||
'Starting download file audit. order=desc include_trashed=yes chunk=%d limit=%s restore_missing=%s',
|
||||
@@ -41,10 +46,11 @@ final class AuditArtworkDownloadFilesCommand extends Command
|
||||
$unresolved = 0;
|
||||
$restored = 0;
|
||||
$restoreFailed = 0;
|
||||
$classifications = [];
|
||||
$lastSeenId = null;
|
||||
|
||||
do {
|
||||
$artworks = $this->nextChunk($artworkId, $chunkSize, $lastSeenId);
|
||||
$artworks = $this->nextChunk($artworkId, $chunkSize, $lastSeenId, is_string($statusFilter) ? $statusFilter : null);
|
||||
if ($artworks->isEmpty()) {
|
||||
break;
|
||||
}
|
||||
@@ -54,41 +60,59 @@ final class AuditArtworkDownloadFilesCommand extends Command
|
||||
break 2;
|
||||
}
|
||||
|
||||
$localPath = $locator->resolveLocalPath($artwork);
|
||||
$missingReason = null;
|
||||
|
||||
if ($localPath === '') {
|
||||
$missingReason = 'unresolved_local_path';
|
||||
$resolution = $locator->resolve($artwork);
|
||||
$classification = match ($resolution['status']) {
|
||||
'available' => strtoupper((string) $resolution['source']).'_OK',
|
||||
'pending' => 'PENDING',
|
||||
'unknown' => 'AMBIGUOUS',
|
||||
default => 'MISSING_EVERYWHERE',
|
||||
};
|
||||
$classifications[$classification] = ($classifications[$classification] ?? 0) + 1;
|
||||
$localPath = (string) $resolution['path'];
|
||||
$missingReason = $resolution['status'] === 'missing' ? 'missing_everywhere' : null;
|
||||
if ($missingReason !== null) {
|
||||
$unresolved++;
|
||||
} elseif (! File::isFile($localPath)) {
|
||||
$missingReason = 'missing_local_file';
|
||||
}
|
||||
|
||||
if ($markStatus) {
|
||||
$this->persistStatus($artwork, $resolution);
|
||||
}
|
||||
|
||||
if ($missingReason === null && (bool) $this->option('missing-only')) {
|
||||
$processed++;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($missingReason === null) {
|
||||
$this->line(sprintf('Artwork %d %s source=%s', (int) $artwork->id, $classification, (string) $resolution['source']));
|
||||
}
|
||||
|
||||
if ($missingReason !== null) {
|
||||
$objectPath = $locator->resolveObjectPath($artwork);
|
||||
$objectPath = (string) $resolution['object_key'];
|
||||
$objectUrl = $locator->resolveObjectUrl($artwork);
|
||||
|
||||
$missing++;
|
||||
$this->warn(sprintf('Artwork %d %s', (int) $artwork->id, $missingReason));
|
||||
$this->line(' artwork_url: ' . route('art.show', [
|
||||
$this->line(' artwork_url: '.route('art.show', [
|
||||
'id' => (int) $artwork->id,
|
||||
'slug' => (string) ($artwork->slug ?? ''),
|
||||
]));
|
||||
$this->line(' download_url: ' . route('art.download', ['id' => (int) $artwork->id]));
|
||||
$this->line(' download_url: '.route('art.download', ['id' => (int) $artwork->id]));
|
||||
|
||||
if ($objectPath !== '') {
|
||||
$this->line(' object_path: ' . $objectPath);
|
||||
$this->line(' object_path: '.$objectPath);
|
||||
}
|
||||
|
||||
if ($objectUrl !== null && $objectUrl !== '') {
|
||||
$this->line(' object_url: ' . $objectUrl);
|
||||
$this->line(' object_url: '.$objectUrl);
|
||||
}
|
||||
|
||||
if ($localPath !== '') {
|
||||
$this->line(' local_path: ' . $localPath);
|
||||
$this->line(' local_path: '.$localPath);
|
||||
}
|
||||
|
||||
if ($restoreMissing && $missingReason === 'missing_local_file' && $localPath !== '') {
|
||||
if ($restoreMissing && $missingReason === 'missing_everywhere' && $localPath !== '') {
|
||||
$restoreResult = $this->restoreLocalFile($storage, $objectPath, $localPath);
|
||||
|
||||
if ($restoreResult === 'restored') {
|
||||
@@ -120,6 +144,9 @@ final class AuditArtworkDownloadFilesCommand extends Command
|
||||
$restored,
|
||||
$restoreFailed,
|
||||
));
|
||||
foreach ($classifications as $classification => $count) {
|
||||
$this->line(sprintf('classification=%s count=%d', $classification, $count));
|
||||
}
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
@@ -127,11 +154,15 @@ final class AuditArtworkDownloadFilesCommand extends Command
|
||||
/**
|
||||
* @return Collection<int, Artwork>
|
||||
*/
|
||||
private function nextChunk(?int $artworkId, int $chunkSize, ?int $lastSeenId): Collection
|
||||
private function nextChunk(?int $artworkId, int $chunkSize, ?int $lastSeenId, ?string $status): Collection
|
||||
{
|
||||
if ($artworkId !== null && $lastSeenId !== null) {
|
||||
return collect();
|
||||
}
|
||||
|
||||
$query = Artwork::query()
|
||||
->withTrashed()
|
||||
->select(['id', 'slug', 'file_path', 'hash', 'file_ext'])
|
||||
->select(['id', 'slug', 'file_name', 'file_path', 'hash', 'file_ext'])
|
||||
->orderByDesc('id');
|
||||
|
||||
if ($artworkId !== null) {
|
||||
@@ -140,9 +171,30 @@ final class AuditArtworkDownloadFilesCommand extends Command
|
||||
$query->where('id', '<', $lastSeenId);
|
||||
}
|
||||
|
||||
if ($status !== null && $status !== '') {
|
||||
$query->where('download_status', $status);
|
||||
}
|
||||
|
||||
return $query->limit($chunkSize)->get();
|
||||
}
|
||||
|
||||
/** @param array{status:string,source:?string} $resolution */
|
||||
private function persistStatus(Artwork $artwork, array $resolution): void
|
||||
{
|
||||
$status = match ($resolution['status']) {
|
||||
'available' => 'available',
|
||||
'pending' => 'pending',
|
||||
'unknown' => 'unknown',
|
||||
default => 'missing',
|
||||
};
|
||||
|
||||
$artwork->forceFill([
|
||||
'download_status' => $status,
|
||||
'download_source' => $resolution['source'],
|
||||
'download_checked_at' => now(),
|
||||
])->saveQuietly();
|
||||
}
|
||||
|
||||
private function restoreLocalFile(UploadStorageService $storage, string $objectPath, string $localPath): string
|
||||
{
|
||||
if ($objectPath === '') {
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\Artwork;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
final class BackfillArtworkPrimaryCategoryCommand extends Command
|
||||
{
|
||||
protected $signature = 'artworks:backfill-primary-category
|
||||
{--dry-run : Report without writing (default unless --apply is passed)}
|
||||
{--report : Print category-count distribution and conflicts}
|
||||
{--apply : Write primary_category_id for artworks with exactly one category}
|
||||
{--force : Overwrite existing primary_category_id values}';
|
||||
|
||||
protected $description = 'Backfill artworks.primary_category_id from unique artwork_category memberships';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$apply = (bool) $this->option('apply');
|
||||
$force = (bool) $this->option('force');
|
||||
$report = (bool) $this->option('report') || ! $apply;
|
||||
|
||||
if ($report) {
|
||||
$this->printDistribution();
|
||||
}
|
||||
|
||||
$conflicts = $this->conflictArtworkIds();
|
||||
$zeros = $this->zeroCategoryArtworkIds();
|
||||
$eligible = $this->eligibleArtworkIds($force);
|
||||
|
||||
$this->info('Zero-category artworks: '.$zeros->count());
|
||||
$this->info('Single-category eligible for backfill: '.$eligible->count());
|
||||
$this->info('Multi-category conflicts (not auto-resolved): '.$conflicts->count());
|
||||
|
||||
if ($conflicts->isNotEmpty()) {
|
||||
$this->warn('Multi-category artwork IDs (first 50): '.$conflicts->take(50)->implode(', '));
|
||||
}
|
||||
|
||||
if (! $apply) {
|
||||
$this->comment('Dry run only. Re-run with --apply to write primary_category_id for single-category artworks.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$updated = 0;
|
||||
foreach ($eligible->chunk(500) as $chunk) {
|
||||
$rows = DB::table('artwork_category')
|
||||
->select('artwork_id', 'category_id')
|
||||
->whereIn('artwork_id', $chunk->all())
|
||||
->get()
|
||||
->groupBy('artwork_id');
|
||||
|
||||
foreach ($rows as $artworkId => $memberships) {
|
||||
if ($memberships->count() !== 1) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$query = Artwork::query()->where('id', (int) $artworkId);
|
||||
if (! $force) {
|
||||
$query->whereNull('primary_category_id');
|
||||
}
|
||||
|
||||
$updated += $query->update([
|
||||
'primary_category_id' => (int) $memberships->first()->category_id,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
$this->info("Updated {$updated} artwork(s).");
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
private function printDistribution(): void
|
||||
{
|
||||
$distribution = DB::query()
|
||||
->fromSub(function ($query): void {
|
||||
$query->from('artwork_category')
|
||||
->select('artwork_id', DB::raw('COUNT(*) as category_count'))
|
||||
->groupBy('artwork_id');
|
||||
}, 'x')
|
||||
->select('category_count', DB::raw('COUNT(*) as artworks'))
|
||||
->groupBy('category_count')
|
||||
->orderBy('category_count')
|
||||
->get();
|
||||
|
||||
$this->table(['category_count', 'artworks'], $distribution->map(fn ($row): array => [
|
||||
(int) $row->category_count,
|
||||
(int) $row->artworks,
|
||||
])->all());
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Collection<int, int>
|
||||
*/
|
||||
private function eligibleArtworkIds(bool $force)
|
||||
{
|
||||
$query = DB::table('artwork_category')
|
||||
->select('artwork_id')
|
||||
->groupBy('artwork_id')
|
||||
->havingRaw('COUNT(*) = 1');
|
||||
|
||||
if (! $force) {
|
||||
$query->whereIn('artwork_id', Artwork::query()->whereNull('primary_category_id')->select('id'));
|
||||
}
|
||||
|
||||
return $query->pluck('artwork_id')->map(fn ($id): int => (int) $id);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Collection<int, int>
|
||||
*/
|
||||
private function conflictArtworkIds()
|
||||
{
|
||||
return DB::table('artwork_category')
|
||||
->select('artwork_id')
|
||||
->groupBy('artwork_id')
|
||||
->havingRaw('COUNT(*) > 1')
|
||||
->orderByRaw('COUNT(*) DESC')
|
||||
->pluck('artwork_id')
|
||||
->map(fn ($id): int => (int) $id);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Collection<int, int>
|
||||
*/
|
||||
private function zeroCategoryArtworkIds()
|
||||
{
|
||||
return Artwork::query()
|
||||
->whereDoesntHave('categories')
|
||||
->pluck('id')
|
||||
->map(fn ($id): int => (int) $id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
|
||||
namespace App\Contracts\Moderation;
|
||||
|
||||
use App\Data\Moderation\CommentSpamClassification;
|
||||
|
||||
interface CommentSpamClassifier
|
||||
{
|
||||
public function classify(string $content): CommentSpamClassification;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
|
||||
namespace App\Data\Moderation;
|
||||
|
||||
final class CommentSpamClassification
|
||||
{
|
||||
public function __construct(
|
||||
public readonly bool $spam,
|
||||
public readonly float $confidence,
|
||||
public readonly string $reason,
|
||||
public readonly string $provider,
|
||||
public readonly string $model,
|
||||
public readonly int $latencyMs,
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Jobs\SyncAdsenseJob;
|
||||
use App\Models\AdsenseConnection;
|
||||
use App\Services\Adsense\AdsenseAnalyticsQueryService;
|
||||
use App\Services\Adsense\AdsenseApiClient;
|
||||
use App\Services\Adsense\AdsenseLogSanitizer;
|
||||
use App\Services\Adsense\AdsenseOAuthService;
|
||||
use App\Services\Adsense\Exceptions\AdsenseApiException;
|
||||
use App\Services\Adsense\Exceptions\AdsenseAuthorizationException;
|
||||
use App\Services\Adsense\Exceptions\AdsenseOAuthException;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
final class AdsenseAnalyticsController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly AdsenseOAuthService $oauth,
|
||||
private readonly AdsenseApiClient $api,
|
||||
private readonly AdsenseAnalyticsQueryService $query,
|
||||
) {}
|
||||
|
||||
public function index(Request $request): Response
|
||||
{
|
||||
$connection = AdsenseConnection::current();
|
||||
$range = $this->query->resolveRange(
|
||||
$request->query('period'),
|
||||
$request->string('from')->toString() ?: null,
|
||||
$request->string('to')->toString() ?: null,
|
||||
);
|
||||
|
||||
$dashboard = null;
|
||||
if ($connection !== null && $connection->isConnected() && filled($connection->account_resource_name)) {
|
||||
$dashboard = $this->query->dashboard(
|
||||
$connection,
|
||||
$range['from'],
|
||||
$range['to'],
|
||||
$range['previous_from'],
|
||||
$range['previous_to'],
|
||||
);
|
||||
}
|
||||
|
||||
return Inertia::render('Admin/Adsense/Index', [
|
||||
'connection' => $this->connectionPayload($connection),
|
||||
'configured' => $this->oauth->isConfigured(),
|
||||
'pendingAccounts' => $request->session()->get(AdsenseOAuthService::SESSION_PENDING_ACCOUNTS_KEY, []),
|
||||
'recommendedPlacements' => array_values((array) config('adsense.recommended_placement_names', [])),
|
||||
'range' => [
|
||||
'period' => $range['period'],
|
||||
'from' => $range['from']->toDateString(),
|
||||
'to' => $range['to']->toDateString(),
|
||||
'previous_from' => $range['previous_from']->toDateString(),
|
||||
'previous_to' => $range['previous_to']->toDateString(),
|
||||
],
|
||||
'dashboard' => $dashboard,
|
||||
'routes' => [
|
||||
'index' => route('admin.adsense.index'),
|
||||
'connect' => route('admin.adsense.connect'),
|
||||
'account' => route('admin.adsense.account'),
|
||||
'sync' => route('admin.adsense.sync'),
|
||||
'disconnect' => route('admin.adsense.disconnect'),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public function connect(Request $request): RedirectResponse
|
||||
{
|
||||
if (! $this->oauth->isConfigured()) {
|
||||
return redirect()
|
||||
->route('admin.adsense.index')
|
||||
->with('error', 'Google AdSense OAuth is not configured.');
|
||||
}
|
||||
|
||||
try {
|
||||
return redirect()->away($this->oauth->authorizationUrl($request, true));
|
||||
} catch (AdsenseOAuthException $exception) {
|
||||
return redirect()
|
||||
->route('admin.adsense.index')
|
||||
->with('error', $exception->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
public function callback(Request $request): RedirectResponse
|
||||
{
|
||||
try {
|
||||
$code = $this->oauth->assertValidCallback($request);
|
||||
$tokens = $this->oauth->exchangeAuthorizationCode($code);
|
||||
$connection = AdsenseConnection::current() ?? new AdsenseConnection;
|
||||
$connection->status = AdsenseConnection::STATUS_PENDING;
|
||||
$this->oauth->persistTokens($connection, $tokens, $request->user()?->id);
|
||||
|
||||
$accounts = $this->api->listAccounts($connection);
|
||||
} catch (AdsenseOAuthException|AdsenseAuthorizationException|AdsenseApiException $exception) {
|
||||
Log::warning('AdSense OAuth callback failed.', AdsenseLogSanitizer::context([
|
||||
'message' => $exception->getMessage(),
|
||||
]));
|
||||
|
||||
return redirect()
|
||||
->route('admin.adsense.index')
|
||||
->with('error', $exception->getMessage());
|
||||
}
|
||||
|
||||
$normalized = collect($accounts)
|
||||
->map(function (array $account): ?array {
|
||||
$name = (string) ($account['name'] ?? '');
|
||||
if ($name === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'resource_name' => $name,
|
||||
'display_name' => (string) ($account['displayName'] ?? $name),
|
||||
];
|
||||
})
|
||||
->filter()
|
||||
->values()
|
||||
->all();
|
||||
|
||||
if ($normalized === []) {
|
||||
$connection->status = AdsenseConnection::STATUS_ERROR;
|
||||
$connection->last_error = 'No Google AdSense accounts were available for this Google account.';
|
||||
$connection->save();
|
||||
$request->session()->forget(AdsenseOAuthService::SESSION_PENDING_ACCOUNTS_KEY);
|
||||
|
||||
return redirect()
|
||||
->route('admin.adsense.index')
|
||||
->with('error', $connection->last_error);
|
||||
}
|
||||
|
||||
if (count($normalized) === 1) {
|
||||
$this->assignAccount($connection, $normalized[0]['resource_name'], $normalized[0]['display_name']);
|
||||
$this->queueAdsenseSync($connection, 30);
|
||||
$request->session()->forget(AdsenseOAuthService::SESSION_PENDING_ACCOUNTS_KEY);
|
||||
|
||||
return redirect()
|
||||
->route('admin.adsense.index')
|
||||
->with('success', 'Google AdSense connected. Initial synchronization has started.');
|
||||
}
|
||||
|
||||
$request->session()->put(AdsenseOAuthService::SESSION_PENDING_ACCOUNTS_KEY, $normalized);
|
||||
$connection->status = AdsenseConnection::STATUS_PENDING;
|
||||
$connection->save();
|
||||
|
||||
return redirect()
|
||||
->route('admin.adsense.index')
|
||||
->with('warning', 'Select the Google AdSense account to use for Skinbase analytics.');
|
||||
}
|
||||
|
||||
public function selectAccount(Request $request): RedirectResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'account_resource_name' => ['required', 'string', 'max:128'],
|
||||
]);
|
||||
|
||||
$connection = AdsenseConnection::current();
|
||||
if ($connection === null || ! $connection->hasRefreshToken()) {
|
||||
return redirect()
|
||||
->route('admin.adsense.index')
|
||||
->with('error', 'Connect Google AdSense before selecting an account.');
|
||||
}
|
||||
|
||||
$pending = collect($request->session()->get(AdsenseOAuthService::SESSION_PENDING_ACCOUNTS_KEY, []));
|
||||
$selected = $pending->firstWhere('resource_name', $validated['account_resource_name']);
|
||||
|
||||
if (! is_array($selected)) {
|
||||
return redirect()
|
||||
->route('admin.adsense.index')
|
||||
->with('error', 'The selected AdSense account is not available.');
|
||||
}
|
||||
|
||||
$this->assignAccount(
|
||||
$connection,
|
||||
(string) $selected['resource_name'],
|
||||
(string) ($selected['display_name'] ?? $selected['resource_name']),
|
||||
);
|
||||
$this->queueAdsenseSync($connection, 30);
|
||||
$request->session()->forget(AdsenseOAuthService::SESSION_PENDING_ACCOUNTS_KEY);
|
||||
|
||||
return redirect()
|
||||
->route('admin.adsense.index')
|
||||
->with('success', 'AdSense account selected. Initial synchronization has started.');
|
||||
}
|
||||
|
||||
public function sync(Request $request): RedirectResponse
|
||||
{
|
||||
$connection = AdsenseConnection::current();
|
||||
if ($connection === null || ! $connection->isConnected()) {
|
||||
return redirect()
|
||||
->route('admin.adsense.index')
|
||||
->with('error', $connection?->needsReconnect()
|
||||
? 'AdSense authorization expired or was revoked. Reconnect Google AdSense.'
|
||||
: 'Google AdSense is not connected.');
|
||||
}
|
||||
|
||||
$this->queueAdsenseSync($connection, 3);
|
||||
|
||||
return redirect()
|
||||
->route('admin.adsense.index')
|
||||
->with('success', 'AdSense synchronization has been queued.');
|
||||
}
|
||||
|
||||
public function disconnect(): RedirectResponse
|
||||
{
|
||||
$connection = AdsenseConnection::current();
|
||||
if ($connection !== null) {
|
||||
$this->oauth->disconnect($connection);
|
||||
}
|
||||
|
||||
return redirect()
|
||||
->route('admin.adsense.index')
|
||||
->with('success', 'Google AdSense has been disconnected. Historical statistics were kept.');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
private function connectionPayload(?AdsenseConnection $connection): ?array
|
||||
{
|
||||
if ($connection === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'status' => $connection->status,
|
||||
'account_resource_name' => $connection->account_resource_name,
|
||||
'account_display_name' => $connection->account_display_name,
|
||||
'connected_at' => optional($connection->connected_at)?->toIso8601String(),
|
||||
'last_sync_attempt_at' => optional($connection->last_sync_attempt_at)?->toIso8601String(),
|
||||
'last_successful_sync_at' => optional($connection->last_successful_sync_at)?->toIso8601String(),
|
||||
'last_error' => $connection->last_error,
|
||||
'needs_reconnect' => $connection->needsReconnect(),
|
||||
];
|
||||
}
|
||||
|
||||
private function assignAccount(AdsenseConnection $connection, string $resourceName, string $displayName): void
|
||||
{
|
||||
$connection->account_resource_name = $resourceName;
|
||||
$connection->account_display_name = $displayName;
|
||||
$connection->status = AdsenseConnection::STATUS_CONNECTED;
|
||||
$connection->last_error = null;
|
||||
if ($connection->connected_at === null) {
|
||||
$connection->connected_at = now();
|
||||
}
|
||||
$connection->save();
|
||||
}
|
||||
|
||||
private function queueAdsenseSync(AdsenseConnection $connection, int $days): void
|
||||
{
|
||||
$to = now()->startOfDay();
|
||||
$from = $to->copy()->subDays(max(1, $days) - 1);
|
||||
|
||||
SyncAdsenseJob::dispatch(
|
||||
(int) $connection->id,
|
||||
$from->toDateString(),
|
||||
$to->toDateString(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Jobs\IndexArtworkJob;
|
||||
use App\Models\Artwork;
|
||||
use App\Services\NotificationService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
final class ArtworkModerationController extends Controller
|
||||
{
|
||||
public function pending(): JsonResponse
|
||||
{
|
||||
$artworks = Artwork::query()
|
||||
->with([
|
||||
'user:id,name,username,level,email',
|
||||
'tags:id,name,slug',
|
||||
'categories:id,name,slug',
|
||||
])
|
||||
->where('artwork_status', 'review')
|
||||
->where('is_approved', false)
|
||||
->latest('created_at')
|
||||
->limit(100)
|
||||
->get([
|
||||
'id', 'user_id', 'title', 'description', 'hash', 'thumb_ext',
|
||||
'artwork_status', 'moderation_note', 'created_at', 'slug',
|
||||
'is_mature', 'maturity_status', 'visibility',
|
||||
])
|
||||
->map(fn (Artwork $artwork): array => $this->present($artwork))
|
||||
->values();
|
||||
|
||||
return response()->json(['data' => $artworks], Response::HTTP_OK);
|
||||
}
|
||||
|
||||
public function approve(int $id, Request $request, NotificationService $notifications): JsonResponse
|
||||
{
|
||||
$artwork = Artwork::query()->with('user')->where('artwork_status', 'review')->find($id);
|
||||
if (! $artwork) {
|
||||
return response()->json(['message' => 'Artwork not found in review queue.'], Response::HTTP_NOT_FOUND);
|
||||
}
|
||||
|
||||
$artwork->forceFill([
|
||||
'is_approved' => true,
|
||||
'is_public' => $artwork->visibility !== Artwork::VISIBILITY_PRIVATE,
|
||||
'artwork_status' => 'published',
|
||||
'published_at' => now(),
|
||||
'approval_source' => 'moderator',
|
||||
'moderated_at' => now(),
|
||||
'moderated_by' => $request->user()->id,
|
||||
'moderation_note' => $request->input('note'),
|
||||
])->save();
|
||||
|
||||
IndexArtworkJob::dispatch((int) $artwork->id);
|
||||
|
||||
if ($artwork->user) {
|
||||
$notifications->notifyArtworkApproved($artwork->user, $request->user(), $artwork);
|
||||
}
|
||||
|
||||
return response()->json(['success' => true, 'id' => $artwork->id, 'status' => 'published']);
|
||||
}
|
||||
|
||||
public function reject(int $id, Request $request, NotificationService $notifications): JsonResponse
|
||||
{
|
||||
$artwork = Artwork::query()->with('user')->where('artwork_status', 'review')->find($id);
|
||||
if (! $artwork) {
|
||||
return response()->json(['message' => 'Artwork not found in review queue.'], Response::HTTP_NOT_FOUND);
|
||||
}
|
||||
|
||||
$note = (string) $request->input('note', 'Rejected during upload moderation.');
|
||||
|
||||
$artwork->forceFill([
|
||||
'is_approved' => false,
|
||||
'is_public' => false,
|
||||
'artwork_status' => 'rejected',
|
||||
'published_at' => null,
|
||||
'moderated_at' => now(),
|
||||
'moderated_by' => $request->user()->id,
|
||||
'moderation_note' => $note,
|
||||
])->save();
|
||||
|
||||
IndexArtworkJob::dispatch((int) $artwork->id);
|
||||
|
||||
if ($artwork->user) {
|
||||
$notifications->notifyArtworkRejected($artwork->user, $request->user(), $artwork, $note);
|
||||
}
|
||||
|
||||
return response()->json(['success' => true, 'id' => $artwork->id, 'status' => 'rejected']);
|
||||
}
|
||||
|
||||
private function present(Artwork $artwork): array
|
||||
{
|
||||
$previewUrl = $artwork->thumbUrl('md') ?: $artwork->thumbUrl('sm');
|
||||
$previewLgUrl = $artwork->thumbUrl('lg') ?: $previewUrl;
|
||||
|
||||
return [
|
||||
'id' => (int) $artwork->id,
|
||||
'title' => (string) ($artwork->title ?: '(untitled artwork)'),
|
||||
'description' => (string) ($artwork->description ?? ''),
|
||||
'type' => 'artwork',
|
||||
'preview_url' => $previewUrl,
|
||||
'preview_lg_url' => $previewLgUrl,
|
||||
'tags' => $artwork->tags
|
||||
->map(fn ($tag): string => trim((string) ($tag->name ?: $tag->slug)))
|
||||
->filter()
|
||||
->values()
|
||||
->all(),
|
||||
'categories' => $artwork->categories
|
||||
->map(fn ($category): string => trim((string) ($category->name ?: $category->slug)))
|
||||
->filter()
|
||||
->values()
|
||||
->all(),
|
||||
'user' => $artwork->user ? [
|
||||
'id' => (int) $artwork->user->id,
|
||||
'name' => (string) $artwork->user->name,
|
||||
'username' => $artwork->user->username,
|
||||
] : null,
|
||||
'slug' => $artwork->slug,
|
||||
'is_mature' => (bool) $artwork->is_mature,
|
||||
'maturity_status' => $artwork->maturity_status,
|
||||
'visibility' => $artwork->visibility,
|
||||
'moderation_note' => $artwork->moderation_note,
|
||||
'created_at' => optional($artwork->created_at)?->toISOString(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -8,33 +8,51 @@ use App\Http\Controllers\Controller;
|
||||
use App\Models\Upload;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Facades\URL;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||
|
||||
final class UploadModerationController extends Controller
|
||||
{
|
||||
public function pending(): JsonResponse
|
||||
{
|
||||
$uploads = Upload::query()
|
||||
->with(['user:id,name,username', 'category:id,name,slug'])
|
||||
->where('status', 'draft')
|
||||
->where('moderation_status', 'pending')
|
||||
->orderBy('created_at')
|
||||
->get([
|
||||
'id',
|
||||
'user_id',
|
||||
'type',
|
||||
'status',
|
||||
'processing_state',
|
||||
'title',
|
||||
'preview_path',
|
||||
'created_at',
|
||||
'moderation_status',
|
||||
]);
|
||||
->get()
|
||||
->map(fn (Upload $upload): array => $this->present($upload))
|
||||
->values();
|
||||
|
||||
return response()->json([
|
||||
'data' => $uploads,
|
||||
], Response::HTTP_OK);
|
||||
}
|
||||
|
||||
public function preview(string $id): StreamedResponse|JsonResponse
|
||||
{
|
||||
$upload = Upload::query()
|
||||
->where('status', 'draft')
|
||||
->where('moderation_status', 'pending')
|
||||
->find($id);
|
||||
|
||||
if (! $upload) {
|
||||
return response()->json(['message' => 'Upload not found.'], Response::HTTP_NOT_FOUND);
|
||||
}
|
||||
|
||||
$path = $this->safePreviewPath($upload);
|
||||
if ($path === null || ! Storage::disk('local')->exists($path)) {
|
||||
return response()->json(['message' => 'Preview not found.'], Response::HTTP_NOT_FOUND);
|
||||
}
|
||||
|
||||
return Storage::disk('local')->response($path, 'preview.webp', [
|
||||
'Content-Type' => 'image/webp',
|
||||
'Cache-Control' => 'private, max-age=120',
|
||||
]);
|
||||
}
|
||||
|
||||
public function approve(string $id, Request $request): JsonResponse
|
||||
{
|
||||
$upload = Upload::query()->find($id);
|
||||
@@ -80,4 +98,56 @@ final class UploadModerationController extends Controller
|
||||
'moderation_status' => (string) $upload->moderation_status,
|
||||
], Response::HTTP_OK);
|
||||
}
|
||||
|
||||
private function present(Upload $upload): array
|
||||
{
|
||||
$tags = collect($upload->tags ?? [])
|
||||
->map(function (mixed $tag): string {
|
||||
if (is_array($tag)) {
|
||||
return trim((string) ($tag['name'] ?? $tag['slug'] ?? ''));
|
||||
}
|
||||
|
||||
return trim((string) $tag);
|
||||
})
|
||||
->filter()
|
||||
->values()
|
||||
->all();
|
||||
|
||||
$hasPreview = $this->safePreviewPath($upload) !== null;
|
||||
|
||||
return [
|
||||
'id' => (string) $upload->id,
|
||||
'title' => (string) ($upload->title ?: '(untitled upload)'),
|
||||
'description' => (string) ($upload->description ?? ''),
|
||||
'type' => (string) ($upload->type ?? 'image'),
|
||||
'preview_url' => $hasPreview
|
||||
? URL::temporarySignedRoute('api.admin.uploads.preview', now()->addMinutes(30), ['id' => $upload->id])
|
||||
: null,
|
||||
'preview_lg_url' => $hasPreview
|
||||
? URL::temporarySignedRoute('api.admin.uploads.preview', now()->addMinutes(30), ['id' => $upload->id])
|
||||
: null,
|
||||
'tags' => $tags,
|
||||
'categories' => $upload->category?->name ? [(string) $upload->category->name] : [],
|
||||
'user' => $upload->user ? [
|
||||
'id' => (int) $upload->user->id,
|
||||
'name' => (string) $upload->user->name,
|
||||
'username' => $upload->user->username,
|
||||
] : null,
|
||||
'nsfw' => (bool) $upload->nsfw,
|
||||
'license' => $upload->license,
|
||||
'created_at' => optional($upload->created_at)?->toISOString(),
|
||||
];
|
||||
}
|
||||
|
||||
private function safePreviewPath(Upload $upload): ?string
|
||||
{
|
||||
$path = str_replace('\\', '/', ltrim((string) $upload->preview_path, '/'));
|
||||
$expectedPrefix = 'tmp/drafts/'.$upload->id.'/';
|
||||
|
||||
if ($path === '' || str_contains($path, '..') || ! str_starts_with($path, $expectedPrefix)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $path;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,15 +3,17 @@
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Services\Activity\UserActivityService;
|
||||
use App\Models\ActivityEvent;
|
||||
use App\Models\Artwork;
|
||||
use App\Models\ArtworkComment;
|
||||
use App\Models\User;
|
||||
use App\Models\UserMention;
|
||||
use App\Notifications\ArtworkCommentedNotification;
|
||||
use App\Notifications\ArtworkMentionedNotification;
|
||||
use App\Services\ContentSanitizer;
|
||||
use App\Services\Activity\UserActivityService;
|
||||
use App\Services\CommentReactionService;
|
||||
use App\Services\ContentSanitizer;
|
||||
use App\Services\Moderation\CommentSpamService;
|
||||
use App\Support\AvatarUrl;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
@@ -31,7 +33,10 @@ class ArtworkCommentController extends Controller
|
||||
{
|
||||
private const MAX_LENGTH = 10_000;
|
||||
|
||||
public function __construct(private readonly CommentReactionService $commentReactions) {}
|
||||
public function __construct(
|
||||
private readonly CommentReactionService $commentReactions,
|
||||
private readonly CommentSpamService $commentSpam,
|
||||
) {}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// List
|
||||
@@ -41,14 +46,14 @@ class ArtworkCommentController extends Controller
|
||||
{
|
||||
$artwork = Artwork::public()->published()->findOrFail($artworkId);
|
||||
|
||||
$page = max(1, (int) $request->query('page', 1));
|
||||
$perPage = 20;
|
||||
$page = max(1, (int) $request->query('page', 1));
|
||||
$perPage = 20;
|
||||
|
||||
// Only fetch top-level comments (no parent). Replies are recursively eager-loaded.
|
||||
$comments = ArtworkComment::with([
|
||||
'user', 'user.profile',
|
||||
'approvedReplies',
|
||||
])
|
||||
'user', 'user.profile',
|
||||
'approvedReplies',
|
||||
])
|
||||
->where('artwork_id', $artwork->id)
|
||||
->where('is_approved', true)
|
||||
->whereNull('parent_id')
|
||||
@@ -58,15 +63,15 @@ class ArtworkCommentController extends Controller
|
||||
$userId = $request->user()?->id;
|
||||
$commentIds = $this->commentIds($comments->getCollection());
|
||||
$reactionTotals = $this->commentReactions->forComments($commentIds, $userId);
|
||||
$items = $comments->getCollection()->map(fn ($c) => $this->formatComment($c, $userId, true, $reactionTotals));
|
||||
$items = $comments->getCollection()->map(fn ($c) => $this->formatComment($c, $userId, true, $reactionTotals));
|
||||
|
||||
return response()->json([
|
||||
'data' => $items,
|
||||
'meta' => [
|
||||
'current_page' => $comments->currentPage(),
|
||||
'last_page' => $comments->lastPage(),
|
||||
'total' => $comments->total(),
|
||||
'per_page' => $comments->perPage(),
|
||||
'last_page' => $comments->lastPage(),
|
||||
'total' => $comments->total(),
|
||||
'per_page' => $comments->perPage(),
|
||||
],
|
||||
]);
|
||||
}
|
||||
@@ -80,7 +85,7 @@ class ArtworkCommentController extends Controller
|
||||
$artwork = Artwork::public()->published()->findOrFail($artworkId);
|
||||
|
||||
$request->validate([
|
||||
'content' => ['required', 'string', 'min:1', 'max:' . self::MAX_LENGTH],
|
||||
'content' => ['required', 'string', 'min:1', 'max:'.self::MAX_LENGTH],
|
||||
'parent_id' => ['nullable', 'integer', 'exists:artwork_comments,id'],
|
||||
]);
|
||||
|
||||
@@ -109,43 +114,54 @@ class ArtworkCommentController extends Controller
|
||||
$rendered = ContentSanitizer::render($raw);
|
||||
|
||||
$comment = ArtworkComment::create([
|
||||
'artwork_id' => $artwork->id,
|
||||
'user_id' => $request->user()->id,
|
||||
'parent_id' => $parentId,
|
||||
'content' => $raw, // legacy column (plain text fallback)
|
||||
'raw_content' => $raw,
|
||||
'artwork_id' => $artwork->id,
|
||||
'user_id' => $request->user()->id,
|
||||
'parent_id' => $parentId,
|
||||
'content' => $raw, // legacy column (plain text fallback)
|
||||
'raw_content' => $raw,
|
||||
'rendered_content' => $rendered,
|
||||
'is_approved' => true, // auto-approve; extend with moderation as needed
|
||||
'is_approved' => true,
|
||||
]);
|
||||
|
||||
$moderation = $this->commentSpam->moderate($comment, $request->user());
|
||||
|
||||
// Bust the comments cache for this user's 'all' feed
|
||||
Cache::forget('comments.latest.all.page1');
|
||||
|
||||
$comment->load(['user', 'user.profile']);
|
||||
$this->notifyRecipients($artwork, $comment, $request->user(), $parentId ? (int) $parentId : null);
|
||||
if ($comment->is_approved) {
|
||||
$this->notifyRecipients($artwork, $comment, $request->user(), $parentId ? (int) $parentId : null);
|
||||
}
|
||||
|
||||
// Record activity event (fire-and-forget; never break the response)
|
||||
try {
|
||||
\App\Models\ActivityEvent::record(
|
||||
actorId: $request->user()->id,
|
||||
type: \App\Models\ActivityEvent::TYPE_COMMENT,
|
||||
targetType: \App\Models\ActivityEvent::TARGET_ARTWORK,
|
||||
targetId: $artwork->id,
|
||||
ActivityEvent::record(
|
||||
actorId: $request->user()->id,
|
||||
type: ActivityEvent::TYPE_COMMENT,
|
||||
targetType: ActivityEvent::TARGET_ARTWORK,
|
||||
targetId: $artwork->id,
|
||||
);
|
||||
} catch (\Throwable) {}
|
||||
} catch (\Throwable) {
|
||||
}
|
||||
|
||||
try {
|
||||
app(UserActivityService::class)->logComment(
|
||||
(int) $request->user()->id,
|
||||
(int) $comment->id,
|
||||
$parentId !== null,
|
||||
['artwork_id' => (int) $artwork->id],
|
||||
);
|
||||
} catch (\Throwable) {}
|
||||
if ($comment->is_approved) {
|
||||
app(UserActivityService::class)->logComment(
|
||||
(int) $request->user()->id,
|
||||
(int) $comment->id,
|
||||
$parentId !== null,
|
||||
['artwork_id' => (int) $artwork->id],
|
||||
);
|
||||
}
|
||||
} catch (\Throwable) {
|
||||
}
|
||||
|
||||
$reactionTotals = $this->commentReactions->forComments([$comment->id], $request->user()->id);
|
||||
|
||||
return response()->json(['data' => $this->formatComment($comment, $request->user()->id, false, $reactionTotals)], 201);
|
||||
return response()->json([
|
||||
'data' => $this->formatComment($comment, $request->user()->id, false, $reactionTotals),
|
||||
'moderation' => ['status' => $moderation['status']],
|
||||
], 201);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
@@ -160,10 +176,10 @@ class ArtworkCommentController extends Controller
|
||||
Gate::authorize('update', $comment);
|
||||
|
||||
$request->validate([
|
||||
'content' => ['required', 'string', 'min:1', 'max:' . self::MAX_LENGTH],
|
||||
'content' => ['required', 'string', 'min:1', 'max:'.self::MAX_LENGTH],
|
||||
]);
|
||||
|
||||
$raw = $request->input('content');
|
||||
$raw = $request->input('content');
|
||||
$errors = ContentSanitizer::validate($raw);
|
||||
if ($errors) {
|
||||
return response()->json(['errors' => ['content' => $errors]], 422);
|
||||
@@ -172,8 +188,8 @@ class ArtworkCommentController extends Controller
|
||||
$rendered = ContentSanitizer::render($raw);
|
||||
|
||||
$comment->update([
|
||||
'content' => $raw,
|
||||
'raw_content' => $raw,
|
||||
'content' => $raw,
|
||||
'raw_content' => $raw,
|
||||
'rendered_content' => $rendered,
|
||||
]);
|
||||
|
||||
@@ -206,27 +222,27 @@ class ArtworkCommentController extends Controller
|
||||
|
||||
private function formatComment(ArtworkComment $c, ?int $currentUserId, bool $includeReplies = false, array $reactionTotals = []): array
|
||||
{
|
||||
$user = $c->user;
|
||||
$userId = (int) ($c->user_id ?? 0);
|
||||
$user = $c->user;
|
||||
$userId = (int) ($c->user_id ?? 0);
|
||||
$avatarHash = $user?->profile?->avatar_hash ?? null;
|
||||
|
||||
$data = [
|
||||
'id' => $c->id,
|
||||
'parent_id' => $c->parent_id,
|
||||
'raw_content' => $c->raw_content ?? $c->content,
|
||||
'id' => $c->id,
|
||||
'parent_id' => $c->parent_id,
|
||||
'raw_content' => $c->raw_content ?? $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,
|
||||
'can_delete' => $currentUserId === $userId,
|
||||
'created_at' => $c->created_at?->toIso8601String(),
|
||||
'time_ago' => $c->created_at ? Carbon::parse($c->created_at)->diffForHumans() : null,
|
||||
'can_edit' => $currentUserId === $userId,
|
||||
'can_delete' => $currentUserId === $userId,
|
||||
'user' => [
|
||||
'id' => $userId,
|
||||
'username' => $user?->username,
|
||||
'display' => $user?->username ?? $user?->name ?? 'User',
|
||||
'profile_url' => $user?->username ? '/@' . $user->username : '/profile/' . $userId,
|
||||
'avatar_url' => AvatarUrl::forUser($userId, $avatarHash, 64),
|
||||
'level' => (int) ($user?->level ?? 1),
|
||||
'rank' => (string) ($user?->rank ?? 'Newbie'),
|
||||
'id' => $userId,
|
||||
'username' => $user?->username,
|
||||
'display' => $user?->username ?? $user?->name ?? 'User',
|
||||
'profile_url' => $user?->username ? '/@'.$user->username : '/profile/'.$userId,
|
||||
'avatar_url' => AvatarUrl::forUser($userId, $avatarHash, 64),
|
||||
'level' => (int) ($user?->level ?? 1),
|
||||
'rank' => (string) ($user?->rank ?? 'Newbie'),
|
||||
],
|
||||
'reactions' => $reactionTotals[(int) $c->id] ?? [],
|
||||
];
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Artworks\ArtworkCreateRequest;
|
||||
use App\Http\Resources\ArtworkListResource;
|
||||
use App\Http\Resources\ArtworkResource;
|
||||
use App\Services\ArtworkService;
|
||||
use App\Services\Artworks\ArtworkDraftService;
|
||||
use App\Models\Category;
|
||||
use App\Services\Artworks\ArtworkDraftService;
|
||||
use App\Services\ArtworkService;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
@@ -29,9 +30,12 @@ class ArtworkController extends Controller
|
||||
$user = $request->user();
|
||||
$data = $request->validated();
|
||||
|
||||
$categoryId = isset($data['category']) && ctype_digit((string) $data['category'])
|
||||
? (int) $data['category']
|
||||
: null;
|
||||
$categoryId = isset($data['primary_category_id']) && ctype_digit((string) $data['primary_category_id'])
|
||||
? (int) $data['primary_category_id']
|
||||
: (isset($data['category']) && ctype_digit((string) $data['category'])
|
||||
? (int) $data['category']
|
||||
: null);
|
||||
$secondaryCategoryIds = array_values(array_map('intval', $data['secondary_category_ids'] ?? []));
|
||||
|
||||
$result = $drafts->createDraft(
|
||||
$user,
|
||||
@@ -40,6 +44,7 @@ class ArtworkController extends Controller
|
||||
$categoryId,
|
||||
(bool) ($data['is_mature'] ?? false),
|
||||
$data['group'] ?? null,
|
||||
$secondaryCategoryIds,
|
||||
);
|
||||
|
||||
return response()->json([
|
||||
|
||||
@@ -174,7 +174,7 @@ final class DiscoveryNegativeSignalController extends Controller
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $meta
|
||||
* @param array<string, mixed> $meta
|
||||
*/
|
||||
private function recordFeedbackEvent(int $userId, int $artworkId, string $eventType, ?string $algoVersion = null, array $meta = []): void
|
||||
{
|
||||
@@ -182,10 +182,9 @@ final class DiscoveryNegativeSignalController extends Controller
|
||||
return;
|
||||
}
|
||||
|
||||
$categoryId = DB::table('artwork_category')
|
||||
->where('artwork_id', $artworkId)
|
||||
->orderBy('category_id')
|
||||
->value('category_id');
|
||||
$categoryId = DB::table('artworks')
|
||||
->where('id', $artworkId)
|
||||
->value('primary_category_id');
|
||||
|
||||
DB::table('user_discovery_events')->insert([
|
||||
'event_id' => (string) Str::uuid(),
|
||||
|
||||
@@ -7,10 +7,11 @@ namespace App\Http\Controllers\Api;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Artwork;
|
||||
use App\Models\User;
|
||||
use Carbon\CarbonInterface;
|
||||
use App\Services\CollectionService;
|
||||
use App\Services\Maturity\ArtworkMaturityService;
|
||||
use App\Services\ThumbnailPresenter;
|
||||
use App\Support\UsernamePolicy;
|
||||
use App\Services\Maturity\ArtworkMaturityService;
|
||||
use Carbon\CarbonInterface;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
@@ -26,6 +27,28 @@ use UnexpectedValueException;
|
||||
*/
|
||||
final class ProfileApiController extends Controller
|
||||
{
|
||||
public function __construct(private readonly CollectionService $collections) {}
|
||||
|
||||
/**
|
||||
* GET /api/profile/{username}/collections
|
||||
* Returns the profile collection cards without rendering the profile page.
|
||||
*/
|
||||
public function collections(Request $request, string $username): JsonResponse
|
||||
{
|
||||
$user = $this->resolveUser($username);
|
||||
if (! $user) {
|
||||
return response()->json(['error' => 'User not found'], 404);
|
||||
}
|
||||
|
||||
$viewer = $request->user();
|
||||
$isOwner = $viewer && (int) $viewer->id === (int) $user->id;
|
||||
$profileCollections = $this->collections->getProfileCollections($user, $viewer);
|
||||
|
||||
return response()->json([
|
||||
'data' => $this->collections->mapCollectionCardPayloads($profileCollections, $isOwner, $viewer),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/profile/{username}/artworks
|
||||
* Returns cursor-paginated artworks for the profile page tabs.
|
||||
@@ -39,18 +62,18 @@ final class ProfileApiController extends Controller
|
||||
}
|
||||
|
||||
$isOwner = Auth::check() && Auth::id() === $user->id;
|
||||
$sort = $request->input('sort', 'latest');
|
||||
$sort = $request->input('sort', 'latest');
|
||||
|
||||
$query = Artwork::with([
|
||||
'user:id,name,username,level,rank',
|
||||
'user.profile:user_id,avatar_hash',
|
||||
'user:id,name,username,level,rank',
|
||||
'user.profile:user_id,avatar_hash',
|
||||
'group:id,name,slug,avatar_path',
|
||||
'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']);
|
||||
},
|
||||
])
|
||||
'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('artworks.deleted_at');
|
||||
|
||||
@@ -85,9 +108,9 @@ final class ProfileApiController extends Controller
|
||||
->values();
|
||||
|
||||
return response()->json([
|
||||
'data' => $data,
|
||||
'data' => $data,
|
||||
'next_cursor' => $paginator->nextCursor()?->encode(),
|
||||
'has_more' => $paginator->hasMorePages(),
|
||||
'has_more' => $paginator->hasMorePages(),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -110,7 +133,7 @@ final class ProfileApiController extends Controller
|
||||
$perPage = 12;
|
||||
$offset = max(0, (int) base64_decode((string) $request->input('cursor', ''), true));
|
||||
|
||||
$favIds = DB::table($favouriteTable . ' as af')
|
||||
$favIds = DB::table($favouriteTable.' as af')
|
||||
->join('artworks as a', 'a.id', '=', 'af.artwork_id')
|
||||
->where('af.user_id', $user->id)
|
||||
->whereNull('a.deleted_at')
|
||||
@@ -128,22 +151,22 @@ final class ProfileApiController extends Controller
|
||||
->pluck('a.id');
|
||||
|
||||
$hasMore = $favIds->count() > $perPage;
|
||||
$favIds = $favIds->take($perPage);
|
||||
$favIds = $favIds->take($perPage);
|
||||
|
||||
if ($favIds->isEmpty()) {
|
||||
return response()->json(['data' => [], 'next_cursor' => null, 'has_more' => false]);
|
||||
}
|
||||
|
||||
$indexed = Artwork::with([
|
||||
'user:id,name,username,level,rank',
|
||||
'user.profile:user_id,avatar_hash',
|
||||
'user:id,name,username,level,rank',
|
||||
'user.profile:user_id,avatar_hash',
|
||||
'group:id,name,slug,avatar_path',
|
||||
'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']);
|
||||
},
|
||||
])
|
||||
'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');
|
||||
@@ -154,9 +177,9 @@ final class ProfileApiController extends Controller
|
||||
->values();
|
||||
|
||||
return response()->json([
|
||||
'data' => $data,
|
||||
'data' => $data,
|
||||
'next_cursor' => $hasMore ? base64_encode((string) ($offset + $perPage)) : null,
|
||||
'has_more' => $hasMore,
|
||||
'has_more' => $hasMore,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -182,7 +205,7 @@ final class ProfileApiController extends Controller
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'stats' => $stats,
|
||||
'stats' => $stats,
|
||||
'follower_count' => $followerCount,
|
||||
]);
|
||||
}
|
||||
@@ -190,6 +213,7 @@ final class ProfileApiController extends Controller
|
||||
private function resolveUser(string $username): ?User
|
||||
{
|
||||
$normalized = UsernamePolicy::normalize($username);
|
||||
|
||||
return User::query()->whereRaw('LOWER(username) = ?', [$normalized])->first();
|
||||
}
|
||||
|
||||
@@ -208,17 +232,17 @@ final class ProfileApiController extends Controller
|
||||
{
|
||||
$statsColumn = match ($sort) {
|
||||
'trending' => 'profile_artwork_stats.ranking_score',
|
||||
'rising' => 'profile_artwork_stats.heat_score',
|
||||
'views' => 'profile_artwork_stats.views',
|
||||
'favs' => 'profile_artwork_stats.favorites',
|
||||
default => null,
|
||||
'rising' => 'profile_artwork_stats.heat_score',
|
||||
'views' => 'profile_artwork_stats.views',
|
||||
'favs' => 'profile_artwork_stats.favorites',
|
||||
default => null,
|
||||
};
|
||||
|
||||
if ($statsColumn !== null) {
|
||||
return $query
|
||||
->leftJoin('artwork_stats as profile_artwork_stats', 'profile_artwork_stats.artwork_id', '=', 'artworks.id')
|
||||
->select('artworks.*')
|
||||
->selectRaw('COALESCE(' . $statsColumn . ', 0) as cursor_sort_value')
|
||||
->selectRaw('COALESCE('.$statsColumn.', 0) as cursor_sort_value')
|
||||
->orderByDesc('cursor_sort_value')
|
||||
->orderByDesc('published_at')
|
||||
->orderByDesc('id');
|
||||
@@ -235,7 +259,7 @@ final class ProfileApiController extends Controller
|
||||
private function mapArtworkCardPayload(Artwork $art): array
|
||||
{
|
||||
$present = ThumbnailPresenter::present($art, 'md');
|
||||
$category = $art->categories->first();
|
||||
$category = $art->resolvedPrimaryCategory();
|
||||
$contentType = $category?->contentType;
|
||||
$stats = $art->stats;
|
||||
$group = $art->group;
|
||||
@@ -245,7 +269,7 @@ final class ProfileApiController extends Controller
|
||||
$avatarUrl = $isGroupPublisher ? $group->avatarUrl() : ($art->user?->profile?->avatar_url ?? null);
|
||||
$profileUrl = $isGroupPublisher
|
||||
? $group->publicUrl()
|
||||
: ($username ? '/@' . $username : null);
|
||||
: ($username ? '/@'.$username : null);
|
||||
$publisherType = $isGroupPublisher ? 'group' : 'user';
|
||||
|
||||
return [
|
||||
|
||||
@@ -5,52 +5,58 @@ declare(strict_types=1);
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Uploads\UploadCancelRequest;
|
||||
use App\Http\Requests\Uploads\UploadChunkRequest;
|
||||
use App\Http\Requests\Uploads\UploadFinishRequest;
|
||||
use App\Http\Requests\Uploads\UploadInitRequest;
|
||||
use App\Http\Requests\Uploads\UploadChunkRequest;
|
||||
use App\Http\Requests\Uploads\UploadCancelRequest;
|
||||
use App\Http\Requests\Uploads\UploadStatusRequest;
|
||||
use App\Jobs\GenerateDerivativesJob;
|
||||
use App\Jobs\AnalyzeArtworkAiAssistJob;
|
||||
use App\Jobs\IndexArtworkJob;
|
||||
use App\Jobs\AutoTagArtworkJob;
|
||||
use App\Jobs\DetectArtworkMaturityJob;
|
||||
use App\Jobs\GenerateArtworkEmbeddingJob;
|
||||
use App\Jobs\GenerateDerivativesJob;
|
||||
use App\Jobs\IndexArtworkJob;
|
||||
use App\Models\ActivityEvent;
|
||||
use App\Models\Artwork;
|
||||
use App\Models\Group;
|
||||
use App\Models\Tag;
|
||||
use App\Notifications\NewArtworkReviewNotification;
|
||||
use App\Repositories\Uploads\UploadSessionRepository;
|
||||
use App\Services\Uploads\UploadChunkService;
|
||||
use App\Services\Uploads\UploadCancelService;
|
||||
use App\Services\Uploads\UploadAuditService;
|
||||
use App\Services\Uploads\UploadPipelineService;
|
||||
use App\Services\Uploads\UploadQuotaService;
|
||||
use App\Services\Uploads\UploadQueueService;
|
||||
use App\Services\Uploads\UploadSessionStatus;
|
||||
use App\Services\Uploads\UploadStatusService;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Throwable;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use App\Services\Upload\Contracts\UploadDraftServiceInterface;
|
||||
use Carbon\Carbon;
|
||||
use App\Uploads\Jobs\VirusScanJob;
|
||||
use App\Uploads\Services\PublishService;
|
||||
use App\Services\Activity\UserActivityService;
|
||||
use App\Services\ArtworkAttributionService;
|
||||
use App\Services\Artworks\ArtworkCategoryService;
|
||||
use App\Services\GroupArtworkReviewService;
|
||||
use App\Services\Maturity\ArtworkMaturityService;
|
||||
use App\Services\Moderation\ArtworkUploadPolicy;
|
||||
use App\Services\Upload\Contracts\UploadDraftServiceInterface;
|
||||
use App\Services\Uploads\UploadAuditService;
|
||||
use App\Services\Uploads\UploadCancelService;
|
||||
use App\Services\Uploads\UploadChunkService;
|
||||
use App\Services\Uploads\UploadPipelineService;
|
||||
use App\Services\Uploads\UploadQueueService;
|
||||
use App\Services\Uploads\UploadQuotaService;
|
||||
use App\Services\Uploads\UploadSessionStatus;
|
||||
use App\Services\Uploads\UploadStatusService;
|
||||
use App\Services\Worlds\WorldSubmissionService;
|
||||
use App\Support\ArtworkDescriptionContentValidator;
|
||||
use App\Uploads\Exceptions\DraftQuotaException;
|
||||
use App\Uploads\Exceptions\UploadNotFoundException;
|
||||
use App\Uploads\Exceptions\UploadOwnershipException;
|
||||
use App\Uploads\Exceptions\UploadPublishValidationException;
|
||||
use App\Uploads\Jobs\VirusScanJob;
|
||||
use App\Uploads\Services\ArchiveInspectorService;
|
||||
use App\Uploads\Services\DraftQuotaService;
|
||||
use App\Uploads\Exceptions\DraftQuotaException;
|
||||
use App\Models\Artwork;
|
||||
use App\Models\Group;
|
||||
use App\Services\GroupArtworkReviewService;
|
||||
use App\Support\ArtworkDescriptionContentValidator;
|
||||
use App\Services\Worlds\WorldSubmissionService;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use App\Uploads\Services\PublishService;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Notification;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Throwable;
|
||||
|
||||
final class UploadController extends Controller
|
||||
{
|
||||
@@ -59,8 +65,7 @@ final class UploadController extends Controller
|
||||
UploadPipelineService $pipeline,
|
||||
UploadQuotaService $quota,
|
||||
UploadAuditService $audit
|
||||
)
|
||||
{
|
||||
) {
|
||||
$user = $request->user();
|
||||
|
||||
try {
|
||||
@@ -251,6 +256,7 @@ final class UploadController extends Controller
|
||||
if ((bool) config('vision.upload.ai_assist.enabled', false)) {
|
||||
AnalyzeArtworkAiAssistJob::dispatch($artworkId)->afterCommit();
|
||||
}
|
||||
|
||||
return UploadSessionStatus::PROCESSED;
|
||||
});
|
||||
|
||||
@@ -305,7 +311,7 @@ final class UploadController extends Controller
|
||||
'realpath' => $chunkFile->getRealPath(),
|
||||
]);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
} catch (Throwable $e) {
|
||||
logger()->warning('Chunk upload debug logging failed', ['error' => $e->getMessage()]);
|
||||
}
|
||||
|
||||
@@ -333,7 +339,7 @@ final class UploadController extends Controller
|
||||
'total_bytes' => $result->totalBytes,
|
||||
'progress' => $result->progress,
|
||||
], Response::HTTP_OK);
|
||||
} catch (\Throwable $e) {
|
||||
} catch (Throwable $e) {
|
||||
logger()->warning('Upload chunk failed', [
|
||||
'session_id' => (string) $request->input('session_id'),
|
||||
'error' => $e->getMessage(),
|
||||
@@ -383,7 +389,7 @@ final class UploadController extends Controller
|
||||
'session_id' => $result['session_id'],
|
||||
'status' => $result['status'],
|
||||
], Response::HTTP_OK);
|
||||
} catch (\Throwable $e) {
|
||||
} catch (Throwable $e) {
|
||||
logger()->warning('Upload cancel failed', [
|
||||
'session_id' => (string) $request->input('session_id'),
|
||||
'error' => $e->getMessage(),
|
||||
@@ -512,6 +518,7 @@ final class UploadController extends Controller
|
||||
return response()->json($response, Response::HTTP_OK);
|
||||
} catch (Throwable $e) {
|
||||
logger()->error('Upload preload failed', ['error' => $e->getMessage()]);
|
||||
|
||||
return response()->json(['message' => 'Preload failed.'], Response::HTTP_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
@@ -538,6 +545,8 @@ final class UploadController extends Controller
|
||||
$validated = $request->validate([
|
||||
'title' => ['nullable', 'string', 'max:255'],
|
||||
'category_id' => ['nullable', 'exists:categories,id'],
|
||||
'secondary_category_ids' => ['nullable', 'array', 'max:'.(int) config('categories.max_secondary_categories', 5)],
|
||||
'secondary_category_ids.*' => ['integer', 'exists:categories,id'],
|
||||
'description' => ['nullable', 'string'],
|
||||
'tags' => ['nullable', 'array'],
|
||||
'license' => ['nullable', 'string'],
|
||||
@@ -547,7 +556,7 @@ final class UploadController extends Controller
|
||||
$this->ensureValidArtworkDescription($validated);
|
||||
|
||||
$updates = [];
|
||||
foreach (['title', 'category_id', 'description', 'tags', 'license', 'nsfw'] as $field) {
|
||||
foreach (['title', 'category_id', 'secondary_category_ids', 'description', 'tags', 'license', 'nsfw'] as $field) {
|
||||
if (array_key_exists($field, $validated)) {
|
||||
$updates[$field] = $validated[$field];
|
||||
}
|
||||
@@ -557,7 +566,7 @@ final class UploadController extends Controller
|
||||
foreach ($updates as $field => $value) {
|
||||
$current = $upload->{$field} ?? null;
|
||||
|
||||
if ($field === 'tags') {
|
||||
if ($field === 'tags' || $field === 'secondary_category_ids') {
|
||||
$current = $current ? json_decode((string) $current, true) : null;
|
||||
}
|
||||
|
||||
@@ -575,6 +584,10 @@ final class UploadController extends Controller
|
||||
$dirty['tags'] = json_encode($dirty['tags']);
|
||||
}
|
||||
|
||||
if (array_key_exists('secondary_category_ids', $dirty)) {
|
||||
$dirty['secondary_category_ids'] = json_encode($dirty['secondary_category_ids']);
|
||||
}
|
||||
|
||||
if (! empty($dirty)) {
|
||||
$dirty['updated_at'] = now();
|
||||
DB::table('uploads')->where('id', $id)->update($dirty);
|
||||
@@ -616,23 +629,26 @@ final class UploadController extends Controller
|
||||
], Response::HTTP_OK);
|
||||
}
|
||||
|
||||
public function publish(string $id, Request $request, PublishService $publishService, ArtworkAttributionService $attribution, ArtworkMaturityService $maturity, WorldSubmissionService $submissions)
|
||||
public function publish(string $id, Request $request, PublishService $publishService, ArtworkAttributionService $attribution, ArtworkMaturityService $maturity, WorldSubmissionService $submissions, ArtworkUploadPolicy $uploadPolicy)
|
||||
{
|
||||
$user = $request->user();
|
||||
|
||||
$validated = $request->validate([
|
||||
'title' => ['nullable', 'string', 'max:150'],
|
||||
'title' => ['nullable', 'string', 'max:150'],
|
||||
'description' => ['nullable', 'string'],
|
||||
'category' => ['nullable', 'integer', 'exists:categories,id'],
|
||||
'tags' => ['nullable', 'array', 'max:' . (int) config('tags.max_user_tags', 30)],
|
||||
'tags.*' => ['string', 'max:64'],
|
||||
'is_mature' => ['nullable', 'boolean'],
|
||||
'nsfw' => ['nullable', 'boolean'],
|
||||
'category' => ['nullable', 'integer', 'exists:categories,id'],
|
||||
'primary_category_id' => ['nullable', 'integer', 'exists:categories,id'],
|
||||
'secondary_category_ids' => ['nullable', 'array', 'max:'.(int) config('categories.max_secondary_categories', 5)],
|
||||
'secondary_category_ids.*' => ['integer', 'exists:categories,id'],
|
||||
'tags' => ['nullable', 'array', 'max:'.(int) config('tags.max_user_tags', 30)],
|
||||
'tags.*' => ['string', 'max:64'],
|
||||
'is_mature' => ['nullable', 'boolean'],
|
||||
'nsfw' => ['nullable', 'boolean'],
|
||||
// Scheduled-publishing fields
|
||||
'mode' => ['nullable', 'string', 'in:now,schedule'],
|
||||
'publish_at' => ['nullable', 'string', 'date'],
|
||||
'timezone' => ['nullable', 'string', 'max:64'],
|
||||
'visibility' => ['nullable', 'string', 'in:public,unlisted,private'],
|
||||
'mode' => ['nullable', 'string', 'in:now,schedule'],
|
||||
'publish_at' => ['nullable', 'string', 'date'],
|
||||
'timezone' => ['nullable', 'string', 'max:64'],
|
||||
'visibility' => ['nullable', 'string', 'in:public,unlisted,private'],
|
||||
'group' => ['nullable', 'string', 'max:90'],
|
||||
'primary_author_user_id' => ['nullable', 'integer', 'min:1'],
|
||||
'contributor_user_ids' => ['nullable', 'array', 'max:20'],
|
||||
@@ -649,21 +665,21 @@ final class UploadController extends Controller
|
||||
|
||||
$this->ensureValidArtworkDescription($validated);
|
||||
|
||||
$mode = $validated['mode'] ?? 'now';
|
||||
$mode = $validated['mode'] ?? 'now';
|
||||
$visibility = $validated['visibility'] ?? 'public';
|
||||
|
||||
// Resolve the UTC publish_at datetime for schedule mode
|
||||
$publishAt = null;
|
||||
if ($mode === 'schedule' && ! empty($validated['publish_at'])) {
|
||||
try {
|
||||
$publishAt = \Carbon\Carbon::parse($validated['publish_at'])->utc();
|
||||
$publishAt = Carbon::parse($validated['publish_at'])->utc();
|
||||
// Must be at least 1 minute in the future (server-side guard)
|
||||
if ($publishAt->lte(now()->addMinute())) {
|
||||
return response()->json([
|
||||
'message' => 'Scheduled publish time must be at least 1 minute in the future.',
|
||||
], Response::HTTP_UNPROCESSABLE_ENTITY);
|
||||
}
|
||||
} catch (\Throwable) {
|
||||
} catch (Throwable) {
|
||||
return response()->json(['message' => 'Invalid publish_at datetime.'], Response::HTTP_UNPROCESSABLE_ENTITY);
|
||||
}
|
||||
}
|
||||
@@ -696,22 +712,25 @@ final class UploadController extends Controller
|
||||
if (array_key_exists('is_mature', $validated) || array_key_exists('nsfw', $validated)) {
|
||||
$artwork->is_mature = (bool) ($validated['is_mature'] ?? $validated['nsfw'] ?? false);
|
||||
}
|
||||
$artwork->slug = Str::limit($slugBase, 160, '');
|
||||
$artwork->slug = Str::limit($slugBase, 160, '');
|
||||
$artwork->artwork_timezone = $validated['timezone'] ?? null;
|
||||
$artwork->uploaded_by_user_id = $artwork->uploaded_by_user_id ?: (int) $user->id;
|
||||
$artwork->primary_author_user_id = $artwork->primary_author_user_id ?: (int) $user->id;
|
||||
|
||||
// Sync category if provided
|
||||
$categoryId = isset($validated['category']) ? (int) $validated['category'] : null;
|
||||
if ($categoryId && \App\Models\Category::where('id', $categoryId)->exists()) {
|
||||
$artwork->categories()->sync([$categoryId]);
|
||||
$categoryId = isset($validated['primary_category_id'])
|
||||
? (int) $validated['primary_category_id']
|
||||
: (isset($validated['category']) ? (int) $validated['category'] : null);
|
||||
$secondaryCategoryIds = array_values(array_map('intval', $validated['secondary_category_ids'] ?? []));
|
||||
if ($categoryId) {
|
||||
app(ArtworkCategoryService::class)
|
||||
->sync($artwork, $categoryId, $secondaryCategoryIds, 'user', false);
|
||||
}
|
||||
|
||||
// Sync tags if provided
|
||||
if (!empty($validated['tags']) && is_array($validated['tags'])) {
|
||||
if (! empty($validated['tags']) && is_array($validated['tags'])) {
|
||||
$tagIds = [];
|
||||
foreach ($validated['tags'] as $tagSlug) {
|
||||
$tag = \App\Models\Tag::firstOrCreate(
|
||||
$tag = Tag::firstOrCreate(
|
||||
['slug' => Str::slug($tagSlug)],
|
||||
['name' => $tagSlug, 'is_active' => true, 'usage_count' => 0]
|
||||
);
|
||||
@@ -727,56 +746,95 @@ final class UploadController extends Controller
|
||||
|
||||
if ($mode === 'schedule' && $publishAt) {
|
||||
// Scheduled: store publish_at but don't make public yet
|
||||
$artwork->visibility = $visibility;
|
||||
$artwork->is_public = false;
|
||||
$artwork->is_approved = true;
|
||||
$artwork->publish_at = $publishAt;
|
||||
$artwork->visibility = $visibility;
|
||||
$artwork->is_public = false;
|
||||
$artwork->is_approved = true;
|
||||
$artwork->publish_at = $publishAt;
|
||||
$artwork->artwork_status = 'scheduled';
|
||||
$artwork->published_at = null;
|
||||
$artwork->published_at = null;
|
||||
$artwork->save();
|
||||
|
||||
IndexArtworkJob::dispatch((int) $artwork->id);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'artwork_id' => (int) $artwork->id,
|
||||
'status' => 'scheduled',
|
||||
'slug' => (string) $artwork->slug,
|
||||
'publish_at' => $publishAt->toISOString(),
|
||||
'success' => true,
|
||||
'artwork_id' => (int) $artwork->id,
|
||||
'status' => 'scheduled',
|
||||
'slug' => (string) $artwork->slug,
|
||||
'publish_at' => $publishAt->toISOString(),
|
||||
'published_at' => null,
|
||||
], Response::HTTP_OK);
|
||||
}
|
||||
|
||||
// Publish immediately
|
||||
$artwork->visibility = $visibility;
|
||||
$artwork->is_public = ($visibility !== 'private');
|
||||
$artwork->is_approved = true;
|
||||
$artwork->published_at = now();
|
||||
// New and suspicious accounts are quarantined. Never let the client
|
||||
// decide that an artwork is approved.
|
||||
$policy = $uploadPolicy->assess($user, $artwork);
|
||||
if ($policy['requires_review']) {
|
||||
$artwork->visibility = $visibility;
|
||||
$artwork->is_public = false;
|
||||
$artwork->is_approved = false;
|
||||
$artwork->approval_source = null;
|
||||
$artwork->artwork_status = 'review';
|
||||
$artwork->published_at = null;
|
||||
$artwork->publish_at = null;
|
||||
$artwork->moderated_at = null;
|
||||
$artwork->moderated_by = null;
|
||||
$artwork->moderation_note = implode(', ', $policy['reasons']);
|
||||
$artwork->save();
|
||||
|
||||
$notifyEmail = trim((string) config('uploads.moderation.notify_email', ''));
|
||||
if ($notifyEmail !== '') {
|
||||
try {
|
||||
Notification::route('mail', $notifyEmail)->notify(
|
||||
new NewArtworkReviewNotification($artwork, $user, $policy['reasons'])
|
||||
);
|
||||
} catch (Throwable) {
|
||||
// Email failure must not expose the artwork.
|
||||
}
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'artwork_id' => (int) $artwork->id,
|
||||
'status' => 'submitted_for_review',
|
||||
'message' => 'Upload received and queued for moderation.',
|
||||
'review_reasons' => $policy['reasons'],
|
||||
], Response::HTTP_OK);
|
||||
}
|
||||
|
||||
// Publish immediately for trusted users only.
|
||||
$artwork->visibility = $visibility;
|
||||
$artwork->is_public = ($visibility !== 'private');
|
||||
$artwork->is_approved = true;
|
||||
$artwork->approval_source = 'trusted_auto';
|
||||
$artwork->published_at = now();
|
||||
$artwork->artwork_status = 'published';
|
||||
$artwork->publish_at = null;
|
||||
$artwork->publish_at = null;
|
||||
$artwork->save();
|
||||
|
||||
IndexArtworkJob::dispatch((int) $artwork->id);
|
||||
|
||||
// Record upload activity event
|
||||
try {
|
||||
\App\Models\ActivityEvent::record(
|
||||
actorId: (int) $user->id,
|
||||
type: \App\Models\ActivityEvent::TYPE_UPLOAD,
|
||||
targetType: \App\Models\ActivityEvent::TARGET_ARTWORK,
|
||||
targetId: (int) $artwork->id,
|
||||
ActivityEvent::record(
|
||||
actorId: (int) $user->id,
|
||||
type: ActivityEvent::TYPE_UPLOAD,
|
||||
targetType: ActivityEvent::TARGET_ARTWORK,
|
||||
targetId: (int) $artwork->id,
|
||||
);
|
||||
} catch (\Throwable) {}
|
||||
} catch (Throwable) {
|
||||
}
|
||||
|
||||
try {
|
||||
app(UserActivityService::class)->logUpload((int) $user->id, (int) $artwork->id);
|
||||
} catch (\Throwable) {}
|
||||
} catch (Throwable) {
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'artwork_id' => (int) $artwork->id,
|
||||
'status' => 'published',
|
||||
'slug' => (string) $artwork->slug,
|
||||
'success' => true,
|
||||
'artwork_id' => (int) $artwork->id,
|
||||
'status' => 'published',
|
||||
'slug' => (string) $artwork->slug,
|
||||
'published_at' => optional($artwork->published_at)->toISOString(),
|
||||
], Response::HTTP_OK);
|
||||
}
|
||||
@@ -785,11 +843,11 @@ final class UploadController extends Controller
|
||||
$upload = $publishService->publish($id, $user);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'upload_id' => (string) $upload->id,
|
||||
'status' => (string) $upload->status,
|
||||
'success' => true,
|
||||
'upload_id' => (string) $upload->id,
|
||||
'status' => (string) $upload->status,
|
||||
'published_at' => optional($upload->published_at)->toISOString(),
|
||||
'final_path' => (string) ($upload->final_path ?? ''),
|
||||
'final_path' => (string) ($upload->final_path ?? ''),
|
||||
], Response::HTTP_OK);
|
||||
} catch (UploadOwnershipException $e) {
|
||||
return response()->json(['message' => $e->getMessage()], Response::HTTP_FORBIDDEN);
|
||||
@@ -808,7 +866,10 @@ final class UploadController extends Controller
|
||||
'title' => ['nullable', 'string', 'max:150'],
|
||||
'description' => ['nullable', 'string'],
|
||||
'category' => ['nullable', 'integer', 'exists:categories,id'],
|
||||
'tags' => ['nullable', 'array', 'max:' . (int) config('tags.max_user_tags', 30)],
|
||||
'primary_category_id' => ['nullable', 'integer', 'exists:categories,id'],
|
||||
'secondary_category_ids' => ['nullable', 'array', 'max:'.(int) config('categories.max_secondary_categories', 5)],
|
||||
'secondary_category_ids.*' => ['integer', 'exists:categories,id'],
|
||||
'tags' => ['nullable', 'array', 'max:'.(int) config('tags.max_user_tags', 30)],
|
||||
'tags.*' => ['string', 'max:64'],
|
||||
'is_mature' => ['nullable', 'boolean'],
|
||||
'nsfw' => ['nullable', 'boolean'],
|
||||
|
||||
@@ -8,11 +8,13 @@ use App\Models\Artwork;
|
||||
use App\Models\ArtworkDownload;
|
||||
use App\Services\ArtworkOriginalFileLocator;
|
||||
use App\Services\ArtworkStatsService;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
use Symfony\Component\HttpFoundation\BinaryFileResponse;
|
||||
|
||||
@@ -43,7 +45,7 @@ final class ArtworkDownloadController extends Controller
|
||||
private readonly ArtworkOriginalFileLocator $originalFiles,
|
||||
) {}
|
||||
|
||||
public function __invoke(Request $request, int $id): BinaryFileResponse|Response
|
||||
public function __invoke(Request $request, int $id): BinaryFileResponse|RedirectResponse|Response
|
||||
{
|
||||
$artwork = Artwork::query()->find($id);
|
||||
|
||||
@@ -51,23 +53,70 @@ final class ArtworkDownloadController extends Controller
|
||||
return $this->notFound();
|
||||
}
|
||||
|
||||
$filePath = $this->originalFiles->resolveLocalPath($artwork);
|
||||
$ext = strtolower(ltrim((string) pathinfo($filePath, PATHINFO_EXTENSION), '.'));
|
||||
$resolution = $this->originalFiles->resolve($artwork);
|
||||
$filePath = (string) $resolution['path'];
|
||||
$objectKey = (string) $resolution['object_key'];
|
||||
$ext = strtolower((string) ($resolution['file_ext'] ?: pathinfo($filePath !== '' ? $filePath : $objectKey, PATHINFO_EXTENSION)));
|
||||
|
||||
if ($filePath === '' || ! in_array($ext, self::ALLOWED_EXTENSIONS, true)) {
|
||||
if (($filePath === '' && $objectKey === '') || ! in_array($ext, self::ALLOWED_EXTENSIONS, true)) {
|
||||
return $this->notFound();
|
||||
}
|
||||
|
||||
if (! File::isFile($filePath)) {
|
||||
if (! $resolution['exists']) {
|
||||
Log::warning('Artwork original file missing for download.', [
|
||||
'artwork_id' => $artwork->id,
|
||||
'ext' => $ext,
|
||||
'resolved_path' => $filePath,
|
||||
'download_status' => $artwork->download_status,
|
||||
'canonical_local_exists' => $filePath !== '' && File::isFile($filePath),
|
||||
'object_exists' => false,
|
||||
'resolution_status' => $resolution['status'],
|
||||
]);
|
||||
|
||||
return $this->notFound();
|
||||
}
|
||||
|
||||
$downloadName = $this->buildDownloadFilename((string) $artwork->file_name, $ext);
|
||||
$temporaryUrl = null;
|
||||
|
||||
if ($resolution['source'] === 'object') {
|
||||
try {
|
||||
$temporaryUrl = Storage::disk((string) $resolution['disk'])->temporaryUrl(
|
||||
$objectKey,
|
||||
now()->addMinutes(5),
|
||||
[
|
||||
'ResponseContentDisposition' => 'attachment; filename="'.addslashes($downloadName).'"',
|
||||
],
|
||||
);
|
||||
} catch (\Throwable $exception) {
|
||||
Log::warning('Artwork object temporary URL generation failed.', [
|
||||
'artwork_id' => $artwork->id,
|
||||
'disk' => $resolution['disk'],
|
||||
'object_key' => $objectKey,
|
||||
'object_exists' => true,
|
||||
'object_size' => $resolution['size'],
|
||||
'exception_class' => $exception::class,
|
||||
'exception_message' => mb_substr($exception->getMessage(), 0, 300),
|
||||
]);
|
||||
|
||||
return $this->notFound();
|
||||
}
|
||||
|
||||
if (! is_string($temporaryUrl) || trim($temporaryUrl) === '') {
|
||||
Log::warning('Artwork object temporary URL generation failed.', [
|
||||
'artwork_id' => $artwork->id,
|
||||
'disk' => $resolution['disk'],
|
||||
'object_key' => $objectKey,
|
||||
'resolution_status' => $resolution['status'],
|
||||
'object_exists' => true,
|
||||
'object_size' => $resolution['size'],
|
||||
'exception_class' => 'InvalidTemporaryUrl',
|
||||
'exception_message' => 'Storage adapter did not return a temporary URL.',
|
||||
]);
|
||||
|
||||
return $this->notFound();
|
||||
}
|
||||
}
|
||||
|
||||
$this->recordDownload($request, $artwork->id);
|
||||
$this->incrementDownloadCountIfAvailable($artwork->id);
|
||||
|
||||
@@ -80,7 +129,13 @@ final class ArtworkDownloadController extends Controller
|
||||
]);
|
||||
}
|
||||
|
||||
$downloadName = $this->buildDownloadFilename((string) $artwork->file_name, $ext);
|
||||
if (array_key_exists('download_status', $artwork->getAttributes())) {
|
||||
$artwork->forceFill([
|
||||
'download_status' => 'available',
|
||||
'download_source' => $resolution['source'],
|
||||
'download_checked_at' => now(),
|
||||
])->saveQuietly();
|
||||
}
|
||||
|
||||
// X-Accel-Redirect is safe only when nginx is explicitly configured to
|
||||
// map the internal URI to the originals root. Otherwise fallback to the
|
||||
@@ -90,11 +145,15 @@ final class ArtworkDownloadController extends Controller
|
||||
return response('', 200, [
|
||||
'X-Accel-Redirect' => $accelUri,
|
||||
'Content-Type' => 'application/octet-stream',
|
||||
'Content-Disposition' => 'attachment; filename="' . addslashes($downloadName) . '"',
|
||||
'Content-Disposition' => 'attachment; filename="'.addslashes($downloadName).'"',
|
||||
'X-Content-Type-Options' => 'nosniff',
|
||||
]);
|
||||
}
|
||||
|
||||
if ($resolution['source'] === 'object') {
|
||||
return redirect()->away($temporaryUrl);
|
||||
}
|
||||
|
||||
return response()->download($filePath, $downloadName);
|
||||
}
|
||||
|
||||
@@ -124,7 +183,7 @@ final class ArtworkDownloadController extends Controller
|
||||
|
||||
$normalizedRoot = str_replace(['/', '\\'], DIRECTORY_SEPARATOR, $root);
|
||||
$normalizedFilePath = str_replace(['/', '\\'], DIRECTORY_SEPARATOR, $filePath);
|
||||
$rootPrefix = $normalizedRoot . DIRECTORY_SEPARATOR;
|
||||
$rootPrefix = $normalizedRoot.DIRECTORY_SEPARATOR;
|
||||
|
||||
if (! str_starts_with($normalizedFilePath, $rootPrefix)) {
|
||||
Log::warning('Artwork download accel path skipped because file is outside originals root.', [
|
||||
@@ -140,7 +199,7 @@ final class ArtworkDownloadController extends Controller
|
||||
return null;
|
||||
}
|
||||
|
||||
return $accelBase . str_replace(DIRECTORY_SEPARATOR, '/', $relativePath);
|
||||
return $accelBase.str_replace(DIRECTORY_SEPARATOR, '/', $relativePath);
|
||||
}
|
||||
|
||||
private function recordDownload(Request $request, int $artworkId): void
|
||||
@@ -195,10 +254,10 @@ final class ArtworkDownloadController extends Controller
|
||||
$brandSuffix = $this->downloadBrandSuffix();
|
||||
|
||||
if ($brandSuffix !== '' && ! Str::contains(Str::lower($baseName), Str::lower($brandSuffix))) {
|
||||
$baseName .= ' (' . $brandSuffix . ')';
|
||||
$baseName .= ' ('.$brandSuffix.')';
|
||||
}
|
||||
|
||||
return $baseName . '.' . $ext;
|
||||
return $baseName.'.'.$ext;
|
||||
}
|
||||
|
||||
private function downloadBrandSuffix(): string
|
||||
|
||||
@@ -3,12 +3,14 @@
|
||||
namespace App\Http\Controllers\Dashboard;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Manage\ManageArtworkDestroyRequest;
|
||||
use App\Http\Requests\Manage\ManageArtworkEditRequest;
|
||||
use App\Http\Requests\Manage\ManageArtworkUpdateRequest;
|
||||
use App\Http\Requests\Manage\ManageArtworkDestroyRequest;
|
||||
use App\Models\Artwork;
|
||||
use App\Services\Artworks\ArtworkCategoryService;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
class ManageController extends Controller
|
||||
{
|
||||
@@ -17,14 +19,12 @@ class ManageController extends Controller
|
||||
$userId = $request->user()->id;
|
||||
$perPage = 50;
|
||||
|
||||
$categorySub = DB::table('artwork_category as ac')
|
||||
->join('categories as c', 'ac.category_id', '=', 'c.id')
|
||||
->select('ac.artwork_id', DB::raw('MIN(c.name) as category_name'))
|
||||
->groupBy('ac.artwork_id');
|
||||
$categorySub = DB::table('categories as c')
|
||||
->select('c.id as category_id', 'c.name as category_name');
|
||||
|
||||
$query = DB::table('artworks as a')
|
||||
->leftJoinSub($categorySub, 'cat', function ($join) {
|
||||
$join->on('a.id', '=', 'cat.artwork_id');
|
||||
$join->on('a.primary_category_id', '=', 'cat.category_id');
|
||||
})
|
||||
->leftJoin('artwork_stats as s', 'a.id', '=', 's.artwork_id')
|
||||
->where('a.user_id', $userId)
|
||||
@@ -52,8 +52,8 @@ class ManageController extends Controller
|
||||
{
|
||||
$artwork = $request->artwork();
|
||||
|
||||
$selectedCategory = DB::table('artwork_category')->where('artwork_id', (int)$id)->value('category_id');
|
||||
$artwork->category = $selectedCategory;
|
||||
$selectedCategory = (int) ($artwork->primary_category_id ?: DB::table('artwork_category')->where('artwork_id', (int) $id)->value('category_id'));
|
||||
$artwork->category = $selectedCategory ?: null;
|
||||
|
||||
$categories = DB::table('categories')
|
||||
->where('content_type_id', 0)
|
||||
@@ -64,7 +64,7 @@ class ManageController extends Controller
|
||||
return view('manage.edit', [
|
||||
'artwork' => $artwork,
|
||||
'categories' => $categories,
|
||||
'page_title' => 'Edit Artwork: ' . ($artwork->title ?? ''),
|
||||
'page_title' => 'Edit Artwork: '.($artwork->title ?? ''),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -91,14 +91,14 @@ class ManageController extends Controller
|
||||
$update['fname'] = basename($attPath);
|
||||
}
|
||||
|
||||
DB::table('artworks')->where('id', (int)$id)->update($update);
|
||||
DB::table('artworks')->where('id', (int) $id)->update($update);
|
||||
|
||||
if (isset($data['section'])) {
|
||||
DB::table('artwork_category')->where('artwork_id', (int)$id)->delete();
|
||||
DB::table('artwork_category')->insert([
|
||||
'artwork_id' => (int)$id,
|
||||
'category_id' => (int)$data['section'],
|
||||
]);
|
||||
$model = Artwork::query()->find((int) $id);
|
||||
if ($model) {
|
||||
app(ArtworkCategoryService::class)
|
||||
->sync($model, (int) $data['section'], [], 'moderator');
|
||||
}
|
||||
}
|
||||
|
||||
return redirect()->route('manage')->with('status', 'Artwork was successfully updated.');
|
||||
@@ -108,14 +108,14 @@ class ManageController extends Controller
|
||||
{
|
||||
$artwork = $request->artwork();
|
||||
|
||||
if (!empty($artwork->fname)) {
|
||||
Storage::delete('public/uploads/attachments/' . $artwork->fname);
|
||||
if (! empty($artwork->fname)) {
|
||||
Storage::delete('public/uploads/attachments/'.$artwork->fname);
|
||||
}
|
||||
if (!empty($artwork->picture)) {
|
||||
Storage::delete('public/uploads/artworks/' . $artwork->picture);
|
||||
if (! empty($artwork->picture)) {
|
||||
Storage::delete('public/uploads/artworks/'.$artwork->picture);
|
||||
}
|
||||
|
||||
DB::table('artworks')->where('id', (int)$id)->delete();
|
||||
DB::table('artworks')->where('id', (int) $id)->delete();
|
||||
|
||||
return redirect()->route('manage')->with('status', 'Artwork deleted.');
|
||||
}
|
||||
|
||||
@@ -3,11 +3,12 @@
|
||||
namespace App\Http\Controllers\Legacy;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
use App\Models\Artwork;
|
||||
use App\Models\ArtworkDownload;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
use App\Services\ThumbnailPresenter;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class TodayDownloadsController extends Controller
|
||||
{
|
||||
@@ -35,16 +36,16 @@ class TodayDownloadsController extends Controller
|
||||
$art = $row->artwork ?? null;
|
||||
// If Eloquent didn't eager load artwork (group queries sometimes don't), fetch it
|
||||
if (! $art && isset($row->artwork_id)) {
|
||||
$art = \App\Models\Artwork::find($row->artwork_id);
|
||||
$art = Artwork::find($row->artwork_id);
|
||||
}
|
||||
|
||||
$name = $art->title ?? null;
|
||||
$picture = $art->file_name ?? null;
|
||||
$ext = pathinfo($picture ?? '', PATHINFO_EXTENSION) ?: 'jpg';
|
||||
$encoded = null; // legacy encoding unavailable; leave null
|
||||
$present = $art ? \App\Services\ThumbnailPresenter::present($art, 'md') : null;
|
||||
$present = $art ? ThumbnailPresenter::present($art, 'md') : null;
|
||||
$thumb = $present ? $present['url'] : 'https://files.skinbase.org/default/missing_md.webp';
|
||||
$categoryId = $art->categories->first()->id ?? null;
|
||||
$categoryId = $art->resolvedPrimaryCategory()?->id;
|
||||
|
||||
return (object) [
|
||||
'id' => $art->id ?? null,
|
||||
|
||||
@@ -3,12 +3,12 @@
|
||||
namespace App\Http\Controllers\Legacy;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use App\Services\AvatarService;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
class UserController extends Controller
|
||||
{
|
||||
@@ -37,7 +37,7 @@ class UserController extends Controller
|
||||
}
|
||||
} else {
|
||||
// Map legacy form fields into the modern schema.
|
||||
$data = $request->only(['name','web','country_code','signature','description','about_me']);
|
||||
$data = $request->only(['name', 'web', 'country_code', 'signature', 'description', 'about_me']);
|
||||
|
||||
// Core user column: `name`
|
||||
if (isset($data['name'])) {
|
||||
@@ -46,24 +46,43 @@ class UserController extends Controller
|
||||
|
||||
// Collect other profile updates to persist into `user_profiles` when available
|
||||
$profileUpdates = [];
|
||||
if (!empty($data['web'])) $profileUpdates['website'] = $data['web'];
|
||||
if (!empty($data['signature'])) $profileUpdates['signature'] = $data['signature'];
|
||||
if (!empty($data['description'])) $profileUpdates['description'] = $data['description'];
|
||||
if (!empty($data['about_me'])) $profileUpdates['about'] = $data['about_me'];
|
||||
if (!empty($data['country_code'])) $profileUpdates['country_code'] = $data['country_code'];
|
||||
if (! empty($data['web'])) {
|
||||
$profileUpdates['website'] = $data['web'];
|
||||
}
|
||||
if (! empty($data['signature'])) {
|
||||
$profileUpdates['signature'] = $data['signature'];
|
||||
}
|
||||
if (! empty($data['description'])) {
|
||||
$profileUpdates['description'] = $data['description'];
|
||||
}
|
||||
if (! empty($data['about_me'])) {
|
||||
$profileUpdates['about'] = $data['about_me'];
|
||||
}
|
||||
if (! empty($data['country_code'])) {
|
||||
$profileUpdates['country_code'] = $data['country_code'];
|
||||
}
|
||||
|
||||
$d1 = $request->input('date1');
|
||||
$d2 = $request->input('date2');
|
||||
$d3 = $request->input('date3');
|
||||
if ($d1 && $d2 && $d3) {
|
||||
$profileUpdates['birthdate'] = sprintf('%04d-%02d-%02d', (int)$d3, (int)$d2, (int)$d1);
|
||||
$profileUpdates['birthdate'] = sprintf('%04d-%02d-%02d', (int) $d3, (int) $d2, (int) $d1);
|
||||
}
|
||||
|
||||
$userGender = $request->input('gender', $user->gender);
|
||||
if (!empty($userGender)) {
|
||||
$g = strtolower($userGender);
|
||||
$map = ['m' => 'M', 'f' => 'F', 'n' => 'X', 'x' => 'X'];
|
||||
$profileUpdates['gender'] = $map[$g] ?? strtoupper($userGender);
|
||||
if ($userGender !== null && $userGender !== '') {
|
||||
$g = strtolower(trim((string) $userGender));
|
||||
$map = [
|
||||
'm' => 'M',
|
||||
'male' => 'M',
|
||||
'f' => 'F',
|
||||
'female' => 'F',
|
||||
'n' => 'X',
|
||||
'x' => 'X',
|
||||
'na' => 'X',
|
||||
'n/a' => 'X',
|
||||
];
|
||||
$profileUpdates['gender'] = $map[$g] ?? null;
|
||||
}
|
||||
|
||||
$profileUpdates['mlist'] = $request->has('newsletter') ? 1 : 0;
|
||||
@@ -85,7 +104,7 @@ class UserController extends Controller
|
||||
$f = $request->file('personal_picture');
|
||||
if (in_array($f->getMimeType(), $allowedLegacyMimes, true)) {
|
||||
$ext = $f->guessExtension() ?: 'jpg';
|
||||
$name = $user->id . '.' . $ext;
|
||||
$name = $user->id.'.'.$ext;
|
||||
$f->move(public_path('user-picture'), $name);
|
||||
$profileUpdates['cover_image'] = $name;
|
||||
$user->picture = $name;
|
||||
@@ -96,7 +115,7 @@ class UserController extends Controller
|
||||
$f = $request->file('emotion_icon');
|
||||
if (in_array($f->getMimeType(), $allowedLegacyMimes, true)) {
|
||||
$ext = $f->guessExtension() ?: 'jpg';
|
||||
$name = $user->id . '.' . $ext;
|
||||
$name = $user->id.'.'.$ext;
|
||||
$f->move(public_path('emotion'), $name);
|
||||
$user->eicon = $name;
|
||||
}
|
||||
@@ -107,9 +126,9 @@ class UserController extends Controller
|
||||
|
||||
// Persist profile updates into `user_profiles` when available, otherwise fallback to `users` table
|
||||
try {
|
||||
if (!empty($profileUpdates) && Schema::hasTable('user_profiles')) {
|
||||
if (! empty($profileUpdates) && Schema::hasTable('user_profiles')) {
|
||||
DB::table('user_profiles')->updateOrInsert(['user_id' => $user->id], $profileUpdates + ['updated_at' => now(), 'created_at' => now()]);
|
||||
} elseif (!empty($profileUpdates)) {
|
||||
} elseif (! empty($profileUpdates)) {
|
||||
DB::table('users')->where('id', $user->id)->update($profileUpdates);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
@@ -143,15 +162,33 @@ class UserController extends Controller
|
||||
$profile = DB::table('user_profiles')->where('user_id', $user->id)->first();
|
||||
if ($profile) {
|
||||
// map modern profile fields onto the legacy user properties/helpers used by the view
|
||||
if (isset($profile->website)) $user->homepage = $profile->website;
|
||||
if (isset($profile->about)) $user->about_me = $profile->about;
|
||||
if (isset($profile->birthdate)) $user->birth = $profile->birthdate;
|
||||
if (isset($profile->gender)) $user->gender = $profile->gender;
|
||||
if (isset($profile->country_code)) $user->country_code = $profile->country_code;
|
||||
if (isset($profile->avatar_hash)) $user->icon = $profile->avatar_hash;
|
||||
if (isset($profile->cover_image)) $user->picture = $profile->cover_image;
|
||||
if (isset($profile->signature)) $user->signature = $profile->signature;
|
||||
if (isset($profile->description)) $user->description = $profile->description;
|
||||
if (isset($profile->website)) {
|
||||
$user->homepage = $profile->website;
|
||||
}
|
||||
if (isset($profile->about)) {
|
||||
$user->about_me = $profile->about;
|
||||
}
|
||||
if (isset($profile->birthdate)) {
|
||||
$user->birth = $profile->birthdate;
|
||||
}
|
||||
if (isset($profile->gender)) {
|
||||
$user->gender = $profile->gender;
|
||||
}
|
||||
if (isset($profile->country_code)) {
|
||||
$user->country_code = $profile->country_code;
|
||||
}
|
||||
if (isset($profile->avatar_hash)) {
|
||||
$user->icon = $profile->avatar_hash;
|
||||
}
|
||||
if (isset($profile->cover_image)) {
|
||||
$user->picture = $profile->cover_image;
|
||||
}
|
||||
if (isset($profile->signature)) {
|
||||
$user->signature = $profile->signature;
|
||||
}
|
||||
if (isset($profile->description)) {
|
||||
$user->description = $profile->description;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
|
||||
@@ -6,27 +6,34 @@ namespace App\Http\Controllers\Studio;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Artwork;
|
||||
use App\Models\ArtworkVersion;
|
||||
use App\Models\Category;
|
||||
use App\Models\ContentType;
|
||||
use App\Models\ArtworkVersion;
|
||||
use App\Services\ArtworkEvolutionService;
|
||||
use App\Services\Cdn\ArtworkCdnPurgeService;
|
||||
use App\Services\ArtworkSearchIndexer;
|
||||
use App\Notifications\NewArtworkReviewNotification;
|
||||
use App\Repositories\Uploads\ArtworkFileRepository;
|
||||
use App\Services\ArtworkAttributionService;
|
||||
use App\Services\ArtworkEvolutionService;
|
||||
use App\Services\Artworks\ArtworkCategoryService;
|
||||
use App\Services\Artworks\ArtworkPublicationService;
|
||||
use App\Services\TagService;
|
||||
use App\Services\ArtworkSearchIndexer;
|
||||
use App\Services\ArtworkVersioningService;
|
||||
use App\Services\Cdn\ArtworkCdnPurgeService;
|
||||
use App\Services\Moderation\ArtworkUploadPolicy;
|
||||
use App\Services\Studio\StudioArtworkQueryService;
|
||||
use App\Services\Studio\StudioBulkActionService;
|
||||
use App\Support\ArtworkDescriptionContentValidator;
|
||||
use App\Services\Tags\TagDiscoveryService;
|
||||
use App\Services\TagService;
|
||||
use App\Services\Uploads\UploadDerivativesService;
|
||||
use App\Services\Uploads\UploadStorageService;
|
||||
use App\Services\Worlds\WorldSubmissionService;
|
||||
use App\Support\ArtworkDescriptionContentValidator;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Notification;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Symfony\Component\HttpKernel\Exception\TooManyRequestsHttpException;
|
||||
@@ -45,6 +52,7 @@ final class StudioArtworksApiController extends Controller
|
||||
private readonly TagService $tagService,
|
||||
private readonly ArtworkCdnPurgeService $cdnPurge,
|
||||
private readonly ArtworkPublicationService $artworkPublication,
|
||||
private readonly ArtworkCategoryService $artworkCategories,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -74,9 +82,9 @@ final class StudioArtworksApiController extends Controller
|
||||
'data' => $items,
|
||||
'meta' => [
|
||||
'current_page' => $paginator->currentPage(),
|
||||
'last_page' => $paginator->lastPage(),
|
||||
'per_page' => $paginator->perPage(),
|
||||
'total' => $paginator->total(),
|
||||
'last_page' => $paginator->lastPage(),
|
||||
'per_page' => $paginator->perPage(),
|
||||
'total' => $paginator->total(),
|
||||
],
|
||||
]);
|
||||
}
|
||||
@@ -88,14 +96,14 @@ final class StudioArtworksApiController extends Controller
|
||||
public function bulk(Request $request): JsonResponse
|
||||
{
|
||||
$validator = Validator::make($request->all(), [
|
||||
'action' => 'required|string|in:publish,unpublish,archive,unarchive,delete,change_category,add_tags,remove_tags',
|
||||
'action' => 'required|string|in:publish,unpublish,archive,unarchive,delete,change_category,add_tags,remove_tags',
|
||||
'artwork_ids' => 'required|array|min:1|max:200',
|
||||
'artwork_ids.*' => 'integer',
|
||||
'params' => 'sometimes|array',
|
||||
'params' => 'sometimes|array',
|
||||
'params.category_id' => 'sometimes|integer|exists:categories,id',
|
||||
'params.tag_ids' => 'sometimes|array',
|
||||
'params.tag_ids.*' => 'integer|exists:tags,id',
|
||||
'confirm' => 'required_if:action,delete|string',
|
||||
'params.tag_ids' => 'sometimes|array',
|
||||
'params.tag_ids.*' => 'integer|exists:tags,id',
|
||||
'confirm' => 'required_if:action,delete|string',
|
||||
]);
|
||||
|
||||
if ($validator->fails()) {
|
||||
@@ -127,23 +135,26 @@ final class StudioArtworksApiController extends Controller
|
||||
* PUT /api/studio/artworks/{id}
|
||||
* Update artwork details (title, description, visibility).
|
||||
*/
|
||||
public function update(Request $request, int $id, ArtworkAttributionService $attribution, WorldSubmissionService $submissions): JsonResponse
|
||||
public function update(Request $request, int $id, ArtworkAttributionService $attribution, WorldSubmissionService $submissions, ArtworkUploadPolicy $uploadPolicy): JsonResponse
|
||||
{
|
||||
$artwork = $request->user()->artworks()->findOrFail($id);
|
||||
$evolution = app(ArtworkEvolutionService::class);
|
||||
|
||||
$validated = $request->validate([
|
||||
'title' => 'sometimes|string|max:255',
|
||||
'title' => 'sometimes|string|max:255',
|
||||
'description' => 'sometimes|nullable|string|max:5000',
|
||||
'is_public' => 'sometimes|boolean',
|
||||
'visibility' => 'sometimes|string|in:public,unlisted,private',
|
||||
'mode' => 'sometimes|string|in:now,schedule',
|
||||
'publish_at' => 'sometimes|nullable|string|date',
|
||||
'timezone' => 'sometimes|nullable|string|max:64',
|
||||
'is_public' => 'sometimes|boolean',
|
||||
'visibility' => 'sometimes|string|in:public,unlisted,private',
|
||||
'mode' => 'sometimes|string|in:now,schedule',
|
||||
'publish_at' => 'sometimes|nullable|string|date',
|
||||
'timezone' => 'sometimes|nullable|string|max:64',
|
||||
'category_id' => 'sometimes|nullable|integer|exists:categories,id',
|
||||
'primary_category_id' => 'sometimes|nullable|integer|exists:categories,id',
|
||||
'secondary_category_ids' => 'sometimes|array|max:'.(int) config('categories.max_secondary_categories', 5),
|
||||
'secondary_category_ids.*' => 'integer|exists:categories,id',
|
||||
'content_type_id' => 'sometimes|nullable|integer|exists:content_types,id',
|
||||
'tags' => 'sometimes|array|max:' . (int) config('tags.max_user_tags', 30),
|
||||
'tags.*' => 'string|max:64',
|
||||
'tags' => 'sometimes|array|max:'.(int) config('tags.max_user_tags', 30),
|
||||
'tags.*' => 'string|max:64',
|
||||
'title_source' => 'sometimes|nullable|string|in:manual,ai_generated,ai_applied,mixed',
|
||||
'description_source' => 'sometimes|nullable|string|in:manual,ai_generated,ai_applied,mixed',
|
||||
'tags_source' => 'sometimes|nullable|string|in:manual,ai_generated,ai_applied,mixed',
|
||||
@@ -210,14 +221,18 @@ final class StudioArtworksApiController extends Controller
|
||||
|
||||
// Extract tags and category before updating core fields
|
||||
$tags = $validated['tags'] ?? null;
|
||||
$categoryId = $validated['category_id'] ?? null;
|
||||
$categoryId = $validated['primary_category_id'] ?? $validated['category_id'] ?? null;
|
||||
$secondaryCategoryIds = array_key_exists('secondary_category_ids', $validated)
|
||||
? array_values(array_map('intval', $validated['secondary_category_ids'] ?? []))
|
||||
: [];
|
||||
$hasSecondaryCategoryUpdate = array_key_exists('secondary_category_ids', $validated);
|
||||
$contentTypeId = $validated['content_type_id'] ?? null;
|
||||
$evolutionPayload = [
|
||||
'target_artwork_id' => $validated['evolution_target_artwork_id'] ?? null,
|
||||
'relation_type' => $validated['evolution_relation_type'] ?? null,
|
||||
'note' => $validated['evolution_note'] ?? null,
|
||||
];
|
||||
unset($validated['tags'], $validated['category_id'], $validated['content_type_id'], $validated['visibility'], $validated['mode'], $validated['publish_at'], $validated['timezone'], $validated['group'], $validated['primary_author_user_id'], $validated['contributor_user_ids'], $validated['contributor_credits'], $validated['world_submissions']);
|
||||
unset($validated['tags'], $validated['category_id'], $validated['primary_category_id'], $validated['secondary_category_ids'], $validated['content_type_id'], $validated['visibility'], $validated['mode'], $validated['publish_at'], $validated['timezone'], $validated['group'], $validated['primary_author_user_id'], $validated['contributor_user_ids'], $validated['contributor_credits'], $validated['world_submissions']);
|
||||
unset($validated['evolution_target_artwork_id'], $validated['evolution_relation_type'], $validated['evolution_note']);
|
||||
|
||||
$validated['visibility'] = $visibility;
|
||||
@@ -231,9 +246,17 @@ final class StudioArtworksApiController extends Controller
|
||||
$validated['artwork_status'] = 'scheduled';
|
||||
} else {
|
||||
$validated['is_public'] = $visibility !== Artwork::VISIBILITY_PRIVATE;
|
||||
$validated['is_approved'] = true;
|
||||
$policy = $uploadPolicy->assess($request->user(), $artwork);
|
||||
$validated['is_approved'] = ! $policy['requires_review'];
|
||||
$validated['approval_source'] = $policy['requires_review'] ? null : 'trusted_auto';
|
||||
$validated['publish_at'] = null;
|
||||
$validated['artwork_status'] = 'published';
|
||||
$validated['artwork_status'] = $policy['requires_review'] ? 'review' : 'published';
|
||||
|
||||
if ($policy['requires_review']) {
|
||||
$validated['is_public'] = false;
|
||||
$validated['published_at'] = null;
|
||||
$validated['moderation_note'] = implode(', ', $policy['reasons']);
|
||||
}
|
||||
|
||||
if (($validated['is_public'] ?? false) && ! $artwork->published_at) {
|
||||
$validated['published_at'] = now();
|
||||
@@ -249,10 +272,19 @@ final class StudioArtworksApiController extends Controller
|
||||
}
|
||||
|
||||
$artwork->update($validated);
|
||||
$requiresReview = ($policy['requires_review'] ?? false) === true;
|
||||
|
||||
// Sync category
|
||||
if ($categoryId !== null) {
|
||||
$artwork->categories()->sync([(int) $categoryId]);
|
||||
if ($categoryId !== null || $hasSecondaryCategoryUpdate) {
|
||||
try {
|
||||
$this->artworkCategories->sync(
|
||||
$artwork,
|
||||
$categoryId !== null ? (int) $categoryId : (int) $artwork->primary_category_id,
|
||||
$hasSecondaryCategoryUpdate ? $secondaryCategoryIds : ($categoryId !== null ? [] : null),
|
||||
(string) ($validated['category_source'] ?? $artwork->category_source ?? 'manual'),
|
||||
);
|
||||
} catch (ValidationException $exception) {
|
||||
return response()->json(['errors' => $exception->errors()], 422);
|
||||
}
|
||||
}
|
||||
|
||||
// Sync tags through the shared tag service so pivot source/usage rules stay valid.
|
||||
@@ -291,24 +323,40 @@ final class StudioArtworksApiController extends Controller
|
||||
// Reindex in Meilisearch — dispatches IndexArtworkJob which writes directly, no Scout hop.
|
||||
$this->searchIndexer->update($artwork);
|
||||
|
||||
if ($requiresReview) {
|
||||
$notifyEmail = trim((string) config('uploads.moderation.notify_email', ''));
|
||||
if ($notifyEmail !== '') {
|
||||
try {
|
||||
Notification::route('mail', $notifyEmail)->notify(
|
||||
new NewArtworkReviewNotification($artwork, $request->user(), $policy['reasons'])
|
||||
);
|
||||
} catch (\Throwable) {
|
||||
// Keep the artwork private if notification delivery fails.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Reload relationships for response
|
||||
$artwork->load(['categories.contentType', 'tags', 'group', 'primaryAuthor.profile', 'contributors.user.profile']);
|
||||
$primaryCategory = $artwork->categories->first();
|
||||
$artwork->load(['categories.contentType', 'primaryCategory.contentType', 'tags', 'group', 'primaryAuthor.profile', 'contributors.user.profile']);
|
||||
$primaryCategory = $artwork->resolvedPrimaryCategory();
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'status' => $requiresReview ? 'submitted_for_review' : 'published',
|
||||
'message' => $requiresReview ? 'Artwork saved and queued for moderation.' : 'Artwork updated successfully.',
|
||||
'review_reasons' => $requiresReview ? $policy['reasons'] : [],
|
||||
'artwork' => [
|
||||
'id' => $artwork->id,
|
||||
'title' => $artwork->title,
|
||||
'description' => $artwork->description,
|
||||
'is_public' => (bool) $artwork->is_public,
|
||||
'visibility' => $artwork->visibility ?: ((bool) $artwork->is_public ? Artwork::VISIBILITY_PUBLIC : Artwork::VISIBILITY_PRIVATE),
|
||||
'publish_mode' => $artwork->artwork_status === 'scheduled' ? 'schedule' : 'now',
|
||||
'publish_at' => $artwork->publish_at?->toIso8601String(),
|
||||
'artwork_status' => $artwork->artwork_status,
|
||||
'id' => $artwork->id,
|
||||
'title' => $artwork->title,
|
||||
'description' => $artwork->description,
|
||||
'is_public' => (bool) $artwork->is_public,
|
||||
'visibility' => $artwork->visibility ?: ((bool) $artwork->is_public ? Artwork::VISIBILITY_PUBLIC : Artwork::VISIBILITY_PRIVATE),
|
||||
'publish_mode' => $artwork->artwork_status === 'scheduled' ? 'schedule' : 'now',
|
||||
'publish_at' => $artwork->publish_at?->toIso8601String(),
|
||||
'artwork_status' => $artwork->artwork_status,
|
||||
'artwork_timezone' => $artwork->artwork_timezone,
|
||||
'slug' => $artwork->slug,
|
||||
'group_slug' => $artwork->group?->slug,
|
||||
'slug' => $artwork->slug,
|
||||
'group_slug' => $artwork->group?->slug,
|
||||
'primary_author_user_id' => (int) ($artwork->primary_author_user_id ?: $artwork->user_id),
|
||||
'contributor_user_ids' => $artwork->contributors->pluck('user_id')->map(fn ($contributorId): int => (int) $contributorId)->values()->all(),
|
||||
'contributor_credits' => $artwork->contributors->map(fn ($contributor): array => [
|
||||
@@ -317,11 +365,18 @@ final class StudioArtworksApiController extends Controller
|
||||
'is_primary' => (bool) $contributor->is_primary,
|
||||
])->values()->all(),
|
||||
'content_type_id' => $primaryCategory?->contentType?->id,
|
||||
'category_id' => $primaryCategory?->id,
|
||||
'tags' => $artwork->tags->map(fn ($t) => ['id' => $t->id, 'name' => $t->name, 'slug' => $t->slug])->values()->all(),
|
||||
'title_source' => $artwork->title_source ?: 'manual',
|
||||
'category_id' => $primaryCategory?->id,
|
||||
'primary_category_id' => $primaryCategory?->id,
|
||||
'secondary_category_ids' => $artwork->categories
|
||||
->reject(fn ($category): bool => (int) $category->id === (int) ($primaryCategory?->id ?? 0))
|
||||
->pluck('id')
|
||||
->map(fn ($id): int => (int) $id)
|
||||
->values()
|
||||
->all(),
|
||||
'tags' => $artwork->tags->map(fn ($t) => ['id' => $t->id, 'name' => $t->name, 'slug' => $t->slug])->values()->all(),
|
||||
'title_source' => $artwork->title_source ?: 'manual',
|
||||
'description_source' => $artwork->description_source ?: 'manual',
|
||||
'tags_source' => $artwork->tags_source ?: 'manual',
|
||||
'tags_source' => $artwork->tags_source ?: 'manual',
|
||||
'category_source' => $artwork->category_source ?: 'manual',
|
||||
'evolution_relation' => $evolution->editorRelation($artwork, $request->user()),
|
||||
],
|
||||
@@ -423,18 +478,18 @@ final class StudioArtworksApiController extends Controller
|
||||
|
||||
return response()->json([
|
||||
'artwork' => [
|
||||
'id' => $artwork->id,
|
||||
'id' => $artwork->id,
|
||||
'title' => $artwork->title,
|
||||
'slug' => $artwork->slug,
|
||||
'slug' => $artwork->slug,
|
||||
],
|
||||
'analytics' => [
|
||||
'views' => (int) ($stats?->views ?? 0),
|
||||
'favourites' => (int) ($stats?->favorites ?? 0),
|
||||
'shares' => (int) ($stats?->shares_count ?? 0),
|
||||
'comments' => (int) ($stats?->comments_count ?? 0),
|
||||
'downloads' => (int) ($stats?->downloads ?? 0),
|
||||
'ranking_score' => (float) ($stats?->ranking_score ?? 0),
|
||||
'heat_score' => (float) ($stats?->heat_score ?? 0),
|
||||
'views' => (int) ($stats?->views ?? 0),
|
||||
'favourites' => (int) ($stats?->favorites ?? 0),
|
||||
'shares' => (int) ($stats?->shares_count ?? 0),
|
||||
'comments' => (int) ($stats?->comments_count ?? 0),
|
||||
'downloads' => (int) ($stats?->downloads ?? 0),
|
||||
'ranking_score' => (float) ($stats?->ranking_score ?? 0),
|
||||
'heat_score' => (float) ($stats?->heat_score ?? 0),
|
||||
'engagement_velocity' => (float) ($stats?->engagement_velocity ?? 0),
|
||||
],
|
||||
]);
|
||||
@@ -449,28 +504,28 @@ final class StudioArtworksApiController extends Controller
|
||||
$stats = $artwork->stats ?? null;
|
||||
|
||||
return [
|
||||
'id' => $artwork->id,
|
||||
'title' => $artwork->title,
|
||||
'slug' => $artwork->slug,
|
||||
'thumb_url' => $artwork->thumbUrl('md') ?? '/images/placeholder.jpg',
|
||||
'is_public' => (bool) $artwork->is_public,
|
||||
'visibility' => $artwork->visibility ?: ((bool) $artwork->is_public ? Artwork::VISIBILITY_PUBLIC : Artwork::VISIBILITY_PRIVATE),
|
||||
'is_approved' => (bool) $artwork->is_approved,
|
||||
'published_at' => $artwork->published_at?->toIso8601String(),
|
||||
'publish_at' => $artwork->publish_at?->toIso8601String(),
|
||||
'artwork_status' => $artwork->artwork_status,
|
||||
'created_at' => $artwork->created_at?->toIso8601String(),
|
||||
'deleted_at' => $artwork->deleted_at?->toIso8601String(),
|
||||
'category' => $artwork->categories->first()?->name,
|
||||
'category_slug' => $artwork->categories->first()?->slug,
|
||||
'tags' => $artwork->tags->pluck('slug')->values()->all(),
|
||||
'views' => (int) ($stats?->views ?? 0),
|
||||
'favourites' => (int) ($stats?->favorites ?? 0),
|
||||
'shares' => (int) ($stats?->shares_count ?? 0),
|
||||
'comments' => (int) ($stats?->comments_count ?? 0),
|
||||
'downloads' => (int) ($stats?->downloads ?? 0),
|
||||
'ranking_score' => (float) ($stats?->ranking_score ?? 0),
|
||||
'heat_score' => (float) ($stats?->heat_score ?? 0),
|
||||
'id' => $artwork->id,
|
||||
'title' => $artwork->title,
|
||||
'slug' => $artwork->slug,
|
||||
'thumb_url' => $artwork->thumbUrl('md') ?? '/images/placeholder.jpg',
|
||||
'is_public' => (bool) $artwork->is_public,
|
||||
'visibility' => $artwork->visibility ?: ((bool) $artwork->is_public ? Artwork::VISIBILITY_PUBLIC : Artwork::VISIBILITY_PRIVATE),
|
||||
'is_approved' => (bool) $artwork->is_approved,
|
||||
'published_at' => $artwork->published_at?->toIso8601String(),
|
||||
'publish_at' => $artwork->publish_at?->toIso8601String(),
|
||||
'artwork_status' => $artwork->artwork_status,
|
||||
'created_at' => $artwork->created_at?->toIso8601String(),
|
||||
'deleted_at' => $artwork->deleted_at?->toIso8601String(),
|
||||
'category' => $artwork->resolvedPrimaryCategory()?->name,
|
||||
'category_slug' => $artwork->resolvedPrimaryCategory()?->slug,
|
||||
'tags' => $artwork->tags->pluck('slug')->values()->all(),
|
||||
'views' => (int) ($stats?->views ?? 0),
|
||||
'favourites' => (int) ($stats?->favorites ?? 0),
|
||||
'shares' => (int) ($stats?->shares_count ?? 0),
|
||||
'comments' => (int) ($stats?->comments_count ?? 0),
|
||||
'downloads' => (int) ($stats?->downloads ?? 0),
|
||||
'ranking_score' => (float) ($stats?->ranking_score ?? 0),
|
||||
'heat_score' => (float) ($stats?->heat_score ?? 0),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -499,7 +554,7 @@ final class StudioArtworksApiController extends Controller
|
||||
$previousSnapshot = $this->versioningService->captureArtworkSnapshot($artwork);
|
||||
|
||||
$request->validate([
|
||||
'file' => 'required|file|mimes:jpeg,jpg,png,webp|max:51200', // 50 MB
|
||||
'file' => 'required|file|mimes:jpeg,jpg,png,webp|max:51200', // 50 MB
|
||||
'change_note' => 'sometimes|nullable|string|max:500',
|
||||
]);
|
||||
|
||||
@@ -510,43 +565,43 @@ final class StudioArtworksApiController extends Controller
|
||||
return response()->json(['success' => false, 'error' => $e->getMessage()], 429);
|
||||
}
|
||||
|
||||
$file = $request->file('file');
|
||||
$file = $request->file('file');
|
||||
$tempPath = $file->getRealPath();
|
||||
$hash = hash_file('sha256', $tempPath);
|
||||
$hash = hash_file('sha256', $tempPath);
|
||||
|
||||
// Reject identical files early (before any disk writes)
|
||||
if ($artwork->hash === $hash) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'error' => 'The uploaded file is identical to the current version.',
|
||||
'error' => 'The uploaded file is identical to the current version.',
|
||||
], 422);
|
||||
}
|
||||
|
||||
try {
|
||||
$derivatives = app(\App\Services\Uploads\UploadDerivativesService::class);
|
||||
$storage = app(\App\Services\Uploads\UploadStorageService::class);
|
||||
$artworkFiles = app(\App\Repositories\Uploads\ArtworkFileRepository::class);
|
||||
$derivatives = app(UploadDerivativesService::class);
|
||||
$storage = app(UploadStorageService::class);
|
||||
$artworkFiles = app(ArtworkFileRepository::class);
|
||||
|
||||
// 1. Store original on disk (preserve extension when possible)
|
||||
$originalAsset = $derivatives->storeOriginal($tempPath, $hash);
|
||||
$originalPath = $originalAsset['local_path'];
|
||||
$origFilename = basename($originalPath);
|
||||
$originalAsset = $derivatives->storeOriginal($tempPath, $hash);
|
||||
$originalPath = $originalAsset['local_path'];
|
||||
$origFilename = basename($originalPath);
|
||||
$originalRelative = $storage->sectionRelativePath('original', $hash, $origFilename);
|
||||
$origMime = File::exists($originalPath) ? File::mimeType($originalPath) : 'application/octet-stream';
|
||||
$origMime = File::exists($originalPath) ? File::mimeType($originalPath) : 'application/octet-stream';
|
||||
$artworkFiles->upsert($artwork->id, 'orig', $originalRelative, $origMime, (int) filesize($originalPath));
|
||||
|
||||
// 2. Generate thumbnails (xs/sm/md/lg/xl)
|
||||
$publicAssets = $derivatives->generatePublicDerivatives($tempPath, $hash);
|
||||
foreach ($publicAssets as $variant => $asset) {
|
||||
$relativePath = $storage->sectionRelativePath($variant, $hash, $hash . '.webp');
|
||||
$relativePath = $storage->sectionRelativePath($variant, $hash, $hash.'.webp');
|
||||
$artworkFiles->upsert($artwork->id, $variant, $relativePath, 'image/webp', (int) ($asset['size'] ?? 0));
|
||||
}
|
||||
|
||||
// 3. Get new dimensions
|
||||
$dims = @getimagesize($tempPath);
|
||||
$width = is_array($dims) && isset($dims[0]) ? (int) $dims[0] : $artwork->width;
|
||||
$dims = @getimagesize($tempPath);
|
||||
$width = is_array($dims) && isset($dims[0]) ? (int) $dims[0] : $artwork->width;
|
||||
$height = is_array($dims) && isset($dims[1]) ? (int) $dims[1] : $artwork->height;
|
||||
$size = (int) filesize($originalPath);
|
||||
$size = (int) filesize($originalPath);
|
||||
|
||||
// 4. Update the artwork's file-serving fields (hash drives thumbnail URLs)
|
||||
$origExt = strtolower(pathinfo($originalPath, PATHINFO_EXTENSION) ?: '');
|
||||
@@ -559,7 +614,7 @@ final class StudioArtworksApiController extends Controller
|
||||
if ($clientName !== '') {
|
||||
$clientExt = strtolower((string) pathinfo($clientName, PATHINFO_EXTENSION));
|
||||
if ($clientExt === '' && $origExt !== '') {
|
||||
$clientName .= '.' . $origExt;
|
||||
$clientName .= '.'.$origExt;
|
||||
}
|
||||
|
||||
$displayFileName = $clientName;
|
||||
@@ -570,11 +625,11 @@ final class StudioArtworksApiController extends Controller
|
||||
'file_path' => '',
|
||||
'file_size' => $size,
|
||||
'mime_type' => $origMime,
|
||||
'hash' => $hash,
|
||||
'file_ext' => $origExt,
|
||||
'hash' => $hash,
|
||||
'file_ext' => $origExt,
|
||||
'thumb_ext' => 'webp',
|
||||
'width' => max(1, $width),
|
||||
'height' => max(1, $height),
|
||||
'width' => max(1, $width),
|
||||
'height' => max(1, $height),
|
||||
]);
|
||||
|
||||
// 5. Create version record from the new full media snapshot.
|
||||
@@ -593,7 +648,7 @@ final class StudioArtworksApiController extends Controller
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('ArtworkVersioningService: Meilisearch reindex failed', [
|
||||
'artwork_id' => $artwork->id,
|
||||
'error' => $e->getMessage(),
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -601,16 +656,17 @@ final class StudioArtworksApiController extends Controller
|
||||
$this->purgeCdnCache($artwork, $hash);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'success' => true,
|
||||
'version_number' => $version->version_number,
|
||||
'requires_reapproval' => (bool) $artwork->requires_reapproval,
|
||||
] + $this->mediaPayload($artwork));
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('replaceFile: processing error', [
|
||||
'artwork_id' => $artwork->id,
|
||||
'error' => $e->getMessage(),
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
return response()->json(['success' => false, 'error' => 'File processing failed: ' . $e->getMessage()], 500);
|
||||
|
||||
return response()->json(['success' => false, 'error' => 'File processing failed: '.$e->getMessage()], 500);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -678,8 +734,8 @@ final class StudioArtworksApiController extends Controller
|
||||
return response()->json(['success' => false, 'error' => $e->getMessage()], 429);
|
||||
}
|
||||
|
||||
$derivatives = app(\App\Services\Uploads\UploadDerivativesService::class);
|
||||
$storage = app(\App\Services\Uploads\UploadStorageService::class);
|
||||
$derivatives = app(UploadDerivativesService::class);
|
||||
$storage = app(UploadStorageService::class);
|
||||
$cleanupLocalPaths = [];
|
||||
$cleanupObjectPaths = [];
|
||||
|
||||
@@ -766,7 +822,7 @@ final class StudioArtworksApiController extends Controller
|
||||
|
||||
$allScreenshotDescriptors = [];
|
||||
foreach (array_values(array_merge($existingScreenshotDescriptors, $newScreenshotDescriptors)) as $index => $descriptor) {
|
||||
$descriptor['variant'] = 'shot' . ($index + 1);
|
||||
$descriptor['variant'] = 'shot'.($index + 1);
|
||||
$allScreenshotDescriptors[] = $descriptor;
|
||||
}
|
||||
|
||||
@@ -862,7 +918,7 @@ final class StudioArtworksApiController extends Controller
|
||||
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'error' => 'Revision update failed: ' . $exception->getMessage(),
|
||||
'error' => 'Revision update failed: '.$exception->getMessage(),
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
@@ -873,27 +929,27 @@ final class StudioArtworksApiController extends Controller
|
||||
*/
|
||||
public function versions(Request $request, int $id): JsonResponse
|
||||
{
|
||||
$artwork = $request->user()->artworks()->findOrFail($id);
|
||||
$artwork = $request->user()->artworks()->findOrFail($id);
|
||||
$versions = $artwork->versions()->reorder()->orderByDesc('version_number')->get();
|
||||
|
||||
return response()->json([
|
||||
'artwork' => [
|
||||
'id' => $artwork->id,
|
||||
'title' => $artwork->title,
|
||||
'id' => $artwork->id,
|
||||
'title' => $artwork->title,
|
||||
'version_count' => (int) ($artwork->version_count ?? 1),
|
||||
],
|
||||
'versions' => $versions->map(fn (ArtworkVersion $v) => [
|
||||
'id' => $v->id,
|
||||
'id' => $v->id,
|
||||
'version_number' => $v->version_number,
|
||||
'file_path' => $v->file_path,
|
||||
'file_hash' => $v->file_hash,
|
||||
'width' => $v->width,
|
||||
'height' => $v->height,
|
||||
'file_size' => $v->file_size,
|
||||
'file_name' => (string) data_get($v->snapshot_json, 'artwork.file_name', ''),
|
||||
'change_note' => $v->change_note,
|
||||
'is_current' => $v->is_current,
|
||||
'created_at' => $v->created_at?->toIso8601String(),
|
||||
'file_path' => $v->file_path,
|
||||
'file_hash' => $v->file_hash,
|
||||
'width' => $v->width,
|
||||
'height' => $v->height,
|
||||
'file_size' => $v->file_size,
|
||||
'file_name' => (string) data_get($v->snapshot_json, 'artwork.file_name', ''),
|
||||
'change_note' => $v->change_note,
|
||||
'is_current' => $v->is_current,
|
||||
'created_at' => $v->created_at?->toIso8601String(),
|
||||
])->values(),
|
||||
]);
|
||||
}
|
||||
@@ -918,17 +974,18 @@ final class StudioArtworksApiController extends Controller
|
||||
// Reindex
|
||||
try {
|
||||
$this->searchIndexer->update($artwork);
|
||||
} catch (\Throwable) {}
|
||||
} catch (\Throwable) {
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'success' => true,
|
||||
'version_number' => $newVersion->version_number,
|
||||
'message' => "Version {$version->version_number} has been restored as version {$newVersion->version_number}.",
|
||||
'message' => "Version {$version->version_number} has been restored as version {$newVersion->version_number}.",
|
||||
] + $this->mediaPayload($artwork));
|
||||
} catch (TooManyRequestsHttpException $e) {
|
||||
return response()->json(['success' => false, 'error' => $e->getMessage()], 429);
|
||||
} catch (\Throwable $e) {
|
||||
return response()->json(['success' => false, 'error' => 'Restore failed: ' . $e->getMessage()], 500);
|
||||
return response()->json(['success' => false, 'error' => 'Restore failed: '.$e->getMessage()], 500);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -938,7 +995,7 @@ final class StudioArtworksApiController extends Controller
|
||||
* This is best-effort; failures are logged but never fatal.
|
||||
* Configure a CDN purge webhook via ARTWORK_CDN_PURGE_URL if needed.
|
||||
*/
|
||||
private function purgeCdnCache(\App\Models\Artwork $artwork, string $oldHash): void
|
||||
private function purgeCdnCache(Artwork $artwork, string $oldHash): void
|
||||
{
|
||||
try {
|
||||
$this->cdnPurge->purgeArtworkHashVariants($oldHash, 'webp', ['xs', 'sm', 'md', 'lg', 'xl', 'sq'], [
|
||||
@@ -951,7 +1008,7 @@ final class StudioArtworksApiController extends Controller
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $snapshot
|
||||
* @param array<string, mixed> $snapshot
|
||||
* @return array<string, array<string, mixed>>
|
||||
*/
|
||||
private function snapshotFilesByVariant(array $snapshot): array
|
||||
@@ -963,7 +1020,7 @@ final class StudioArtworksApiController extends Controller
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $snapshot
|
||||
* @param array<string, mixed> $snapshot
|
||||
* @return array<string, array{path: string, mime: string, size: int}>
|
||||
*/
|
||||
private function currentPublicAssetsFromSnapshot(array $snapshot): array
|
||||
@@ -981,7 +1038,7 @@ final class StudioArtworksApiController extends Controller
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed>|null $file
|
||||
* @param array<string, mixed>|null $file
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
private function existingVariantDescriptor(?array $file, string $fallbackVariant): ?array
|
||||
@@ -1002,7 +1059,7 @@ final class StudioArtworksApiController extends Controller
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $stored
|
||||
* @param array<string, mixed> $stored
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function storedOriginalDescriptor(array $stored, string $variant): array
|
||||
@@ -1028,7 +1085,7 @@ final class StudioArtworksApiController extends Controller
|
||||
|
||||
$candidateExt = strtolower((string) pathinfo($candidate, PATHINFO_EXTENSION));
|
||||
if ($candidateExt === '' && $ext !== '') {
|
||||
$candidate .= '.' . ltrim($ext, '.');
|
||||
$candidate .= '.'.ltrim($ext, '.');
|
||||
}
|
||||
|
||||
return $candidate;
|
||||
@@ -1058,7 +1115,7 @@ final class StudioArtworksApiController extends Controller
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $snapshot
|
||||
* @param array<string, mixed> $snapshot
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
private function screenshotAssetsFromSnapshot(array $snapshot): array
|
||||
@@ -1070,11 +1127,11 @@ final class StudioArtworksApiController extends Controller
|
||||
->values()
|
||||
->map(function (array $file, int $index) use ($base): array {
|
||||
$path = trim((string) ($file['path'] ?? ''), '/');
|
||||
$url = $base . '/' . $path;
|
||||
$url = $base.'/'.$path;
|
||||
|
||||
return [
|
||||
'id' => (string) ($file['variant'] ?? ('shot' . ($index + 1))),
|
||||
'label' => 'Screenshot ' . ($index + 1),
|
||||
'id' => (string) ($file['variant'] ?? ('shot'.($index + 1))),
|
||||
'label' => 'Screenshot '.($index + 1),
|
||||
'url' => $url,
|
||||
'thumb_url' => $url,
|
||||
'mime_type' => (string) ($file['mime'] ?? 'image/jpeg'),
|
||||
|
||||
@@ -5,26 +5,27 @@ declare(strict_types=1);
|
||||
namespace App\Http\Controllers\Studio;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Group;
|
||||
use App\Models\ContentType;
|
||||
use App\Models\Group;
|
||||
use App\Services\ArtworkEvolutionService;
|
||||
use App\Services\Artworks\ArtworkPublicationService;
|
||||
use App\Services\GroupMembershipService;
|
||||
use App\Services\GroupService;
|
||||
use App\Services\Studio\CreatorStudioActivityService;
|
||||
use App\Services\Studio\CreatorStudioAnalyticsService;
|
||||
use App\Services\Studio\CreatorStudioAssetService;
|
||||
use App\Services\Studio\CreatorStudioCalendarService;
|
||||
use App\Services\Studio\CreatorStudioChallengeService;
|
||||
use App\Services\Studio\CreatorStudioCommentService;
|
||||
use App\Services\Studio\CreatorStudioContentService;
|
||||
use App\Services\Studio\CreatorStudioFollowersService;
|
||||
use App\Services\Studio\CreatorStudioGrowthService;
|
||||
use App\Services\Studio\CreatorStudioActivityService;
|
||||
use App\Services\Studio\CreatorStudioInboxService;
|
||||
use App\Services\Studio\CreatorStudioOverviewService;
|
||||
use App\Services\Studio\CreatorStudioPreferenceService;
|
||||
use App\Services\Studio\CreatorStudioChallengeService;
|
||||
use App\Services\Studio\CreatorStudioSearchService;
|
||||
use App\Services\Studio\CreatorStudioScheduledService;
|
||||
use App\Services\Studio\CreatorStudioSearchService;
|
||||
use App\Services\Uploads\UploadQueueService;
|
||||
use App\Services\Worlds\WorldSubmissionService;
|
||||
use App\Support\CoverUrl;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
@@ -132,7 +133,7 @@ final class StudioController extends Controller
|
||||
|
||||
public function uploadQueue(Request $request): Response
|
||||
{
|
||||
$queue = app(\App\Services\Uploads\UploadQueueService::class)->listPayload(
|
||||
$queue = app(UploadQueueService::class)->listPayload(
|
||||
$request->user(),
|
||||
$request->only(['batch_id', 'status', 'sort'])
|
||||
);
|
||||
@@ -361,7 +362,7 @@ final class StudioController extends Controller
|
||||
'cover_url' => $user->cover_hash && $user->cover_ext ? CoverUrl::forUser($user->cover_hash, $user->cover_ext, time()) : null,
|
||||
'cover_position' => (int) ($user->cover_position ?? 50),
|
||||
'followers' => (int) ($user->statistics?->followers_count ?? 0),
|
||||
'profile_url' => '/@' . strtolower((string) $user->username),
|
||||
'profile_url' => '/@'.strtolower((string) $user->username),
|
||||
'social_links' => $socialLinks,
|
||||
],
|
||||
'moduleSummaries' => $this->content->moduleSummaries($user),
|
||||
@@ -449,13 +450,13 @@ final class StudioController extends Controller
|
||||
{
|
||||
$user = $request->user();
|
||||
$artwork = $user->artworks()
|
||||
->with(['stats', 'categories.contentType', 'tags', 'artworkAiAssist', 'group.members', 'primaryAuthor.profile', 'contributors.user.profile'])
|
||||
->with(['stats', 'categories.contentType', 'primaryCategory.contentType', 'tags', 'artworkAiAssist', 'group.members', 'primaryAuthor.profile', 'contributors.user.profile'])
|
||||
->findOrFail($id);
|
||||
|
||||
$artwork = app(ArtworkPublicationService::class)->publishIfDue($artwork);
|
||||
$artwork->loadMissing(['stats', 'categories.contentType', 'tags', 'artworkAiAssist', 'group.members', 'primaryAuthor.profile', 'contributors.user.profile']);
|
||||
$artwork->loadMissing(['stats', 'categories.contentType', 'primaryCategory.contentType', 'tags', 'artworkAiAssist', 'group.members', 'primaryAuthor.profile', 'contributors.user.profile']);
|
||||
|
||||
$primaryCategory = $artwork->categories->first();
|
||||
$primaryCategory = $artwork->resolvedPrimaryCategory();
|
||||
$availableGroups = app(GroupService::class)->studioOptionsForUser($user);
|
||||
$membershipService = app(GroupMembershipService::class);
|
||||
$contributorOptionsByGroup = [];
|
||||
@@ -470,30 +471,30 @@ final class StudioController extends Controller
|
||||
}
|
||||
|
||||
return Inertia::render('Studio/StudioArtworkEdit', [
|
||||
'artwork' => [
|
||||
'id' => $artwork->id,
|
||||
'title' => $artwork->title,
|
||||
'slug' => $artwork->slug,
|
||||
'description' => $artwork->description,
|
||||
'is_public' => (bool) $artwork->is_public,
|
||||
'visibility' => $artwork->visibility ?: ((bool) $artwork->is_public ? 'public' : 'private'),
|
||||
'is_approved' => (bool) $artwork->is_approved,
|
||||
'publish_mode' => $artwork->artwork_status === 'scheduled' ? 'schedule' : 'now',
|
||||
'publish_at' => $artwork->publish_at?->toIso8601String(),
|
||||
'artwork_status' => $artwork->artwork_status,
|
||||
'artwork' => [
|
||||
'id' => $artwork->id,
|
||||
'title' => $artwork->title,
|
||||
'slug' => $artwork->slug,
|
||||
'description' => $artwork->description,
|
||||
'is_public' => (bool) $artwork->is_public,
|
||||
'visibility' => $artwork->visibility ?: ((bool) $artwork->is_public ? 'public' : 'private'),
|
||||
'is_approved' => (bool) $artwork->is_approved,
|
||||
'publish_mode' => $artwork->artwork_status === 'scheduled' ? 'schedule' : 'now',
|
||||
'publish_at' => $artwork->publish_at?->toIso8601String(),
|
||||
'artwork_status' => $artwork->artwork_status,
|
||||
'artwork_timezone' => $artwork->artwork_timezone,
|
||||
'thumb_url' => $artwork->thumbUrl('md'),
|
||||
'thumb_url_lg' => $artwork->thumbUrl('lg'),
|
||||
'download_url' => route('art.download', ['id' => $artwork->id]),
|
||||
'file_name' => $artwork->file_name,
|
||||
'file_ext' => $artwork->file_ext,
|
||||
'file_size' => $artwork->file_size,
|
||||
'width' => $artwork->width,
|
||||
'height' => $artwork->height,
|
||||
'mime_type' => $artwork->mime_type,
|
||||
'thumb_url' => $artwork->thumbUrl('md'),
|
||||
'thumb_url_lg' => $artwork->thumbUrl('lg'),
|
||||
'download_url' => route('art.download', ['id' => $artwork->id]),
|
||||
'file_name' => $artwork->file_name,
|
||||
'file_ext' => $artwork->file_ext,
|
||||
'file_size' => $artwork->file_size,
|
||||
'width' => $artwork->width,
|
||||
'height' => $artwork->height,
|
||||
'mime_type' => $artwork->mime_type,
|
||||
'has_archive_file' => $this->artworkHasArchiveFile((int) $artwork->id),
|
||||
'screenshots' => $this->screenshotAssetsForArtwork((int) $artwork->id),
|
||||
'group_slug' => $artwork->group?->slug,
|
||||
'screenshots' => $this->screenshotAssetsForArtwork((int) $artwork->id),
|
||||
'group_slug' => $artwork->group?->slug,
|
||||
'primary_author_user_id' => (int) ($artwork->primary_author_user_id ?: $artwork->user_id),
|
||||
'contributor_user_ids' => $artwork->contributors->pluck('user_id')->map(fn ($id): int => (int) $id)->values()->all(),
|
||||
'contributor_credits' => $artwork->contributors->map(fn ($contributor): array => [
|
||||
@@ -502,19 +503,27 @@ final class StudioController extends Controller
|
||||
'is_primary' => (bool) $contributor->is_primary,
|
||||
])->values()->all(),
|
||||
'content_type_id' => $primaryCategory?->contentType?->id,
|
||||
'category_id' => $primaryCategory?->id,
|
||||
'category_id' => $primaryCategory?->id,
|
||||
'primary_category_id' => $primaryCategory?->id,
|
||||
'parent_category_id' => $primaryCategory?->parent_id ? $primaryCategory->parent_id : $primaryCategory?->id,
|
||||
'sub_category_id' => $primaryCategory?->parent_id ? $primaryCategory->id : null,
|
||||
'categories' => $artwork->categories->map(fn ($c) => ['id' => $c->id, 'name' => $c->name, 'slug' => $c->slug])->values()->all(),
|
||||
'tags' => $artwork->tags->map(fn ($t) => ['id' => $t->id, 'name' => $t->name, 'slug' => $t->slug])->values()->all(),
|
||||
'ai_status' => $artwork->ai_status,
|
||||
'title_source' => $artwork->title_source ?: 'manual',
|
||||
'sub_category_id' => $primaryCategory?->parent_id ? $primaryCategory->id : null,
|
||||
'secondary_category_ids' => $artwork->categories
|
||||
->reject(fn ($category): bool => (int) $category->id === (int) ($primaryCategory?->id ?? 0))
|
||||
->pluck('id')
|
||||
->map(fn ($id): int => (int) $id)
|
||||
->values()
|
||||
->all(),
|
||||
'max_secondary_categories' => (int) config('categories.max_secondary_categories', 5),
|
||||
'categories' => $artwork->categories->map(fn ($c) => ['id' => $c->id, 'name' => $c->name, 'slug' => $c->slug, 'is_primary' => (int) $c->id === (int) ($primaryCategory?->id ?? 0)])->values()->all(),
|
||||
'tags' => $artwork->tags->map(fn ($t) => ['id' => $t->id, 'name' => $t->name, 'slug' => $t->slug])->values()->all(),
|
||||
'ai_status' => $artwork->ai_status,
|
||||
'title_source' => $artwork->title_source ?: 'manual',
|
||||
'description_source' => $artwork->description_source ?: 'manual',
|
||||
'tags_source' => $artwork->tags_source ?: 'manual',
|
||||
'tags_source' => $artwork->tags_source ?: 'manual',
|
||||
'category_source' => $artwork->category_source ?: 'manual',
|
||||
'evolution_relation' => app(ArtworkEvolutionService::class)->editorRelation($artwork, $user),
|
||||
// Versioning
|
||||
'version_count' => (int) ($artwork->version_count ?? 1),
|
||||
'version_count' => (int) ($artwork->version_count ?? 1),
|
||||
'requires_reapproval' => (bool) $artwork->requires_reapproval,
|
||||
],
|
||||
'worldSubmissionOptions' => app(WorldSubmissionService::class)->artworkSubmissionOptions($artwork, $user),
|
||||
@@ -538,19 +547,19 @@ final class StudioController extends Controller
|
||||
|
||||
return Inertia::render('Studio/StudioArtworkAnalytics', [
|
||||
'artwork' => [
|
||||
'id' => $artwork->id,
|
||||
'title' => $artwork->title,
|
||||
'slug' => $artwork->slug,
|
||||
'thumb_url' => $artwork->thumbUrl('md'),
|
||||
'id' => $artwork->id,
|
||||
'title' => $artwork->title,
|
||||
'slug' => $artwork->slug,
|
||||
'thumb_url' => $artwork->thumbUrl('md'),
|
||||
],
|
||||
'analytics' => [
|
||||
'views' => (int) ($stats?->views ?? 0),
|
||||
'favourites' => (int) ($stats?->favorites ?? 0),
|
||||
'shares' => (int) ($stats?->shares_count ?? 0),
|
||||
'comments' => (int) ($stats?->comments_count ?? 0),
|
||||
'downloads' => (int) ($stats?->downloads ?? 0),
|
||||
'ranking_score' => (float) ($stats?->ranking_score ?? 0),
|
||||
'heat_score' => (float) ($stats?->heat_score ?? 0),
|
||||
'views' => (int) ($stats?->views ?? 0),
|
||||
'favourites' => (int) ($stats?->favorites ?? 0),
|
||||
'shares' => (int) ($stats?->shares_count ?? 0),
|
||||
'comments' => (int) ($stats?->comments_count ?? 0),
|
||||
'downloads' => (int) ($stats?->downloads ?? 0),
|
||||
'ranking_score' => (float) ($stats?->ranking_score ?? 0),
|
||||
'heat_score' => (float) ($stats?->heat_score ?? 0),
|
||||
'engagement_velocity' => (float) ($stats?->engagement_velocity ?? 0),
|
||||
],
|
||||
]);
|
||||
@@ -569,16 +578,16 @@ final class StudioController extends Controller
|
||||
$data = $this->analytics->overview($user, $rangeDays);
|
||||
|
||||
return Inertia::render('Studio/StudioAnalytics', [
|
||||
'totals' => $data['totals'],
|
||||
'topContent' => $data['top_content'],
|
||||
'moduleBreakdown' => $data['module_breakdown'],
|
||||
'viewsTrend' => $data['views_trend'],
|
||||
'engagementTrend' => $data['engagement_trend'],
|
||||
'totals' => $data['totals'],
|
||||
'topContent' => $data['top_content'],
|
||||
'moduleBreakdown' => $data['module_breakdown'],
|
||||
'viewsTrend' => $data['views_trend'],
|
||||
'engagementTrend' => $data['engagement_trend'],
|
||||
'publishingTimeline' => $data['publishing_timeline'],
|
||||
'comparison' => $data['comparison'],
|
||||
'insightBlocks' => $data['insight_blocks'],
|
||||
'rangeDays' => $data['range_days'],
|
||||
'recentComments' => $this->overview->recentComments($user, 8),
|
||||
'comparison' => $data['comparison'],
|
||||
'insightBlocks' => $data['insight_blocks'],
|
||||
'rangeDays' => $data['range_days'],
|
||||
'recentComments' => $this->overview->recentComments($user, 8),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -586,16 +595,16 @@ final class StudioController extends Controller
|
||||
{
|
||||
return ContentType::with(['rootCategories.children'])->ordered()->get()->map(function ($ct) {
|
||||
return [
|
||||
'id' => $ct->id,
|
||||
'id' => $ct->id,
|
||||
'name' => $ct->name,
|
||||
'slug' => $ct->slug,
|
||||
'categories' => $ct->rootCategories->map(function ($c) {
|
||||
return [
|
||||
'id' => $c->id,
|
||||
'name' => $c->name,
|
||||
'slug' => $c->slug,
|
||||
'id' => $c->id,
|
||||
'name' => $c->name,
|
||||
'slug' => $c->slug,
|
||||
'children' => $c->children->map(fn ($ch) => [
|
||||
'id' => $ch->id,
|
||||
'id' => $ch->id,
|
||||
'name' => $ch->name,
|
||||
'slug' => $ch->slug,
|
||||
])->values()->all(),
|
||||
@@ -642,11 +651,11 @@ final class StudioController extends Controller
|
||||
return null;
|
||||
}
|
||||
|
||||
$url = $base . '/' . $path;
|
||||
$url = $base.'/'.$path;
|
||||
|
||||
return [
|
||||
'id' => (string) ($row->variant ?? ('shot' . ($index + 1))),
|
||||
'label' => 'Screenshot ' . ($index + 1),
|
||||
'id' => (string) ($row->variant ?? ('shot'.($index + 1))),
|
||||
'label' => 'Screenshot '.($index + 1),
|
||||
'url' => $url,
|
||||
'thumb_url' => $url,
|
||||
'mime_type' => (string) ($row->mime ?? 'image/jpeg'),
|
||||
|
||||
@@ -25,40 +25,43 @@ use App\Models\GroupReleaseContributor;
|
||||
use App\Models\ProfileComment;
|
||||
use App\Models\Story;
|
||||
use App\Models\User;
|
||||
use Carbon\CarbonInterface;
|
||||
use App\Services\Security\CaptchaVerifier;
|
||||
use App\Services\AvatarService;
|
||||
use App\Services\ArtworkService;
|
||||
use App\Services\FollowService;
|
||||
use App\Services\AchievementService;
|
||||
use App\Services\ArtworkService;
|
||||
use App\Services\AvatarService;
|
||||
use App\Services\CollectionService;
|
||||
use App\Services\FollowAnalyticsService;
|
||||
use App\Services\LeaderboardService;
|
||||
use App\Services\UserSuggestionService;
|
||||
use App\Services\Countries\CountryCatalogService;
|
||||
use App\Services\FollowAnalyticsService;
|
||||
use App\Services\FollowService;
|
||||
use App\Services\LeaderboardService;
|
||||
use App\Services\Maturity\ArtworkMaturityService;
|
||||
use App\Services\ThumbnailPresenter;
|
||||
use App\Services\Worlds\WorldRewardService;
|
||||
use App\Services\XPService;
|
||||
use App\Services\UsernameApprovalService;
|
||||
use App\Services\Profile\CreatorJourneyService;
|
||||
use App\Services\Profile\WorldProfileHistoryService;
|
||||
use App\Services\Security\CaptchaVerifier;
|
||||
use App\Services\ThumbnailPresenter;
|
||||
use App\Services\UsernameApprovalService;
|
||||
use App\Services\UserStatsService;
|
||||
use App\Services\UserSuggestionService;
|
||||
use App\Services\Worlds\WorldRewardService;
|
||||
use App\Services\XPService;
|
||||
use App\Support\AvatarUrl;
|
||||
use App\Support\CoverUrl;
|
||||
use App\Support\Seo\SeoFactory;
|
||||
use App\Support\UsernamePolicy;
|
||||
use Carbon\Carbon;
|
||||
use Carbon\CarbonInterface;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Redirect;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\View\View;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Illuminate\Support\Facades\Redirect;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Validation\Rules\Password as PasswordRule;
|
||||
use Illuminate\View\View;
|
||||
use Inertia\Inertia;
|
||||
|
||||
class ProfileController extends Controller
|
||||
@@ -95,9 +98,7 @@ class ProfileController extends Controller
|
||||
private readonly CreatorJourneyService $creatorJourney,
|
||||
private readonly WorldRewardService $worldRewards,
|
||||
private readonly WorldProfileHistoryService $worldProfileHistory,
|
||||
)
|
||||
{
|
||||
}
|
||||
) {}
|
||||
|
||||
public function showByUsername(Request $request, string $username)
|
||||
{
|
||||
@@ -253,11 +254,11 @@ class ProfileController extends Controller
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$url = url('/@' . strtolower((string) $user->username) . '/followers');
|
||||
$url = url('/@'.strtolower((string) $user->username).'/followers');
|
||||
$query = $request->query();
|
||||
|
||||
if ($query !== []) {
|
||||
$url .= '?' . http_build_query($query);
|
||||
$url .= '?'.http_build_query($query);
|
||||
}
|
||||
|
||||
return redirect()->to($url, 301);
|
||||
@@ -267,18 +268,18 @@ class ProfileController extends Controller
|
||||
public function toggleFollow(Request $request, string $username): JsonResponse
|
||||
{
|
||||
$normalized = UsernamePolicy::normalize($username);
|
||||
$target = User::query()->whereRaw('LOWER(username) = ?', [$normalized])->firstOrFail();
|
||||
$actorId = (int) Auth::id();
|
||||
$target = User::query()->whereRaw('LOWER(username) = ?', [$normalized])->firstOrFail();
|
||||
$actorId = (int) Auth::id();
|
||||
|
||||
if ($actorId === $target->id) {
|
||||
return response()->json(['error' => 'Cannot follow yourself.'], 422);
|
||||
}
|
||||
|
||||
$following = $this->followService->toggle($actorId, (int) $target->id);
|
||||
$count = $this->followService->followersCount((int) $target->id);
|
||||
$count = $this->followService->followersCount((int) $target->id);
|
||||
|
||||
return response()->json([
|
||||
'following' => $following,
|
||||
'following' => $following,
|
||||
'follower_count' => $count,
|
||||
]);
|
||||
}
|
||||
@@ -295,8 +296,8 @@ class ProfileController extends Controller
|
||||
|
||||
$comment = ProfileComment::create([
|
||||
'profile_user_id' => $target->id,
|
||||
'author_user_id' => Auth::id(),
|
||||
'body' => $request->input('body'),
|
||||
'author_user_id' => Auth::id(),
|
||||
'body' => $request->input('body'),
|
||||
]);
|
||||
|
||||
app(XPService::class)->awardCommentCreated((int) Auth::id(), (int) $comment->id, 'profile');
|
||||
@@ -347,27 +348,49 @@ class ProfileController extends Controller
|
||||
$profile = DB::table('user_profiles')->where('user_id', $user->id)->first();
|
||||
if ($profile) {
|
||||
$profileData = (array) $profile;
|
||||
if (isset($profile->website)) $user->homepage = $profile->website;
|
||||
if (isset($profile->about)) $user->about_me = $profile->about;
|
||||
if (isset($profile->birthdate)) $user->birth = $profile->birthdate;
|
||||
if (isset($profile->gender)) $user->gender = $profile->gender;
|
||||
if (isset($profile->country_code)) $user->country_code = $profile->country_code;
|
||||
if (isset($profile->signature)) $user->signature = $profile->signature;
|
||||
if (isset($profile->description)) $user->description = $profile->description;
|
||||
if (isset($profile->mlist)) $user->mlist = $profile->mlist;
|
||||
if (isset($profile->friend_upload_notice)) $user->friend_upload_notice = $profile->friend_upload_notice;
|
||||
if (isset($profile->auto_post_upload)) $user->auto_post_upload = $profile->auto_post_upload;
|
||||
if (isset($profile->website)) {
|
||||
$user->homepage = $profile->website;
|
||||
}
|
||||
if (isset($profile->about)) {
|
||||
$user->about_me = $profile->about;
|
||||
}
|
||||
if (isset($profile->birthdate)) {
|
||||
$user->birth = $profile->birthdate;
|
||||
}
|
||||
if (isset($profile->gender)) {
|
||||
$user->gender = $profile->gender;
|
||||
}
|
||||
if (isset($profile->country_code)) {
|
||||
$user->country_code = $profile->country_code;
|
||||
}
|
||||
if (isset($profile->signature)) {
|
||||
$user->signature = $profile->signature;
|
||||
}
|
||||
if (isset($profile->description)) {
|
||||
$user->description = $profile->description;
|
||||
}
|
||||
if (isset($profile->mlist)) {
|
||||
$user->mlist = $profile->mlist;
|
||||
}
|
||||
if (isset($profile->friend_upload_notice)) {
|
||||
$user->friend_upload_notice = $profile->friend_upload_notice;
|
||||
}
|
||||
if (isset($profile->auto_post_upload)) {
|
||||
$user->auto_post_upload = $profile->auto_post_upload;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (\Throwable $e) {}
|
||||
} catch (\Throwable $e) {
|
||||
}
|
||||
|
||||
if (!empty($user->birth)) {
|
||||
if (! empty($user->birth)) {
|
||||
try {
|
||||
$dt = \Carbon\Carbon::parse($user->birth);
|
||||
$dt = Carbon::parse($user->birth);
|
||||
$birthDay = $dt->format('d');
|
||||
$birthMonth = $dt->format('m');
|
||||
$birthYear = $dt->format('Y');
|
||||
} catch (\Throwable $e) {}
|
||||
} catch (\Throwable $e) {
|
||||
}
|
||||
}
|
||||
|
||||
$selectedCountry = $this->countryCatalog->resolveUserCountry($user);
|
||||
@@ -375,7 +398,7 @@ class ProfileController extends Controller
|
||||
|
||||
// Avatar URL
|
||||
$avatarHash = $profileData['avatar_hash'] ?? $user->icon ?? null;
|
||||
$avatarUrl = !empty($avatarHash)
|
||||
$avatarUrl = ! empty($avatarHash)
|
||||
? AvatarUrl::forUser((int) $user->id, $avatarHash, 256)
|
||||
: AvatarUrl::default();
|
||||
|
||||
@@ -449,7 +472,7 @@ class ProfileController extends Controller
|
||||
|
||||
$avatarUrl = AvatarUrl::forUser((int) $user->id, null, 256);
|
||||
|
||||
if (!empty($validated['remove_avatar'])) {
|
||||
if (! empty($validated['remove_avatar'])) {
|
||||
$avatarService->removeAvatar((int) $user->id);
|
||||
$avatarUrl = AvatarUrl::default();
|
||||
}
|
||||
@@ -631,8 +654,8 @@ class ProfileController extends Controller
|
||||
'country_code' => $selectedCountry?->iso2,
|
||||
];
|
||||
|
||||
if (!empty($validated['gender'])) {
|
||||
$profileUpdates['gender'] = strtoupper((string) $validated['gender']);
|
||||
if (array_key_exists('gender', $validated)) {
|
||||
$profileUpdates['gender'] = $this->normalizeProfileGender($validated['gender'] ?? null);
|
||||
}
|
||||
|
||||
$this->persistProfileUpdates((int) $request->user()->id, $profileUpdates);
|
||||
@@ -698,7 +721,7 @@ class ProfileController extends Controller
|
||||
|
||||
private function persistProfileUpdates(int $userId, array $updates): void
|
||||
{
|
||||
if ($updates === [] || !Schema::hasTable('user_profiles')) {
|
||||
if ($updates === [] || ! Schema::hasTable('user_profiles')) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -716,6 +739,29 @@ class ProfileController extends Controller
|
||||
DB::table('user_profiles')->updateOrInsert(['user_id' => $userId], $filtered);
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize gender to the MySQL enum values used by user_profiles.gender.
|
||||
*/
|
||||
private function normalizeProfileGender(mixed $value): ?string
|
||||
{
|
||||
if ($value === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$normalized = strtolower(trim((string) $value));
|
||||
|
||||
if ($normalized === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return match ($normalized) {
|
||||
'm', 'male', 'man', 'boy' => 'M',
|
||||
'f', 'female', 'woman', 'girl' => 'F',
|
||||
'x', 'n', 'na', 'n/a', 'nonbinary', 'non-binary', 'prefer_not_to_say', 'prefer not to say' => 'X',
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
|
||||
private function resolveCountrySelection(int|string|null $countryId = null, ?string $countryCode = null): ?Country
|
||||
{
|
||||
if (is_numeric($countryId) && (int) $countryId > 0) {
|
||||
@@ -799,7 +845,7 @@ class ProfileController extends Controller
|
||||
);
|
||||
}
|
||||
|
||||
public function update(ProfileUpdateRequest $request, \App\Services\AvatarService $avatarService): RedirectResponse|JsonResponse
|
||||
public function update(ProfileUpdateRequest $request, AvatarService $avatarService): RedirectResponse|JsonResponse
|
||||
{
|
||||
$user = $request->user();
|
||||
|
||||
@@ -826,6 +872,7 @@ class ProfileController extends Controller
|
||||
if ($request->expectsJson()) {
|
||||
return response()->json(['errors' => $error], 422);
|
||||
}
|
||||
|
||||
return Redirect::back()->withErrors($error);
|
||||
}
|
||||
|
||||
@@ -838,6 +885,7 @@ class ProfileController extends Controller
|
||||
if ($request->expectsJson()) {
|
||||
return response()->json(['errors' => $error], 422);
|
||||
}
|
||||
|
||||
return Redirect::back()->withErrors($error);
|
||||
}
|
||||
|
||||
@@ -852,7 +900,7 @@ class ProfileController extends Controller
|
||||
}
|
||||
}
|
||||
|
||||
if (!empty($validated['email']) && empty($user->email)) {
|
||||
if (! empty($validated['email']) && empty($user->email)) {
|
||||
$user->email = $validated['email'];
|
||||
$user->email_verified_at = null;
|
||||
}
|
||||
@@ -860,11 +908,13 @@ class ProfileController extends Controller
|
||||
$user->save();
|
||||
|
||||
$profileUpdates = [];
|
||||
if (!empty($validated['about'])) $profileUpdates['about'] = $validated['about'];
|
||||
if (! empty($validated['about'])) {
|
||||
$profileUpdates['about'] = $validated['about'];
|
||||
}
|
||||
|
||||
if (!empty($validated['web'])) {
|
||||
if (! empty($validated['web'])) {
|
||||
$profileUpdates['website'] = $validated['web'];
|
||||
} elseif (!empty($validated['homepage'])) {
|
||||
} elseif (! empty($validated['homepage'])) {
|
||||
$profileUpdates['website'] = $validated['homepage'];
|
||||
}
|
||||
|
||||
@@ -872,13 +922,11 @@ class ProfileController extends Controller
|
||||
$month = $validated['month'] ?? null;
|
||||
$year = $validated['year'] ?? null;
|
||||
if ($year && $month && $day) {
|
||||
$profileUpdates['birthdate'] = sprintf('%04d-%02d-%02d', (int)$year, (int)$month, (int)$day);
|
||||
$profileUpdates['birthdate'] = sprintf('%04d-%02d-%02d', (int) $year, (int) $month, (int) $day);
|
||||
}
|
||||
|
||||
if (!empty($validated['gender'])) {
|
||||
$g = strtolower($validated['gender']);
|
||||
$map = ['m' => 'M', 'f' => 'F', 'n' => 'X', 'x' => 'X'];
|
||||
$profileUpdates['gender'] = $map[$g] ?? strtoupper($validated['gender']);
|
||||
if (array_key_exists('gender', $validated)) {
|
||||
$profileUpdates['gender'] = $this->normalizeProfileGender($validated['gender'] ?? null);
|
||||
}
|
||||
|
||||
if (array_key_exists('country_id', $validated) || array_key_exists('country', $validated)) {
|
||||
@@ -902,19 +950,26 @@ class ProfileController extends Controller
|
||||
$profileUpdates['auto_post_upload'] = filter_var($validated['auto_post_upload'], FILTER_VALIDATE_BOOLEAN) ? 1 : 0;
|
||||
}
|
||||
|
||||
if (isset($validated['signature'])) $profileUpdates['signature'] = $validated['signature'];
|
||||
if (isset($validated['description'])) $profileUpdates['description'] = $validated['description'];
|
||||
if (isset($validated['signature'])) {
|
||||
$profileUpdates['signature'] = $validated['signature'];
|
||||
}
|
||||
if (isset($validated['description'])) {
|
||||
$profileUpdates['description'] = $validated['description'];
|
||||
}
|
||||
|
||||
if (isset($validated['about'])) $profileUpdates['about'] = $validated['about'];
|
||||
if (isset($validated['about'])) {
|
||||
$profileUpdates['about'] = $validated['about'];
|
||||
}
|
||||
|
||||
if ($request->hasFile('avatar')) {
|
||||
try {
|
||||
$avatarService->storeFromUploadedFile($user->id, $request->file('avatar'));
|
||||
} catch (\Exception $e) {
|
||||
if ($request->expectsJson()) {
|
||||
return response()->json(['errors' => ['avatar' => ['Avatar processing failed: ' . $e->getMessage()]]], 422);
|
||||
return response()->json(['errors' => ['avatar' => ['Avatar processing failed: '.$e->getMessage()]]], 422);
|
||||
}
|
||||
return Redirect::back()->with('error', 'Avatar processing failed: ' . $e->getMessage());
|
||||
|
||||
return Redirect::back()->with('error', 'Avatar processing failed: '.$e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -924,11 +979,12 @@ class ProfileController extends Controller
|
||||
$file = $request->file('emoticon');
|
||||
if (in_array($file->getMimeType(), $allowedImageMimes, true)) {
|
||||
$ext = $file->guessExtension() ?: 'jpg';
|
||||
$fname = $user->id . '_emoticon_' . time() . '.' . $ext;
|
||||
\Illuminate\Support\Facades\Storage::disk('public')->putFileAs('user-emoticons/'.$user->id, $file, $fname);
|
||||
$fname = $user->id.'_emoticon_'.time().'.'.$ext;
|
||||
Storage::disk('public')->putFileAs('user-emoticons/'.$user->id, $file, $fname);
|
||||
try {
|
||||
\Illuminate\Support\Facades\DB::table('users')->where('id', $user->id)->update(['eicon' => $fname]);
|
||||
} catch (\Exception $e) {}
|
||||
DB::table('users')->where('id', $user->id)->update(['eicon' => $fname]);
|
||||
} catch (\Exception $e) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -936,26 +992,27 @@ class ProfileController extends Controller
|
||||
$file = $request->file('photo');
|
||||
if (in_array($file->getMimeType(), $allowedImageMimes, true)) {
|
||||
$ext = $file->guessExtension() ?: 'jpg';
|
||||
$fname = $user->id . '_photo_' . time() . '.' . $ext;
|
||||
\Illuminate\Support\Facades\Storage::disk('public')->putFileAs('user-picture/'.$user->id, $file, $fname);
|
||||
if (\Illuminate\Support\Facades\Schema::hasTable('user_profiles')) {
|
||||
$fname = $user->id.'_photo_'.time().'.'.$ext;
|
||||
Storage::disk('public')->putFileAs('user-picture/'.$user->id, $file, $fname);
|
||||
if (Schema::hasTable('user_profiles')) {
|
||||
$profileUpdates['cover_image'] = $fname;
|
||||
} else {
|
||||
try {
|
||||
\Illuminate\Support\Facades\DB::table('users')->where('id', $user->id)->update(['picture' => $fname]);
|
||||
} catch (\Exception $e) {}
|
||||
DB::table('users')->where('id', $user->id)->update(['picture' => $fname]);
|
||||
} catch (\Exception $e) {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
if (\Illuminate\Support\Facades\Schema::hasTable('user_profiles')) {
|
||||
if (!empty($profileUpdates)) {
|
||||
\Illuminate\Support\Facades\DB::table('user_profiles')->updateOrInsert(['user_id' => $user->id], $profileUpdates);
|
||||
if (Schema::hasTable('user_profiles')) {
|
||||
if (! empty($profileUpdates)) {
|
||||
DB::table('user_profiles')->updateOrInsert(['user_id' => $user->id], $profileUpdates);
|
||||
}
|
||||
} else {
|
||||
if (!empty($profileUpdates)) {
|
||||
\Illuminate\Support\Facades\DB::table('users')->where('id', $user->id)->update($profileUpdates);
|
||||
if (! empty($profileUpdates)) {
|
||||
DB::table('users')->where('id', $user->id)->update($profileUpdates);
|
||||
}
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
@@ -1016,11 +1073,10 @@ class ProfileController extends Controller
|
||||
string $component = 'Profile/ProfileShow',
|
||||
bool $galleryOnly = false,
|
||||
?string $initialTab = null,
|
||||
)
|
||||
{
|
||||
$isOwner = Auth::check() && Auth::id() === $user->id;
|
||||
$viewer = Auth::user();
|
||||
$perPage = 24;
|
||||
) {
|
||||
$isOwner = Auth::check() && Auth::id() === $user->id;
|
||||
$viewer = Auth::user();
|
||||
$perPage = 24;
|
||||
|
||||
// ── Artworks (cursor-paginated) ──────────────────────────────────────
|
||||
$artworks = $this->artworkService->getArtworksByUser($user->id, $isOwner, $perPage, $viewer)
|
||||
@@ -1044,7 +1100,7 @@ class ProfileController extends Controller
|
||||
];
|
||||
}
|
||||
if ($favourites !== null && $favouriteTable !== null) {
|
||||
$favIds = DB::table($favouriteTable . ' as af')
|
||||
$favIds = DB::table($favouriteTable.' as af')
|
||||
->join('artworks as a', 'a.id', '=', 'af.artwork_id')
|
||||
->where('af.user_id', $user->id)
|
||||
->whereNull('a.deleted_at')
|
||||
@@ -1065,25 +1121,25 @@ class ProfileController extends Controller
|
||||
$favIds = $favIds->take($favouriteLimit);
|
||||
|
||||
$indexed = Artwork::with([
|
||||
'user:id,name,username,level,rank',
|
||||
'user.profile:user_id,avatar_hash',
|
||||
'group:id,name,slug,avatar_path',
|
||||
'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']);
|
||||
},
|
||||
])
|
||||
'user:id,name,username,level,rank',
|
||||
'user.profile:user_id,avatar_hash',
|
||||
'group:id,name,slug,avatar_path',
|
||||
'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');
|
||||
|
||||
$favourites = [
|
||||
'data' => $favIds
|
||||
->filter(fn ($id) => $indexed->has($id))
|
||||
->map(fn ($id) => $this->mapArtworkCardPayload($indexed[$id]))
|
||||
->values()
|
||||
->all(),
|
||||
->filter(fn ($id) => $indexed->has($id))
|
||||
->map(fn ($id) => $this->mapArtworkCardPayload($indexed[$id]))
|
||||
->values()
|
||||
->all(),
|
||||
'next_cursor' => $hasMore ? base64_encode((string) $favouriteLimit) : null,
|
||||
];
|
||||
}
|
||||
@@ -1105,8 +1161,8 @@ class ProfileController extends Controller
|
||||
}
|
||||
|
||||
// ── Follower data ────────────────────────────────────────────────────
|
||||
$followerCount = 0;
|
||||
$recentFollowers = collect();
|
||||
$followerCount = 0;
|
||||
$recentFollowers = collect();
|
||||
$viewerIsFollowing = false;
|
||||
$followingCount = 0;
|
||||
|
||||
@@ -1124,11 +1180,11 @@ class ProfileController extends Controller
|
||||
->select(['u.id', 'u.username', 'u.name', 'up.avatar_hash', 'uf.created_at as followed_at'])
|
||||
->get()
|
||||
->map(fn ($row) => (object) [
|
||||
'id' => $row->id,
|
||||
'username' => $row->username,
|
||||
'uname' => $row->username ?? $row->name,
|
||||
'avatar_url' => AvatarUrl::forUser((int) $row->id, $row->avatar_hash, 50),
|
||||
'profile_url' => '/@' . strtolower((string) ($row->username ?? $row->id)),
|
||||
'id' => $row->id,
|
||||
'username' => $row->username,
|
||||
'uname' => $row->username ?? $row->name,
|
||||
'avatar_url' => AvatarUrl::forUser((int) $row->id, $row->avatar_hash, 50),
|
||||
'profile_url' => '/@'.strtolower((string) ($row->username ?? $row->id)),
|
||||
'followed_at' => $row->followed_at,
|
||||
]);
|
||||
|
||||
@@ -1203,14 +1259,14 @@ class ProfileController extends Controller
|
||||
])
|
||||
->get()
|
||||
->map(fn ($row) => (object) [
|
||||
'id' => $row->id,
|
||||
'body' => $row->body,
|
||||
'created_at' => $row->created_at,
|
||||
'author_id' => $row->author_id,
|
||||
'id' => $row->id,
|
||||
'body' => $row->body,
|
||||
'created_at' => $row->created_at,
|
||||
'author_id' => $row->author_id,
|
||||
'author_name' => $row->author_username ?? $row->author_name ?? 'Unknown',
|
||||
'author_level' => (int) ($row->author_level ?? 1),
|
||||
'author_rank' => (string) ($row->author_rank ?? 'Newbie'),
|
||||
'author_profile_url' => '/@' . strtolower((string) ($row->author_username ?? $row->author_id)),
|
||||
'author_profile_url' => '/@'.strtolower((string) ($row->author_username ?? $row->author_id)),
|
||||
'author_avatar' => AvatarUrl::forUser((int) $row->author_id, $row->author_avatar_hash, 50),
|
||||
'author_signature' => $row->author_signature,
|
||||
]);
|
||||
@@ -1256,8 +1312,11 @@ class ProfileController extends Controller
|
||||
'published_at' => $story->published_at?->toISOString(),
|
||||
]);
|
||||
|
||||
$profileCollectionsPayload = null;
|
||||
if ($initialTab === 'collections') {
|
||||
$profileCollections = $this->collections->getProfileCollections($user, $viewer);
|
||||
$profileCollectionsPayload = $this->collections->mapCollectionCardPayloads($profileCollections, $isOwner, $viewer);
|
||||
}
|
||||
|
||||
// ── Profile data ─────────────────────────────────────────────────────
|
||||
$profile = $user->profile;
|
||||
@@ -1276,15 +1335,16 @@ class ProfileController extends Controller
|
||||
if (! $isOwner) {
|
||||
try {
|
||||
$this->userStats->incrementProfileViews($user->id);
|
||||
} catch (\Throwable) {}
|
||||
} catch (\Throwable) {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Normalise artworks for JSON serialisation ────────────────────
|
||||
$artworkItems = collect($artworks->items())->values();
|
||||
$artworkPayload = [
|
||||
'data' => $artworkItems,
|
||||
'data' => $artworkItems,
|
||||
'next_cursor' => $artworks->nextCursor()?->encode(),
|
||||
'has_more' => $artworks->hasMorePages(),
|
||||
'has_more' => $artworks->hasMorePages(),
|
||||
];
|
||||
|
||||
// ── Avatar URL on user object ────────────────────────────────────
|
||||
@@ -1293,24 +1353,24 @@ class ProfileController extends Controller
|
||||
// ── Auth context for JS ───────────────────────────────────────────
|
||||
$authData = null;
|
||||
if (Auth::check()) {
|
||||
/** @var \App\Models\User $authUser */
|
||||
$authUser = Auth::user();
|
||||
$authAvatarUrl = AvatarUrl::forUser((int) $authUser->id, $authUser->profile?->avatar_hash, 64);
|
||||
/** @var User $authUser */
|
||||
$authUser = Auth::user();
|
||||
$authAvatarUrl = AvatarUrl::forUser((int) $authUser->id, $authUser->profile?->avatar_hash, 64);
|
||||
$authData = [
|
||||
'user' => [
|
||||
'id' => $authUser->id,
|
||||
'id' => $authUser->id,
|
||||
'username' => $authUser->username,
|
||||
'name' => $authUser->name,
|
||||
'avatar' => $authAvatarUrl,
|
||||
'name' => $authUser->name,
|
||||
'avatar' => $authAvatarUrl,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
$usernameSlug = strtolower((string) ($user->username ?? ''));
|
||||
$canonical = url('/@' . $usernameSlug);
|
||||
$galleryUrl = url('/@' . $usernameSlug . '/gallery');
|
||||
$canonical = url('/@'.$usernameSlug);
|
||||
$galleryUrl = url('/@'.$usernameSlug.'/gallery');
|
||||
$profileTabUrls = collect(self::PROFILE_TABS)
|
||||
->mapWithKeys(fn (string $tab) => [$tab => url('/@' . $usernameSlug . '/' . $tab)])
|
||||
->mapWithKeys(fn (string $tab) => [$tab => url('/@'.$usernameSlug.'/'.$tab)])
|
||||
->all();
|
||||
$achievementSummary = $this->achievements->summary((int) $user->id);
|
||||
$worldRewardSummary = $this->worldRewards->summaryForUser($user);
|
||||
@@ -1360,17 +1420,17 @@ class ProfileController extends Controller
|
||||
}
|
||||
|
||||
$pageTitle = $galleryOnly
|
||||
? (($user->username ?? $user->name ?? 'User') . ' Gallery on Skinbase')
|
||||
? (($user->username ?? $user->name ?? 'User').' Gallery on Skinbase')
|
||||
: ($isTabLanding
|
||||
? (($user->username ?? $user->name ?? 'User') . ' ' . $tabMetaLabel . ' on Skinbase')
|
||||
: (($user->username ?? $user->name ?? 'User') . ' on Skinbase'));
|
||||
? (($user->username ?? $user->name ?? 'User').' '.$tabMetaLabel.' on Skinbase')
|
||||
: (($user->username ?? $user->name ?? 'User').' on Skinbase'));
|
||||
$pageDescription = $galleryOnly
|
||||
? ('Browse the public gallery of ' . ($user->username ?? $user->name) . ' on Skinbase.')
|
||||
? ('Browse the public gallery of '.($user->username ?? $user->name).' on Skinbase.')
|
||||
: ($resolvedInitialTab === 'followers'
|
||||
? ('People who follow ' . ($user->username ?? $user->name) . ' on Skinbase.')
|
||||
? ('People who follow '.($user->username ?? $user->name).' on Skinbase.')
|
||||
: ($isTabLanding
|
||||
? ('Explore the ' . strtolower((string) $tabMetaLabel) . ' section for ' . ($user->username ?? $user->name) . ' on Skinbase.')
|
||||
: ('View the profile of ' . ($user->username ?? $user->name) . ' on Skinbase — artworks, favourites and more.')));
|
||||
? ('Explore the '.strtolower((string) $tabMetaLabel).' section for '.($user->username ?? $user->name).' on Skinbase.')
|
||||
: ('View the profile of '.($user->username ?? $user->name).' on Skinbase — artworks, favourites and more.')));
|
||||
$profileSeo = app(SeoFactory::class)->profilePage(
|
||||
$pageTitle,
|
||||
$galleryOnly ? $galleryUrl : $activeProfileUrl,
|
||||
@@ -1383,72 +1443,72 @@ class ProfileController extends Controller
|
||||
)->toArray();
|
||||
|
||||
return Inertia::render($component, [
|
||||
'user' => [
|
||||
'id' => $user->id,
|
||||
'username' => $user->username,
|
||||
'name' => $user->name,
|
||||
'avatar_url' => $avatarUrl,
|
||||
'cover_url' => $heroBgUrl,
|
||||
'cover_position'=> (int) ($user->cover_position ?? 50),
|
||||
'created_at' => $user->created_at?->toISOString(),
|
||||
'user' => [
|
||||
'id' => $user->id,
|
||||
'username' => $user->username,
|
||||
'name' => $user->name,
|
||||
'avatar_url' => $avatarUrl,
|
||||
'cover_url' => $heroBgUrl,
|
||||
'cover_position' => (int) ($user->cover_position ?? 50),
|
||||
'created_at' => $user->created_at?->toISOString(),
|
||||
'last_visit_at' => $user->last_visit_at ? (string) $user->last_visit_at : null,
|
||||
'xp' => $xpSummary['xp'],
|
||||
'level' => $xpSummary['level'],
|
||||
'rank' => $xpSummary['rank'],
|
||||
'xp' => $xpSummary['xp'],
|
||||
'level' => $xpSummary['level'],
|
||||
'rank' => $xpSummary['rank'],
|
||||
'next_level_xp' => $xpSummary['next_level_xp'],
|
||||
'current_level_xp' => $xpSummary['current_level_xp'],
|
||||
'progress_percent' => $xpSummary['progress_percent'],
|
||||
'max_level' => $xpSummary['max_level'],
|
||||
],
|
||||
'profile' => $profile ? [
|
||||
'about' => $profile->about ?? null,
|
||||
'website' => $profile->website ?? null,
|
||||
'profile' => $profile ? [
|
||||
'about' => $profile->about ?? null,
|
||||
'website' => $profile->website ?? null,
|
||||
'country_code' => $countryCode,
|
||||
'gender' => $profile->gender ?? null,
|
||||
'birthdate' => $profile->birthdate ?? null,
|
||||
'cover_image' => $profile->cover_image ?? null,
|
||||
'gender' => $profile->gender ?? null,
|
||||
'birthdate' => $profile->birthdate ?? null,
|
||||
'cover_image' => $profile->cover_image ?? null,
|
||||
] : null,
|
||||
'artworks' => $artworkPayload,
|
||||
'featuredArtworks' => $featuredArtworks?->values(),
|
||||
'favourites' => $favourites,
|
||||
'stats' => $statsPayload,
|
||||
'socialLinks' => $socialLinks,
|
||||
'followerCount' => $followerCount,
|
||||
'recentFollowers' => $recentFollowers->values(),
|
||||
'followersListing' => $followersListing,
|
||||
'followContext' => $followContext,
|
||||
'followAnalytics' => $followAnalytics,
|
||||
'suggestedUsers' => $suggestedUsers,
|
||||
'artworks' => $artworkPayload,
|
||||
'featuredArtworks' => $featuredArtworks?->values(),
|
||||
'favourites' => $favourites,
|
||||
'stats' => $statsPayload,
|
||||
'socialLinks' => $socialLinks,
|
||||
'followerCount' => $followerCount,
|
||||
'recentFollowers' => $recentFollowers->values(),
|
||||
'followersListing' => $followersListing,
|
||||
'followContext' => $followContext,
|
||||
'followAnalytics' => $followAnalytics,
|
||||
'suggestedUsers' => $suggestedUsers,
|
||||
'viewerIsFollowing' => $viewerIsFollowing,
|
||||
'heroBgUrl' => $heroBgUrl,
|
||||
'profileComments' => $profileComments->values(),
|
||||
'creatorStories' => $creatorStories->values(),
|
||||
'collections' => $profileCollectionsPayload,
|
||||
'achievements' => $achievementSummary,
|
||||
'worldRewards' => $worldRewardSummary,
|
||||
'worldHistory' => $worldHistory,
|
||||
'leaderboardRank' => $leaderboardRank,
|
||||
'journey' => $journey,
|
||||
'heroBgUrl' => $heroBgUrl,
|
||||
'profileComments' => $profileComments->values(),
|
||||
'creatorStories' => $creatorStories->values(),
|
||||
'collections' => $profileCollectionsPayload,
|
||||
'achievements' => $achievementSummary,
|
||||
'worldRewards' => $worldRewardSummary,
|
||||
'worldHistory' => $worldHistory,
|
||||
'leaderboardRank' => $leaderboardRank,
|
||||
'journey' => $journey,
|
||||
'groupContributionHistory' => $groupContributionHistory,
|
||||
'countryName' => $countryName,
|
||||
'isOwner' => $isOwner,
|
||||
'auth' => $authData,
|
||||
'initialTab' => $resolvedInitialTab,
|
||||
'profileUrl' => $canonical,
|
||||
'galleryUrl' => $galleryUrl,
|
||||
'countryName' => $countryName,
|
||||
'isOwner' => $isOwner,
|
||||
'auth' => $authData,
|
||||
'initialTab' => $resolvedInitialTab,
|
||||
'profileUrl' => $canonical,
|
||||
'galleryUrl' => $galleryUrl,
|
||||
'collectionCreateUrl' => $isOwner ? route('settings.collections.create') : null,
|
||||
'collectionReorderUrl' => $isOwner ? route('settings.collections.reorder-profile') : null,
|
||||
'collectionsFeaturedUrl' => route('collections.featured'),
|
||||
'collectionFeatureLimit' => (int) config('collections.featured_limit', 3),
|
||||
'profileTabUrls' => $profileTabUrls,
|
||||
'journeyApiUrl' => route('api.profile.journey', ['username' => $usernameSlug]),
|
||||
'profileTabUrls' => $profileTabUrls,
|
||||
'journeyApiUrl' => route('api.profile.journey', ['username' => $usernameSlug]),
|
||||
])->withViewData([
|
||||
'page_title' => $pageTitle,
|
||||
'page_canonical' => $galleryOnly ? $galleryUrl : $activeProfileUrl,
|
||||
'page_title' => $pageTitle,
|
||||
'page_canonical' => $galleryOnly ? $galleryUrl : $activeProfileUrl,
|
||||
'page_meta_description' => $pageDescription,
|
||||
'og_image' => $avatarUrl,
|
||||
'seo' => $profileSeo,
|
||||
'useUnifiedSeo' => true,
|
||||
'og_image' => $avatarUrl,
|
||||
'seo' => $profileSeo,
|
||||
'useUnifiedSeo' => true,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -1456,7 +1516,7 @@ class ProfileController extends Controller
|
||||
* Build the canonical featured artwork representation shared by SSR and
|
||||
* the lazy Artworks-tab endpoint.
|
||||
*/
|
||||
private function loadFeaturedArtworks(User $user, ?User $viewer): ?\Illuminate\Support\Collection
|
||||
private function loadFeaturedArtworks(User $user, ?User $viewer): ?Collection
|
||||
{
|
||||
if (! Schema::hasTable('artwork_features')) {
|
||||
return collect();
|
||||
@@ -1509,13 +1569,13 @@ class ProfileController extends Controller
|
||||
|
||||
private function redirectToProfileTab(Request $request, string $username, string $tab): RedirectResponse
|
||||
{
|
||||
$baseUrl = url('/@' . strtolower($username) . '/' . $tab);
|
||||
$baseUrl = url('/@'.strtolower($username).'/'.$tab);
|
||||
|
||||
$query = $request->query();
|
||||
unset($query['tab']);
|
||||
|
||||
if ($query !== []) {
|
||||
$baseUrl .= '?' . http_build_query($query);
|
||||
$baseUrl .= '?'.http_build_query($query);
|
||||
}
|
||||
|
||||
return redirect()->to($baseUrl, 301);
|
||||
@@ -1634,10 +1694,10 @@ class ProfileController extends Controller
|
||||
* Load the same top-three public releases per group as the previous
|
||||
* per-group LIMIT query, while keeping the existing two-column ordering.
|
||||
*
|
||||
* @param array<int, int> $groupIds
|
||||
* @return \Illuminate\Support\Collection<int, \Illuminate\Support\Collection<int, string>>
|
||||
* @param array<int, int> $groupIds
|
||||
* @return Collection<int, Collection<int, string>>
|
||||
*/
|
||||
private function loadRecentGroupReleaseTitles(User $user, array $groupIds): \Illuminate\Support\Collection
|
||||
private function loadRecentGroupReleaseTitles(User $user, array $groupIds): Collection
|
||||
{
|
||||
$eligibleReleases = GroupRelease::query()
|
||||
->whereIn('group_id', $groupIds)
|
||||
@@ -1686,7 +1746,7 @@ class ProfileController extends Controller
|
||||
private function mapArtworkCardPayload(Artwork $art): array
|
||||
{
|
||||
$present = ThumbnailPresenter::present($art, 'md');
|
||||
$category = $art->categories->first();
|
||||
$category = $art->resolvedPrimaryCategory();
|
||||
$contentType = $category?->contentType;
|
||||
$stats = $art->stats;
|
||||
$group = $art->group;
|
||||
@@ -1696,7 +1756,7 @@ class ProfileController extends Controller
|
||||
$avatarUrl = $isGroupPublisher ? $group->avatarUrl() : ($art->user?->profile?->avatar_url ?? null);
|
||||
$profileUrl = $isGroupPublisher
|
||||
? $group->publicUrl()
|
||||
: ($username ? '/@' . $username : null);
|
||||
: ($username ? '/@'.$username : null);
|
||||
$publisherType = $isGroupPublisher ? 'group' : 'user';
|
||||
|
||||
return app(ArtworkMaturityService::class)->decoratePayload([
|
||||
|
||||
@@ -3,7 +3,10 @@
|
||||
namespace App\Http\Controllers\User;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Artwork;
|
||||
use App\Models\ArtworkDownload;
|
||||
use App\Services\ThumbnailPresenter;
|
||||
use App\Support\AvatarUrl;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Str;
|
||||
@@ -54,7 +57,7 @@ class TodayDownloadsController extends Controller
|
||||
$paginator->getCollection()->transform(function ($row) {
|
||||
$art = $row->artwork ?? null;
|
||||
if (! $art && isset($row->artwork_id)) {
|
||||
$art = \App\Models\Artwork::find($row->artwork_id);
|
||||
$art = Artwork::find($row->artwork_id);
|
||||
}
|
||||
|
||||
if (! $art) {
|
||||
@@ -75,9 +78,9 @@ class TodayDownloadsController extends Controller
|
||||
$picture = $art->file_name ?? null;
|
||||
$ext = pathinfo($picture ?? '', PATHINFO_EXTENSION) ?: 'jpg';
|
||||
$encoded = null;
|
||||
$present = $art ? \App\Services\ThumbnailPresenter::present($art, 'md') : null;
|
||||
$present = $art ? ThumbnailPresenter::present($art, 'md') : null;
|
||||
$thumb = $present ? $present['url'] : 'https://files.skinbase.org/default/missing_md.webp';
|
||||
$primaryCategory = $art->categories->first();
|
||||
$primaryCategory = $art->resolvedPrimaryCategory();
|
||||
$categoryId = $primaryCategory->id ?? null;
|
||||
$categoryName = $primaryCategory->name ?? '';
|
||||
$categorySlug = $primaryCategory->slug ?? '';
|
||||
@@ -98,7 +101,7 @@ class TodayDownloadsController extends Controller
|
||||
'category_slug' => $categorySlug,
|
||||
'uname' => $art->user->name ?? 'Skinbase',
|
||||
'username' => $art->user->username ?? '',
|
||||
'avatar_url' => \App\Support\AvatarUrl::forUser((int) ($art->user->id ?? 0), $avatarHash, 64),
|
||||
'avatar_url' => AvatarUrl::forUser((int) ($art->user->id ?? 0), $avatarHash, 64),
|
||||
'width' => $art->width,
|
||||
'height' => $art->height,
|
||||
'published_at' => $art->published_at,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Web;
|
||||
@@ -7,17 +8,19 @@ use App\Enums\ReactionType;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Resources\ArtworkResource;
|
||||
use App\Models\Artwork;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use App\Services\ThumbnailPresenter;
|
||||
use App\Models\Category;
|
||||
use App\Services\ArtworkNavigationService;
|
||||
use App\Services\ArtworkRelatedService;
|
||||
use App\Services\ErrorSuggestionService;
|
||||
use App\Services\GroupService;
|
||||
use App\Services\Maturity\ArtworkMaturityService;
|
||||
use App\Services\ThumbnailPresenter;
|
||||
use App\Support\Seo\SeoFactory;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\View\View;
|
||||
use Inertia\Inertia;
|
||||
@@ -29,6 +32,7 @@ final class ArtworkPageController extends Controller
|
||||
private readonly GroupService $groups,
|
||||
private readonly ArtworkMaturityService $maturity,
|
||||
private readonly ArtworkNavigationService $navigation,
|
||||
private readonly ArtworkRelatedService $relatedArtworks,
|
||||
) {}
|
||||
|
||||
public function show(Request $request, int $id, ?string $slug = null): View|RedirectResponse|Response|InertiaResponse
|
||||
@@ -39,6 +43,7 @@ final class ArtworkPageController extends Controller
|
||||
if (! $raw) {
|
||||
// Artwork never existed → contextual 404
|
||||
$suggestions = app(ErrorSuggestionService::class);
|
||||
|
||||
return response(view('errors.contextual.artwork-not-found', [
|
||||
'trendingArtworks' => $this->safeSuggestions(fn () => $suggestions->trendingArtworks()),
|
||||
]), 404);
|
||||
@@ -73,24 +78,25 @@ final class ArtworkPageController extends Controller
|
||||
->limit(6)
|
||||
->get()
|
||||
->map(function (Artwork $a) {
|
||||
$slug = \Illuminate\Support\Str::slug((string) ($a->slug ?: $a->title)) ?: (string) $a->id;
|
||||
$md = \App\Services\ThumbnailPresenter::present($a, 'md');
|
||||
$slug = Str::slug((string) ($a->slug ?: $a->title)) ?: (string) $a->id;
|
||||
$md = ThumbnailPresenter::present($a, 'md');
|
||||
|
||||
return [
|
||||
'id' => $a->id,
|
||||
'title' => html_entity_decode((string) $a->title, ENT_QUOTES | ENT_HTML5, 'UTF-8'),
|
||||
'id' => $a->id,
|
||||
'title' => html_entity_decode((string) $a->title, ENT_QUOTES | ENT_HTML5, 'UTF-8'),
|
||||
'author' => html_entity_decode((string) ($a->user?->name ?: $a->user?->username ?: 'Artist'), ENT_QUOTES | ENT_HTML5, 'UTF-8'),
|
||||
'url' => route('art.show', ['id' => $a->id, 'slug' => $slug]),
|
||||
'thumb' => $md['url'] ?? null,
|
||||
'url' => route('art.show', ['id' => $a->id, 'slug' => $slug]),
|
||||
'thumb' => $md['url'] ?? null,
|
||||
];
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return response(view('errors.contextual.artwork-not-found', [
|
||||
'message' => 'This artwork is not publicly available.',
|
||||
'isForbidden' => true,
|
||||
'creatorArtworks' => $creatorArtworks,
|
||||
'creatorUsername' => $creatorUsername,
|
||||
'message' => 'This artwork is not publicly available.',
|
||||
'isForbidden' => true,
|
||||
'creatorArtworks' => $creatorArtworks,
|
||||
'creatorUsername' => $creatorUsername,
|
||||
'trendingArtworks' => $this->safeSuggestions(fn () => $suggestions->trendingArtworks()),
|
||||
]), 403);
|
||||
}
|
||||
@@ -101,9 +107,15 @@ final class ArtworkPageController extends Controller
|
||||
// distinct responses. Reusing that model avoids a second identical
|
||||
// primary-key lookup on the public path.
|
||||
$artwork = $raw;
|
||||
$artwork->load(['user.profile', 'group.owner.profile', 'uploadedBy.profile', 'primaryAuthor.profile', 'contributors.user.profile', 'categories.contentType', 'categories.parent.contentType', 'tags', 'stats', 'awardStat']);
|
||||
$artwork->load(['user.profile', 'group.owner.profile', 'uploadedBy.profile', 'primaryAuthor.profile', 'contributors.user.profile', 'categories.contentType', 'categories.parent.contentType', 'primaryCategory.contentType', 'primaryCategory.parent.contentType', 'tags', 'stats', 'awardStat']);
|
||||
|
||||
$this->loadCategoryAncestors($artwork->categories);
|
||||
$this->loadCategoryAncestors(
|
||||
$artwork->categories
|
||||
->concat(collect([$artwork->resolvedPrimaryCategory()]))
|
||||
->filter()
|
||||
->unique('id')
|
||||
->values()
|
||||
);
|
||||
|
||||
$canonicalSlug = Str::slug((string) ($artwork->slug ?: $artwork->title));
|
||||
if ($canonicalSlug === '') {
|
||||
@@ -152,37 +164,7 @@ final class ArtworkPageController extends Controller
|
||||
'xl' => $thumbXl,
|
||||
], $canonical, $this->artworkBreadcrumbs($artwork, $canonical))->toArray();
|
||||
|
||||
$categoryIds = $artwork->categories->pluck('id')->filter()->values();
|
||||
$tagIds = $artwork->tags->pluck('id')->filter()->values();
|
||||
|
||||
$related = Artwork::query()
|
||||
->with(['user', 'group', 'categories.contentType'])
|
||||
->whereKeyNot($artwork->id)
|
||||
->public()
|
||||
->published()
|
||||
->tap(fn ($builder) => $this->maturity->applyViewerFilter($builder, $request->user()))
|
||||
->where(function ($query) use ($artwork, $categoryIds, $tagIds): void {
|
||||
$query->where('user_id', $artwork->user_id);
|
||||
|
||||
if ($artwork->group_id) {
|
||||
$query->orWhere('group_id', $artwork->group_id);
|
||||
}
|
||||
|
||||
if ($categoryIds->isNotEmpty()) {
|
||||
$query->orWhereHas('categories', function ($categoryQuery) use ($categoryIds): void {
|
||||
$categoryQuery->whereIn('categories.id', $categoryIds->all());
|
||||
});
|
||||
}
|
||||
|
||||
if ($tagIds->isNotEmpty()) {
|
||||
$query->orWhereHas('tags', function ($tagQuery) use ($tagIds): void {
|
||||
$tagQuery->whereIn('tags.id', $tagIds->all());
|
||||
});
|
||||
}
|
||||
})
|
||||
->latest('published_at')
|
||||
->limit(12)
|
||||
->get()
|
||||
$related = $this->relatedArtworks->related($artwork, $request->user(), 12)
|
||||
->map(function (Artwork $item): array {
|
||||
$itemSlug = Str::slug((string) ($item->slug ?: $item->title));
|
||||
if ($itemSlug === '') {
|
||||
@@ -201,7 +183,7 @@ final class ArtworkPageController extends Controller
|
||||
'publisher_id' => $item->group ? (int) $item->group->id : (int) ($item->user?->id ?? 0),
|
||||
'url' => route('art.show', ['id' => $item->id, 'slug' => $itemSlug]),
|
||||
'thumb' => $sm['url'] ?? null,
|
||||
'thumb_srcset' => ($sm['url'] ?? '') . ' 320w, ' . ($md['url'] ?? '') . ' 640w',
|
||||
'thumb_srcset' => ($sm['url'] ?? '').' 320w, '.($md['url'] ?? '').' 640w',
|
||||
], $item, request()->user());
|
||||
})
|
||||
->values()
|
||||
@@ -210,20 +192,19 @@ final class ArtworkPageController extends Controller
|
||||
$canReadSession = $request->hasSession() && ! $request->attributes->get('skinbase.session_skipped');
|
||||
$viewerId = ($canReadSession && $request->user() !== null) ? (int) $request->user()->id : null;
|
||||
|
||||
|
||||
return Inertia::render('ArtworkPage', [
|
||||
'artwork' => $artworkData,
|
||||
'navigation' => $navigationData,
|
||||
'presentMd' => $thumbMd,
|
||||
'presentLg' => $thumbLg,
|
||||
'presentXl' => $thumbXl,
|
||||
'presentSq' => $thumbSq,
|
||||
'related' => $related,
|
||||
'canonicalUrl' => $canonical,
|
||||
'groupSummary' => $groupSummary,
|
||||
'artwork' => $artworkData,
|
||||
'navigation' => $navigationData,
|
||||
'presentMd' => $thumbMd,
|
||||
'presentLg' => $thumbLg,
|
||||
'presentXl' => $thumbXl,
|
||||
'presentSq' => $thumbSq,
|
||||
'related' => $related,
|
||||
'canonicalUrl' => $canonical,
|
||||
'groupSummary' => $groupSummary,
|
||||
'isAuthenticated' => $viewerId !== null,
|
||||
'reactionTotals' => $this->artworkReactionTotals((int) $artwork->id, $viewerId),
|
||||
'seo' => $seo,
|
||||
'reactionTotals' => $this->artworkReactionTotals((int) $artwork->id, $viewerId),
|
||||
'seo' => $seo,
|
||||
])->rootView('artworks.show');
|
||||
}
|
||||
|
||||
@@ -245,7 +226,7 @@ final class ArtworkPageController extends Controller
|
||||
|
||||
$totals = [];
|
||||
foreach (ReactionType::cases() as $type) {
|
||||
$slug = $type->value;
|
||||
$slug = $type->value;
|
||||
$count = (int) ($rows[$slug]->total ?? 0);
|
||||
|
||||
$mine = false;
|
||||
@@ -261,7 +242,7 @@ final class ArtworkPageController extends Controller
|
||||
'emoji' => $type->emoji(),
|
||||
'label' => $type->label(),
|
||||
'count' => $count,
|
||||
'mine' => $mine,
|
||||
'mine' => $mine,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -282,7 +263,7 @@ final class ArtworkPageController extends Controller
|
||||
->values();
|
||||
|
||||
if ($missingParentIds->isNotEmpty()) {
|
||||
$fetchedParents = \App\Models\Category::query()
|
||||
$fetchedParents = Category::query()
|
||||
->with('contentType')
|
||||
->whereIn('id', $missingParentIds->all())
|
||||
->get()
|
||||
@@ -308,12 +289,7 @@ final class ArtworkPageController extends Controller
|
||||
*/
|
||||
private function artworkBreadcrumbs(Artwork $artwork, string $canonical): array
|
||||
{
|
||||
$primaryCategory = $artwork->categories
|
||||
->sortBy(fn ($category) => [
|
||||
(int) ($category->sort_order ?? 0),
|
||||
(string) ($category->name ?? ''),
|
||||
])
|
||||
->first();
|
||||
$primaryCategory = $artwork->resolvedPrimaryCategory();
|
||||
|
||||
if ($primaryCategory === null) {
|
||||
return [
|
||||
@@ -338,7 +314,7 @@ final class ArtworkPageController extends Controller
|
||||
if ($contentTypeSlug !== '' && $contentTypeName !== '') {
|
||||
$breadcrumbs[] = [
|
||||
'name' => $contentTypeName,
|
||||
'url' => url('/' . $contentTypeSlug),
|
||||
'url' => url('/'.$contentTypeSlug),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -355,7 +331,7 @@ final class ArtworkPageController extends Controller
|
||||
$pathSegments[] = $slug;
|
||||
$breadcrumbs[] = [
|
||||
'name' => $name,
|
||||
'url' => url('/' . $contentTypeSlug . '/' . implode('/', $pathSegments)),
|
||||
'url' => url('/'.$contentTypeSlug.'/'.implode('/', $pathSegments)),
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -35,8 +35,8 @@ final class SimilarArtworksPageController extends Controller
|
||||
private const QDRANT_LIMIT = 120;
|
||||
|
||||
public function __construct(
|
||||
private readonly VectorService $vectors,
|
||||
private readonly ArtworkMaturityService $maturity,
|
||||
private readonly VectorService $vectors,
|
||||
private readonly ArtworkMaturityService $maturity,
|
||||
private readonly HybridSimilarArtworksService $hybridService,
|
||||
) {}
|
||||
|
||||
@@ -57,53 +57,53 @@ final class SimilarArtworksPageController extends Controller
|
||||
$baseUrl = url("/art/{$id}/similar");
|
||||
|
||||
// ── Normalise source artwork for the view ──────────────────────────────
|
||||
$primaryCat = $source->categories->sortBy('sort_order')->first();
|
||||
$sourceMd = ThumbnailPresenter::present($source, 'md');
|
||||
$sourceLg = ThumbnailPresenter::present($source, 'lg');
|
||||
$primaryCat = $source->categories->sortBy('sort_order')->first();
|
||||
$sourceMd = ThumbnailPresenter::present($source, 'md');
|
||||
$sourceLg = ThumbnailPresenter::present($source, 'lg');
|
||||
$sourceTitle = html_entity_decode((string) ($source->title ?? 'Artwork'), ENT_QUOTES | ENT_HTML5, 'UTF-8');
|
||||
$sourceUrl = route('art.show', ['id' => $source->id, 'slug' => $source->slug]);
|
||||
$sourceUrl = route('art.show', ['id' => $source->id, 'slug' => $source->slug]);
|
||||
|
||||
$sourceCard = (object) [
|
||||
'id' => $source->id,
|
||||
'title' => $sourceTitle,
|
||||
'url' => $sourceUrl,
|
||||
'thumb_md' => $sourceMd['url'] ?? null,
|
||||
'thumb_lg' => $sourceLg['url'] ?? null,
|
||||
'thumb_srcset' => $sourceMd['srcset'] ?? $sourceMd['url'] ?? null,
|
||||
'author_name' => $source->user?->name ?? 'Artist',
|
||||
'author_username' => $source->user?->username ?? '',
|
||||
'author_profile_url'=> $source->user?->username ? '/@' . $source->user->username : null,
|
||||
'author_avatar' => AvatarUrl::forUser(
|
||||
(int) ($source->user_id ?? 0),
|
||||
$source->user?->profile?->avatar_hash ?? null,
|
||||
80
|
||||
),
|
||||
'category_name' => $primaryCat?->name ?? '',
|
||||
'category_slug' => $primaryCat?->slug ?? '',
|
||||
'id' => $source->id,
|
||||
'title' => $sourceTitle,
|
||||
'url' => $sourceUrl,
|
||||
'thumb_md' => $sourceMd['url'] ?? null,
|
||||
'thumb_lg' => $sourceLg['url'] ?? null,
|
||||
'thumb_srcset' => $sourceMd['srcset'] ?? $sourceMd['url'] ?? null,
|
||||
'author_name' => $source->user?->name ?? 'Artist',
|
||||
'author_username' => $source->user?->username ?? '',
|
||||
'author_profile_url' => $source->user?->username ? '/@'.$source->user->username : null,
|
||||
'author_avatar' => AvatarUrl::forUser(
|
||||
(int) ($source->user_id ?? 0),
|
||||
$source->user?->profile?->avatar_hash ?? null,
|
||||
80
|
||||
),
|
||||
'category_name' => $primaryCat?->name ?? '',
|
||||
'category_slug' => $primaryCat?->slug ?? '',
|
||||
'content_type_name' => $primaryCat?->contentType?->name ?? '',
|
||||
'content_type_slug' => $primaryCat?->contentType?->slug ?? '',
|
||||
'browse_url' => $primaryCat?->contentType?->slug ? url('/' . $primaryCat->contentType->slug) : url('/explore'),
|
||||
'tag_slugs' => $source->tags->pluck('slug')->take(5)->all(),
|
||||
'width' => $source->width ?? null,
|
||||
'height' => $source->height ?? null,
|
||||
'browse_url' => $primaryCat?->contentType?->slug ? url('/'.$primaryCat->contentType->slug) : url('/explore'),
|
||||
'tag_slugs' => $source->tags->pluck('slug')->take(5)->all(),
|
||||
'width' => $source->width ?? null,
|
||||
'height' => $source->height ?? null,
|
||||
];
|
||||
|
||||
return view('gallery.similar', [
|
||||
'sourceArtwork' => $sourceCard,
|
||||
'gallery_type' => 'similar',
|
||||
'gallery_nav_section' => 'artworks',
|
||||
'mainCategories' => collect(),
|
||||
'subcategories' => collect(),
|
||||
'contentType' => null,
|
||||
'category' => null,
|
||||
'spotlight' => collect(),
|
||||
'current_sort' => 'trending',
|
||||
'sort_options' => [],
|
||||
'page_title' => 'Similar to "' . $sourceTitle . '" — Skinbase',
|
||||
'page_meta_description' => 'Discover artworks similar to "' . $sourceTitle . '" on Skinbase.',
|
||||
'page_canonical' => $baseUrl,
|
||||
'page_robots' => 'index,follow',
|
||||
'breadcrumbs' => collect([
|
||||
'sourceArtwork' => $sourceCard,
|
||||
'gallery_type' => 'similar',
|
||||
'gallery_nav_section' => 'artworks',
|
||||
'mainCategories' => collect(),
|
||||
'subcategories' => collect(),
|
||||
'contentType' => null,
|
||||
'category' => null,
|
||||
'spotlight' => collect(),
|
||||
'current_sort' => 'trending',
|
||||
'sort_options' => [],
|
||||
'page_title' => 'Similar to "'.$sourceTitle.'" — Skinbase',
|
||||
'page_meta_description' => 'Discover artworks similar to "'.$sourceTitle.'" on Skinbase.',
|
||||
'page_canonical' => $baseUrl,
|
||||
'page_robots' => 'index,follow',
|
||||
'breadcrumbs' => collect([
|
||||
(object) ['name' => 'Explore', 'url' => '/explore'],
|
||||
(object) ['name' => $sourceTitle, 'url' => $sourceUrl],
|
||||
(object) ['name' => 'Similar Artworks', 'url' => $baseUrl],
|
||||
@@ -130,37 +130,37 @@ final class SimilarArtworksPageController extends Controller
|
||||
])
|
||||
->findOrFail($id);
|
||||
|
||||
$page = max(1, (int) $request->query('page', 1));
|
||||
$page = max(1, (int) $request->query('page', 1));
|
||||
$baseUrl = url("/art/{$id}/similar");
|
||||
|
||||
[$artworks, $similaritySource] = $this->resolveSimilarArtworks($source, $page, $baseUrl);
|
||||
|
||||
$galleryItems = $artworks->getCollection()->map(fn ($art) => [
|
||||
'id' => $art->id ?? null,
|
||||
'name' => $art->name ?? null,
|
||||
'thumb' => $art->thumb_url ?? $art->thumb ?? null,
|
||||
'thumb_srcset' => $art->thumb_srcset ?? null,
|
||||
'uname' => $art->uname ?? '',
|
||||
'username' => $art->username ?? $art->uname ?? '',
|
||||
'avatar_url' => $art->avatar_url ?? null,
|
||||
'id' => $art->id ?? null,
|
||||
'name' => $art->name ?? null,
|
||||
'thumb' => $art->thumb_url ?? $art->thumb ?? null,
|
||||
'thumb_srcset' => $art->thumb_srcset ?? null,
|
||||
'uname' => $art->uname ?? '',
|
||||
'username' => $art->username ?? $art->uname ?? '',
|
||||
'avatar_url' => $art->avatar_url ?? null,
|
||||
'category_name' => $art->category_name ?? '',
|
||||
'category_slug' => $art->category_slug ?? '',
|
||||
'slug' => $art->slug ?? '',
|
||||
'width' => $art->width ?? null,
|
||||
'height' => $art->height ?? null,
|
||||
'maturity' => $art->maturity ?? null,
|
||||
'slug' => $art->slug ?? '',
|
||||
'width' => $art->width ?? null,
|
||||
'height' => $art->height ?? null,
|
||||
'maturity' => $art->maturity ?? null,
|
||||
])->values();
|
||||
|
||||
$galleryItems = collect($this->maturity->filterPayloadItems($galleryItems->all(), $request->user()))->values();
|
||||
|
||||
return response()->json([
|
||||
'data' => $galleryItems,
|
||||
'data' => $galleryItems,
|
||||
'similarity_source' => $similaritySource,
|
||||
'total' => $artworks->total(),
|
||||
'current_page' => $artworks->currentPage(),
|
||||
'last_page' => $artworks->lastPage(),
|
||||
'next_page_url' => $artworks->nextPageUrl(),
|
||||
'prev_page_url' => $artworks->previousPageUrl(),
|
||||
'total' => $artworks->total(),
|
||||
'current_page' => $artworks->currentPage(),
|
||||
'last_page' => $artworks->lastPage(),
|
||||
'next_page_url' => $artworks->nextPageUrl(),
|
||||
'prev_page_url' => $artworks->previousPageUrl(),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -171,16 +171,13 @@ final class SimilarArtworksPageController extends Controller
|
||||
*/
|
||||
private function resolveSimilarArtworks(Artwork $source, int $page, string $baseUrl): array
|
||||
{
|
||||
// Priority 1 — Qdrant visual (vision) similarity
|
||||
// Priority 1 — Qdrant visual (vision) similarity.
|
||||
// Paginate IDs before hydrating/presenting full Artwork models so a
|
||||
// request only pays the relation/presenter cost for the current page.
|
||||
if ($this->vectors->isConfigured()) {
|
||||
$qdrantItems = $this->resolveViaQdrant($source);
|
||||
if ($qdrantItems !== null && $qdrantItems->isNotEmpty()) {
|
||||
$paginator = $this->paginateCollection(
|
||||
$qdrantItems->map(fn ($a) => $this->presentArtwork($a)),
|
||||
$page,
|
||||
$baseUrl,
|
||||
);
|
||||
return [$paginator, 'visual'];
|
||||
$qdrantPaginator = $this->resolveViaQdrant($source, $page, $baseUrl);
|
||||
if ($qdrantPaginator !== null && $qdrantPaginator->total() > 0) {
|
||||
return [$qdrantPaginator, 'visual'];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -192,54 +189,106 @@ final class SimilarArtworksPageController extends Controller
|
||||
$page,
|
||||
$baseUrl,
|
||||
);
|
||||
|
||||
return [$paginator, 'hybrid'];
|
||||
}
|
||||
|
||||
// Priority 3 — Meilisearch tag/category overlap
|
||||
$paginator = $this->meilisearchFallback($source, $page);
|
||||
|
||||
return [$paginator, 'tags'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Query Qdrant via VectorGateway, then re-hydrate full Artwork models
|
||||
* (so we have category/dimension data for the masonry grid).
|
||||
* Query Qdrant via VectorGateway, preserve relevance order, then paginate
|
||||
* candidate IDs before hydrating full Artwork models for the current page.
|
||||
*
|
||||
* Returns null when the gateway call fails, so the caller can fall through.
|
||||
* The light eligibility query keeps the existing public/published semantics
|
||||
* for cached Qdrant results without loading relations for every candidate.
|
||||
* Returns null when the gateway call fails or no eligible candidates remain,
|
||||
* allowing the caller to continue to the existing hybrid/tag fallbacks.
|
||||
*/
|
||||
private function resolveViaQdrant(Artwork $source): ?Collection
|
||||
{
|
||||
private function resolveViaQdrant(
|
||||
Artwork $source,
|
||||
int $page,
|
||||
string $baseUrl,
|
||||
): ?LengthAwarePaginator {
|
||||
try {
|
||||
$raw = $this->vectors->similarToArtwork($source, self::QDRANT_LIMIT);
|
||||
} catch (RuntimeException) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (empty($raw)) {
|
||||
if ($raw === []) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Preserve Qdrant relevance order; IDs are already filtered to public+published
|
||||
$orderedIds = array_column($raw, 'id');
|
||||
// Preserve Qdrant relevance order while normalising IDs defensively.
|
||||
$orderedIds = collect(array_column($raw, 'id'))
|
||||
->map(static fn ($id): int => is_numeric($id) ? (int) $id : 0)
|
||||
->filter(static fn (int $id): bool => $id > 0 && $id !== (int) $source->id)
|
||||
->unique()
|
||||
->values();
|
||||
|
||||
$artworks = Artwork::query()
|
||||
->whereIn('id', $orderedIds)
|
||||
->where('id', '!=', $source->id) // belt-and-braces exclusion
|
||||
if ($orderedIds->isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Keep cached Qdrant results honest if an artwork has since become
|
||||
// private/unpublished. Fetch IDs only; do not hydrate relations yet.
|
||||
$eligibleSet = Artwork::query()
|
||||
->whereIn('id', $orderedIds->all())
|
||||
->public()
|
||||
->published()
|
||||
->with([
|
||||
'categories:id,slug,name,content_type_id',
|
||||
'categories.contentType:id,name,slug',
|
||||
'user:id,name,username',
|
||||
'user.profile:user_id,avatar_hash',
|
||||
'group:id,name,slug,avatar_path',
|
||||
])
|
||||
->get()
|
||||
->keyBy('id');
|
||||
->pluck('id')
|
||||
->mapWithKeys(static fn ($id): array => [(int) $id => true]);
|
||||
|
||||
return collect($orderedIds)
|
||||
->map(fn (int $id) => $artworks->get($id))
|
||||
->filter()
|
||||
$eligibleIds = $orderedIds
|
||||
->filter(static fn (int $id): bool => $eligibleSet->has($id))
|
||||
->values();
|
||||
|
||||
if ($eligibleIds->isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$perPage = self::PER_PAGE;
|
||||
$total = $eligibleIds->count();
|
||||
$pageIds = $eligibleIds->forPage($page, $perPage)->values();
|
||||
|
||||
$items = collect();
|
||||
|
||||
if ($pageIds->isNotEmpty()) {
|
||||
$artworks = Artwork::query()
|
||||
->whereIn('id', $pageIds->all())
|
||||
->public()
|
||||
->published()
|
||||
->with([
|
||||
'categories:id,slug,name,content_type_id',
|
||||
'categories.contentType:id,name,slug',
|
||||
'user:id,name,username',
|
||||
'user.profile:user_id,avatar_hash',
|
||||
'group:id,name,slug,avatar_path',
|
||||
])
|
||||
->get()
|
||||
->keyBy('id');
|
||||
|
||||
$items = $pageIds
|
||||
->map(fn (int $id) => $artworks->get($id))
|
||||
->filter()
|
||||
->map(fn (Artwork $artwork) => $this->presentArtwork($artwork))
|
||||
->values();
|
||||
}
|
||||
|
||||
return new LengthAwarePaginator(
|
||||
$items,
|
||||
$total,
|
||||
$perPage,
|
||||
$page,
|
||||
[
|
||||
'path' => $baseUrl,
|
||||
'query' => [],
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -247,26 +296,26 @@ final class SimilarArtworksPageController extends Controller
|
||||
*/
|
||||
private function meilisearchFallback(Artwork $source, int $page): LengthAwarePaginator
|
||||
{
|
||||
$tagSlugs = $source->tags->pluck('slug')->values()->all();
|
||||
$tagSlugs = $source->tags->pluck('slug')->values()->all();
|
||||
$categorySlugs = $source->categories->pluck('slug')->values()->all();
|
||||
|
||||
$filterParts = [
|
||||
'is_public = true',
|
||||
'is_approved = true',
|
||||
'id != ' . $source->id,
|
||||
'id != '.$source->id,
|
||||
];
|
||||
|
||||
if ($tagSlugs !== []) {
|
||||
$quoted = array_map(fn (string $t): string => 'tags = "' . addslashes($t) . '"', $tagSlugs);
|
||||
$filterParts[] = '(' . implode(' OR ', $quoted) . ')';
|
||||
$quoted = array_map(fn (string $t): string => 'tags = "'.addslashes($t).'"', $tagSlugs);
|
||||
$filterParts[] = '('.implode(' OR ', $quoted).')';
|
||||
} elseif ($categorySlugs !== []) {
|
||||
$quoted = array_map(fn (string $c): string => '(category = "' . addslashes($c) . '" OR categories = "' . addslashes($c) . '")', $categorySlugs);
|
||||
$filterParts[] = '(' . implode(' OR ', $quoted) . ')';
|
||||
$quoted = array_map(fn (string $c): string => '(category = "'.addslashes($c).'" OR categories = "'.addslashes($c).'")', $categorySlugs);
|
||||
$filterParts[] = '('.implode(' OR ', $quoted).')';
|
||||
}
|
||||
|
||||
$results = Artwork::search('')->options([
|
||||
'filter' => implode(' AND ', $filterParts),
|
||||
'sort' => ['trending_score_7d:desc', 'created_at:desc'],
|
||||
'sort' => ['trending_score_7d:desc', 'created_at:desc'],
|
||||
])->paginate(self::PER_PAGE, 'page', $page);
|
||||
|
||||
$results->getCollection()->load([
|
||||
@@ -287,15 +336,15 @@ final class SimilarArtworksPageController extends Controller
|
||||
*/
|
||||
private function paginateCollection(
|
||||
Collection $items,
|
||||
int $page,
|
||||
string $path,
|
||||
int $page,
|
||||
string $path,
|
||||
): LengthAwarePaginator {
|
||||
$perPage = self::PER_PAGE;
|
||||
$total = $items->count();
|
||||
$slice = $items->forPage($page, $perPage)->values();
|
||||
$total = $items->count();
|
||||
$slice = $items->forPage($page, $perPage)->values();
|
||||
|
||||
return new LengthAwarePaginator($slice, $total, $perPage, $page, [
|
||||
'path' => $path,
|
||||
'path' => $path,
|
||||
'query' => [],
|
||||
]);
|
||||
}
|
||||
@@ -304,9 +353,9 @@ final class SimilarArtworksPageController extends Controller
|
||||
|
||||
private function presentArtwork(Artwork $artwork): object
|
||||
{
|
||||
$primary = $artwork->categories->sortBy('sort_order')->first();
|
||||
$present = ThumbnailPresenter::present($artwork, 'md');
|
||||
$group = $artwork->group;
|
||||
$primary = $artwork->categories->sortBy('sort_order')->first();
|
||||
$present = ThumbnailPresenter::present($artwork, 'md');
|
||||
$group = $artwork->group;
|
||||
$isGroupPublisher = $group !== null;
|
||||
$avatarUrl = $isGroupPublisher
|
||||
? $group->avatarUrl()
|
||||
@@ -317,33 +366,33 @@ final class SimilarArtworksPageController extends Controller
|
||||
);
|
||||
$displayName = $isGroupPublisher ? ($group->name ?? 'Skinbase') : ($artwork->user?->name ?? 'Skinbase');
|
||||
$username = $isGroupPublisher ? '' : ($artwork->user?->username ?? '');
|
||||
$profileUrl = $isGroupPublisher ? $group->publicUrl() : ($username !== '' ? '/@' . $username : null);
|
||||
$profileUrl = $isGroupPublisher ? $group->publicUrl() : ($username !== '' ? '/@'.$username : null);
|
||||
|
||||
return (object) $this->maturity->decoratePayload([
|
||||
'id' => $artwork->id,
|
||||
'name' => $artwork->title,
|
||||
'id' => $artwork->id,
|
||||
'name' => $artwork->title,
|
||||
'content_type_name' => $primary?->contentType?->name ?? '',
|
||||
'content_type_slug' => $primary?->contentType?->slug ?? '',
|
||||
'category_name' => $primary?->name ?? '',
|
||||
'category_slug' => $primary?->slug ?? '',
|
||||
'thumb_url' => $present['url'],
|
||||
'thumb_srcset' => $present['srcset'] ?? $present['url'],
|
||||
'uname' => $displayName,
|
||||
'username' => $username,
|
||||
'avatar_url' => $avatarUrl,
|
||||
'profile_url' => $profileUrl,
|
||||
'category_name' => $primary?->name ?? '',
|
||||
'category_slug' => $primary?->slug ?? '',
|
||||
'thumb_url' => $present['url'],
|
||||
'thumb_srcset' => $present['srcset'] ?? $present['url'],
|
||||
'uname' => $displayName,
|
||||
'username' => $username,
|
||||
'avatar_url' => $avatarUrl,
|
||||
'profile_url' => $profileUrl,
|
||||
'published_as_type' => $isGroupPublisher ? 'group' : 'user',
|
||||
'publisher' => [
|
||||
'publisher' => [
|
||||
'type' => $isGroupPublisher ? 'group' : 'user',
|
||||
'name' => $displayName,
|
||||
'username' => $username,
|
||||
'avatar_url' => $avatarUrl,
|
||||
'profile_url' => $profileUrl,
|
||||
],
|
||||
'published_at' => $artwork->published_at,
|
||||
'slug' => $artwork->slug ?? '',
|
||||
'width' => $artwork->width ?? null,
|
||||
'height' => $artwork->height ?? null,
|
||||
'published_at' => $artwork->published_at,
|
||||
'slug' => $artwork->slug ?? '',
|
||||
'width' => $artwork->width ?? null,
|
||||
'height' => $artwork->height ?? null,
|
||||
], $artwork, request()->user());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use cPad\Plugins\Forum\Services\Security\BotProtectionService;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class CommentCaptchaMiddleware
|
||||
{
|
||||
public function __construct(private readonly BotProtectionService $botProtection) {}
|
||||
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
if (! (bool) config('comment_spam.captcha.enabled', false)) {
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
$assessment = $this->botProtection->assess($request, 'comment_create');
|
||||
if ((bool) ($assessment['blocked'] ?? false)) {
|
||||
return response()->json(['message' => 'Suspicious activity detected.', 'errors' => ['bot' => ['Suspicious activity detected.']]], 429);
|
||||
}
|
||||
$threshold = (int) config('comment_spam.captcha.threshold', 40);
|
||||
if ((int) ($assessment['risk_score'] ?? 0) < $threshold) {
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
$token = (string) ($request->input('comment-turnstile-response') ?: $request->header('X-Turnstile-Token', ''));
|
||||
$valid = false;
|
||||
if ($token !== '') {
|
||||
try {
|
||||
$valid = (bool) Http::asForm()->timeout(5)->post(
|
||||
(string) config('comment_spam.captcha.verify_url'),
|
||||
['secret' => (string) config('comment_spam.captcha.secret_key'), 'response' => $token, 'remoteip' => $request->ip()],
|
||||
)->json('success', false);
|
||||
} catch (\Throwable) {
|
||||
$valid = (bool) config('comment_spam.captcha.fail_open', false);
|
||||
}
|
||||
}
|
||||
|
||||
if ($valid) {
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
$payload = [
|
||||
'message' => 'Complete the captcha challenge to continue.',
|
||||
'errors' => ['captcha' => ['Complete the captcha challenge to continue.']],
|
||||
'requires_captcha' => true,
|
||||
'captcha' => [
|
||||
'provider' => 'turnstile',
|
||||
'siteKey' => (string) config('comment_spam.captcha.site_key'),
|
||||
'inputName' => 'comment-turnstile-response',
|
||||
'scriptUrl' => (string) config('comment_spam.captcha.script_url'),
|
||||
],
|
||||
'captcha_provider' => 'turnstile',
|
||||
'captcha_site_key' => (string) config('comment_spam.captcha.site_key'),
|
||||
'captcha_input' => 'comment-turnstile-response',
|
||||
'captcha_script_url' => (string) config('comment_spam.captcha.script_url'),
|
||||
];
|
||||
|
||||
return response()->json($payload, 422);
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,9 @@ final class ArtworkCreateRequest extends FormRequest
|
||||
'title' => 'required|string|max:150',
|
||||
'description' => 'nullable|string',
|
||||
'category' => 'nullable|integer|exists:categories,id',
|
||||
'primary_category_id' => 'nullable|integer|exists:categories,id',
|
||||
'secondary_category_ids' => 'nullable|array|max:'.(int) config('categories.max_secondary_categories', 5),
|
||||
'secondary_category_ids.*' => 'integer|exists:categories,id',
|
||||
'tags' => 'nullable|string|max:200',
|
||||
'license' => 'nullable|boolean',
|
||||
'is_mature' => 'nullable|boolean',
|
||||
@@ -45,7 +48,7 @@ final class ArtworkCreateRequest extends FormRequest
|
||||
|
||||
private function denyAsNotFound(): void
|
||||
{
|
||||
throw new NotFoundHttpException();
|
||||
throw new NotFoundHttpException;
|
||||
}
|
||||
|
||||
private function logUnauthorized(string $reason): void
|
||||
|
||||
@@ -23,10 +23,13 @@ final class ApplyArtworkAiAssistRequest extends FormRequest
|
||||
'title_mode' => ['sometimes', Rule::in(['replace', 'insert'])],
|
||||
'description' => ['sometimes', 'nullable', 'string', 'max:5000'],
|
||||
'description_mode' => ['sometimes', Rule::in(['replace', 'append'])],
|
||||
'tags' => ['sometimes', 'array', 'max:' . (int) config('tags.max_user_tags', 30)],
|
||||
'tags' => ['sometimes', 'array', 'max:'.(int) config('tags.max_user_tags', 30)],
|
||||
'tags.*' => ['string', 'max:64'],
|
||||
'tag_mode' => ['sometimes', Rule::in(['add', 'replace', 'remove'])],
|
||||
'category_id' => ['sometimes', 'nullable', 'integer', 'exists:categories,id'],
|
||||
'primary_category_id' => ['sometimes', 'nullable', 'integer', 'exists:categories,id'],
|
||||
'secondary_category_ids' => ['sometimes', 'array', 'max:'.(int) config('categories.max_secondary_categories', 5)],
|
||||
'secondary_category_ids.*' => ['integer', 'exists:categories,id'],
|
||||
'content_type_id' => ['sometimes', 'nullable', 'integer', 'exists:content_types,id'],
|
||||
'similar_actions' => ['sometimes', 'array', 'max:10'],
|
||||
'similar_actions.*.artwork_id' => ['required_with:similar_actions', 'integer'],
|
||||
@@ -42,4 +45,4 @@ final class ApplyArtworkAiAssistRequest extends FormRequest
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Resources;
|
||||
|
||||
use App\Models\World;
|
||||
use App\Models\WorldRelation;
|
||||
use App\Models\WorldSubmission;
|
||||
use App\Models\World;
|
||||
use App\Services\ArtworkEvolutionService;
|
||||
use App\Services\ContentSanitizer;
|
||||
use App\Services\Maturity\ArtworkMaturityService;
|
||||
@@ -12,6 +13,7 @@ use App\Services\Worlds\WorldRewardService;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class ArtworkResource extends JsonResource
|
||||
{
|
||||
@@ -23,7 +25,7 @@ class ArtworkResource extends JsonResource
|
||||
*/
|
||||
public function toArray($request): array
|
||||
{
|
||||
$this->resource->loadMissing(['group', 'uploadedBy.profile', 'primaryAuthor.profile', 'contributors.user.profile', 'worldSubmissions.world', 'worldRewardGrants.world']);
|
||||
$this->resource->loadMissing(['group', 'uploadedBy.profile', 'primaryAuthor.profile', 'contributors.user.profile', 'worldSubmissions.world']);
|
||||
|
||||
$md = ThumbnailPresenter::present($this->resource, 'md');
|
||||
$lg = ThumbnailPresenter::present($this->resource, 'lg');
|
||||
@@ -31,18 +33,16 @@ class ArtworkResource extends JsonResource
|
||||
$sq = ThumbnailPresenter::present($this->resource, 'sq');
|
||||
$screenshots = $this->resolveScreenshotAssets();
|
||||
|
||||
$canonicalSlug = \Illuminate\Support\Str::slug((string) ($this->slug ?: $this->title));
|
||||
$canonicalSlug = Str::slug((string) ($this->slug ?: $this->title));
|
||||
if ($canonicalSlug === '') {
|
||||
$canonicalSlug = (string) $this->id;
|
||||
}
|
||||
|
||||
$followerCount = 0;
|
||||
if (!empty($this->user?->id)) {
|
||||
if ($this->hasTable('user_statistics')) {
|
||||
$followerCount = (int) DB::table('user_statistics')
|
||||
->where('user_id', (int) $this->user->id)
|
||||
->value('followers_count');
|
||||
}
|
||||
if (! empty($this->user?->id)) {
|
||||
$followerCount = (int) DB::table('user_statistics')
|
||||
->where('user_id', (int) $this->user->id)
|
||||
->value('followers_count');
|
||||
|
||||
// Legacy fallback for environments where new tables are unavailable.
|
||||
if (($followerCount <= 0) && $this->hasTable('friends_list')) {
|
||||
@@ -61,9 +61,9 @@ class ArtworkResource extends JsonResource
|
||||
$viewerAward = null;
|
||||
$isOwner = $viewerId > 0 && $viewerId === (int) ($this->user?->id ?? 0);
|
||||
|
||||
$bookmarksCount = $this->hasTable('artwork_bookmarks')
|
||||
? (int) DB::table('artwork_bookmarks')->where('artwork_id', (int) $this->id)->count()
|
||||
: 0;
|
||||
$bookmarksCount = (int) DB::table('artwork_bookmarks')
|
||||
->where('artwork_id', (int) $this->id)
|
||||
->count();
|
||||
|
||||
if ($viewerId > 0) {
|
||||
if ($this->hasTable('artwork_likes')) {
|
||||
@@ -73,19 +73,17 @@ class ArtworkResource extends JsonResource
|
||||
->exists();
|
||||
}
|
||||
|
||||
if ($this->hasTable('artwork_bookmarks')) {
|
||||
$isBookmarked = DB::table('artwork_bookmarks')
|
||||
->where('user_id', $viewerId)
|
||||
->where('artwork_id', (int) $this->id)
|
||||
->exists();
|
||||
}
|
||||
$isBookmarked = DB::table('artwork_bookmarks')
|
||||
->where('user_id', $viewerId)
|
||||
->where('artwork_id', (int) $this->id)
|
||||
->exists();
|
||||
|
||||
$isFavorited = DB::table('artwork_favourites')
|
||||
->where('user_id', $viewerId)
|
||||
->where('artwork_id', (int) $this->id)
|
||||
->exists();
|
||||
|
||||
if (!empty($this->user?->id)) {
|
||||
if (! empty($this->user?->id)) {
|
||||
if ($this->hasTable('user_followers')) {
|
||||
$isFollowing = DB::table('user_followers')
|
||||
->where('user_id', (int) $this->user->id)
|
||||
@@ -100,7 +98,7 @@ class ArtworkResource extends JsonResource
|
||||
}
|
||||
}
|
||||
|
||||
if (!empty($this->group?->id) && $this->hasTable('group_follows')) {
|
||||
if (! empty($this->group?->id) && $this->hasTable('group_follows')) {
|
||||
$isFollowingGroup = DB::table('group_follows')
|
||||
->where('group_id', (int) $this->group->id)
|
||||
->where('user_id', $viewerId)
|
||||
@@ -125,7 +123,7 @@ class ArtworkResource extends JsonResource
|
||||
'id' => (int) ($user->id ?? 0),
|
||||
'name' => html_entity_decode((string) ($user->name ?? ''), ENT_QUOTES | ENT_HTML5, 'UTF-8'),
|
||||
'username' => (string) ($user->username ?? ''),
|
||||
'profile_url' => ! empty($user->username) ? '/@' . $user->username : null,
|
||||
'profile_url' => ! empty($user->username) ? '/@'.$user->username : null,
|
||||
'avatar_url' => $user->profile?->avatar_url,
|
||||
];
|
||||
};
|
||||
@@ -150,7 +148,7 @@ class ArtworkResource extends JsonResource
|
||||
'slug' => (string) ($this->user?->username ?? ''),
|
||||
'headline' => '',
|
||||
'avatar_url' => $this->user?->profile?->avatar_url,
|
||||
'profile_url' => $this->user?->username ? '/@' . $this->user->username : null,
|
||||
'profile_url' => $this->user?->username ? '/@'.$this->user->username : null,
|
||||
'followers_count' => $followerCount,
|
||||
'follow_url' => null,
|
||||
'unfollow_url' => null,
|
||||
@@ -179,6 +177,8 @@ class ArtworkResource extends JsonResource
|
||||
'title' => $decode($this->title),
|
||||
'description' => $decode($this->description),
|
||||
'description_html' => $this->renderDescriptionHtml(),
|
||||
'download_status' => (string) ($this->download_status ?? 'unknown'),
|
||||
'download_source' => $this->download_source,
|
||||
'dimensions' => [
|
||||
'width' => (int) ($this->width ?? 0),
|
||||
'height' => (int) ($this->height ?? 0),
|
||||
@@ -201,7 +201,7 @@ class ArtworkResource extends JsonResource
|
||||
'id' => (int) ($this->user?->id ?? 0),
|
||||
'name' => html_entity_decode((string) ($this->user?->name ?? ''), ENT_QUOTES | ENT_HTML5, 'UTF-8'),
|
||||
'username' => (string) ($this->user?->username ?? ''),
|
||||
'profile_url' => $this->user?->username ? '/@' . $this->user->username : null,
|
||||
'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'),
|
||||
@@ -258,20 +258,40 @@ class ArtworkResource extends JsonResource
|
||||
'maturity' => app(ArtworkMaturityService::class)->presentation($this->resource, $request->user()),
|
||||
'evolution' => app(ArtworkEvolutionService::class)->publicPayload($this->resource, $request->user()),
|
||||
'world_participation' => $this->resolveWorldParticipation(),
|
||||
'categories' => $this->categories->map(fn ($category) => [
|
||||
'id' => (int) $category->id,
|
||||
'slug' => (string) $category->slug,
|
||||
'name' => html_entity_decode((string) $category->name, ENT_QUOTES | ENT_HTML5, 'UTF-8'),
|
||||
'content_type_slug' => (string) ($category->contentType?->slug ?? ''),
|
||||
'url' => $category->contentType ? $category->url : null,
|
||||
'parent' => $category->parent ? [
|
||||
'id' => (int) $category->parent->id,
|
||||
'slug' => (string) $category->parent->slug,
|
||||
'name' => html_entity_decode((string) $category->parent->name, ENT_QUOTES | ENT_HTML5, 'UTF-8'),
|
||||
'content_type_slug' => (string) ($category->parent->contentType?->slug ?? ''),
|
||||
'url' => $category->parent->contentType ? $category->parent->url : null,
|
||||
] : null,
|
||||
])->values(),
|
||||
'category' => ($primary = $this->resolvedPrimaryCategory()) ? [
|
||||
'id' => (int) $primary->id,
|
||||
'slug' => (string) $primary->slug,
|
||||
'name' => html_entity_decode((string) $primary->name, ENT_QUOTES | ENT_HTML5, 'UTF-8'),
|
||||
'content_type_slug' => (string) ($primary->contentType?->slug ?? ''),
|
||||
'url' => $primary->contentType ? $primary->url : null,
|
||||
'is_primary' => true,
|
||||
] : null,
|
||||
'primary_category' => ($primary = $this->resolvedPrimaryCategory()) ? [
|
||||
'id' => (int) $primary->id,
|
||||
'slug' => (string) $primary->slug,
|
||||
'name' => html_entity_decode((string) $primary->name, ENT_QUOTES | ENT_HTML5, 'UTF-8'),
|
||||
'content_type_slug' => (string) ($primary->contentType?->slug ?? ''),
|
||||
'url' => $primary->contentType ? $primary->url : null,
|
||||
] : null,
|
||||
'categories' => $this->categories->map(function ($category) {
|
||||
$primaryId = (int) ($this->primary_category_id ?? 0);
|
||||
|
||||
return [
|
||||
'id' => (int) $category->id,
|
||||
'slug' => (string) $category->slug,
|
||||
'name' => html_entity_decode((string) $category->name, ENT_QUOTES | ENT_HTML5, 'UTF-8'),
|
||||
'content_type_slug' => (string) ($category->contentType?->slug ?? ''),
|
||||
'url' => $category->contentType ? $category->url : null,
|
||||
'is_primary' => $primaryId > 0 && (int) $category->id === $primaryId,
|
||||
'parent' => $category->parent ? [
|
||||
'id' => (int) $category->parent->id,
|
||||
'slug' => (string) $category->parent->slug,
|
||||
'name' => html_entity_decode((string) $category->parent->name, ENT_QUOTES | ENT_HTML5, 'UTF-8'),
|
||||
'content_type_slug' => (string) ($category->parent->contentType?->slug ?? ''),
|
||||
'url' => $category->parent->contentType ? $category->parent->url : null,
|
||||
] : null,
|
||||
];
|
||||
})->values(),
|
||||
'tags' => $this->tags->map(fn ($tag) => [
|
||||
'id' => (int) $tag->id,
|
||||
'slug' => (string) $tag->slug,
|
||||
@@ -282,10 +302,6 @@ class ArtworkResource extends JsonResource
|
||||
|
||||
private function resolveScreenshotAssets(): array
|
||||
{
|
||||
if (! $this->hasTable('artwork_files')) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return DB::table('artwork_files')
|
||||
->where('artwork_id', (int) $this->id)
|
||||
->where('variant', 'like', 'shot%')
|
||||
@@ -296,9 +312,9 @@ class ArtworkResource extends JsonResource
|
||||
$url = $this->objectUrl($path);
|
||||
|
||||
return [
|
||||
'id' => (string) ($row->variant ?? ('shot' . ($index + 1))),
|
||||
'id' => (string) ($row->variant ?? ('shot'.($index + 1))),
|
||||
'variant' => (string) ($row->variant ?? ''),
|
||||
'label' => 'Screenshot ' . ($index + 1),
|
||||
'label' => 'Screenshot '.($index + 1),
|
||||
'url' => $url,
|
||||
'thumb_url' => $url,
|
||||
'mime_type' => (string) ($row->mime ?? 'image/jpeg'),
|
||||
@@ -319,7 +335,7 @@ class ArtworkResource extends JsonResource
|
||||
|
||||
$base = rtrim((string) config('cdn.files_url', 'https://files.skinbase.org'), '/');
|
||||
|
||||
return $base . '/' . $trimmedPath;
|
||||
return $base.'/'.$trimmedPath;
|
||||
}
|
||||
|
||||
private function renderDescriptionHtml(): string
|
||||
@@ -342,73 +358,75 @@ class ArtworkResource extends JsonResource
|
||||
$items = collect();
|
||||
$participationWorlds = collect();
|
||||
|
||||
if ($this->hasTable('world_relations') && $this->hasTable('worlds')) {
|
||||
$relations = WorldRelation::query()
|
||||
->with('world')
|
||||
->where('related_type', WorldRelation::TYPE_ARTWORK)
|
||||
->where('related_id', (int) $this->id)
|
||||
->get()
|
||||
->filter(fn (WorldRelation $relation): bool => $relation->world !== null && $relation->world->isPubliclyVisible())
|
||||
->values();
|
||||
$relations = WorldRelation::query()
|
||||
->with('world')
|
||||
->where('related_type', WorldRelation::TYPE_ARTWORK)
|
||||
->where('related_id', (int) $this->id)
|
||||
->get()
|
||||
->filter(fn (WorldRelation $relation): bool => $relation->world !== null && $relation->world->isPubliclyVisible())
|
||||
->values();
|
||||
|
||||
$participationWorlds = $participationWorlds->concat($relations->pluck('world')->filter());
|
||||
$participationWorlds = $participationWorlds->concat(
|
||||
$relations->pluck('world')->filter()
|
||||
);
|
||||
|
||||
$items = $items->concat(
|
||||
$relations->map(function (WorldRelation $relation): array {
|
||||
$world = $relation->world;
|
||||
$items = $items->concat(
|
||||
$relations->map(function (WorldRelation $relation): array {
|
||||
$world = $relation->world;
|
||||
|
||||
return [
|
||||
'world_id' => (int) $relation->world_id,
|
||||
'world_title' => (string) $world->title,
|
||||
'world_slug' => (string) $world->slug,
|
||||
'world_url' => $world->publicUrl(),
|
||||
'badge_label' => 'Part of ' . $world->title,
|
||||
'status' => 'curated',
|
||||
'status_label' => 'Curated',
|
||||
'tone' => 'curated',
|
||||
'sort_priority' => 1,
|
||||
];
|
||||
})
|
||||
);
|
||||
}
|
||||
return [
|
||||
'world_id' => (int) $relation->world_id,
|
||||
'world_title' => (string) $world->title,
|
||||
'world_slug' => (string) $world->slug,
|
||||
'world_url' => $world->publicUrl(),
|
||||
'badge_label' => 'Part of '.$world->title,
|
||||
'status' => 'curated',
|
||||
'status_label' => 'Curated',
|
||||
'tone' => 'curated',
|
||||
'sort_priority' => 1,
|
||||
];
|
||||
})
|
||||
);
|
||||
|
||||
if ($this->hasTable('world_submissions')) {
|
||||
$liveSubmissions = $this->worldSubmissions
|
||||
->filter(function (WorldSubmission $submission): bool {
|
||||
return (string) $submission->status === WorldSubmission::STATUS_LIVE
|
||||
&& $submission->world !== null
|
||||
&& $submission->world->isPubliclyVisible();
|
||||
})
|
||||
->values();
|
||||
$liveSubmissions = $this->worldSubmissions
|
||||
->filter(function (WorldSubmission $submission): bool {
|
||||
return (string) $submission->status === WorldSubmission::STATUS_LIVE
|
||||
&& $submission->world !== null
|
||||
&& $submission->world->isPubliclyVisible();
|
||||
})
|
||||
->values();
|
||||
|
||||
$participationWorlds = $participationWorlds->concat($liveSubmissions->pluck('world')->filter());
|
||||
World::primeCanonicalEditionIds($participationWorlds->pluck('recurrence_key')->all());
|
||||
$participationWorlds = $participationWorlds->concat(
|
||||
$liveSubmissions->pluck('world')->filter()
|
||||
);
|
||||
|
||||
$items = $items->concat(
|
||||
$liveSubmissions->map(function (WorldSubmission $submission): array {
|
||||
$world = $submission->world;
|
||||
$isFeatured = (bool) $submission->is_featured;
|
||||
World::primeCanonicalEditionIds(
|
||||
$participationWorlds->pluck('recurrence_key')->all()
|
||||
);
|
||||
|
||||
return [
|
||||
'world_id' => (int) $submission->world_id,
|
||||
'world_title' => (string) $world->title,
|
||||
'world_slug' => (string) $world->slug,
|
||||
'world_url' => $world->publicUrl(),
|
||||
'badge_label' => ($isFeatured ? 'Featured in ' : 'Part of ') . $world->title,
|
||||
'status' => (string) $submission->status,
|
||||
'status_label' => $isFeatured ? 'Featured' : 'Community submission',
|
||||
'tone' => $isFeatured ? 'featured' : 'community',
|
||||
'sort_priority' => $isFeatured ? 0 : 2,
|
||||
];
|
||||
})
|
||||
);
|
||||
} elseif ($participationWorlds->isNotEmpty()) {
|
||||
World::primeCanonicalEditionIds($participationWorlds->pluck('recurrence_key')->all());
|
||||
}
|
||||
$items = $items->concat(
|
||||
$liveSubmissions->map(function (WorldSubmission $submission): array {
|
||||
$world = $submission->world;
|
||||
$isFeatured = (bool) $submission->is_featured;
|
||||
|
||||
if ($this->hasTable('world_reward_grants')) {
|
||||
$items = $items->concat(app(WorldRewardService::class)->artworkRewardBadges($this->resource));
|
||||
}
|
||||
return [
|
||||
'world_id' => (int) $submission->world_id,
|
||||
'world_title' => (string) $world->title,
|
||||
'world_slug' => (string) $world->slug,
|
||||
'world_url' => $world->publicUrl(),
|
||||
'badge_label' => ($isFeatured ? 'Featured in ' : 'Part of ').$world->title,
|
||||
'status' => (string) $submission->status,
|
||||
'status_label' => $isFeatured ? 'Featured' : 'Community submission',
|
||||
'tone' => $isFeatured ? 'featured' : 'community',
|
||||
'sort_priority' => $isFeatured ? 0 : 2,
|
||||
];
|
||||
})
|
||||
);
|
||||
|
||||
$items = $items->concat(
|
||||
app(WorldRewardService::class)
|
||||
->artworkRewardBadges($this->resource)
|
||||
);
|
||||
|
||||
return $items
|
||||
->sortBy('sort_priority')
|
||||
|
||||
@@ -7,9 +7,8 @@ namespace App\Jobs;
|
||||
use App\Models\Artwork;
|
||||
use App\Models\ArtworkEmbedding;
|
||||
use App\Services\Vision\ArtworkEmbeddingClient;
|
||||
use App\Services\Vision\ArtworkVisionImageUrl;
|
||||
use App\Services\Vision\ArtworkVectorIndexService;
|
||||
use App\Services\Vision\VectorService;
|
||||
use App\Services\Vision\ArtworkVisionImageUrl;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
@@ -49,8 +48,7 @@ final class GenerateArtworkEmbeddingJob implements ShouldQueue
|
||||
ArtworkEmbeddingClient $client,
|
||||
ArtworkVisionImageUrl $imageUrlBuilder,
|
||||
ArtworkVectorIndexService $vectors,
|
||||
): void
|
||||
{
|
||||
): void {
|
||||
if (! (bool) config('recommendations.embedding.enabled', true)) {
|
||||
return;
|
||||
}
|
||||
@@ -78,17 +76,73 @@ final class GenerateArtworkEmbeddingJob implements ShouldQueue
|
||||
->where('model_version', $modelVersion)
|
||||
->first();
|
||||
|
||||
if ($existing && (string) ($existing->source_hash ?? '') === $sourceHash) {
|
||||
if (
|
||||
$existing
|
||||
&& (string) ($existing->source_hash ?? '') === $sourceHash
|
||||
&& $this->vectorIndexCoversEmbedding(
|
||||
$artwork,
|
||||
$existing
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
$lockKey = $this->lockKey($artwork->id, $model, $modelVersion);
|
||||
$lockKey = $this->lockKey(
|
||||
$artwork->id,
|
||||
$model,
|
||||
$modelVersion
|
||||
);
|
||||
|
||||
if (! $this->acquireLock($lockKey)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
/*
|
||||
* Another worker may have generated or indexed the
|
||||
* embedding between our first read and lock acquisition.
|
||||
* Re-read both records under the lock.
|
||||
*/
|
||||
if (! $this->force) {
|
||||
$artwork->refresh();
|
||||
|
||||
$existing = ArtworkEmbedding::query()
|
||||
->where('artwork_id', $artwork->id)
|
||||
->where('model', $model)
|
||||
->where('model_version', $modelVersion)
|
||||
->first();
|
||||
|
||||
if (
|
||||
$existing
|
||||
&& (string) ($existing->source_hash ?? '')
|
||||
=== $sourceHash
|
||||
) {
|
||||
if (
|
||||
$this->vectorIndexCoversEmbedding(
|
||||
$artwork,
|
||||
$existing
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
$storedVector = $this->decodeStoredVector(
|
||||
$existing
|
||||
);
|
||||
|
||||
if ($storedVector !== []) {
|
||||
$this->upsertVectorIndex(
|
||||
$vectors,
|
||||
$artwork,
|
||||
$storedVector
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$imageUrl = $imageUrlBuilder->fromArtwork($artwork);
|
||||
if ($imageUrl === null) {
|
||||
return;
|
||||
@@ -121,23 +175,33 @@ final class GenerateArtworkEmbeddingJob implements ShouldQueue
|
||||
]
|
||||
);
|
||||
|
||||
$this->upsertVectorIndex($vectors, $artwork);
|
||||
$this->upsertVectorIndex(
|
||||
$vectors,
|
||||
$artwork,
|
||||
$normalized
|
||||
);
|
||||
} finally {
|
||||
$this->releaseLock($lockKey);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, float> $vector
|
||||
*/
|
||||
private function upsertVectorIndex(
|
||||
ArtworkVectorIndexService $vectors,
|
||||
Artwork $artwork
|
||||
): void
|
||||
{
|
||||
Artwork $artwork,
|
||||
array $vector
|
||||
): void {
|
||||
if (! $vectors->isConfigured()) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$vectors->upsertArtwork($artwork);
|
||||
$vectors->upsertArtworkVector(
|
||||
$artwork,
|
||||
$vector
|
||||
);
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('GenerateArtworkEmbeddingJob vector upsert failed', [
|
||||
'artwork_id' => (int) $artwork->id,
|
||||
@@ -146,8 +210,96 @@ final class GenerateArtworkEmbeddingJob implements ShouldQueue
|
||||
}
|
||||
}
|
||||
|
||||
private function vectorIndexCoversEmbedding(
|
||||
Artwork $artwork,
|
||||
ArtworkEmbedding $embedding
|
||||
): bool {
|
||||
$indexedAt = $artwork->last_vector_indexed_at;
|
||||
$generatedAt = $embedding->generated_at;
|
||||
|
||||
if ($indexedAt === null || $generatedAt === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $indexedAt->greaterThanOrEqualTo(
|
||||
$generatedAt
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, float> $vector
|
||||
* @return array<int, float>
|
||||
*/
|
||||
private function decodeStoredVector(
|
||||
ArtworkEmbedding $embedding
|
||||
): array {
|
||||
try {
|
||||
$decoded = json_decode(
|
||||
(string) $embedding->embedding_json,
|
||||
true,
|
||||
512,
|
||||
JSON_THROW_ON_ERROR
|
||||
);
|
||||
} catch (\Throwable) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (! is_array($decoded) || $decoded === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$vector = [];
|
||||
|
||||
foreach ($decoded as $value) {
|
||||
if (! is_int($value) && ! is_float($value)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$value = (float) $value;
|
||||
|
||||
if (! is_finite($value)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$vector[] = $value;
|
||||
}
|
||||
|
||||
$count = count($vector);
|
||||
|
||||
$minDim = max(
|
||||
1,
|
||||
(int) config(
|
||||
'recommendations.embedding.min_dim',
|
||||
64
|
||||
)
|
||||
);
|
||||
|
||||
$maxDim = max(
|
||||
$minDim,
|
||||
(int) config(
|
||||
'recommendations.embedding.max_dim',
|
||||
4096
|
||||
)
|
||||
);
|
||||
|
||||
if (
|
||||
$count < $minDim
|
||||
|| $count > $maxDim
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (
|
||||
(int) $embedding->dim > 0
|
||||
&& $count !== (int) $embedding->dim
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return $vector;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, float> $vector
|
||||
* @return array<int, float>
|
||||
*/
|
||||
private function normalize(array $vector): array
|
||||
@@ -162,12 +314,13 @@ final class GenerateArtworkEmbeddingJob implements ShouldQueue
|
||||
}
|
||||
|
||||
$norm = sqrt($sumSquares);
|
||||
|
||||
return array_map(static fn (float $value): float => $value / $norm, $vector);
|
||||
}
|
||||
|
||||
private function lockKey(int $artworkId, string $model, string $version): string
|
||||
{
|
||||
return 'artwork-embedding:lock:' . $artworkId . ':' . $model . ':' . $version;
|
||||
return 'artwork-embedding:lock:'.$artworkId.':'.$model.':'.$version;
|
||||
}
|
||||
|
||||
private function acquireLock(string $key): bool
|
||||
@@ -177,6 +330,7 @@ final class GenerateArtworkEmbeddingJob implements ShouldQueue
|
||||
if ($didSet) {
|
||||
Redis::expire($key, 1800);
|
||||
}
|
||||
|
||||
return (bool) $didSet;
|
||||
} catch (\Throwable) {
|
||||
return true;
|
||||
|
||||
@@ -4,8 +4,8 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Services\Recommendations\UserInterestProfileService;
|
||||
use App\Services\Recommendations\SessionRecoService;
|
||||
use App\Services\Recommendations\UserInterestProfileService;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
@@ -30,7 +30,7 @@ final class IngestUserDiscoveryEventJob implements ShouldQueue
|
||||
public array $backoff = [5, 30, 120];
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $meta
|
||||
* @param array<string, mixed> $meta
|
||||
*/
|
||||
public function __construct(
|
||||
public readonly string $eventId,
|
||||
@@ -40,8 +40,7 @@ final class IngestUserDiscoveryEventJob implements ShouldQueue
|
||||
public readonly string $algoVersion,
|
||||
public readonly string $occurredAt,
|
||||
public readonly array $meta = []
|
||||
) {
|
||||
}
|
||||
) {}
|
||||
|
||||
public function handle(UserInterestProfileService $profileService, SessionRecoService $sessionRecoService): void
|
||||
{
|
||||
@@ -70,10 +69,9 @@ final class IngestUserDiscoveryEventJob implements ShouldQueue
|
||||
$eventVersion = (string) config('discovery.event_version', 'event-v1');
|
||||
$eventWeight = (float) ((array) config('discovery.weights', []))[$this->eventType] ?? 1.0;
|
||||
|
||||
$categoryId = DB::table('artwork_category')
|
||||
->where('artwork_id', $this->artworkId)
|
||||
->orderBy('category_id')
|
||||
->value('category_id');
|
||||
$categoryId = DB::table('artworks')
|
||||
->where('id', $this->artworkId)
|
||||
->value('primary_category_id');
|
||||
|
||||
$insertPayload = [
|
||||
'event_id' => $this->eventId,
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Models\AdsenseConnection;
|
||||
use App\Services\Adsense\AdsenseSyncService;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldBeUnique;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Carbon;
|
||||
|
||||
final class SyncAdsenseJob implements ShouldBeUnique, ShouldQueue
|
||||
{
|
||||
use Dispatchable;
|
||||
use InteractsWithQueue;
|
||||
use Queueable;
|
||||
use SerializesModels;
|
||||
|
||||
public int $tries = 1;
|
||||
|
||||
public int $timeout = 180;
|
||||
|
||||
public int $uniqueFor = 600;
|
||||
|
||||
public function __construct(
|
||||
public readonly int $connectionId,
|
||||
public readonly string $from,
|
||||
public readonly string $to,
|
||||
public readonly bool $entitiesOnly = false,
|
||||
) {}
|
||||
|
||||
public function uniqueId(): string
|
||||
{
|
||||
return (string) $this->connectionId;
|
||||
}
|
||||
|
||||
public function handle(AdsenseSyncService $sync): void
|
||||
{
|
||||
$connection = AdsenseConnection::query()->find($this->connectionId);
|
||||
if ($connection === null || ! $connection->hasRefreshToken()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$sync->sync(
|
||||
$connection,
|
||||
Carbon::parse($this->from)->startOfDay(),
|
||||
Carbon::parse($this->to)->startOfDay(),
|
||||
$this->entitiesOnly,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
final class AdsenseConnection extends Model
|
||||
{
|
||||
public const STATUS_PENDING = 'pending';
|
||||
|
||||
public const STATUS_CONNECTED = 'connected';
|
||||
|
||||
public const STATUS_RECONNECT_REQUIRED = 'reconnect_required';
|
||||
|
||||
public const STATUS_ERROR = 'error';
|
||||
|
||||
public const STATUS_DISCONNECTED = 'disconnected';
|
||||
|
||||
protected $fillable = [
|
||||
'account_resource_name',
|
||||
'account_display_name',
|
||||
'encrypted_refresh_token',
|
||||
'status',
|
||||
'connected_by_user_id',
|
||||
'connected_at',
|
||||
'last_sync_attempt_at',
|
||||
'last_successful_sync_at',
|
||||
'last_error',
|
||||
];
|
||||
|
||||
protected $hidden = [
|
||||
'encrypted_refresh_token',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'encrypted_refresh_token' => 'encrypted',
|
||||
'connected_at' => 'datetime',
|
||||
'last_sync_attempt_at' => 'datetime',
|
||||
'last_successful_sync_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
public function connectedBy(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'connected_by_user_id');
|
||||
}
|
||||
|
||||
public static function current(): ?self
|
||||
{
|
||||
return self::query()->latest('id')->first();
|
||||
}
|
||||
|
||||
public function hasRefreshToken(): bool
|
||||
{
|
||||
return filled($this->encrypted_refresh_token);
|
||||
}
|
||||
|
||||
public function isConnected(): bool
|
||||
{
|
||||
return $this->status === self::STATUS_CONNECTED && $this->hasRefreshToken();
|
||||
}
|
||||
|
||||
public function needsReconnect(): bool
|
||||
{
|
||||
return $this->status === self::STATUS_RECONNECT_REQUIRED || (
|
||||
in_array($this->status, [self::STATUS_PENDING, self::STATUS_CONNECTED, self::STATUS_ERROR], true)
|
||||
&& ! $this->hasRefreshToken()
|
||||
);
|
||||
}
|
||||
|
||||
public function isDisconnected(): bool
|
||||
{
|
||||
return $this->status === self::STATUS_DISCONNECTED || ! $this->hasRefreshToken();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
final class AdsenseDailyStat extends Model
|
||||
{
|
||||
public const DIMENSION_TOTAL = 'total';
|
||||
|
||||
public const DIMENSION_AD_UNIT = 'ad_unit';
|
||||
|
||||
public const DIMENSION_CUSTOM_CHANNEL = 'custom_channel';
|
||||
|
||||
public const DIMENSION_PLATFORM = 'platform';
|
||||
|
||||
public const DIMENSION_COUNTRY = 'country';
|
||||
|
||||
public const TOTAL_DIMENSION_KEY = '__total__';
|
||||
|
||||
protected $fillable = [
|
||||
'date',
|
||||
'account_resource_name',
|
||||
'dimension_type',
|
||||
'dimension_key',
|
||||
'dimension_label',
|
||||
'currency_code',
|
||||
'page_views',
|
||||
'ad_requests',
|
||||
'matched_ad_requests',
|
||||
'impressions',
|
||||
'clicks',
|
||||
'estimated_earnings',
|
||||
'page_views_ctr',
|
||||
'page_views_rpm',
|
||||
'ad_requests_coverage',
|
||||
'ad_requests_ctr',
|
||||
'ad_requests_rpm',
|
||||
'impressions_ctr',
|
||||
'impressions_rpm',
|
||||
'cost_per_click',
|
||||
'last_synced_at',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'date' => 'date',
|
||||
'page_views' => 'integer',
|
||||
'ad_requests' => 'integer',
|
||||
'matched_ad_requests' => 'integer',
|
||||
'impressions' => 'integer',
|
||||
'clicks' => 'integer',
|
||||
'estimated_earnings' => 'decimal:6',
|
||||
'page_views_ctr' => 'decimal:8',
|
||||
'page_views_rpm' => 'decimal:6',
|
||||
'ad_requests_coverage' => 'decimal:8',
|
||||
'ad_requests_ctr' => 'decimal:8',
|
||||
'ad_requests_rpm' => 'decimal:6',
|
||||
'impressions_ctr' => 'decimal:8',
|
||||
'impressions_rpm' => 'decimal:6',
|
||||
'cost_per_click' => 'decimal:6',
|
||||
'last_synced_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
final class AdsenseEntity extends Model
|
||||
{
|
||||
public const TYPE_AD_UNIT = 'ad_unit';
|
||||
|
||||
public const TYPE_CUSTOM_CHANNEL = 'custom_channel';
|
||||
|
||||
protected $fillable = [
|
||||
'account_resource_name',
|
||||
'ad_client_resource_name',
|
||||
'type',
|
||||
'resource_name',
|
||||
'reporting_dimension_id',
|
||||
'display_name',
|
||||
'active',
|
||||
'last_seen_at',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'active' => 'boolean',
|
||||
'last_seen_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
}
|
||||
+58
-11
@@ -66,6 +66,9 @@ class Artwork extends Model
|
||||
'description',
|
||||
'file_name',
|
||||
'file_path',
|
||||
'download_status',
|
||||
'download_source',
|
||||
'download_checked_at',
|
||||
'hash',
|
||||
'file_ext',
|
||||
'thumb_ext',
|
||||
@@ -81,6 +84,10 @@ class Artwork extends Model
|
||||
'is_public',
|
||||
'visibility',
|
||||
'is_approved',
|
||||
'approval_source',
|
||||
'moderated_at',
|
||||
'moderated_by',
|
||||
'moderation_note',
|
||||
'is_mature',
|
||||
'maturity_level',
|
||||
'maturity_source',
|
||||
@@ -117,6 +124,7 @@ class Artwork extends Model
|
||||
'description_source',
|
||||
'tags_source',
|
||||
'category_source',
|
||||
'primary_category_id',
|
||||
// Versioning
|
||||
'current_version_id',
|
||||
'version_count',
|
||||
@@ -132,6 +140,7 @@ class Artwork extends Model
|
||||
'is_public' => 'boolean',
|
||||
'visibility' => 'string',
|
||||
'is_approved' => 'boolean',
|
||||
'moderated_at' => 'datetime',
|
||||
'is_mature' => 'boolean',
|
||||
'maturity_level' => 'string',
|
||||
'maturity_source' => 'string',
|
||||
@@ -171,6 +180,7 @@ class Artwork extends Model
|
||||
'category_source' => 'string',
|
||||
'version_updated_at' => 'datetime',
|
||||
'requires_reapproval' => 'boolean',
|
||||
'download_checked_at' => 'datetime',
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -449,6 +459,32 @@ class Artwork extends Model
|
||||
return $this->belongsToMany(Category::class, 'artwork_category', 'artwork_id', 'category_id');
|
||||
}
|
||||
|
||||
public function primaryCategory(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Category::class, 'primary_category_id');
|
||||
}
|
||||
|
||||
public function resolvedPrimaryCategory(): ?Category
|
||||
{
|
||||
if ($this->relationLoaded('primaryCategory') && $this->getRelation('primaryCategory') instanceof Category) {
|
||||
return $this->getRelation('primaryCategory');
|
||||
}
|
||||
|
||||
$primaryId = $this->primary_category_id !== null ? (int) $this->primary_category_id : 0;
|
||||
if ($primaryId > 0 && $this->relationLoaded('categories')) {
|
||||
$match = $this->categories->firstWhere('id', $primaryId);
|
||||
if ($match instanceof Category) {
|
||||
return $match;
|
||||
}
|
||||
}
|
||||
|
||||
if ($primaryId > 0) {
|
||||
return $this->primaryCategory;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public function collections(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(Collection::class, 'collection_artwork', 'artwork_id', 'collection_id')
|
||||
@@ -559,19 +595,25 @@ class Artwork extends Model
|
||||
*/
|
||||
public function toSearchableArray(): array
|
||||
{
|
||||
$this->loadMissing(['user', 'group', 'tags', 'categories.contentType', 'stats', 'awardStat']);
|
||||
$this->loadMissing(['user', 'group', 'tags', 'categories.contentType', 'primaryCategory.contentType', 'stats', 'awardStat']);
|
||||
|
||||
$stat = $this->stats;
|
||||
$awardStat = $this->awardStat;
|
||||
$publishedSortAt = $this->published_at ?? $this->created_at;
|
||||
$sortedCategories = $this->categories->sortBy(
|
||||
fn ($category) => sprintf(
|
||||
'%010d|%s|%010d',
|
||||
(int) ($category->sort_order ?? 999999999),
|
||||
strtolower((string) ($category->name ?? '')),
|
||||
(int) ($category->id ?? 0)
|
||||
)
|
||||
)->values();
|
||||
$primaryCategory = $this->resolvedPrimaryCategory();
|
||||
$sortedCategories = $this->categories
|
||||
->sortBy(function ($category) use ($primaryCategory) {
|
||||
$isPrimary = $primaryCategory && (int) $category->id === (int) $primaryCategory->id;
|
||||
|
||||
return sprintf(
|
||||
'%d|%010d|%s|%010d',
|
||||
$isPrimary ? 0 : 1,
|
||||
(int) ($category->sort_order ?? 999999999),
|
||||
strtolower((string) ($category->name ?? '')),
|
||||
(int) ($category->id ?? 0)
|
||||
);
|
||||
})
|
||||
->values();
|
||||
|
||||
// Orientation derived from pixel dimensions
|
||||
$orientation = 'square';
|
||||
@@ -588,8 +630,6 @@ class Artwork extends Model
|
||||
? $this->width.'x'.$this->height
|
||||
: '';
|
||||
|
||||
// Primary category slug follows the same sort_order-first semantics used by page presenters.
|
||||
$primaryCategory = $sortedCategories->first();
|
||||
$categorySlugs = $sortedCategories
|
||||
->pluck('slug')
|
||||
->filter()
|
||||
@@ -619,6 +659,13 @@ class Artwork extends Model
|
||||
'author_name' => $this->group?->name ?? $this->user?->name ?? 'Skinbase',
|
||||
'published_as_type' => $this->publishedAsType(),
|
||||
'category' => $category,
|
||||
'primary_category_id' => $primaryCategory?->id ? (int) $primaryCategory->id : null,
|
||||
'category_ids' => $this->categories
|
||||
->pluck('id')
|
||||
->map(static fn ($id): int => (int) $id)
|
||||
->unique()
|
||||
->values()
|
||||
->all(),
|
||||
'categories' => $categorySlugs,
|
||||
'content_type' => $content_type,
|
||||
'content_types' => $contentTypeSlugs,
|
||||
|
||||
@@ -4,9 +4,12 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Observers\ArtworkAwardObserver;
|
||||
use Illuminate\Database\Eloquent\Attributes\ObservedBy;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
#[ObservedBy([ArtworkAwardObserver::class])]
|
||||
class ArtworkAward extends Model
|
||||
{
|
||||
protected $table = 'artwork_medals';
|
||||
@@ -21,21 +24,21 @@ class ArtworkAward extends Model
|
||||
|
||||
protected $casts = [
|
||||
'artwork_id' => 'integer',
|
||||
'user_id' => 'integer',
|
||||
'weight' => 'integer',
|
||||
'user_id' => 'integer',
|
||||
'weight' => 'integer',
|
||||
];
|
||||
|
||||
public const MEDALS = ['gold', 'silver', 'bronze'];
|
||||
|
||||
public const WEIGHTS = [
|
||||
'gold' => 5,
|
||||
'gold' => 5,
|
||||
'silver' => 3,
|
||||
'bronze' => 1,
|
||||
];
|
||||
|
||||
public static function weightFor(string $medal): int
|
||||
{
|
||||
return (int) config('artwork_medals.weights.' . $medal, self::WEIGHTS[$medal] ?? 0);
|
||||
return (int) config('artwork_medals.weights.'.$medal, self::WEIGHTS[$medal] ?? 0);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,95 +1,110 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Observers\ArtworkCommentObserver;
|
||||
use App\Services\ContentSanitizer;
|
||||
use Illuminate\Database\Eloquent\Attributes\ObservedBy;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
/**
|
||||
* App\Models\ArtworkComment
|
||||
*
|
||||
* @property int $id
|
||||
* @property int $artwork_id
|
||||
* @property int $user_id
|
||||
* @property string|null $content Legacy plain-text column
|
||||
* @property string|null $raw_content User-submitted Markdown
|
||||
* @property string|null $rendered_content Cached sanitized HTML
|
||||
* @property bool $is_approved
|
||||
* @property int $id
|
||||
* @property int $artwork_id
|
||||
* @property int $user_id
|
||||
* @property string|null $content Legacy plain-text column
|
||||
* @property string|null $raw_content User-submitted Markdown
|
||||
* @property string|null $rendered_content Cached sanitized HTML
|
||||
* @property bool $is_approved
|
||||
* @property-read Artwork $artwork
|
||||
* @property-read User $user
|
||||
* @property-read \Illuminate\Database\Eloquent\Collection|CommentReaction[] $reactions
|
||||
* @property-read Collection|CommentReaction[] $reactions
|
||||
*/
|
||||
#[ObservedBy([ArtworkCommentObserver::class])]
|
||||
class ArtworkComment extends Model
|
||||
{
|
||||
use HasFactory, SoftDeletes;
|
||||
use HasFactory, SoftDeletes;
|
||||
|
||||
protected $table = 'artwork_comments';
|
||||
protected $table = 'artwork_comments';
|
||||
|
||||
protected $fillable = [
|
||||
'legacy_id',
|
||||
'artwork_id',
|
||||
'user_id',
|
||||
'parent_id',
|
||||
'content',
|
||||
'raw_content',
|
||||
'rendered_content',
|
||||
'is_approved',
|
||||
];
|
||||
protected $fillable = [
|
||||
'legacy_id',
|
||||
'artwork_id',
|
||||
'user_id',
|
||||
'parent_id',
|
||||
'content',
|
||||
'raw_content',
|
||||
'rendered_content',
|
||||
'is_approved',
|
||||
'spam_score',
|
||||
'spam_probability',
|
||||
'spam_reason',
|
||||
'moderation_source',
|
||||
'moderated_at',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'is_approved' => 'boolean',
|
||||
];
|
||||
protected $casts = [
|
||||
'is_approved' => 'boolean',
|
||||
'spam_score' => 'integer',
|
||||
'spam_probability' => 'integer',
|
||||
'moderated_at' => 'datetime',
|
||||
];
|
||||
|
||||
public function artwork(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Artwork::class);
|
||||
}
|
||||
public function artwork(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Artwork::class);
|
||||
}
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
public function parent(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(self::class, 'parent_id');
|
||||
}
|
||||
public function parent(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(self::class, 'parent_id');
|
||||
}
|
||||
|
||||
public function replies(): HasMany
|
||||
{
|
||||
return $this->hasMany(self::class, 'parent_id')->orderBy('created_at');
|
||||
}
|
||||
public function replies(): HasMany
|
||||
{
|
||||
return $this->hasMany(self::class, 'parent_id')->orderBy('created_at');
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively eager-load approved replies (tree structure).
|
||||
*/
|
||||
public function approvedReplies(): HasMany
|
||||
{
|
||||
return $this->hasMany(self::class, 'parent_id')
|
||||
->where('is_approved', true)
|
||||
->orderBy('created_at')
|
||||
->with(['user.profile', 'approvedReplies']);
|
||||
}
|
||||
/**
|
||||
* Recursively eager-load approved replies (tree structure).
|
||||
*/
|
||||
public function approvedReplies(): HasMany
|
||||
{
|
||||
return $this->hasMany(self::class, 'parent_id')
|
||||
->where('is_approved', true)
|
||||
->orderBy('created_at')
|
||||
->with(['user.profile', 'approvedReplies']);
|
||||
}
|
||||
|
||||
public function reactions(): HasMany
|
||||
{
|
||||
return $this->hasMany(CommentReaction::class, 'comment_id');
|
||||
}
|
||||
public function reactions(): HasMany
|
||||
{
|
||||
return $this->hasMany(CommentReaction::class, 'comment_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the best available rendered content for display.
|
||||
* Falls back to escaping raw legacy content if rendering isn't done yet.
|
||||
*/
|
||||
public function getDisplayHtml(): string
|
||||
{
|
||||
if ($this->rendered_content !== null) {
|
||||
return $this->rendered_content;
|
||||
}
|
||||
/**
|
||||
* Return the best available rendered content for display.
|
||||
* Falls back to escaping raw legacy content if rendering isn't done yet.
|
||||
*/
|
||||
public function getDisplayHtml(): string
|
||||
{
|
||||
if ($this->rendered_content !== null) {
|
||||
return $this->rendered_content;
|
||||
}
|
||||
|
||||
// Lazy render: raw_content takes priority over legacy content
|
||||
$raw = $this->raw_content ?? $this->content ?? '';
|
||||
return \App\Services\ContentSanitizer::render($raw);
|
||||
}
|
||||
// Lazy render: raw_content takes priority over legacy content
|
||||
$raw = $this->raw_content ?? $this->content ?? '';
|
||||
|
||||
return ContentSanitizer::render($raw);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,22 +2,25 @@
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Observers\ArtworkFavouriteObserver;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Database\Eloquent\Attributes\ObservedBy;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
/**
|
||||
* Represents a user's "favourite" bookmark on an artwork.
|
||||
*
|
||||
* @property int $id
|
||||
* @property int $user_id
|
||||
* @property int $artwork_id
|
||||
* @property int|null $legacy_id Original favourite_id from the old site
|
||||
* @property \Carbon\Carbon $created_at
|
||||
* @property \Carbon\Carbon $updated_at
|
||||
*
|
||||
* @property int $id
|
||||
* @property int $user_id
|
||||
* @property int $artwork_id
|
||||
* @property int|null $legacy_id Original favourite_id from the old site
|
||||
* @property Carbon $created_at
|
||||
* @property Carbon $updated_at
|
||||
* @property-read User $user
|
||||
* @property-read Artwork $artwork
|
||||
*/
|
||||
#[ObservedBy([ArtworkFavouriteObserver::class])]
|
||||
class ArtworkFavourite extends Model
|
||||
{
|
||||
protected $table = 'artwork_favourites';
|
||||
@@ -29,9 +32,9 @@ class ArtworkFavourite extends Model
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'user_id' => 'integer',
|
||||
'user_id' => 'integer',
|
||||
'artwork_id' => 'integer',
|
||||
'legacy_id' => 'integer',
|
||||
'legacy_id' => 'integer',
|
||||
];
|
||||
|
||||
// ── Relations ──────────────────────────────────────────────────────────
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Observers\ArtworkFeatureObserver;
|
||||
use Illuminate\Database\Eloquent\Attributes\ObservedBy;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
#[ObservedBy([ArtworkFeatureObserver::class])]
|
||||
class ArtworkFeature extends Model
|
||||
{
|
||||
use SoftDeletes;
|
||||
|
||||
@@ -2,15 +2,18 @@
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Observers\ArtworkReactionObserver;
|
||||
use Illuminate\Database\Eloquent\Attributes\ObservedBy;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
/**
|
||||
* @property int $id
|
||||
* @property int $artwork_id
|
||||
* @property int $user_id
|
||||
* @property string $reaction ReactionType slug (e.g. thumbs_up, heart, fire…)
|
||||
* @property int $id
|
||||
* @property int $artwork_id
|
||||
* @property int $user_id
|
||||
* @property string $reaction ReactionType slug (e.g. thumbs_up, heart, fire…)
|
||||
*/
|
||||
#[ObservedBy([ArtworkReactionObserver::class])]
|
||||
class ArtworkReaction extends Model
|
||||
{
|
||||
public $timestamps = false;
|
||||
@@ -21,7 +24,7 @@ class ArtworkReaction extends Model
|
||||
|
||||
protected $casts = [
|
||||
'artwork_id' => 'integer',
|
||||
'user_id' => 'integer',
|
||||
'user_id' => 'integer',
|
||||
'created_at' => 'datetime',
|
||||
];
|
||||
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class CommentSpamSignature extends Model
|
||||
{
|
||||
public $timestamps = false;
|
||||
|
||||
protected $fillable = [
|
||||
'content_hash', 'pattern_signature', 'source', 'reason',
|
||||
'confidence', 'hit_count', 'reviewed_by', 'metadata', 'created_at',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'confidence' => 'integer',
|
||||
'hit_count' => 'integer',
|
||||
'metadata' => 'array',
|
||||
'created_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
@@ -2,15 +2,16 @@
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Observers\ContentTypeObserver;
|
||||
use Illuminate\Database\Eloquent\Attributes\ObservedBy;
|
||||
use Illuminate\Database\Eloquent\Builder as EloquentBuilder;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\Builder as EloquentBuilder;
|
||||
|
||||
use App\Models\Artwork;
|
||||
|
||||
#[ObservedBy([ContentTypeObserver::class])]
|
||||
class ContentType extends Model
|
||||
{
|
||||
protected $fillable = ['name','slug','description','order','hide_from_menu','mascot_path','cover_art_path'];
|
||||
protected $fillable = ['name', 'slug', 'description', 'order', 'hide_from_menu', 'mascot_path', 'cover_art_path'];
|
||||
|
||||
protected $casts = [
|
||||
'order' => 'integer',
|
||||
@@ -81,6 +82,6 @@ class ContentType extends Model
|
||||
return $path;
|
||||
}
|
||||
|
||||
return rtrim((string) config('cdn.files_url', 'https://cdn.skinbase.org'), '/') . '/' . ltrim($path, '/');
|
||||
return rtrim((string) config('cdn.files_url', 'https://cdn.skinbase.org'), '/').'/'.ltrim($path, '/');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Observers\GroupReleaseObserver;
|
||||
use Illuminate\Database\Eloquent\Attributes\ObservedBy;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
@@ -11,29 +13,44 @@ use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
#[ObservedBy([GroupReleaseObserver::class])]
|
||||
class GroupRelease extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
use SoftDeletes;
|
||||
|
||||
public const STATUS_PLANNED = 'planned';
|
||||
|
||||
public const STATUS_IN_PROGRESS = 'in_progress';
|
||||
|
||||
public const STATUS_INTERNAL_REVIEW = 'internal_review';
|
||||
|
||||
public const STATUS_SCHEDULED = 'scheduled';
|
||||
|
||||
public const STATUS_RELEASED = 'released';
|
||||
|
||||
public const STATUS_ARCHIVED = 'archived';
|
||||
|
||||
public const STATUS_CANCELLED = 'cancelled';
|
||||
|
||||
public const STAGE_CONCEPT = 'concept';
|
||||
|
||||
public const STAGE_PRODUCTION = 'production';
|
||||
|
||||
public const STAGE_REVIEW = 'review';
|
||||
|
||||
public const STAGE_PACKAGING = 'packaging';
|
||||
|
||||
public const STAGE_APPROVAL = 'approval';
|
||||
|
||||
public const STAGE_PUBLISHING = 'publishing';
|
||||
|
||||
public const STAGE_RELEASED = 'released';
|
||||
|
||||
public const VISIBILITY_PUBLIC = 'public';
|
||||
|
||||
public const VISIBILITY_UNLISTED = 'unlisted';
|
||||
|
||||
public const VISIBILITY_PRIVATE = 'private';
|
||||
|
||||
protected $fillable = [
|
||||
@@ -151,6 +168,6 @@ class GroupRelease extends Model
|
||||
return $path;
|
||||
}
|
||||
|
||||
return rtrim((string) config('cdn.files_url', 'https://files.skinbase.org'), '/') . '/' . ltrim($path, '/');
|
||||
return rtrim((string) config('cdn.files_url', 'https://files.skinbase.org'), '/').'/'.ltrim($path, '/');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,10 +4,13 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Observers\GroupReleaseContributorObserver;
|
||||
use Illuminate\Database\Eloquent\Attributes\ObservedBy;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
#[ObservedBy([GroupReleaseContributorObserver::class])]
|
||||
class GroupReleaseContributor extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
@@ -28,4 +31,4 @@ class GroupReleaseContributor extends Model
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,48 +4,75 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Observers\HomepageAnnouncementObserver;
|
||||
use Illuminate\Database\Eloquent\Attributes\ObservedBy;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
use Illuminate\Support\Carbon;
|
||||
|
||||
#[ObservedBy([HomepageAnnouncementObserver::class])]
|
||||
class HomepageAnnouncement extends Model
|
||||
{
|
||||
use SoftDeletes;
|
||||
|
||||
public const STATUS_DRAFT = 'draft';
|
||||
|
||||
public const STATUS_PUBLISHED = 'published';
|
||||
|
||||
public const STATUS_ARCHIVED = 'archived';
|
||||
|
||||
public const TYPE_ANNOUNCEMENT = 'announcement';
|
||||
|
||||
public const TYPE_LAUNCH = 'launch';
|
||||
|
||||
public const TYPE_NEWS = 'news';
|
||||
|
||||
public const TYPE_WORLD = 'world';
|
||||
|
||||
public const TYPE_EVENT = 'event';
|
||||
|
||||
public const TYPE_NOTICE = 'notice';
|
||||
|
||||
public const TYPE_MAINTENANCE = 'maintenance';
|
||||
|
||||
public const PLACEMENT_HOMEPAGE_AFTER_FEATURED = 'homepage_after_featured';
|
||||
|
||||
public const LINK_TYPE_NONE = 'none';
|
||||
|
||||
public const LINK_TYPE_CUSTOM_URL = 'custom_url';
|
||||
|
||||
public const LINK_TYPE_NEWS = 'news';
|
||||
|
||||
public const LINK_TYPE_WORLD = 'world';
|
||||
|
||||
public const LINK_TYPE_ARTWORK = 'artwork';
|
||||
|
||||
public const LINK_TYPE_COLLECTION = 'collection';
|
||||
|
||||
public const LINK_TYPE_GROUP = 'group';
|
||||
|
||||
public const LINK_TYPE_PROFILE = 'profile';
|
||||
|
||||
public const LINK_TYPE_EXPLORE = 'explore';
|
||||
|
||||
public const LINK_TYPE_UPLOAD = 'upload';
|
||||
|
||||
public const GRADIENT_NOVA_AURORA = 'nova_aurora';
|
||||
|
||||
public const GRADIENT_DEEP_SPACE = 'deep_space';
|
||||
|
||||
public const GRADIENT_SUNRISE = 'sunrise';
|
||||
|
||||
public const GRADIENT_OCEAN_GLOW = 'ocean_glow';
|
||||
|
||||
public const GRADIENT_SPRING_VIBES = 'spring_vibes';
|
||||
|
||||
public const GRADIENT_FANTASY_REALMS = 'fantasy_realms';
|
||||
|
||||
public const GRADIENT_MINIMAL_LIGHT = 'minimal_light';
|
||||
|
||||
public const GRADIENT_DARK_GLASS = 'dark_glass';
|
||||
|
||||
protected $table = 'homepage_announcements';
|
||||
@@ -194,4 +221,4 @@ class HomepageAnnouncement extends Model
|
||||
{
|
||||
return $this->belongsTo(User::class, 'updated_by');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class Upload extends Model
|
||||
{
|
||||
@@ -23,6 +24,7 @@ class Upload extends Model
|
||||
'title',
|
||||
'slug',
|
||||
'category_id',
|
||||
'secondary_category_ids',
|
||||
'description',
|
||||
'tags',
|
||||
'license',
|
||||
@@ -41,6 +43,7 @@ class Upload extends Model
|
||||
|
||||
protected $casts = [
|
||||
'tags' => 'array',
|
||||
'secondary_category_ids' => 'array',
|
||||
'nsfw' => 'boolean',
|
||||
'is_scanned' => 'boolean',
|
||||
'has_tags' => 'boolean',
|
||||
@@ -48,4 +51,14 @@ class Upload extends Model
|
||||
'expires_at' => 'datetime',
|
||||
'moderated_at' => 'datetime',
|
||||
];
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
public function category(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Category::class);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Notifications;
|
||||
|
||||
use App\Models\Artwork;
|
||||
use App\Models\User;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Notifications\Messages\MailMessage;
|
||||
use Illuminate\Notifications\Notification;
|
||||
|
||||
final class NewArtworkReviewNotification extends Notification implements ShouldQueue
|
||||
{
|
||||
use Queueable;
|
||||
|
||||
public function __construct(
|
||||
private readonly Artwork $artwork,
|
||||
private readonly User $uploader,
|
||||
private readonly array $reasons,
|
||||
) {}
|
||||
|
||||
public function via(object $notifiable): array
|
||||
{
|
||||
return ['mail'];
|
||||
}
|
||||
|
||||
public function toMail(object $notifiable): MailMessage
|
||||
{
|
||||
return (new MailMessage)
|
||||
->subject('Skinbase: new artwork awaiting review')
|
||||
->greeting('New artwork review')
|
||||
->line(sprintf('“%s” by %s is being held before publication.', $this->artwork->title ?: 'Untitled artwork', $this->uploader->username ?: $this->uploader->name))
|
||||
->line('Reasons: '.implode(', ', $this->reasons))
|
||||
->action('Open moderation queue', url('/moderation/uploads'))
|
||||
->line('The artwork is not public until it is approved.');
|
||||
}
|
||||
}
|
||||
@@ -3,53 +3,59 @@
|
||||
namespace App\Providers;
|
||||
|
||||
use App\Contracts\Images\SubjectDetectorInterface;
|
||||
use Illuminate\Cache\RateLimiting\Limit;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
use App\Contracts\Moderation\CommentSpamClassifier;
|
||||
use App\Events\Achievements\AchievementCheckRequested;
|
||||
use App\Events\Achievements\UserXpUpdated;
|
||||
use App\Events\Posts\ArtworkShared;
|
||||
use App\Events\Posts\PostCommented;
|
||||
use App\Http\Middleware\ConditionalCors;
|
||||
use App\Listeners\Academy\HandleAcademyStripeWebhook;
|
||||
use App\Listeners\Academy\HandleAcademyStripeWebhookHandled;
|
||||
use App\Listeners\Achievements\CheckUserAchievements;
|
||||
use App\Listeners\Posts\AwardXpForPostCommented;
|
||||
use App\Listeners\Posts\SendArtworkSharedNotification;
|
||||
use App\Listeners\Posts\SendPostCommentedNotification;
|
||||
use App\Models\Artwork;
|
||||
use App\Models\ArtworkAward;
|
||||
use App\Models\ArtworkComment;
|
||||
use App\Models\ArtworkFeature;
|
||||
use App\Models\ArtworkFavourite;
|
||||
use App\Models\ArtworkMedal;
|
||||
use App\Models\ArtworkReaction;
|
||||
use App\Models\ContentType;
|
||||
use App\Models\GroupRelease;
|
||||
use App\Models\GroupReleaseContributor;
|
||||
use App\Models\HomepageAnnouncement;
|
||||
use App\Observers\ArtworkAwardObserver;
|
||||
use App\Observers\ArtworkCommentObserver;
|
||||
use App\Observers\ArtworkFeatureObserver;
|
||||
use App\Observers\ArtworkFavouriteObserver;
|
||||
use App\Observers\ArtworkObserver;
|
||||
use App\Observers\ArtworkReactionObserver;
|
||||
use App\Observers\ContentTypeObserver;
|
||||
use App\Observers\GroupReleaseContributorObserver;
|
||||
use App\Observers\GroupReleaseObserver;
|
||||
use App\Observers\HomepageAnnouncementObserver;
|
||||
use App\Services\Upload\Contracts\UploadDraftServiceInterface;
|
||||
use App\Services\Upload\UploadDraftService;
|
||||
use App\Services\ContentTypes\ContentTypeSlugResolver;
|
||||
use App\Services\Worlds\WorldService;
|
||||
use Illuminate\Support\Facades\Blade;
|
||||
use Illuminate\Support\Facades\View;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Event;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Queue\Events\JobFailed;
|
||||
use App\Services\ReceivedCommentsInboxService;
|
||||
use App\Services\Countries\CountryRemoteProvider;
|
||||
use App\Services\Countries\CountryRemoteProviderInterface;
|
||||
use App\Services\EarlyGrowth\SpotlightEngine;
|
||||
use App\Services\EarlyGrowth\SpotlightEngineInterface;
|
||||
use App\Services\Images\Detectors\ChainedSubjectDetector;
|
||||
use App\Services\Images\Detectors\HeuristicSubjectDetector;
|
||||
use App\Services\Images\Detectors\NullSubjectDetector;
|
||||
use App\Services\Images\Detectors\VisionSubjectDetector;
|
||||
use App\Services\Moderation\TogetherCommentSpamClassifier;
|
||||
use App\Services\ReceivedCommentsInboxService;
|
||||
use App\Services\Recommendations\VectorSimilarity\VectorAdapterFactory;
|
||||
use App\Services\Recommendations\VectorSimilarity\VectorAdapterInterface;
|
||||
use App\Services\Upload\Contracts\UploadDraftServiceInterface;
|
||||
use App\Services\Upload\UploadDraftService;
|
||||
use App\Services\Worlds\WorldService;
|
||||
use App\Support\Http\TimedInertiaSsrGateway;
|
||||
use Illuminate\Cache\RateLimiting\Limit;
|
||||
use Illuminate\Contracts\Http\Kernel;
|
||||
use Illuminate\Foundation\Configuration\Middleware;
|
||||
use Illuminate\Http\Middleware\HandleCors;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Queue\Events\JobFailed;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Event;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
use Illuminate\View\Factory;
|
||||
use Inertia\Ssr\Gateway;
|
||||
use Klevze\ControlPanel\Framework\Core\Menu;
|
||||
use Laravel\Cashier\Events\WebhookHandled;
|
||||
use Laravel\Cashier\Events\WebhookReceived;
|
||||
use App\Listeners\Academy\HandleAcademyStripeWebhook;
|
||||
use App\Listeners\Academy\HandleAcademyStripeWebhookHandled;
|
||||
use Meilisearch\Client;
|
||||
use SocialiteProviders\Discord\DiscordExtendSocialite;
|
||||
use SocialiteProviders\Manager\SocialiteWasCalled;
|
||||
|
||||
class AppServiceProvider extends ServiceProvider
|
||||
{
|
||||
@@ -58,14 +64,18 @@ class AppServiceProvider extends ServiceProvider
|
||||
*/
|
||||
public function register(): void
|
||||
{
|
||||
$this->app->bind(CommentSpamClassifier::class, function ($app) {
|
||||
return $app->make(TogetherCommentSpamClassifier::class);
|
||||
});
|
||||
|
||||
$this->app->bind(
|
||||
\Inertia\Ssr\Gateway::class,
|
||||
\App\Support\Http\TimedInertiaSsrGateway::class,
|
||||
Gateway::class,
|
||||
TimedInertiaSsrGateway::class,
|
||||
);
|
||||
|
||||
$this->app->singleton(
|
||||
\App\Services\Countries\CountryRemoteProviderInterface::class,
|
||||
\App\Services\Countries\CountryRemoteProvider::class,
|
||||
CountryRemoteProviderInterface::class,
|
||||
CountryRemoteProvider::class,
|
||||
);
|
||||
|
||||
// Bind UploadDraftService interface to implementation
|
||||
@@ -75,14 +85,14 @@ class AppServiceProvider extends ServiceProvider
|
||||
|
||||
// Bind vector adapter interface for similarity system (resolves via factory)
|
||||
$this->app->bind(
|
||||
\App\Services\Recommendations\VectorSimilarity\VectorAdapterInterface::class,
|
||||
fn () => \App\Services\Recommendations\VectorSimilarity\VectorAdapterFactory::make(),
|
||||
VectorAdapterInterface::class,
|
||||
fn () => VectorAdapterFactory::make(),
|
||||
);
|
||||
|
||||
// EGS: bind SpotlightEngineInterface to the concrete SpotlightEngine
|
||||
$this->app->singleton(
|
||||
\App\Services\EarlyGrowth\SpotlightEngineInterface::class,
|
||||
\App\Services\EarlyGrowth\SpotlightEngine::class,
|
||||
SpotlightEngineInterface::class,
|
||||
SpotlightEngine::class,
|
||||
);
|
||||
|
||||
$this->app->singleton(SubjectDetectorInterface::class, function ($app) {
|
||||
@@ -97,7 +107,7 @@ class AppServiceProvider extends ServiceProvider
|
||||
// with no HTTP timeout, so a slow/overloaded Meilisearch leaves PHP-FPM
|
||||
// workers blocked forever in curl_exec(), draining the whole pool under
|
||||
// a search traffic spike. Fail fast instead.
|
||||
$this->app->singleton(\Meilisearch\Client::class, function ($app) {
|
||||
$this->app->singleton(Client::class, function ($app) {
|
||||
$config = $app['config']->get('scout.meilisearch');
|
||||
|
||||
$httpClient = new \GuzzleHttp\Client([
|
||||
@@ -105,7 +115,7 @@ class AppServiceProvider extends ServiceProvider
|
||||
'timeout' => 5,
|
||||
]);
|
||||
|
||||
return new \Meilisearch\Client($config['host'], $config['key'], $httpClient);
|
||||
return new Client($config['host'], $config['key'], $httpClient);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -119,12 +129,29 @@ class AppServiceProvider extends ServiceProvider
|
||||
// Map the 'legacy' view namespace to resources/views/_legacy so all
|
||||
// view('legacy::foo') and @include('legacy::foo') calls resolve correctly
|
||||
// after the folder was renamed from legacy/ to _legacy/.
|
||||
View::addNamespace('legacy', resource_path('views/_legacy'));
|
||||
$legacyViewPath = resource_path('views/_legacy');
|
||||
$registerLegacyViewNamespace = static function (Factory $view) use ($legacyViewPath): void {
|
||||
$view->addNamespace('legacy', $legacyViewPath);
|
||||
};
|
||||
|
||||
if ($this->app->resolved('view')) {
|
||||
$registerLegacyViewNamespace($this->app->make('view'));
|
||||
} else {
|
||||
$this->app->afterResolving('view', $registerLegacyViewNamespace);
|
||||
}
|
||||
|
||||
$exceptionRendererComponentsPath = base_path('vendor/laravel/framework/src/Illuminate/Foundation/resources/exceptions/renderer/components');
|
||||
|
||||
if (is_dir($exceptionRendererComponentsPath)) {
|
||||
Blade::anonymousComponentNamespace($exceptionRendererComponentsPath, 'laravel-exceptions-renderer');
|
||||
$registerExceptionRendererComponents = static function ($compiler) use ($exceptionRendererComponentsPath): void {
|
||||
if (is_dir($exceptionRendererComponentsPath)) {
|
||||
$compiler->anonymousComponentNamespace($exceptionRendererComponentsPath, 'laravel-exceptions-renderer');
|
||||
}
|
||||
};
|
||||
|
||||
if ($this->app->resolved('blade.compiler')) {
|
||||
$registerExceptionRendererComponents($this->app->make('blade.compiler'));
|
||||
} else {
|
||||
$this->app->afterResolving('blade.compiler', $registerExceptionRendererComponents);
|
||||
}
|
||||
|
||||
$this->configureAuthRateLimiters();
|
||||
@@ -140,45 +167,35 @@ class AppServiceProvider extends ServiceProvider
|
||||
$this->configureSettingsRateLimiters();
|
||||
$this->configureMailFailureLogging();
|
||||
|
||||
ArtworkAward::observe(ArtworkAwardObserver::class);
|
||||
ArtworkMedal::observe(ArtworkAwardObserver::class);
|
||||
Artwork::observe(ArtworkObserver::class);
|
||||
ArtworkFeature::observe(ArtworkFeatureObserver::class);
|
||||
ArtworkFavourite::observe(ArtworkFavouriteObserver::class);
|
||||
ArtworkComment::observe(ArtworkCommentObserver::class);
|
||||
ArtworkReaction::observe(ArtworkReactionObserver::class);
|
||||
ContentType::observe(ContentTypeObserver::class);
|
||||
GroupRelease::observe(GroupReleaseObserver::class);
|
||||
GroupReleaseContributor::observe(GroupReleaseContributorObserver::class);
|
||||
HomepageAnnouncement::observe(HomepageAnnouncementObserver::class);
|
||||
|
||||
// ── OAuth / SocialiteProviders ──────────────────────────────────────
|
||||
Event::listen(
|
||||
\SocialiteProviders\Manager\SocialiteWasCalled::class,
|
||||
\SocialiteProviders\Discord\DiscordExtendSocialite::class,
|
||||
SocialiteWasCalled::class,
|
||||
DiscordExtendSocialite::class,
|
||||
);
|
||||
// Apple provider removed — no listener registered
|
||||
|
||||
// ── Posts / Feed System Events ──────────────────────────────────────
|
||||
Event::listen(
|
||||
\App\Events\Posts\ArtworkShared::class,
|
||||
\App\Listeners\Posts\SendArtworkSharedNotification::class,
|
||||
ArtworkShared::class,
|
||||
SendArtworkSharedNotification::class,
|
||||
);
|
||||
Event::listen(
|
||||
\App\Events\Posts\PostCommented::class,
|
||||
\App\Listeners\Posts\SendPostCommentedNotification::class,
|
||||
PostCommented::class,
|
||||
SendPostCommentedNotification::class,
|
||||
);
|
||||
Event::listen(
|
||||
\App\Events\Posts\PostCommented::class,
|
||||
\App\Listeners\Posts\AwardXpForPostCommented::class,
|
||||
PostCommented::class,
|
||||
AwardXpForPostCommented::class,
|
||||
);
|
||||
Event::listen(
|
||||
\App\Events\Achievements\AchievementCheckRequested::class,
|
||||
\App\Listeners\Achievements\CheckUserAchievements::class,
|
||||
AchievementCheckRequested::class,
|
||||
CheckUserAchievements::class,
|
||||
);
|
||||
Event::listen(
|
||||
\App\Events\Achievements\UserXpUpdated::class,
|
||||
\App\Listeners\Achievements\CheckUserAchievements::class,
|
||||
UserXpUpdated::class,
|
||||
CheckUserAchievements::class,
|
||||
);
|
||||
Event::listen(
|
||||
WebhookReceived::class,
|
||||
@@ -190,130 +207,138 @@ class AppServiceProvider extends ServiceProvider
|
||||
);
|
||||
|
||||
// Provide toolbar counts and user info to layout views (port of legacy toolbar logic)
|
||||
View::composer(['layouts.nova', 'layouts.nova.*'], function ($view) {
|
||||
$uploadCount = $favCount = $msgCount = $noticeCount = $receivedCommentsCount = 0;
|
||||
$avatarHash = null;
|
||||
$displayName = null;
|
||||
$userId = null;
|
||||
$toolbarContentTypes = collect();
|
||||
$toolbarActiveCampaign = null;
|
||||
$request = request();
|
||||
$canReadSessionAuth = $request instanceof \Illuminate\Http\Request
|
||||
&& $request->hasSession()
|
||||
&& $request->attributes->get('skinbase.session_skipped') !== true;
|
||||
$authUser = $canReadSessionAuth ? Auth::user() : null;
|
||||
|
||||
try {
|
||||
$toolbarContentTypes = $this->app
|
||||
->make(ContentTypeSlugResolver::class)
|
||||
->toolbarContentTypes()
|
||||
->map(fn (ContentType $contentType) => $contentType->only(['id', 'name', 'slug']))
|
||||
->map(fn (array $attributes) => new ContentType($attributes));
|
||||
} catch (\Throwable $e) {
|
||||
$registerNovaLayoutComposer = function (Factory $factory): void {
|
||||
$factory->composer(['layouts.nova', 'layouts.nova.*'], function ($view) {
|
||||
$uploadCount = $favCount = $msgCount = $noticeCount = $receivedCommentsCount = 0;
|
||||
$avatarHash = null;
|
||||
$displayName = null;
|
||||
$userId = null;
|
||||
$toolbarContentTypes = collect();
|
||||
}
|
||||
|
||||
try {
|
||||
$toolbarActiveCampaign = $this->app
|
||||
->make(WorldService::class)
|
||||
->navigationCampaign();
|
||||
} catch (\Throwable $e) {
|
||||
$toolbarActiveCampaign = null;
|
||||
}
|
||||
|
||||
if ($authUser) {
|
||||
$authUser->loadMissing('profile');
|
||||
$userId = (int) $authUser->id;
|
||||
$request = request();
|
||||
$canReadSessionAuth = $request instanceof Request
|
||||
&& $request->hasSession()
|
||||
&& $request->attributes->get('skinbase.session_skipped') !== true;
|
||||
$authUser = $canReadSessionAuth ? Auth::user() : null;
|
||||
|
||||
try {
|
||||
$ttl = (int) config('toolbar.cache_ttl_seconds', 30);
|
||||
|
||||
$stats = Cache::remember("toolbar:{$userId}", $ttl, function () use ($userId) {
|
||||
$toolbarStats = DB::table('users')
|
||||
->select('id')
|
||||
->selectSub(
|
||||
DB::table('artworks')
|
||||
->selectRaw('COUNT(*)')
|
||||
->whereColumn('user_id', 'users.id'),
|
||||
'upload_count'
|
||||
)
|
||||
->selectSub(
|
||||
DB::table('artwork_favourites')
|
||||
->selectRaw('COUNT(*)')
|
||||
->whereColumn('user_id', 'users.id'),
|
||||
'fav_count'
|
||||
)
|
||||
->selectSub(
|
||||
DB::table('notifications')
|
||||
->selectRaw('COUNT(*)')
|
||||
->whereColumn('user_id', 'users.id')
|
||||
->whereNull('read_at'),
|
||||
'notice_count'
|
||||
)
|
||||
->where('id', $userId)
|
||||
->first();
|
||||
|
||||
$uploadCount = (int) ($toolbarStats->upload_count ?? 0);
|
||||
$favCount = (int) ($toolbarStats->fav_count ?? 0);
|
||||
$noticeCount = (int) ($toolbarStats->notice_count ?? 0);
|
||||
|
||||
$msgCount = (int) DB::table('conversation_participants as cp')
|
||||
->join('messages as m', 'm.conversation_id', '=', 'cp.conversation_id')
|
||||
->where('cp.user_id', $userId)
|
||||
->whereNull('cp.left_at')
|
||||
->whereNull('m.deleted_at')
|
||||
->where('m.sender_id', '!=', $userId)
|
||||
->where(function ($q) {
|
||||
$q->whereNull('cp.last_read_at')
|
||||
->orWhereColumn('m.created_at', '>', 'cp.last_read_at');
|
||||
})
|
||||
->count();
|
||||
|
||||
return [
|
||||
'upload_count' => $uploadCount,
|
||||
'fav_count' => $favCount,
|
||||
'notice_count' => $noticeCount,
|
||||
'msg_count' => $msgCount,
|
||||
];
|
||||
});
|
||||
|
||||
$uploadCount = (int) ($stats['upload_count'] ?? 0);
|
||||
$favCount = (int) ($stats['fav_count'] ?? 0);
|
||||
$noticeCount = (int) ($stats['notice_count'] ?? 0);
|
||||
$msgCount = (int) ($stats['msg_count'] ?? 0);
|
||||
$toolbarContentTypes = $this->app
|
||||
->make(ContentTypeSlugResolver::class)
|
||||
->toolbarContentTypes()
|
||||
->map(fn (ContentType $contentType) => $contentType->only(['id', 'name', 'slug']))
|
||||
->map(fn (array $attributes) => new ContentType($attributes));
|
||||
} catch (\Throwable $e) {
|
||||
$uploadCount = 0;
|
||||
$favCount = 0;
|
||||
$msgCount = 0;
|
||||
$noticeCount = 0;
|
||||
$toolbarContentTypes = collect();
|
||||
}
|
||||
|
||||
try {
|
||||
$receivedCommentsCount = $this->app->make(ReceivedCommentsInboxService::class)
|
||||
->unreadCountForUser($authUser);
|
||||
$toolbarActiveCampaign = $this->app
|
||||
->make(WorldService::class)
|
||||
->navigationCampaign();
|
||||
} catch (\Throwable $e) {
|
||||
$receivedCommentsCount = 0;
|
||||
$toolbarActiveCampaign = null;
|
||||
}
|
||||
|
||||
$avatarHash = $authUser->profile?->avatar_hash;
|
||||
$displayName = $authUser->name ?: ($authUser->username ?? '');
|
||||
}
|
||||
if ($authUser) {
|
||||
$authUser->loadMissing('profile');
|
||||
$userId = (int) $authUser->id;
|
||||
|
||||
$view->with(compact('userId','uploadCount', 'favCount', 'msgCount', 'noticeCount', 'receivedCommentsCount', 'avatarHash', 'displayName', 'toolbarContentTypes', 'toolbarActiveCampaign'));
|
||||
});
|
||||
try {
|
||||
$ttl = (int) config('toolbar.cache_ttl_seconds', 30);
|
||||
|
||||
$stats = Cache::remember("toolbar:{$userId}", $ttl, function () use ($userId) {
|
||||
$toolbarStats = DB::table('users')
|
||||
->select('id')
|
||||
->selectSub(
|
||||
DB::table('artworks')
|
||||
->selectRaw('COUNT(*)')
|
||||
->whereColumn('user_id', 'users.id'),
|
||||
'upload_count'
|
||||
)
|
||||
->selectSub(
|
||||
DB::table('artwork_favourites')
|
||||
->selectRaw('COUNT(*)')
|
||||
->whereColumn('user_id', 'users.id'),
|
||||
'fav_count'
|
||||
)
|
||||
->selectSub(
|
||||
DB::table('notifications')
|
||||
->selectRaw('COUNT(*)')
|
||||
->whereColumn('user_id', 'users.id')
|
||||
->whereNull('read_at'),
|
||||
'notice_count'
|
||||
)
|
||||
->where('id', $userId)
|
||||
->first();
|
||||
|
||||
$uploadCount = (int) ($toolbarStats->upload_count ?? 0);
|
||||
$favCount = (int) ($toolbarStats->fav_count ?? 0);
|
||||
$noticeCount = (int) ($toolbarStats->notice_count ?? 0);
|
||||
|
||||
$msgCount = (int) DB::table('conversation_participants as cp')
|
||||
->join('messages as m', 'm.conversation_id', '=', 'cp.conversation_id')
|
||||
->where('cp.user_id', $userId)
|
||||
->whereNull('cp.left_at')
|
||||
->whereNull('m.deleted_at')
|
||||
->where('m.sender_id', '!=', $userId)
|
||||
->where(function ($q) {
|
||||
$q->whereNull('cp.last_read_at')
|
||||
->orWhereColumn('m.created_at', '>', 'cp.last_read_at');
|
||||
})
|
||||
->count();
|
||||
|
||||
return [
|
||||
'upload_count' => $uploadCount,
|
||||
'fav_count' => $favCount,
|
||||
'notice_count' => $noticeCount,
|
||||
'msg_count' => $msgCount,
|
||||
];
|
||||
});
|
||||
|
||||
$uploadCount = (int) ($stats['upload_count'] ?? 0);
|
||||
$favCount = (int) ($stats['fav_count'] ?? 0);
|
||||
$noticeCount = (int) ($stats['notice_count'] ?? 0);
|
||||
$msgCount = (int) ($stats['msg_count'] ?? 0);
|
||||
} catch (\Throwable $e) {
|
||||
$uploadCount = 0;
|
||||
$favCount = 0;
|
||||
$msgCount = 0;
|
||||
$noticeCount = 0;
|
||||
}
|
||||
|
||||
try {
|
||||
$receivedCommentsCount = $this->app->make(ReceivedCommentsInboxService::class)
|
||||
->unreadCountForUser($authUser);
|
||||
} catch (\Throwable $e) {
|
||||
$receivedCommentsCount = 0;
|
||||
}
|
||||
|
||||
$avatarHash = $authUser->profile?->avatar_hash;
|
||||
$displayName = $authUser->name ?: ($authUser->username ?? '');
|
||||
}
|
||||
|
||||
$view->with(compact('userId', 'uploadCount', 'favCount', 'msgCount', 'noticeCount', 'receivedCommentsCount', 'avatarHash', 'displayName', 'toolbarContentTypes', 'toolbarActiveCampaign'));
|
||||
});
|
||||
};
|
||||
|
||||
if ($this->app->resolved('view')) {
|
||||
$registerNovaLayoutComposer($this->app->make('view'));
|
||||
} else {
|
||||
$this->app->afterResolving('view', $registerNovaLayoutComposer);
|
||||
}
|
||||
|
||||
// Replace the framework HandleCors with our ConditionalCors so the
|
||||
// CP_ENABLE_CORS / config('cors.paths') toggle takes effect.
|
||||
try {
|
||||
$middlewareConfig = $this->app->make(\Illuminate\Foundation\Configuration\Middleware::class);
|
||||
$middlewareConfig = $this->app->make(Middleware::class);
|
||||
$middlewareConfig->replace(
|
||||
\Illuminate\Http\Middleware\HandleCors::class,
|
||||
\App\Http\Middleware\ConditionalCors::class
|
||||
HandleCors::class,
|
||||
ConditionalCors::class
|
||||
);
|
||||
} catch (\Throwable $_) {
|
||||
// Fallback: push to kernel if replace isn't available in this app instance
|
||||
$this->app->make(\Illuminate\Contracts\Http\Kernel::class)
|
||||
->pushMiddleware(\App\Http\Middleware\ConditionalCors::class);
|
||||
$this->app->make(Kernel::class)
|
||||
->pushMiddleware(ConditionalCors::class);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -322,13 +347,13 @@ class AppServiceProvider extends ServiceProvider
|
||||
RateLimiter::for('register-ip', function (Request $request): Limit {
|
||||
$limit = max(1, (int) config('registration.ip_per_minute_limit', 3));
|
||||
|
||||
return Limit::perMinute($limit)->by('register:ip:' . $request->ip());
|
||||
return Limit::perMinute($limit)->by('register:ip:'.$request->ip());
|
||||
});
|
||||
|
||||
RateLimiter::for('register-ip-daily', function (Request $request): Limit {
|
||||
$limit = max(1, (int) config('registration.ip_per_day_limit', 20));
|
||||
|
||||
return Limit::perDay($limit)->by('register:ip:daily:' . $request->ip());
|
||||
return Limit::perDay($limit)->by('register:ip:daily:'.$request->ip());
|
||||
});
|
||||
|
||||
RateLimiter::for('register', function (Request $request): array {
|
||||
@@ -337,8 +362,8 @@ class AppServiceProvider extends ServiceProvider
|
||||
$emailLimit = (int) config('registration.email_per_minute_limit', 6);
|
||||
|
||||
return [
|
||||
Limit::perMinute($ipLimit)->by('register:ip:' . $request->ip()),
|
||||
Limit::perMinute($emailLimit)->by('register:email:' . $emailKey),
|
||||
Limit::perMinute($ipLimit)->by('register:ip:'.$request->ip()),
|
||||
Limit::perMinute($emailLimit)->by('register:email:'.$emailKey),
|
||||
];
|
||||
});
|
||||
}
|
||||
@@ -382,15 +407,15 @@ class AppServiceProvider extends ServiceProvider
|
||||
private function configureNovaCardRateLimiters(): void
|
||||
{
|
||||
foreach (['drafts', 'autosave', 'publish', 'background_upload', 'render'] as $key) {
|
||||
RateLimiter::for('nova-cards-' . str_replace('_', '-', $key), function (Request $request) use ($key): array {
|
||||
$config = (array) config('nova_cards.rate_limits.' . $key, []);
|
||||
RateLimiter::for('nova-cards-'.str_replace('_', '-', $key), function (Request $request) use ($key): array {
|
||||
$config = (array) config('nova_cards.rate_limits.'.$key, []);
|
||||
$perUser = max(1, (int) ($config['per_user'] ?? 30));
|
||||
$perIp = max(1, (int) ($config['per_ip'] ?? 60));
|
||||
$scope = 'nova-cards:' . $key . ':';
|
||||
$scope = 'nova-cards:'.$key.':';
|
||||
|
||||
return [
|
||||
Limit::perMinute($perUser)->by($scope . 'user:' . ($request->user()?->id ?? 'guest')),
|
||||
Limit::perMinute($perIp)->by($scope . 'ip:' . $request->ip()),
|
||||
Limit::perMinute($perUser)->by($scope.'user:'.($request->user()?->id ?? 'guest')),
|
||||
Limit::perMinute($perIp)->by($scope.'ip:'.$request->ip()),
|
||||
];
|
||||
});
|
||||
}
|
||||
@@ -398,7 +423,7 @@ class AppServiceProvider extends ServiceProvider
|
||||
|
||||
private function buildUploadLimits(Request $request, string $key): array
|
||||
{
|
||||
$config = (array) config('uploads.rate_limits.' . $key, []);
|
||||
$config = (array) config('uploads.rate_limits.'.$key, []);
|
||||
$decay = (int) config('uploads.rate_limits.decay_minutes', 1);
|
||||
$perUser = (int) ($config['per_user'] ?? 0);
|
||||
$perIp = (int) ($config['per_ip'] ?? 0);
|
||||
@@ -407,11 +432,11 @@ class AppServiceProvider extends ServiceProvider
|
||||
|
||||
if ($perUser > 0) {
|
||||
$userId = $request->user()?->id ?? 'guest';
|
||||
$limits[] = Limit::perMinutes($decay, $perUser)->by('u:' . $userId);
|
||||
$limits[] = Limit::perMinutes($decay, $perUser)->by('u:'.$userId);
|
||||
}
|
||||
|
||||
if ($perIp > 0) {
|
||||
$limits[] = Limit::perMinutes($decay, $perIp)->by('ip:' . $request->ip());
|
||||
$limits[] = Limit::perMinutes($decay, $perIp)->by('ip:'.$request->ip());
|
||||
}
|
||||
|
||||
return $limits;
|
||||
@@ -423,8 +448,8 @@ class AppServiceProvider extends ServiceProvider
|
||||
$userId = $request->user()?->id ?? 'guest';
|
||||
|
||||
return [
|
||||
Limit::perMinute(20)->by('messages:user:' . $userId),
|
||||
Limit::perMinute(40)->by('messages:ip:' . $request->ip()),
|
||||
Limit::perMinute(20)->by('messages:user:'.$userId),
|
||||
Limit::perMinute(40)->by('messages:ip:'.$request->ip()),
|
||||
];
|
||||
});
|
||||
|
||||
@@ -432,8 +457,8 @@ class AppServiceProvider extends ServiceProvider
|
||||
$userId = $request->user()?->id ?? 'guest';
|
||||
|
||||
return [
|
||||
Limit::perMinute(60)->by('messages:react:user:' . $userId),
|
||||
Limit::perMinute(120)->by('messages:react:ip:' . $request->ip()),
|
||||
Limit::perMinute(60)->by('messages:react:user:'.$userId),
|
||||
Limit::perMinute(120)->by('messages:react:ip:'.$request->ip()),
|
||||
];
|
||||
});
|
||||
|
||||
@@ -441,8 +466,8 @@ class AppServiceProvider extends ServiceProvider
|
||||
$userId = $request->user()?->id ?? 'guest';
|
||||
|
||||
return [
|
||||
Limit::perMinute(120)->by('messages:read:user:' . $userId),
|
||||
Limit::perMinute(240)->by('messages:read:ip:' . $request->ip()),
|
||||
Limit::perMinute(120)->by('messages:read:user:'.$userId),
|
||||
Limit::perMinute(240)->by('messages:read:ip:'.$request->ip()),
|
||||
];
|
||||
});
|
||||
|
||||
@@ -451,8 +476,8 @@ class AppServiceProvider extends ServiceProvider
|
||||
$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()),
|
||||
Limit::perMinute(90)->by('messages:typing:user:'.$userId.':conv:'.$conversationId),
|
||||
Limit::perMinute(180)->by('messages:typing:ip:'.$request->ip()),
|
||||
];
|
||||
});
|
||||
|
||||
@@ -461,8 +486,8 @@ class AppServiceProvider extends ServiceProvider
|
||||
$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()),
|
||||
Limit::perMinute(30)->by('messages:recovery:user:'.$userId.':conv:'.$conversationId),
|
||||
Limit::perMinute(60)->by('messages:recovery:ip:'.$request->ip()),
|
||||
];
|
||||
});
|
||||
|
||||
@@ -470,8 +495,8 @@ class AppServiceProvider extends ServiceProvider
|
||||
$userId = $request->user()?->id ?? 'guest';
|
||||
|
||||
return [
|
||||
Limit::perMinute(180)->by('messages:presence:user:' . $userId),
|
||||
Limit::perMinute(300)->by('messages:presence:ip:' . $request->ip()),
|
||||
Limit::perMinute(180)->by('messages:presence:user:'.$userId),
|
||||
Limit::perMinute(300)->by('messages:presence:ip:'.$request->ip()),
|
||||
];
|
||||
});
|
||||
}
|
||||
@@ -484,8 +509,8 @@ class AppServiceProvider extends ServiceProvider
|
||||
// Higher user-based allowance prevents false positives for active users,
|
||||
// while IP limit still protects guest endpoints from bursts.
|
||||
return [
|
||||
Limit::perMinute(60)->by('downloads:user:' . ($userId ?? 'guest')),
|
||||
Limit::perMinute(120)->by('downloads:ip:' . $request->ip()),
|
||||
Limit::perMinute(60)->by('downloads:user:'.($userId ?? 'guest')),
|
||||
Limit::perMinute(120)->by('downloads:ip:'.$request->ip()),
|
||||
];
|
||||
});
|
||||
}
|
||||
@@ -498,8 +523,8 @@ class AppServiceProvider extends ServiceProvider
|
||||
// Search fans out to Meilisearch + DB queries per request, so IP
|
||||
// limits are kept tight to blunt scripted floods of /search traffic.
|
||||
return [
|
||||
Limit::perMinute(20)->by('search:user:' . ($userId ?? 'guest')),
|
||||
Limit::perMinute(30)->by('search:ip:' . $request->ip()),
|
||||
Limit::perMinute(20)->by('search:user:'.($userId ?? 'guest')),
|
||||
Limit::perMinute(30)->by('search:ip:'.$request->ip()),
|
||||
];
|
||||
});
|
||||
}
|
||||
@@ -514,8 +539,8 @@ class AppServiceProvider extends ServiceProvider
|
||||
// per-IP allowance tight — unlike cached list endpoints, a flood of
|
||||
// distinct artwork IDs can't be absorbed by cache alone.
|
||||
return [
|
||||
Limit::perMinute(30)->by('vector-search:user:' . ($userId ?? 'guest')),
|
||||
Limit::perMinute(20)->by('vector-search:ip:' . $request->ip()),
|
||||
Limit::perMinute(30)->by('vector-search:user:'.($userId ?? 'guest')),
|
||||
Limit::perMinute(20)->by('vector-search:ip:'.$request->ip()),
|
||||
];
|
||||
});
|
||||
}
|
||||
@@ -529,10 +554,10 @@ class AppServiceProvider extends ServiceProvider
|
||||
|
||||
return [
|
||||
// Prevent burst spam on a single artwork while allowing normal exploration.
|
||||
Limit::perMinute($perMinute)->by('awards:user:' . ($userId ?? 'guest') . ':art:' . $artworkId),
|
||||
Limit::perMinute($perMinute)->by('awards:user:'.($userId ?? 'guest').':art:'.$artworkId),
|
||||
// Global safety net for user/IP across all artworks.
|
||||
Limit::perMinute($perMinute * 6)->by('awards:user:' . ($userId ?? 'guest')),
|
||||
Limit::perMinute($perMinute * 9)->by('awards:ip:' . $request->ip()),
|
||||
Limit::perMinute($perMinute * 6)->by('awards:user:'.($userId ?? 'guest')),
|
||||
Limit::perMinute($perMinute * 9)->by('awards:ip:'.$request->ip()),
|
||||
];
|
||||
});
|
||||
}
|
||||
@@ -544,8 +569,8 @@ class AppServiceProvider extends ServiceProvider
|
||||
|
||||
return [
|
||||
// Comment-heavy pages can trigger many reaction reads at once.
|
||||
Limit::perMinute(600)->by('reactions-read:user:' . ($userId ?? 'guest')),
|
||||
Limit::perMinute(900)->by('reactions-read:ip:' . $request->ip()),
|
||||
Limit::perMinute(600)->by('reactions-read:user:'.($userId ?? 'guest')),
|
||||
Limit::perMinute(900)->by('reactions-read:ip:'.$request->ip()),
|
||||
];
|
||||
});
|
||||
|
||||
@@ -553,8 +578,8 @@ class AppServiceProvider extends ServiceProvider
|
||||
$userId = $request->user()?->id;
|
||||
|
||||
return [
|
||||
Limit::perMinute(120)->by('reactions-write:user:' . ($userId ?? 'guest')),
|
||||
Limit::perMinute(180)->by('reactions-write:ip:' . $request->ip()),
|
||||
Limit::perMinute(120)->by('reactions-write:user:'.($userId ?? 'guest')),
|
||||
Limit::perMinute(180)->by('reactions-write:ip:'.$request->ip()),
|
||||
];
|
||||
});
|
||||
}
|
||||
@@ -565,9 +590,9 @@ class AppServiceProvider extends ServiceProvider
|
||||
$userId = $request->user()?->id ?? 'guest';
|
||||
|
||||
return [
|
||||
Limit::perMinute(12)->by('follow-write:minute:user:' . $userId),
|
||||
Limit::perHour(120)->by('follow-write:hour:user:' . $userId),
|
||||
Limit::perMinute(24)->by('follow-write:minute:ip:' . $request->ip()),
|
||||
Limit::perMinute(12)->by('follow-write:minute:user:'.$userId),
|
||||
Limit::perHour(120)->by('follow-write:hour:user:'.$userId),
|
||||
Limit::perMinute(24)->by('follow-write:minute:ip:'.$request->ip()),
|
||||
];
|
||||
});
|
||||
|
||||
@@ -575,8 +600,8 @@ class AppServiceProvider extends ServiceProvider
|
||||
$userId = $request->user()?->id ?? 'guest';
|
||||
|
||||
return [
|
||||
Limit::perMinute(60)->by('social-write:user:' . $userId),
|
||||
Limit::perMinute(120)->by('social-write:ip:' . $request->ip()),
|
||||
Limit::perMinute(60)->by('social-write:user:'.$userId),
|
||||
Limit::perMinute(120)->by('social-write:ip:'.$request->ip()),
|
||||
];
|
||||
});
|
||||
|
||||
@@ -584,8 +609,8 @@ class AppServiceProvider extends ServiceProvider
|
||||
$userId = $request->user()?->id ?? 'guest';
|
||||
|
||||
return [
|
||||
Limit::perMinute(240)->by('social-read:user:' . $userId),
|
||||
Limit::perMinute(480)->by('social-read:ip:' . $request->ip()),
|
||||
Limit::perMinute(240)->by('social-read:user:'.$userId),
|
||||
Limit::perMinute(480)->by('social-read:ip:'.$request->ip()),
|
||||
];
|
||||
});
|
||||
}
|
||||
@@ -593,7 +618,7 @@ class AppServiceProvider extends ServiceProvider
|
||||
private function configureSettingsRateLimiters(): void
|
||||
{
|
||||
RateLimiter::for('username-check', function (Request $request): Limit {
|
||||
$key = 'username-check:ip:' . $request->ip();
|
||||
$key = 'username-check:ip:'.$request->ip();
|
||||
|
||||
if (method_exists(Limit::class, 'perSecond')) {
|
||||
return Limit::perSecond(5)->by($key);
|
||||
@@ -605,8 +630,8 @@ class AppServiceProvider extends ServiceProvider
|
||||
RateLimiter::for('email-change-request', function (Request $request): Limit {
|
||||
$userId = $request->user()?->id;
|
||||
$key = $userId !== null
|
||||
? 'email-change-request:user:' . $userId
|
||||
: 'email-change-request:ip:' . $request->ip();
|
||||
? 'email-change-request:user:'.$userId
|
||||
: 'email-change-request:ip:'.$request->ip();
|
||||
|
||||
return Limit::perHour(1)->by($key);
|
||||
});
|
||||
@@ -648,6 +673,6 @@ class AppServiceProvider extends ServiceProvider
|
||||
|
||||
$request = $this->app->make('request');
|
||||
|
||||
return $request->is($prefix) || $request->is($prefix . '/*');
|
||||
return $request->is($prefix) || $request->is($prefix.'/*');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Providers;
|
||||
|
||||
use Carbon\Carbon;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Carbon\CarbonInterval;
|
||||
use Carbon\CarbonPeriod;
|
||||
use Illuminate\Contracts\Events\Dispatcher as DispatcherContract;
|
||||
use Illuminate\Foundation\Events\LocaleUpdated;
|
||||
use Illuminate\Support\Carbon as IlluminateCarbon;
|
||||
use Illuminate\Support\Facades\Date;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
use Throwable;
|
||||
|
||||
final class LazyCarbonServiceProvider extends ServiceProvider
|
||||
{
|
||||
private bool $localeListenerRegistered = false;
|
||||
|
||||
public function register(): void
|
||||
{
|
||||
$registerListener = function (DispatcherContract $events): void {
|
||||
if ($this->localeListenerRegistered) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->localeListenerRegistered = true;
|
||||
|
||||
$events->listen(
|
||||
LocaleUpdated::class,
|
||||
function (LocaleUpdated $event): void {
|
||||
$this->synchronizeCarbonLocale($event->locale);
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
if ($this->app->resolved('events')) {
|
||||
$registerListener(
|
||||
$this->app->make('events')
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->app->afterResolving(
|
||||
'events',
|
||||
$registerListener
|
||||
);
|
||||
}
|
||||
|
||||
private function synchronizeCarbonLocale(string $locale): void
|
||||
{
|
||||
$fallback = (string) $this->app['config']->get(
|
||||
'app.fallback_locale',
|
||||
'en'
|
||||
);
|
||||
|
||||
Carbon::setLocale($locale);
|
||||
CarbonImmutable::setLocale($locale);
|
||||
CarbonPeriod::setLocale($locale);
|
||||
CarbonInterval::setLocale($locale);
|
||||
|
||||
Carbon::setFallbackLocale($fallback);
|
||||
CarbonImmutable::setFallbackLocale($fallback);
|
||||
CarbonPeriod::setFallbackLocale($fallback);
|
||||
CarbonInterval::setFallbackLocale($fallback);
|
||||
|
||||
IlluminateCarbon::setLocale($locale);
|
||||
|
||||
if (
|
||||
method_exists(
|
||||
IlluminateCarbon::class,
|
||||
'setFallbackLocale'
|
||||
)
|
||||
) {
|
||||
IlluminateCarbon::setFallbackLocale($fallback);
|
||||
}
|
||||
|
||||
try {
|
||||
$date = Date::getFacadeRoot();
|
||||
|
||||
if ($date && method_exists($date, 'setLocale')) {
|
||||
$date->setLocale($locale);
|
||||
}
|
||||
|
||||
if (
|
||||
$date &&
|
||||
method_exists($date, 'setFallbackLocale')
|
||||
) {
|
||||
$date->setFallbackLocale($fallback);
|
||||
}
|
||||
} catch (Throwable) {
|
||||
// Preserve Carbon provider tolerance for custom Date drivers.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Adsense;
|
||||
|
||||
use App\Models\AdsenseConnection;
|
||||
use App\Models\AdsenseDailyStat;
|
||||
use App\Models\AdsenseEntity;
|
||||
use App\Models\Country;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
final class AdsenseAnalyticsQueryService
|
||||
{
|
||||
/**
|
||||
* @return array{from: Carbon, to: Carbon, previous_from: Carbon, previous_to: Carbon, period: string}
|
||||
*/
|
||||
public function resolveRange(?string $period, ?string $from, ?string $to): array
|
||||
{
|
||||
$period = $period ?: '30d';
|
||||
$today = now()->startOfDay();
|
||||
|
||||
[$start, $end] = match ($period) {
|
||||
'today' => [$today->copy(), $today->copy()],
|
||||
'yesterday' => [$today->copy()->subDay(), $today->copy()->subDay()],
|
||||
'7d' => [$today->copy()->subDays(6), $today->copy()],
|
||||
'custom' => [
|
||||
Carbon::parse($from ?: $today->copy()->subDays(29)->toDateString())->startOfDay(),
|
||||
Carbon::parse($to ?: $today->toDateString())->startOfDay(),
|
||||
],
|
||||
default => [$today->copy()->subDays(29), $today->copy()],
|
||||
};
|
||||
|
||||
if ($end->lt($start)) {
|
||||
[$start, $end] = [$end, $start];
|
||||
}
|
||||
|
||||
$days = $start->diffInDays($end) + 1;
|
||||
$previousEnd = $start->copy()->subDay();
|
||||
$previousStart = $previousEnd->copy()->subDays($days - 1);
|
||||
|
||||
return [
|
||||
'from' => $start,
|
||||
'to' => $end,
|
||||
'previous_from' => $previousStart,
|
||||
'previous_to' => $previousEnd,
|
||||
'period' => $period === 'custom' ? 'custom' : (in_array($period, ['today', 'yesterday', '7d', '30d'], true) ? $period : '30d'),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function dashboard(AdsenseConnection $connection, Carbon $from, Carbon $to, Carbon $previousFrom, Carbon $previousTo): array
|
||||
{
|
||||
$account = (string) $connection->account_resource_name;
|
||||
$current = $this->totals($account, $from, $to);
|
||||
$previous = $this->totals($account, $previousFrom, $previousTo);
|
||||
$currency = $current['currency_code'] ?? $previous['currency_code'] ?? 'USD';
|
||||
|
||||
return [
|
||||
'kpis' => [
|
||||
$this->kpi('estimated_earnings', 'Estimated Earnings', $current['estimated_earnings'], $previous['estimated_earnings'], 'money', $currency),
|
||||
$this->kpi('page_views', 'Page Views', $current['page_views'], $previous['page_views'], 'integer'),
|
||||
$this->kpi('impressions', 'Ad Impressions', $current['impressions'], $previous['impressions'], 'integer'),
|
||||
$this->kpi('clicks', 'Clicks', $current['clicks'], $previous['clicks'], 'integer'),
|
||||
$this->kpi('page_views_ctr', 'Page CTR', $this->ratio($current['clicks'], $current['page_views']), $this->ratio($previous['clicks'], $previous['page_views']), 'percent'),
|
||||
$this->kpi('page_views_rpm', 'Page RPM', $this->rpm($current['estimated_earnings'], $current['page_views']), $this->rpm($previous['estimated_earnings'], $previous['page_views']), 'money', $currency),
|
||||
$this->kpi('ad_requests_coverage', 'Ad Request Coverage', $this->ratio($current['matched_ad_requests'], $current['ad_requests']), $this->ratio($previous['matched_ad_requests'], $previous['ad_requests']), 'percent'),
|
||||
$this->kpi('cost_per_click', 'CPC', $this->cpc($current['estimated_earnings'], $current['clicks']), $this->cpc($previous['estimated_earnings'], $previous['clicks']), 'money', $currency),
|
||||
],
|
||||
'chart' => $this->chart($account, $from, $to),
|
||||
'placements' => $this->dimensionTable($account, AdsenseDailyStat::DIMENSION_AD_UNIT, $from, $to, $currency),
|
||||
'platforms' => $this->dimensionTable($account, AdsenseDailyStat::DIMENSION_PLATFORM, $from, $to, $currency),
|
||||
'countries' => array_slice($this->dimensionTable($account, AdsenseDailyStat::DIMENSION_COUNTRY, $from, $to, $currency), 0, 20),
|
||||
'currency_code' => $currency,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function totals(string $account, Carbon $from, Carbon $to): array
|
||||
{
|
||||
$row = AdsenseDailyStat::query()
|
||||
->where('account_resource_name', $account)
|
||||
->where('dimension_type', AdsenseDailyStat::DIMENSION_TOTAL)
|
||||
->whereDate('date', '>=', $from->toDateString())
|
||||
->whereDate('date', '<=', $to->toDateString())
|
||||
->selectRaw('
|
||||
SUM(page_views) as page_views,
|
||||
SUM(ad_requests) as ad_requests,
|
||||
SUM(matched_ad_requests) as matched_ad_requests,
|
||||
SUM(impressions) as impressions,
|
||||
SUM(clicks) as clicks,
|
||||
SUM(estimated_earnings) as estimated_earnings,
|
||||
MAX(currency_code) as currency_code
|
||||
')
|
||||
->first();
|
||||
|
||||
return [
|
||||
'page_views' => (int) ($row?->page_views ?? 0),
|
||||
'ad_requests' => (int) ($row?->ad_requests ?? 0),
|
||||
'matched_ad_requests' => (int) ($row?->matched_ad_requests ?? 0),
|
||||
'impressions' => (int) ($row?->impressions ?? 0),
|
||||
'clicks' => (int) ($row?->clicks ?? 0),
|
||||
'estimated_earnings' => (string) ($row?->estimated_earnings ?? '0'),
|
||||
'currency_code' => $row?->currency_code,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array<string, mixed>>
|
||||
*/
|
||||
private function chart(string $account, Carbon $from, Carbon $to): array
|
||||
{
|
||||
$rows = AdsenseDailyStat::query()
|
||||
->where('account_resource_name', $account)
|
||||
->where('dimension_type', AdsenseDailyStat::DIMENSION_TOTAL)
|
||||
->whereDate('date', '>=', $from->toDateString())
|
||||
->whereDate('date', '<=', $to->toDateString())
|
||||
->orderBy('date')
|
||||
->get();
|
||||
|
||||
return $rows->map(function (AdsenseDailyStat $row): array {
|
||||
return [
|
||||
'date' => optional($row->date)?->toDateString(),
|
||||
'estimated_earnings' => (float) $row->estimated_earnings,
|
||||
'page_views' => (int) $row->page_views,
|
||||
'page_views_rpm' => (float) $row->page_views_rpm,
|
||||
'clicks' => (int) $row->clicks,
|
||||
];
|
||||
})->values()->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array<string, mixed>>
|
||||
*/
|
||||
private function dimensionTable(string $account, string $dimensionType, Carbon $from, Carbon $to, ?string $currency): array
|
||||
{
|
||||
$rows = AdsenseDailyStat::query()
|
||||
->where('account_resource_name', $account)
|
||||
->where('dimension_type', $dimensionType)
|
||||
->whereDate('date', '>=', $from->toDateString())
|
||||
->whereDate('date', '<=', $to->toDateString())
|
||||
->selectRaw('
|
||||
dimension_key,
|
||||
MAX(dimension_label) as dimension_label,
|
||||
SUM(page_views) as page_views,
|
||||
SUM(impressions) as impressions,
|
||||
SUM(clicks) as clicks,
|
||||
SUM(estimated_earnings) as estimated_earnings
|
||||
')
|
||||
->groupBy('dimension_key')
|
||||
->orderByDesc('estimated_earnings')
|
||||
->get();
|
||||
|
||||
$labels = $this->entityLabels($account, $dimensionType);
|
||||
$countryNames = $dimensionType === AdsenseDailyStat::DIMENSION_COUNTRY ? $this->countryNames() : collect();
|
||||
|
||||
return $rows->map(function ($row) use ($labels, $countryNames, $currency, $dimensionType): array {
|
||||
$key = (string) $row->dimension_key;
|
||||
$label = (string) ($labels[$key] ?? $countryNames[$key] ?? $row->dimension_label ?? $key);
|
||||
if ($dimensionType === AdsenseDailyStat::DIMENSION_PLATFORM) {
|
||||
$label = $this->platformLabel($key, $label);
|
||||
}
|
||||
|
||||
$pageViews = (int) $row->page_views;
|
||||
$impressions = (int) $row->impressions;
|
||||
$clicks = (int) $row->clicks;
|
||||
$earnings = (string) ($row->estimated_earnings ?? '0');
|
||||
|
||||
return [
|
||||
'key' => $key,
|
||||
'label' => $label,
|
||||
'page_views' => $pageViews,
|
||||
'impressions' => $impressions,
|
||||
'clicks' => $clicks,
|
||||
'ctr' => $this->ratio($clicks, $pageViews),
|
||||
'rpm' => $this->rpm($earnings, $pageViews),
|
||||
'revenue' => $earnings,
|
||||
'currency_code' => $currency,
|
||||
];
|
||||
})->values()->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
private function entityLabels(string $account, string $dimensionType): array
|
||||
{
|
||||
if (! in_array($dimensionType, [AdsenseDailyStat::DIMENSION_AD_UNIT, AdsenseDailyStat::DIMENSION_CUSTOM_CHANNEL], true)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return AdsenseEntity::query()
|
||||
->where('account_resource_name', $account)
|
||||
->where('type', $dimensionType)
|
||||
->pluck('display_name', 'reporting_dimension_id')
|
||||
->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Collection<string, string>
|
||||
*/
|
||||
private function countryNames(): Collection
|
||||
{
|
||||
if (! Schema::hasTable('countries')) {
|
||||
return collect();
|
||||
}
|
||||
|
||||
return Country::query()
|
||||
->get(['iso2', 'name', 'name_common'])
|
||||
->mapWithKeys(function (Country $country): array {
|
||||
$code = strtoupper((string) $country->iso2);
|
||||
$name = (string) ($country->name_common ?: $country->name ?: $code);
|
||||
|
||||
return $code !== '' ? [$code => $name] : [];
|
||||
});
|
||||
}
|
||||
|
||||
private function platformLabel(string $key, string $fallback): string
|
||||
{
|
||||
return match (strtoupper($key)) {
|
||||
'DESKTOP' => 'Desktop',
|
||||
'HIGH_END_MOBILE', 'MOBILE' => 'Mobile',
|
||||
'TABLET' => 'Tablet',
|
||||
default => $fallback !== '' ? $fallback : $key,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function kpi(string $key, string $label, mixed $current, mixed $previous, string $format, ?string $currency = null): array
|
||||
{
|
||||
return [
|
||||
'key' => $key,
|
||||
'label' => $label,
|
||||
'value' => $current,
|
||||
'previous' => $previous,
|
||||
'change_percent' => $this->changePercent($current, $previous),
|
||||
'format' => $format,
|
||||
'currency_code' => $currency,
|
||||
];
|
||||
}
|
||||
|
||||
private function changePercent(mixed $current, mixed $previous): ?float
|
||||
{
|
||||
$current = (float) $current;
|
||||
$previous = (float) $previous;
|
||||
|
||||
if ($previous == 0.0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (($current - $previous) / $previous) * 100;
|
||||
}
|
||||
|
||||
private function ratio(int $numerator, int $denominator): ?float
|
||||
{
|
||||
if ($denominator === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $numerator / $denominator;
|
||||
}
|
||||
|
||||
private function rpm(string|float|int|null $earnings, int $pageViews): ?float
|
||||
{
|
||||
if ($pageViews === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return ((float) $earnings / $pageViews) * 1000;
|
||||
}
|
||||
|
||||
private function cpc(string|float|int|null $earnings, int $clicks): ?float
|
||||
{
|
||||
if ($clicks === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (float) $earnings / $clicks;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,350 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Adsense;
|
||||
|
||||
use App\Models\AdsenseConnection;
|
||||
use App\Services\Adsense\Exceptions\AdsenseApiException;
|
||||
use App\Services\Adsense\Exceptions\AdsenseAuthorizationException;
|
||||
use Illuminate\Http\Client\ConnectionException;
|
||||
use Illuminate\Http\Client\Response;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
final class AdsenseApiClient
|
||||
{
|
||||
public function __construct(private readonly AdsenseOAuthService $oauth) {}
|
||||
|
||||
/**
|
||||
* @return list<array<string, mixed>>
|
||||
*/
|
||||
public function listAccounts(AdsenseConnection $connection): array
|
||||
{
|
||||
return $this->paginate($connection, '/accounts', 'accounts');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array<string, mixed>>
|
||||
*/
|
||||
public function listAdClients(AdsenseConnection $connection, string $accountResourceName): array
|
||||
{
|
||||
return $this->paginate($connection, '/'.$this->trimPath($accountResourceName).'/adclients', 'adClients');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array<string, mixed>>
|
||||
*/
|
||||
public function listAdUnits(AdsenseConnection $connection, string $adClientResourceName): array
|
||||
{
|
||||
return $this->paginate($connection, '/'.$this->trimPath($adClientResourceName).'/adunits', 'adUnits');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array<string, mixed>>
|
||||
*/
|
||||
public function listCustomChannels(AdsenseConnection $connection, string $adClientResourceName): array
|
||||
{
|
||||
return $this->paginate($connection, '/'.$this->trimPath($adClientResourceName).'/customchannels', 'customChannels');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $dimensions
|
||||
* @param list<string> $metrics
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function generateReport(
|
||||
AdsenseConnection $connection,
|
||||
string $accountResourceName,
|
||||
array $dimensions,
|
||||
array $metrics,
|
||||
Carbon $from,
|
||||
Carbon $to,
|
||||
string $report = '',
|
||||
): array {
|
||||
$path = '/'.$this->trimPath($accountResourceName).'/reports:generate';
|
||||
$merged = [
|
||||
'headers' => [],
|
||||
'rows' => [],
|
||||
];
|
||||
|
||||
$pageToken = null;
|
||||
|
||||
do {
|
||||
$query = $this->reportQuery($dimensions, $metrics, $from, $to, $pageToken);
|
||||
$json = $this->getJson($connection, $path, $query, $report);
|
||||
|
||||
if ($merged['headers'] === [] && isset($json['headers']) && is_array($json['headers'])) {
|
||||
$merged['headers'] = $json['headers'];
|
||||
}
|
||||
|
||||
$rows = $json['rows'] ?? [];
|
||||
if (is_array($rows) && $rows !== []) {
|
||||
$merged['rows'] = array_merge($merged['rows'], $rows);
|
||||
}
|
||||
|
||||
foreach (['totals', 'averages', 'startDate', 'endDate', 'warnings'] as $metaKey) {
|
||||
if (! isset($merged[$metaKey]) && isset($json[$metaKey])) {
|
||||
$merged[$metaKey] = $json[$metaKey];
|
||||
}
|
||||
}
|
||||
|
||||
$pageToken = isset($json['nextPageToken']) && is_string($json['nextPageToken']) && $json['nextPageToken'] !== ''
|
||||
? $json['nextPageToken']
|
||||
: null;
|
||||
} while ($pageToken !== null);
|
||||
|
||||
return $merged;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, scalar|null> $query
|
||||
* @return list<array<string, mixed>>
|
||||
*/
|
||||
private function paginate(AdsenseConnection $connection, string $path, string $itemsKey): array
|
||||
{
|
||||
$items = [];
|
||||
$pageToken = null;
|
||||
|
||||
do {
|
||||
$query = [];
|
||||
if ($pageToken !== null) {
|
||||
$query['pageToken'] = $pageToken;
|
||||
}
|
||||
|
||||
$json = $this->getJson($connection, $path, $query);
|
||||
$pageItems = $json[$itemsKey] ?? [];
|
||||
if (is_array($pageItems)) {
|
||||
foreach ($pageItems as $item) {
|
||||
if (is_array($item)) {
|
||||
$items[] = $item;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$pageToken = isset($json['nextPageToken']) && is_string($json['nextPageToken']) && $json['nextPageToken'] !== ''
|
||||
? $json['nextPageToken']
|
||||
: null;
|
||||
} while ($pageToken !== null);
|
||||
|
||||
return $items;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $dimensions
|
||||
* @param list<string> $metrics
|
||||
* @return array<string, scalar>
|
||||
*/
|
||||
private function reportQuery(array $dimensions, array $metrics, Carbon $from, Carbon $to, ?string $pageToken): array
|
||||
{
|
||||
$query = [
|
||||
'startDate.year' => $from->year,
|
||||
'startDate.month' => $from->month,
|
||||
'startDate.day' => $from->day,
|
||||
'endDate.year' => $to->year,
|
||||
'endDate.month' => $to->month,
|
||||
'endDate.day' => $to->day,
|
||||
];
|
||||
|
||||
foreach ($dimensions as $index => $dimension) {
|
||||
$query['dimensions'.$index] = $dimension;
|
||||
}
|
||||
|
||||
foreach ($metrics as $index => $metric) {
|
||||
$query['metrics'.$index] = $metric;
|
||||
}
|
||||
|
||||
if ($pageToken !== null) {
|
||||
$query['pageToken'] = $pageToken;
|
||||
}
|
||||
|
||||
return $query;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, scalar|null> $query
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function getJson(AdsenseConnection $connection, string $path, array $query = [], string $report = ''): array
|
||||
{
|
||||
$url = $this->url($path, $query);
|
||||
$response = $this->send($connection, $url, $report);
|
||||
$json = $response->json();
|
||||
|
||||
if (! is_array($json)) {
|
||||
throw new AdsenseApiException('Malformed AdSense API response.', $response->status(), $report);
|
||||
}
|
||||
|
||||
return $json;
|
||||
}
|
||||
|
||||
private function send(AdsenseConnection $connection, string $url, string $report = ''): Response
|
||||
{
|
||||
$maxAttempts = max(1, (int) config('adsense.retry_times', 3));
|
||||
$sleepMs = max(0, (int) config('adsense.retry_sleep_ms', 400));
|
||||
$refreshed = false;
|
||||
$attempt = 0;
|
||||
|
||||
while (true) {
|
||||
$attempt++;
|
||||
|
||||
try {
|
||||
$response = Http::acceptJson()
|
||||
->withToken($this->oauth->accessToken($connection))
|
||||
->timeout((int) config('adsense.timeout', 20))
|
||||
->connectTimeout((int) config('adsense.connect_timeout', 5))
|
||||
->get($url);
|
||||
} catch (AdsenseAuthorizationException $exception) {
|
||||
throw $exception;
|
||||
} catch (ConnectionException $exception) {
|
||||
if ($attempt < $maxAttempts) {
|
||||
$this->backoff($sleepMs, $attempt);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
Log::warning('AdSense API connection failed.', AdsenseLogSanitizer::context([
|
||||
'message' => $exception->getMessage(),
|
||||
'report' => $report,
|
||||
]));
|
||||
|
||||
throw new AdsenseApiException('AdSense API connection failed.', 0, $report, $exception);
|
||||
}
|
||||
|
||||
$status = $response->status();
|
||||
|
||||
if ($status === 401 && ! $refreshed) {
|
||||
$this->oauth->forgetAccessToken((int) $connection->id);
|
||||
|
||||
try {
|
||||
$this->oauth->accessToken($connection, true);
|
||||
} catch (AdsenseAuthorizationException $exception) {
|
||||
throw $exception;
|
||||
}
|
||||
|
||||
$refreshed = true;
|
||||
$attempt--;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (in_array($status, [429, 500, 502, 503, 504], true) && $attempt < $maxAttempts) {
|
||||
$this->backoff($sleepMs, $attempt);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($status === 401 || $status === 403) {
|
||||
$message = $this->errorMessage($response, 'AdSense API authorization failed.');
|
||||
Log::warning('AdSense API authorization failed.', AdsenseLogSanitizer::context([
|
||||
'status' => $status,
|
||||
'report' => $report,
|
||||
]));
|
||||
|
||||
if ($this->isRevoked($response, $message)) {
|
||||
$this->oauth->markReconnectRequired($connection, 'AdSense authorization expired or was revoked.');
|
||||
|
||||
throw new AdsenseAuthorizationException('AdSense authorization expired or was revoked.');
|
||||
}
|
||||
|
||||
throw new AdsenseApiException($message, $status, $report);
|
||||
}
|
||||
|
||||
if ($response->failed()) {
|
||||
$message = $this->errorMessage($response, 'AdSense API request failed.');
|
||||
Log::warning('AdSense API request failed.', AdsenseLogSanitizer::context([
|
||||
'status' => $status,
|
||||
'report' => $report,
|
||||
]));
|
||||
|
||||
throw new AdsenseApiException($message, $status, $report);
|
||||
}
|
||||
|
||||
return $response;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, scalar|null> $query
|
||||
*/
|
||||
private function url(string $path, array $query): string
|
||||
{
|
||||
$base = rtrim((string) config('adsense.api_base'), '/');
|
||||
$url = $base.'/'.$this->trimPath($path);
|
||||
|
||||
$parts = [];
|
||||
foreach ($query as $key => $value) {
|
||||
if ($value === null || $value === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (str_starts_with((string) $key, 'dimensions')) {
|
||||
$parts[] = 'dimensions='.rawurlencode((string) $value);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (str_starts_with((string) $key, 'metrics')) {
|
||||
$parts[] = 'metrics='.rawurlencode((string) $value);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$parts[] = rawurlencode((string) $key).'='.rawurlencode((string) $value);
|
||||
}
|
||||
|
||||
if ($parts === []) {
|
||||
return $url;
|
||||
}
|
||||
|
||||
return $url.'?'.implode('&', $parts);
|
||||
}
|
||||
|
||||
private function trimPath(string $path): string
|
||||
{
|
||||
return ltrim($path, '/');
|
||||
}
|
||||
|
||||
private function backoff(int $sleepMs, int $attempt): void
|
||||
{
|
||||
if ($sleepMs <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
usleep($sleepMs * 1000 * $attempt);
|
||||
}
|
||||
|
||||
private function errorMessage(Response $response, string $fallback): string
|
||||
{
|
||||
$json = $response->json();
|
||||
if (! is_array($json)) {
|
||||
return $fallback;
|
||||
}
|
||||
|
||||
$error = $json['error'] ?? null;
|
||||
if (is_string($error) && $error !== '') {
|
||||
return $error;
|
||||
}
|
||||
|
||||
if (is_array($error)) {
|
||||
$status = (string) ($error['status'] ?? '');
|
||||
$message = (string) ($error['message'] ?? '');
|
||||
$combined = trim($status.' '.$message);
|
||||
|
||||
return $combined !== '' ? $combined : $fallback;
|
||||
}
|
||||
|
||||
return $fallback;
|
||||
}
|
||||
|
||||
private function isRevoked(Response $response, string $message): bool
|
||||
{
|
||||
$haystack = Str::lower($message.' '.$response->body());
|
||||
|
||||
return str_contains($haystack, 'invalid_grant')
|
||||
|| str_contains($haystack, 'invalid credentials')
|
||||
|| str_contains($haystack, 'revoked');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Adsense;
|
||||
|
||||
final class AdsenseLogSanitizer
|
||||
{
|
||||
/**
|
||||
* @var list<string>
|
||||
*/
|
||||
private const SENSITIVE_KEYS = [
|
||||
'access_token',
|
||||
'refresh_token',
|
||||
'id_token',
|
||||
'token',
|
||||
'code',
|
||||
'client_secret',
|
||||
'authorization',
|
||||
'encrypted_refresh_token',
|
||||
];
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $context
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public static function context(array $context): array
|
||||
{
|
||||
$clean = [];
|
||||
|
||||
foreach ($context as $key => $value) {
|
||||
$normalized = strtolower((string) $key);
|
||||
|
||||
if (in_array($normalized, self::SENSITIVE_KEYS, true) || str_contains($normalized, 'token') || str_contains($normalized, 'secret')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (is_array($value)) {
|
||||
$clean[$key] = self::context($value);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (is_string($value) && self::looksLikeSecret($value)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$clean[$key] = $value;
|
||||
}
|
||||
|
||||
return $clean;
|
||||
}
|
||||
|
||||
public static function looksLikeSecret(string $value): bool
|
||||
{
|
||||
if ($value === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach (['refresh-secret', 'access-secret', 'ya29.', '1//'] as $marker) {
|
||||
if (str_contains($value, $marker)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,327 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Adsense;
|
||||
|
||||
use App\Models\AdsenseConnection;
|
||||
use App\Services\Adsense\Exceptions\AdsenseAuthorizationException;
|
||||
use App\Services\Adsense\Exceptions\AdsenseOAuthException;
|
||||
use Illuminate\Http\Client\ConnectionException;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
final class AdsenseOAuthService
|
||||
{
|
||||
public const SESSION_STATE_KEY = 'adsense.oauth_state';
|
||||
|
||||
public const SESSION_PENDING_ACCOUNTS_KEY = 'adsense.pending_accounts';
|
||||
|
||||
public function isConfigured(): bool
|
||||
{
|
||||
return $this->clientId() !== '' && $this->clientSecret() !== '' && $this->redirectUri() !== '';
|
||||
}
|
||||
|
||||
public function authorizationUrl(Request $request, bool $promptConsent = true): string
|
||||
{
|
||||
if (! $this->isConfigured()) {
|
||||
throw new AdsenseOAuthException('Google AdSense OAuth is not configured.');
|
||||
}
|
||||
|
||||
$state = bin2hex(random_bytes(32));
|
||||
$request->session()->put(self::SESSION_STATE_KEY, $state);
|
||||
|
||||
$query = [
|
||||
'client_id' => $this->clientId(),
|
||||
'redirect_uri' => $this->redirectUri(),
|
||||
'response_type' => 'code',
|
||||
'scope' => (string) config('adsense.scope'),
|
||||
'access_type' => 'offline',
|
||||
'include_granted_scopes' => 'true',
|
||||
'state' => $state,
|
||||
];
|
||||
|
||||
if ($promptConsent) {
|
||||
$query['prompt'] = 'consent';
|
||||
}
|
||||
|
||||
return (string) config('adsense.oauth_authorize_url').'?'.http_build_query($query);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{access_token: string, refresh_token: ?string, expires_in: int}
|
||||
*/
|
||||
public function exchangeAuthorizationCode(string $code): array
|
||||
{
|
||||
return $this->requestToken([
|
||||
'grant_type' => 'authorization_code',
|
||||
'code' => $code,
|
||||
'redirect_uri' => $this->redirectUri(),
|
||||
'client_id' => $this->clientId(),
|
||||
'client_secret' => $this->clientSecret(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{access_token: string, refresh_token: ?string, expires_in: int}
|
||||
*/
|
||||
public function refreshAccessToken(string $refreshToken): array
|
||||
{
|
||||
try {
|
||||
return $this->requestToken([
|
||||
'grant_type' => 'refresh_token',
|
||||
'refresh_token' => $refreshToken,
|
||||
'client_id' => $this->clientId(),
|
||||
'client_secret' => $this->clientSecret(),
|
||||
]);
|
||||
} catch (AdsenseOAuthException $exception) {
|
||||
if ($this->isInvalidGrant($exception)) {
|
||||
throw new AdsenseAuthorizationException(
|
||||
'AdSense authorization expired or was revoked.',
|
||||
0,
|
||||
$exception,
|
||||
);
|
||||
}
|
||||
|
||||
throw $exception;
|
||||
}
|
||||
}
|
||||
|
||||
public function persistTokens(AdsenseConnection $connection, array $tokens, ?int $userId = null): void
|
||||
{
|
||||
$refreshToken = $tokens['refresh_token'] ?? null;
|
||||
if (is_string($refreshToken) && $refreshToken !== '') {
|
||||
$connection->encrypted_refresh_token = $refreshToken;
|
||||
}
|
||||
|
||||
if (! $connection->hasRefreshToken()) {
|
||||
throw new AdsenseOAuthException('Google did not return a refresh token. Reconnect with consent.');
|
||||
}
|
||||
|
||||
if ($userId !== null) {
|
||||
$connection->connected_by_user_id = $userId;
|
||||
}
|
||||
|
||||
if ($connection->connected_at === null) {
|
||||
$connection->connected_at = now();
|
||||
}
|
||||
|
||||
if ($connection->status === AdsenseConnection::STATUS_DISCONNECTED) {
|
||||
$connection->status = AdsenseConnection::STATUS_PENDING;
|
||||
}
|
||||
|
||||
$connection->last_error = null;
|
||||
$connection->save();
|
||||
|
||||
if (isset($tokens['access_token']) && is_string($tokens['access_token']) && $tokens['access_token'] !== '') {
|
||||
$this->cacheAccessToken(
|
||||
(int) $connection->id,
|
||||
$tokens['access_token'],
|
||||
(int) ($tokens['expires_in'] ?? 3600),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public function accessToken(AdsenseConnection $connection, bool $forceRefresh = false): string
|
||||
{
|
||||
if (! $connection->hasRefreshToken()) {
|
||||
$this->markReconnectRequired($connection, 'AdSense authorization expired or was revoked.');
|
||||
|
||||
throw new AdsenseAuthorizationException('AdSense authorization expired or was revoked.');
|
||||
}
|
||||
|
||||
$cacheKey = $this->cacheKey((int) $connection->id);
|
||||
|
||||
if (! $forceRefresh) {
|
||||
$cached = Cache::get($cacheKey);
|
||||
if (is_string($cached) && $cached !== '') {
|
||||
return $cached;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
$tokens = $this->refreshAccessToken((string) $connection->encrypted_refresh_token);
|
||||
} catch (AdsenseAuthorizationException $exception) {
|
||||
$this->forgetAccessToken((int) $connection->id);
|
||||
$this->markReconnectRequired($connection, $exception->getMessage());
|
||||
|
||||
throw $exception;
|
||||
}
|
||||
|
||||
$accessToken = (string) ($tokens['access_token'] ?? '');
|
||||
if ($accessToken === '') {
|
||||
throw new AdsenseOAuthException('Google did not return an access token.');
|
||||
}
|
||||
|
||||
if (isset($tokens['refresh_token']) && is_string($tokens['refresh_token']) && $tokens['refresh_token'] !== '') {
|
||||
$connection->encrypted_refresh_token = $tokens['refresh_token'];
|
||||
$connection->save();
|
||||
}
|
||||
|
||||
$this->cacheAccessToken((int) $connection->id, $accessToken, (int) ($tokens['expires_in'] ?? 3600));
|
||||
|
||||
return $accessToken;
|
||||
}
|
||||
|
||||
public function cacheAccessToken(int $connectionId, string $accessToken, int $expiresIn): void
|
||||
{
|
||||
$ttl = max(30, $expiresIn - (int) config('adsense.access_token_skew_seconds', 60));
|
||||
Cache::put($this->cacheKey($connectionId), $accessToken, $ttl);
|
||||
}
|
||||
|
||||
public function forgetAccessToken(int $connectionId): void
|
||||
{
|
||||
Cache::forget($this->cacheKey($connectionId));
|
||||
}
|
||||
|
||||
public function markReconnectRequired(AdsenseConnection $connection, string $message): void
|
||||
{
|
||||
$connection->status = AdsenseConnection::STATUS_RECONNECT_REQUIRED;
|
||||
$connection->last_error = $message;
|
||||
$connection->save();
|
||||
}
|
||||
|
||||
public function disconnect(AdsenseConnection $connection): void
|
||||
{
|
||||
$refreshToken = $connection->encrypted_refresh_token;
|
||||
$this->forgetAccessToken((int) $connection->id);
|
||||
|
||||
if (is_string($refreshToken) && $refreshToken !== '') {
|
||||
$this->revokeToken($refreshToken);
|
||||
}
|
||||
|
||||
$connection->encrypted_refresh_token = null;
|
||||
$connection->status = AdsenseConnection::STATUS_DISCONNECTED;
|
||||
$connection->last_error = null;
|
||||
$connection->save();
|
||||
}
|
||||
|
||||
public function revokeToken(string $token): void
|
||||
{
|
||||
try {
|
||||
Http::asForm()
|
||||
->timeout((int) config('adsense.timeout', 20))
|
||||
->connectTimeout((int) config('adsense.connect_timeout', 5))
|
||||
->post((string) config('adsense.oauth_revoke_url'), [
|
||||
'token' => $token,
|
||||
]);
|
||||
} catch (\Throwable $exception) {
|
||||
Log::warning('AdSense token revocation failed.', AdsenseLogSanitizer::context([
|
||||
'message' => $exception->getMessage(),
|
||||
]));
|
||||
}
|
||||
}
|
||||
|
||||
public function assertValidCallback(Request $request): string
|
||||
{
|
||||
$error = trim((string) $request->query('error', ''));
|
||||
if ($error !== '') {
|
||||
$description = trim((string) $request->query('error_description', ''));
|
||||
$request->session()->forget(self::SESSION_STATE_KEY);
|
||||
|
||||
throw new AdsenseOAuthException(
|
||||
$description !== ''
|
||||
? 'Google AdSense authorization was denied: '.$description
|
||||
: 'Google AdSense authorization was denied.'
|
||||
);
|
||||
}
|
||||
|
||||
$expected = (string) $request->session()->pull(self::SESSION_STATE_KEY, '');
|
||||
$provided = (string) $request->query('state', '');
|
||||
|
||||
if ($expected === '' || $provided === '' || ! hash_equals($expected, $provided)) {
|
||||
throw new AdsenseOAuthException('Invalid OAuth state.');
|
||||
}
|
||||
|
||||
$code = trim((string) $request->query('code', ''));
|
||||
if ($code === '') {
|
||||
throw new AdsenseOAuthException('Missing authorization code.');
|
||||
}
|
||||
|
||||
return $code;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, string> $payload
|
||||
* @return array{access_token: string, refresh_token: ?string, expires_in: int}
|
||||
*/
|
||||
private function requestToken(array $payload): array
|
||||
{
|
||||
try {
|
||||
$response = Http::asForm()
|
||||
->acceptJson()
|
||||
->timeout((int) config('adsense.timeout', 20))
|
||||
->connectTimeout((int) config('adsense.connect_timeout', 5))
|
||||
->post((string) config('adsense.oauth_token_url'), $payload);
|
||||
} catch (ConnectionException $exception) {
|
||||
Log::warning('AdSense OAuth token request failed.', AdsenseLogSanitizer::context([
|
||||
'message' => $exception->getMessage(),
|
||||
]));
|
||||
|
||||
throw new AdsenseOAuthException('Unable to contact Google OAuth.', 0, $exception);
|
||||
}
|
||||
|
||||
$json = $response->json();
|
||||
$json = is_array($json) ? $json : [];
|
||||
|
||||
if ($response->failed()) {
|
||||
$error = $json['error'] ?? 'oauth_error';
|
||||
if (is_array($error)) {
|
||||
$error = (string) ($error['message'] ?? $error['status'] ?? 'oauth_error');
|
||||
} else {
|
||||
$error = (string) $error;
|
||||
}
|
||||
Log::warning('AdSense OAuth token request rejected.', AdsenseLogSanitizer::context([
|
||||
'status' => $response->status(),
|
||||
'error' => $error,
|
||||
]));
|
||||
|
||||
throw new AdsenseOAuthException($error, $response->status());
|
||||
}
|
||||
|
||||
$accessToken = (string) ($json['access_token'] ?? '');
|
||||
if ($accessToken === '') {
|
||||
throw new AdsenseOAuthException('Google did not return an access token.');
|
||||
}
|
||||
|
||||
$refreshToken = $json['refresh_token'] ?? null;
|
||||
|
||||
return [
|
||||
'access_token' => $accessToken,
|
||||
'refresh_token' => is_string($refreshToken) && $refreshToken !== '' ? $refreshToken : null,
|
||||
'expires_in' => (int) ($json['expires_in'] ?? 3600),
|
||||
];
|
||||
}
|
||||
|
||||
private function isInvalidGrant(AdsenseOAuthException $exception): bool
|
||||
{
|
||||
$message = Str::lower($exception->getMessage());
|
||||
|
||||
return str_contains($message, 'invalid_grant')
|
||||
|| str_contains($message, 'revoked')
|
||||
|| str_contains($message, 'invalid_token');
|
||||
}
|
||||
|
||||
private function cacheKey(int $connectionId): string
|
||||
{
|
||||
return (string) config('adsense.access_token_cache_prefix', 'adsense.access_token.').$connectionId;
|
||||
}
|
||||
|
||||
private function clientId(): string
|
||||
{
|
||||
return trim((string) config('adsense.client_id'));
|
||||
}
|
||||
|
||||
private function clientSecret(): string
|
||||
{
|
||||
return trim((string) config('adsense.client_secret'));
|
||||
}
|
||||
|
||||
public function redirectUri(): string
|
||||
{
|
||||
return trim((string) config('adsense.redirect_uri'));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Adsense;
|
||||
|
||||
use App\Models\AdsenseDailyStat;
|
||||
|
||||
final class AdsenseReportParser
|
||||
{
|
||||
/**
|
||||
* @var array<string, string>
|
||||
*/
|
||||
private const METRIC_COLUMNS = [
|
||||
'ESTIMATED_EARNINGS' => 'estimated_earnings',
|
||||
'PAGE_VIEWS' => 'page_views',
|
||||
'PAGE_VIEWS_CTR' => 'page_views_ctr',
|
||||
'PAGE_VIEWS_RPM' => 'page_views_rpm',
|
||||
'AD_REQUESTS' => 'ad_requests',
|
||||
'AD_REQUESTS_COVERAGE' => 'ad_requests_coverage',
|
||||
'AD_REQUESTS_CTR' => 'ad_requests_ctr',
|
||||
'AD_REQUESTS_RPM' => 'ad_requests_rpm',
|
||||
'MATCHED_AD_REQUESTS' => 'matched_ad_requests',
|
||||
'IMPRESSIONS' => 'impressions',
|
||||
'IMPRESSIONS_CTR' => 'impressions_ctr',
|
||||
'IMPRESSIONS_RPM' => 'impressions_rpm',
|
||||
'CLICKS' => 'clicks',
|
||||
'COST_PER_CLICK' => 'cost_per_click',
|
||||
];
|
||||
|
||||
/**
|
||||
* @var list<string>
|
||||
*/
|
||||
private const INTEGER_COLUMNS = [
|
||||
'page_views',
|
||||
'ad_requests',
|
||||
'matched_ad_requests',
|
||||
'impressions',
|
||||
'clicks',
|
||||
];
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $report
|
||||
* @return list<array<string, mixed>>
|
||||
*/
|
||||
public function parse(array $report, string $dimensionType, string $accountResourceName): array
|
||||
{
|
||||
$indexByName = $this->headerIndex($report['headers'] ?? []);
|
||||
$currencyCode = $this->currencyCode($report['headers'] ?? []);
|
||||
$rows = [];
|
||||
|
||||
foreach ($report['rows'] ?? [] as $row) {
|
||||
if (! is_array($row)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$cells = $row['cells'] ?? [];
|
||||
if (! is_array($cells)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$date = $this->cell($cells, $indexByName, 'DATE');
|
||||
if (! is_string($date) || $date === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$dimensionKey = $this->dimensionKey($cells, $indexByName, $dimensionType);
|
||||
if ($dimensionKey === null || $dimensionKey === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$parsed = [
|
||||
'date' => $date,
|
||||
'account_resource_name' => $accountResourceName,
|
||||
'dimension_type' => $dimensionType,
|
||||
'dimension_key' => $dimensionKey,
|
||||
'dimension_label' => $this->dimensionLabel($cells, $indexByName, $dimensionType, $dimensionKey),
|
||||
'currency_code' => $currencyCode,
|
||||
];
|
||||
|
||||
foreach (self::METRIC_COLUMNS as $header => $column) {
|
||||
$parsed[$column] = $this->metricValue($cells, $indexByName, $header, $column);
|
||||
}
|
||||
|
||||
$rows[] = $parsed;
|
||||
}
|
||||
|
||||
return $rows;
|
||||
}
|
||||
|
||||
public function currencyCode(array $headers): ?string
|
||||
{
|
||||
foreach ($headers as $header) {
|
||||
if (! is_array($header)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$type = strtoupper((string) ($header['type'] ?? ''));
|
||||
$code = trim((string) ($header['currencyCode'] ?? ''));
|
||||
if ($type === 'METRIC_CURRENCY' && $code !== '') {
|
||||
return $code;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, int>
|
||||
*/
|
||||
private function headerIndex(mixed $headers): array
|
||||
{
|
||||
$index = [];
|
||||
if (! is_array($headers)) {
|
||||
return $index;
|
||||
}
|
||||
|
||||
foreach ($headers as $position => $header) {
|
||||
if (! is_array($header)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$name = strtoupper(trim((string) ($header['name'] ?? '')));
|
||||
if ($name === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$index[$name] = (int) $position;
|
||||
}
|
||||
|
||||
return $index;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, mixed> $cells
|
||||
* @param array<string, int> $indexByName
|
||||
*/
|
||||
private function dimensionKey(array $cells, array $indexByName, string $dimensionType): ?string
|
||||
{
|
||||
return match ($dimensionType) {
|
||||
AdsenseDailyStat::DIMENSION_TOTAL => AdsenseDailyStat::TOTAL_DIMENSION_KEY,
|
||||
AdsenseDailyStat::DIMENSION_AD_UNIT => $this->cell($cells, $indexByName, 'AD_UNIT_ID')
|
||||
?? $this->cell($cells, $indexByName, 'AD_UNIT_NAME'),
|
||||
AdsenseDailyStat::DIMENSION_CUSTOM_CHANNEL => $this->cell($cells, $indexByName, 'CUSTOM_CHANNEL_ID')
|
||||
?? $this->cell($cells, $indexByName, 'CUSTOM_CHANNEL_NAME'),
|
||||
AdsenseDailyStat::DIMENSION_PLATFORM => $this->cell($cells, $indexByName, 'PLATFORM_TYPE_CODE')
|
||||
?? $this->cell($cells, $indexByName, 'PLATFORM_TYPE_NAME'),
|
||||
AdsenseDailyStat::DIMENSION_COUNTRY => $this->cell($cells, $indexByName, 'COUNTRY_CODE')
|
||||
?? $this->cell($cells, $indexByName, 'COUNTRY_NAME'),
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, mixed> $cells
|
||||
* @param array<string, int> $indexByName
|
||||
*/
|
||||
private function dimensionLabel(array $cells, array $indexByName, string $dimensionType, string $dimensionKey): string
|
||||
{
|
||||
$label = match ($dimensionType) {
|
||||
AdsenseDailyStat::DIMENSION_TOTAL => 'Total',
|
||||
AdsenseDailyStat::DIMENSION_AD_UNIT => $this->cell($cells, $indexByName, 'AD_UNIT_NAME'),
|
||||
AdsenseDailyStat::DIMENSION_CUSTOM_CHANNEL => $this->cell($cells, $indexByName, 'CUSTOM_CHANNEL_NAME'),
|
||||
AdsenseDailyStat::DIMENSION_PLATFORM => $this->cell($cells, $indexByName, 'PLATFORM_TYPE_NAME')
|
||||
?? $this->cell($cells, $indexByName, 'PLATFORM_TYPE_CODE'),
|
||||
AdsenseDailyStat::DIMENSION_COUNTRY => $this->cell($cells, $indexByName, 'COUNTRY_NAME')
|
||||
?? $this->cell($cells, $indexByName, 'COUNTRY_CODE'),
|
||||
default => $dimensionKey,
|
||||
};
|
||||
|
||||
return is_string($label) && $label !== '' ? $label : $dimensionKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, mixed> $cells
|
||||
* @param array<string, int> $indexByName
|
||||
*/
|
||||
private function cell(array $cells, array $indexByName, string $name): ?string
|
||||
{
|
||||
$name = strtoupper($name);
|
||||
if (! array_key_exists($name, $indexByName)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$cell = $cells[$indexByName[$name]] ?? null;
|
||||
if (is_array($cell)) {
|
||||
$value = $cell['value'] ?? null;
|
||||
|
||||
return is_scalar($value) ? (string) $value : null;
|
||||
}
|
||||
|
||||
return is_scalar($cell) ? (string) $cell : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, mixed> $cells
|
||||
* @param array<string, int> $indexByName
|
||||
*/
|
||||
private function metricValue(array $cells, array $indexByName, string $header, string $column): int|string|null
|
||||
{
|
||||
$raw = $this->cell($cells, $indexByName, $header);
|
||||
if ($raw === null || $raw === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (in_array($column, self::INTEGER_COLUMNS, true)) {
|
||||
return (int) $raw;
|
||||
}
|
||||
|
||||
if (! is_numeric($raw)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $raw;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Adsense;
|
||||
|
||||
final class AdsenseSyncResult
|
||||
{
|
||||
/**
|
||||
* @param list<string> $failedReports
|
||||
*/
|
||||
public function __construct(
|
||||
public string $accountDisplayName = '',
|
||||
public string $from = '',
|
||||
public string $to = '',
|
||||
public int $adUnits = 0,
|
||||
public int $customChannels = 0,
|
||||
public int $totalRows = 0,
|
||||
public int $adUnitRows = 0,
|
||||
public int $customChannelRows = 0,
|
||||
public int $platformRows = 0,
|
||||
public int $countryRows = 0,
|
||||
public array $failedReports = [],
|
||||
public bool $entitiesSynced = false,
|
||||
public bool $reconnectRequired = false,
|
||||
) {}
|
||||
|
||||
public function succeeded(): bool
|
||||
{
|
||||
return ! $this->reconnectRequired && $this->failedReports === [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,358 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Adsense;
|
||||
|
||||
use App\Models\AdsenseConnection;
|
||||
use App\Models\AdsenseDailyStat;
|
||||
use App\Models\AdsenseEntity;
|
||||
use App\Services\Adsense\Exceptions\AdsenseApiException;
|
||||
use App\Services\Adsense\Exceptions\AdsenseAuthorizationException;
|
||||
use App\Services\Adsense\Exceptions\AdsenseOAuthException;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
final class AdsenseSyncService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly AdsenseApiClient $api,
|
||||
private readonly AdsenseReportParser $parser,
|
||||
private readonly AdsenseOAuthService $oauth,
|
||||
) {}
|
||||
|
||||
public function sync(
|
||||
AdsenseConnection $connection,
|
||||
Carbon $from,
|
||||
Carbon $to,
|
||||
bool $entitiesOnly = false,
|
||||
): AdsenseSyncResult {
|
||||
$result = new AdsenseSyncResult(
|
||||
accountDisplayName: (string) ($connection->account_display_name ?: $connection->account_resource_name),
|
||||
from: $from->toDateString(),
|
||||
to: $to->toDateString(),
|
||||
);
|
||||
|
||||
$connection->last_sync_attempt_at = now();
|
||||
$connection->save();
|
||||
|
||||
try {
|
||||
$this->syncEntities($connection, $result);
|
||||
|
||||
if (! $entitiesOnly) {
|
||||
$this->syncReports($connection, $from, $to, $result);
|
||||
}
|
||||
|
||||
if ($result->reconnectRequired) {
|
||||
return $result;
|
||||
}
|
||||
|
||||
$connection->last_successful_sync_at = now();
|
||||
$connection->status = $result->failedReports === []
|
||||
? AdsenseConnection::STATUS_CONNECTED
|
||||
: AdsenseConnection::STATUS_ERROR;
|
||||
$connection->last_error = $result->failedReports === []
|
||||
? null
|
||||
: 'Partial AdSense sync failure: '.implode(', ', $result->failedReports);
|
||||
$connection->save();
|
||||
} catch (AdsenseAuthorizationException $exception) {
|
||||
$result->reconnectRequired = true;
|
||||
$this->oauth->markReconnectRequired($connection, $exception->getMessage());
|
||||
} catch (AdsenseOAuthException $exception) {
|
||||
$result->failedReports[] = 'oauth';
|
||||
$connection->status = AdsenseConnection::STATUS_ERROR;
|
||||
$connection->last_error = $exception->getMessage();
|
||||
$connection->save();
|
||||
} catch (AdsenseApiException $exception) {
|
||||
$result->failedReports[] = 'entities';
|
||||
Log::warning('AdSense entity synchronization failed.', AdsenseLogSanitizer::context([
|
||||
'status' => $exception->status,
|
||||
'message' => $exception->getMessage(),
|
||||
]));
|
||||
$connection->status = AdsenseConnection::STATUS_ERROR;
|
||||
$connection->last_error = $exception->getMessage();
|
||||
$connection->save();
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
public function syncEntities(AdsenseConnection $connection, ?AdsenseSyncResult $result = null): AdsenseSyncResult
|
||||
{
|
||||
$result ??= new AdsenseSyncResult(
|
||||
accountDisplayName: (string) ($connection->account_display_name ?: $connection->account_resource_name),
|
||||
);
|
||||
|
||||
$account = (string) $connection->account_resource_name;
|
||||
if ($account === '') {
|
||||
throw new AdsenseApiException('No AdSense account is selected.');
|
||||
}
|
||||
|
||||
$adClients = $this->api->listAdClients($connection, $account);
|
||||
$now = now();
|
||||
$adUnits = 0;
|
||||
$customChannels = 0;
|
||||
|
||||
foreach ($adClients as $client) {
|
||||
$clientName = (string) ($client['name'] ?? '');
|
||||
if ($clientName === '' || ! $this->supportsInventory($client)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
foreach ($this->api->listAdUnits($connection, $clientName) as $unit) {
|
||||
$this->upsertEntity($account, $clientName, AdsenseEntity::TYPE_AD_UNIT, $unit, $now);
|
||||
$adUnits++;
|
||||
}
|
||||
} catch (AdsenseAuthorizationException $exception) {
|
||||
throw $exception;
|
||||
} catch (AdsenseApiException $exception) {
|
||||
$this->logSkippedClient($clientName, 'ad_units', $exception);
|
||||
}
|
||||
|
||||
try {
|
||||
foreach ($this->api->listCustomChannels($connection, $clientName) as $channel) {
|
||||
$this->upsertEntity($account, $clientName, AdsenseEntity::TYPE_CUSTOM_CHANNEL, $channel, $now);
|
||||
$customChannels++;
|
||||
}
|
||||
} catch (AdsenseAuthorizationException $exception) {
|
||||
throw $exception;
|
||||
} catch (AdsenseApiException $exception) {
|
||||
$this->logSkippedClient($clientName, 'custom_channels', $exception);
|
||||
}
|
||||
}
|
||||
|
||||
$result->adUnits = $adUnits;
|
||||
$result->customChannels = $customChannels;
|
||||
$result->entitiesSynced = true;
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
private function syncReports(
|
||||
AdsenseConnection $connection,
|
||||
Carbon $from,
|
||||
Carbon $to,
|
||||
AdsenseSyncResult $result,
|
||||
): void {
|
||||
$account = (string) $connection->account_resource_name;
|
||||
$metrics = array_values((array) config('adsense.metrics', []));
|
||||
|
||||
$definitions = [
|
||||
AdsenseDailyStat::DIMENSION_TOTAL => ['DATE'],
|
||||
AdsenseDailyStat::DIMENSION_AD_UNIT => ['DATE', 'AD_UNIT_ID'],
|
||||
AdsenseDailyStat::DIMENSION_CUSTOM_CHANNEL => ['DATE', 'CUSTOM_CHANNEL_ID'],
|
||||
AdsenseDailyStat::DIMENSION_PLATFORM => ['DATE', 'PLATFORM_TYPE_CODE'],
|
||||
AdsenseDailyStat::DIMENSION_COUNTRY => ['DATE', 'COUNTRY_CODE'],
|
||||
];
|
||||
|
||||
foreach ($definitions as $dimensionType => $dimensions) {
|
||||
try {
|
||||
$report = $this->api->generateReport(
|
||||
$connection,
|
||||
$account,
|
||||
$dimensions,
|
||||
$metrics,
|
||||
$from,
|
||||
$to,
|
||||
$dimensionType,
|
||||
);
|
||||
$rows = $this->parser->parse($report, $dimensionType, $account);
|
||||
$this->applyEntityLabels($rows, $account, $dimensionType);
|
||||
$saved = $this->upsertStats($rows);
|
||||
|
||||
match ($dimensionType) {
|
||||
AdsenseDailyStat::DIMENSION_TOTAL => $result->totalRows = $saved,
|
||||
AdsenseDailyStat::DIMENSION_AD_UNIT => $result->adUnitRows = $saved,
|
||||
AdsenseDailyStat::DIMENSION_CUSTOM_CHANNEL => $result->customChannelRows = $saved,
|
||||
AdsenseDailyStat::DIMENSION_PLATFORM => $result->platformRows = $saved,
|
||||
AdsenseDailyStat::DIMENSION_COUNTRY => $result->countryRows = $saved,
|
||||
default => null,
|
||||
};
|
||||
} catch (AdsenseAuthorizationException $exception) {
|
||||
$result->reconnectRequired = true;
|
||||
throw $exception;
|
||||
} catch (AdsenseApiException $exception) {
|
||||
$result->failedReports[] = $dimensionType;
|
||||
Log::warning('AdSense report synchronization failed.', AdsenseLogSanitizer::context([
|
||||
'report' => $dimensionType,
|
||||
'status' => $exception->status,
|
||||
'message' => $exception->getMessage(),
|
||||
]));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Website content ad clients (AFC / ca-pub-...) expose ad units.
|
||||
* YouTube host clients (ca-yt-host-pub-...) are listed by Google but 404 on adunits.
|
||||
*
|
||||
* @param array<string, mixed> $client
|
||||
*/
|
||||
private function supportsInventory(array $client): bool
|
||||
{
|
||||
$name = strtolower((string) ($client['name'] ?? ''));
|
||||
$reportingId = strtolower((string) ($client['reportingDimensionId'] ?? ''));
|
||||
$haystack = $name.' '.$reportingId;
|
||||
|
||||
foreach ((array) config('adsense.inventory_excluded_client_prefixes', ['ca-yt-host-']) as $prefix) {
|
||||
$prefix = strtolower(trim((string) $prefix));
|
||||
if ($prefix !== '' && str_contains($haystack, $prefix)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
$product = strtoupper(trim((string) ($client['productCode'] ?? '')));
|
||||
$allowed = array_values(array_filter(array_map(
|
||||
static fn (mixed $code): string => strtoupper(trim((string) $code)),
|
||||
(array) config('adsense.inventory_product_codes', ['AFC']),
|
||||
)));
|
||||
|
||||
if ($product !== '' && $allowed !== [] && ! in_array($product, $allowed, true)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private function logSkippedClient(string $clientName, string $kind, AdsenseApiException $exception): void
|
||||
{
|
||||
Log::warning('AdSense skipped an ad client inventory request.', AdsenseLogSanitizer::context([
|
||||
'client' => $clientName,
|
||||
'kind' => $kind,
|
||||
'status' => $exception->status,
|
||||
'message' => $exception->getMessage(),
|
||||
]));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $entity
|
||||
*/
|
||||
private function upsertEntity(
|
||||
string $account,
|
||||
string $adClient,
|
||||
string $type,
|
||||
array $entity,
|
||||
Carbon $seenAt,
|
||||
): void {
|
||||
$resourceName = (string) ($entity['name'] ?? '');
|
||||
$reportingId = (string) ($entity['reportingDimensionId'] ?? '');
|
||||
if ($reportingId === '' && $resourceName !== '') {
|
||||
$reportingId = (string) str($resourceName)->afterLast('/');
|
||||
}
|
||||
if ($reportingId === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
$state = strtoupper((string) ($entity['state'] ?? ''));
|
||||
$active = $state === '' ? null : $state === 'ACTIVE' || $state === 'READY';
|
||||
|
||||
AdsenseEntity::query()->updateOrCreate(
|
||||
[
|
||||
'account_resource_name' => $account,
|
||||
'type' => $type,
|
||||
'reporting_dimension_id' => $reportingId,
|
||||
],
|
||||
[
|
||||
'ad_client_resource_name' => $adClient,
|
||||
'resource_name' => $resourceName !== '' ? $resourceName : null,
|
||||
'display_name' => (string) ($entity['displayName'] ?? $reportingId),
|
||||
'active' => $active,
|
||||
'last_seen_at' => $seenAt,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<array<string, mixed>> $rows
|
||||
*/
|
||||
private function applyEntityLabels(array &$rows, string $account, string $dimensionType): void
|
||||
{
|
||||
if (! in_array($dimensionType, [AdsenseDailyStat::DIMENSION_AD_UNIT, AdsenseDailyStat::DIMENSION_CUSTOM_CHANNEL], true)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$labels = AdsenseEntity::query()
|
||||
->where('account_resource_name', $account)
|
||||
->where('type', $dimensionType)
|
||||
->pluck('display_name', 'reporting_dimension_id');
|
||||
|
||||
foreach ($rows as &$row) {
|
||||
$key = (string) ($row['dimension_key'] ?? '');
|
||||
if ($key !== '' && isset($labels[$key]) && is_string($labels[$key]) && $labels[$key] !== '') {
|
||||
$row['dimension_label'] = $labels[$key];
|
||||
}
|
||||
}
|
||||
unset($row);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<array<string, mixed>> $rows
|
||||
*/
|
||||
private function upsertStats(array $rows): int
|
||||
{
|
||||
if ($rows === []) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$now = now();
|
||||
$payload = [];
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$payload[] = [
|
||||
'date' => $row['date'],
|
||||
'account_resource_name' => $row['account_resource_name'],
|
||||
'dimension_type' => $row['dimension_type'],
|
||||
'dimension_key' => $row['dimension_key'],
|
||||
'dimension_label' => $row['dimension_label'] ?? null,
|
||||
'currency_code' => $row['currency_code'] ?? null,
|
||||
'page_views' => $row['page_views'] ?? null,
|
||||
'ad_requests' => $row['ad_requests'] ?? null,
|
||||
'matched_ad_requests' => $row['matched_ad_requests'] ?? null,
|
||||
'impressions' => $row['impressions'] ?? null,
|
||||
'clicks' => $row['clicks'] ?? null,
|
||||
'estimated_earnings' => $row['estimated_earnings'] ?? null,
|
||||
'page_views_ctr' => $row['page_views_ctr'] ?? null,
|
||||
'page_views_rpm' => $row['page_views_rpm'] ?? null,
|
||||
'ad_requests_coverage' => $row['ad_requests_coverage'] ?? null,
|
||||
'ad_requests_ctr' => $row['ad_requests_ctr'] ?? null,
|
||||
'ad_requests_rpm' => $row['ad_requests_rpm'] ?? null,
|
||||
'impressions_ctr' => $row['impressions_ctr'] ?? null,
|
||||
'impressions_rpm' => $row['impressions_rpm'] ?? null,
|
||||
'cost_per_click' => $row['cost_per_click'] ?? null,
|
||||
'last_synced_at' => $now,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
];
|
||||
}
|
||||
|
||||
foreach (array_chunk($payload, 200) as $chunk) {
|
||||
AdsenseDailyStat::query()->upsert(
|
||||
$chunk,
|
||||
['account_resource_name', 'date', 'dimension_type', 'dimension_key'],
|
||||
[
|
||||
'dimension_label',
|
||||
'currency_code',
|
||||
'page_views',
|
||||
'ad_requests',
|
||||
'matched_ad_requests',
|
||||
'impressions',
|
||||
'clicks',
|
||||
'estimated_earnings',
|
||||
'page_views_ctr',
|
||||
'page_views_rpm',
|
||||
'ad_requests_coverage',
|
||||
'ad_requests_ctr',
|
||||
'ad_requests_rpm',
|
||||
'impressions_ctr',
|
||||
'impressions_rpm',
|
||||
'cost_per_click',
|
||||
'last_synced_at',
|
||||
'updated_at',
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
return count($payload);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Adsense\Exceptions;
|
||||
|
||||
final class AdsenseApiException extends AdsenseException
|
||||
{
|
||||
public function __construct(
|
||||
string $message,
|
||||
public readonly int $status = 0,
|
||||
public readonly string $report = '',
|
||||
?\Throwable $previous = null,
|
||||
) {
|
||||
parent::__construct($message, $status, $previous);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Adsense\Exceptions;
|
||||
|
||||
final class AdsenseAuthorizationException extends AdsenseException {}
|
||||
@@ -0,0 +1,9 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Adsense\Exceptions;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
class AdsenseException extends RuntimeException {}
|
||||
@@ -0,0 +1,7 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Adsense\Exceptions;
|
||||
|
||||
final class AdsenseOAuthException extends AdsenseException {}
|
||||
@@ -10,6 +10,12 @@ use Illuminate\Support\Facades\Storage;
|
||||
|
||||
final class ArtworkOriginalFileLocator
|
||||
{
|
||||
/** @var list<string> */
|
||||
private const ALLOWED_EXTENSIONS = [
|
||||
'jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp', 'tiff',
|
||||
'zip', 'rar', '7z', 'tar', 'gz',
|
||||
];
|
||||
|
||||
public function __construct(
|
||||
private readonly UploadStorageService $storage,
|
||||
) {}
|
||||
@@ -27,7 +33,7 @@ final class ArtworkOriginalFileLocator
|
||||
}
|
||||
|
||||
$hash = strtolower((string) $artwork->hash);
|
||||
$ext = strtolower(ltrim((string) $artwork->file_ext, '.'));
|
||||
$ext = $this->safeExtension($artwork);
|
||||
|
||||
if (! $this->isValidHash($hash) || $ext === '') {
|
||||
return '';
|
||||
@@ -41,17 +47,136 @@ final class ArtworkOriginalFileLocator
|
||||
. DIRECTORY_SEPARATOR . $hash . '.' . $ext;
|
||||
}
|
||||
|
||||
/** @return array{status:string,source:?string,disk:?string,path:string,object_key:string,exists:bool,size:?int,file_name:string,file_ext:string} */
|
||||
public function resolve(Artwork $artwork): array
|
||||
{
|
||||
$fileName = trim((string) ($artwork->file_name ?? ''));
|
||||
$extension = $this->safeExtension($artwork);
|
||||
$localPath = $this->resolveLocalPath($artwork);
|
||||
|
||||
if ($localPath !== '' && is_file($localPath)) {
|
||||
return $this->result('available', 'local', null, $localPath, '', true, (int) filesize($localPath), $fileName, $extension);
|
||||
}
|
||||
|
||||
if ($this->isTruePending($artwork)) {
|
||||
return $this->result('pending', null, null, $localPath, '', false, null, $fileName, $extension);
|
||||
}
|
||||
|
||||
$objectKey = $this->resolveObjectPath($artwork);
|
||||
if ($objectKey !== '') {
|
||||
try {
|
||||
$diskName = $this->storage->objectDiskName();
|
||||
$disk = Storage::disk($diskName);
|
||||
if ($disk->exists($objectKey)) {
|
||||
return $this->result('available', 'object', $diskName, '', $objectKey, true, (int) $disk->size($objectKey), $fileName, $extension);
|
||||
}
|
||||
} catch (\Throwable) {
|
||||
// Treat unavailable storage as unresolved; the route logs context.
|
||||
}
|
||||
}
|
||||
|
||||
$legacyPath = $this->resolveReadonlyLegacyPath($artwork);
|
||||
if ($legacyPath !== '' && is_file($legacyPath)) {
|
||||
return $this->result('available', 'legacy', null, $legacyPath, '', true, (int) filesize($legacyPath), $fileName, $extension);
|
||||
}
|
||||
|
||||
if ($this->hasIncompleteLegacyMetadata($artwork)) {
|
||||
return $this->result('unknown', null, null, $localPath, $objectKey, false, null, $fileName, $extension);
|
||||
}
|
||||
|
||||
return $this->result('missing', null, null, $localPath, $objectKey, false, null, $fileName, $extension);
|
||||
}
|
||||
|
||||
private function isTruePending(Artwork $artwork): bool
|
||||
{
|
||||
return strtolower(trim((string) ($artwork->file_name ?? ''))) === 'pending'
|
||||
&& trim((string) ($artwork->hash ?? '')) === ''
|
||||
&& trim((string) ($artwork->file_ext ?? '')) === ''
|
||||
&& trim((string) ($artwork->file_path ?? '')) === '';
|
||||
}
|
||||
|
||||
private function hasIncompleteLegacyMetadata(Artwork $artwork): bool
|
||||
{
|
||||
return ! $this->isValidHash(strtolower((string) ($artwork->hash ?? '')))
|
||||
|| $this->safeExtension($artwork) === '';
|
||||
}
|
||||
|
||||
private function resolveReadonlyLegacyPath(Artwork $artwork): string
|
||||
{
|
||||
$legacyRoot = rtrim(str_replace(['/', '\\'], DIRECTORY_SEPARATOR, trim((string) config('uploads.legacy_originals_root', ''))), DIRECTORY_SEPARATOR);
|
||||
$legacyRelative = $this->legacyRelativePath($artwork);
|
||||
if ($legacyRoot !== '' && $legacyRelative !== '') {
|
||||
return $legacyRoot . DIRECTORY_SEPARATOR . str_replace(['/', '\\'], DIRECTORY_SEPARATOR, $legacyRelative);
|
||||
}
|
||||
|
||||
$root = rtrim(str_replace(['/', '\\'], DIRECTORY_SEPARATOR, trim((string) config('uploads.readonly_backup_originals_root', ''))), DIRECTORY_SEPARATOR);
|
||||
$hash = strtolower((string) $artwork->hash);
|
||||
$extension = $this->safeExtension($artwork);
|
||||
if ($root === '' || ! $this->isValidHash($hash) || $extension === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
return $root . DIRECTORY_SEPARATOR . substr($hash, 0, 2) . DIRECTORY_SEPARATOR . substr($hash, 2, 2) . DIRECTORY_SEPARATOR . $hash . '.' . $extension;
|
||||
}
|
||||
|
||||
private function legacyRelativePath(Artwork $artwork): string
|
||||
{
|
||||
$path = trim((string) ($artwork->file_path ?? ''), '/\\');
|
||||
if ($path === '' || ! str_starts_with(strtolower($path), 'legacy/uploads/')) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$relative = substr($path, strlen('legacy/uploads/'));
|
||||
if ($relative === false || $relative === '' || str_contains($relative, '..')) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return 'legacy/uploads/' . ltrim($relative, '/\\');
|
||||
}
|
||||
|
||||
private function safeExtension(Artwork $artwork): string
|
||||
{
|
||||
$stored = strtolower(ltrim(trim((string) ($artwork->file_ext ?? '')), '.'));
|
||||
if (in_array($stored, self::ALLOWED_EXTENSIONS, true)) {
|
||||
return $stored;
|
||||
}
|
||||
|
||||
foreach ([(string) ($artwork->file_name ?? ''), (string) ($artwork->file_path ?? '')] as $candidate) {
|
||||
$inferred = strtolower(ltrim((string) pathinfo($candidate, PATHINFO_EXTENSION), '.'));
|
||||
if (in_array($inferred, self::ALLOWED_EXTENSIONS, true)) {
|
||||
return $inferred;
|
||||
}
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
private function result(string $status, ?string $source, ?string $disk, string $path, string $objectKey, bool $exists, ?int $size, string $fileName, string $extension): array
|
||||
{
|
||||
return [
|
||||
'status' => $status,
|
||||
'source' => $source,
|
||||
'disk' => $disk,
|
||||
'path' => $path,
|
||||
'object_key' => $objectKey,
|
||||
'exists' => $exists,
|
||||
'size' => $size,
|
||||
'file_name' => $fileName,
|
||||
'file_ext' => $extension,
|
||||
];
|
||||
}
|
||||
|
||||
public function resolveObjectPath(Artwork $artwork): string
|
||||
{
|
||||
$relative = trim((string) $artwork->file_path, '/');
|
||||
$prefix = $this->originalObjectPrefix();
|
||||
|
||||
if ($relative !== '' && str_starts_with($relative, $prefix)) {
|
||||
if ($relative !== '' && (str_starts_with($relative, $prefix) || str_starts_with(strtolower($relative), 'legacy/uploads/'))) {
|
||||
return $relative;
|
||||
}
|
||||
|
||||
$hash = strtolower((string) $artwork->hash);
|
||||
$ext = strtolower(ltrim((string) $artwork->file_ext, '.'));
|
||||
$ext = $this->safeExtension($artwork);
|
||||
|
||||
if (! $this->isValidHash($hash) || $ext === '') {
|
||||
return '';
|
||||
@@ -77,6 +202,6 @@ final class ArtworkOriginalFileLocator
|
||||
|
||||
private function isValidHash(string $hash): bool
|
||||
{
|
||||
return $hash !== '' && preg_match('/^[a-f0-9]+$/', $hash) === 1;
|
||||
return preg_match('/^[a-f0-9]{40}$/', $hash) === 1;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Artwork;
|
||||
use App\Models\User;
|
||||
use App\Services\Maturity\ArtworkMaturityService;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
final class ArtworkRelatedService
|
||||
{
|
||||
private const SPLIT_PIVOT_ROW_THRESHOLD = 1000;
|
||||
|
||||
private const RELATIONS = ['user', 'group', 'categories.contentType'];
|
||||
|
||||
public function __construct(private readonly ArtworkMaturityService $maturity) {}
|
||||
|
||||
/**
|
||||
* Return the related artwork models used by the artwork page.
|
||||
*
|
||||
* The fallback query is intentionally kept equivalent to the controller's
|
||||
* original OR/EXISTS query. The split path only changes how candidates are
|
||||
* found; the final models and their relations remain the same.
|
||||
*/
|
||||
public function related(Artwork $artwork, ?User $viewer, int $limit = 12): EloquentCollection
|
||||
{
|
||||
$categoryIds = $artwork->categories->pluck('id')->filter()->values();
|
||||
$tagIds = $artwork->tags->pluck('id')->filter()->values();
|
||||
|
||||
if ($categoryIds->isEmpty() || $this->matchingPivotRows($categoryIds) > self::SPLIT_PIVOT_ROW_THRESHOLD) {
|
||||
return $this->originalQuery($artwork, $viewer, $categoryIds, $tagIds, $limit)->get();
|
||||
}
|
||||
|
||||
$candidateRows = collect()
|
||||
->merge($this->topAuthorCandidates($artwork, $viewer, $limit))
|
||||
->merge($this->topGroupCandidates($artwork, $viewer, $limit))
|
||||
->merge($this->topCategoryCandidates($artwork, $viewer, $categoryIds, $limit))
|
||||
->merge($this->topTagCandidates($artwork, $viewer, $tagIds, $limit))
|
||||
->reject(fn (object $row): bool => (int) $row->id === (int) $artwork->id)
|
||||
->unique(fn (object $row): string => (string) $row->id)
|
||||
->sortByDesc(fn (object $row): mixed => $row->published_at)
|
||||
->take($limit)
|
||||
->values();
|
||||
|
||||
$ids = $candidateRows->pluck('id')->map(static fn ($id): int => (int) $id)->all();
|
||||
if ($ids === []) {
|
||||
return new EloquentCollection;
|
||||
}
|
||||
|
||||
$hydrated = Artwork::query()
|
||||
->with(self::RELATIONS)
|
||||
->whereIn('id', $ids)
|
||||
->tap(fn (Builder $builder) => $this->maturity->applyViewerFilter($builder, $viewer))
|
||||
->get()
|
||||
->keyBy('id');
|
||||
|
||||
return new EloquentCollection(collect($ids)
|
||||
->map(fn (int $id): ?Artwork => $hydrated->get($id))
|
||||
->filter()
|
||||
->values()
|
||||
->all());
|
||||
}
|
||||
|
||||
private function matchingPivotRows(Collection $categoryIds): int
|
||||
{
|
||||
return (int) DB::table('artwork_category')
|
||||
->whereIn('category_id', $categoryIds->all())
|
||||
->count();
|
||||
}
|
||||
|
||||
private function candidateQuery(Artwork $artwork, ?User $viewer): Builder
|
||||
{
|
||||
return Artwork::query()
|
||||
->select(['artworks.id', 'artworks.published_at'])
|
||||
->whereKeyNot($artwork->id)
|
||||
->public()
|
||||
->published()
|
||||
->tap(fn (Builder $builder) => $this->maturity->applyViewerFilter($builder, $viewer));
|
||||
}
|
||||
|
||||
private function topAuthorCandidates(Artwork $artwork, ?User $viewer, int $limit): Collection
|
||||
{
|
||||
return $this->candidateQuery($artwork, $viewer)
|
||||
->where('user_id', $artwork->user_id)
|
||||
->latest('published_at')
|
||||
->limit($limit)
|
||||
->get();
|
||||
}
|
||||
|
||||
private function topGroupCandidates(Artwork $artwork, ?User $viewer, int $limit): Collection
|
||||
{
|
||||
if (! $artwork->group_id) {
|
||||
return collect();
|
||||
}
|
||||
|
||||
return $this->candidateQuery($artwork, $viewer)
|
||||
->where('group_id', $artwork->group_id)
|
||||
->latest('published_at')
|
||||
->limit($limit)
|
||||
->get();
|
||||
}
|
||||
|
||||
private function topCategoryCandidates(Artwork $artwork, ?User $viewer, Collection $categoryIds, int $limit): Collection
|
||||
{
|
||||
return $this->candidateQuery($artwork, $viewer)
|
||||
->join('artwork_category', 'artwork_category.artwork_id', '=', 'artworks.id')
|
||||
->whereIn('artwork_category.category_id', $categoryIds->all())
|
||||
->select(['artworks.id', 'artworks.published_at'])
|
||||
->distinct()
|
||||
->orderByDesc('artworks.published_at')
|
||||
->limit($limit)
|
||||
->get();
|
||||
}
|
||||
|
||||
private function topTagCandidates(Artwork $artwork, ?User $viewer, Collection $tagIds, int $limit): Collection
|
||||
{
|
||||
if ($tagIds->isEmpty()) {
|
||||
return collect();
|
||||
}
|
||||
|
||||
return $this->candidateQuery($artwork, $viewer)
|
||||
->whereHas('tags', fn ($query) => $query->whereIn('tags.id', $tagIds->all()))
|
||||
->latest('published_at')
|
||||
->limit($limit)
|
||||
->get();
|
||||
}
|
||||
|
||||
private function originalQuery(Artwork $artwork, ?User $viewer, Collection $categoryIds, Collection $tagIds, int $limit): Builder
|
||||
{
|
||||
return Artwork::query()
|
||||
->with(self::RELATIONS)
|
||||
->whereKeyNot($artwork->id)
|
||||
->public()
|
||||
->published()
|
||||
->tap(fn (Builder $builder) => $this->maturity->applyViewerFilter($builder, $viewer))
|
||||
->where(function ($query) use ($artwork, $categoryIds, $tagIds): void {
|
||||
$query->where('user_id', $artwork->user_id);
|
||||
|
||||
if ($artwork->group_id) {
|
||||
$query->orWhere('group_id', $artwork->group_id);
|
||||
}
|
||||
|
||||
if ($categoryIds->isNotEmpty()) {
|
||||
$query->orWhereHas('categories', function ($categoryQuery) use ($categoryIds): void {
|
||||
$categoryQuery->whereIn('categories.id', $categoryIds->all());
|
||||
});
|
||||
}
|
||||
|
||||
if ($tagIds->isNotEmpty()) {
|
||||
$query->orWhereHas('tags', function ($tagQuery) use ($tagIds): void {
|
||||
$tagQuery->whereIn('tags.id', $tagIds->all());
|
||||
});
|
||||
}
|
||||
})
|
||||
->latest('published_at')
|
||||
->limit($limit);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Artworks;
|
||||
|
||||
use App\Models\Artwork;
|
||||
use App\Models\Category;
|
||||
use App\Services\ArtworkSearchIndexer;
|
||||
use App\Services\ArtworkService;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
final class ArtworkCategoryService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ArtworkSearchIndexer $searchIndexer,
|
||||
private readonly ArtworkService $artworkService,
|
||||
) {}
|
||||
|
||||
public function maxSecondary(): int
|
||||
{
|
||||
return max(0, (int) config('categories.max_secondary_categories', 5));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<int|string>|null $secondaryCategoryIds null keeps existing secondaries that remain compatible
|
||||
*/
|
||||
public function sync(
|
||||
Artwork $artwork,
|
||||
?int $primaryCategoryId,
|
||||
?array $secondaryCategoryIds = [],
|
||||
?string $source = null,
|
||||
bool $reindex = true,
|
||||
): void {
|
||||
$primaryCategoryId = $primaryCategoryId !== null && $primaryCategoryId > 0 ? $primaryCategoryId : null;
|
||||
$secondaryCategoryIds = $secondaryCategoryIds === null
|
||||
? $this->existingSecondaryIds($artwork, $primaryCategoryId)
|
||||
: $this->normalizeIds($secondaryCategoryIds);
|
||||
|
||||
if ($primaryCategoryId === null) {
|
||||
if ($secondaryCategoryIds !== []) {
|
||||
throw ValidationException::withMessages([
|
||||
'primary_category_id' => ['A primary category is required when additional categories are selected.'],
|
||||
]);
|
||||
}
|
||||
|
||||
$this->persist($artwork, null, [], $source, $reindex);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$secondaryCategoryIds = array_values(array_filter(
|
||||
$secondaryCategoryIds,
|
||||
static fn (int $id): bool => $id !== $primaryCategoryId,
|
||||
));
|
||||
|
||||
if (count($secondaryCategoryIds) > $this->maxSecondary()) {
|
||||
throw ValidationException::withMessages([
|
||||
'secondary_category_ids' => [sprintf('You can add at most %d additional categories.', $this->maxSecondary())],
|
||||
]);
|
||||
}
|
||||
|
||||
$requestedIds = array_values(array_unique([$primaryCategoryId, ...$secondaryCategoryIds]));
|
||||
$categories = Category::query()
|
||||
->whereIn('id', $requestedIds)
|
||||
->get()
|
||||
->keyBy('id');
|
||||
|
||||
if ($categories->count() !== count($requestedIds)) {
|
||||
throw ValidationException::withMessages([
|
||||
'primary_category_id' => ['One or more selected categories do not exist.'],
|
||||
]);
|
||||
}
|
||||
|
||||
$primary = $categories->get($primaryCategoryId);
|
||||
if (! $primary) {
|
||||
throw ValidationException::withMessages([
|
||||
'primary_category_id' => ['Choose a valid primary category.'],
|
||||
]);
|
||||
}
|
||||
|
||||
if (! $primary->is_active) {
|
||||
throw ValidationException::withMessages([
|
||||
'primary_category_id' => ['The primary category is not available.'],
|
||||
]);
|
||||
}
|
||||
|
||||
$contentTypeId = (int) $primary->content_type_id;
|
||||
|
||||
foreach ($secondaryCategoryIds as $secondaryId) {
|
||||
$category = $categories->get($secondaryId);
|
||||
if (! $category || ! $category->is_active) {
|
||||
throw ValidationException::withMessages([
|
||||
'secondary_category_ids' => ['One or more additional categories are not available.'],
|
||||
]);
|
||||
}
|
||||
|
||||
if ((int) $category->content_type_id !== $contentTypeId) {
|
||||
throw ValidationException::withMessages([
|
||||
'secondary_category_ids' => ['Additional categories must belong to the same content type as the primary category.'],
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
$this->persist($artwork, $primaryCategoryId, $secondaryCategoryIds, $source, $reindex);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function presentCategory(Category $category, bool $isPrimary): array
|
||||
{
|
||||
return [
|
||||
'id' => (int) $category->id,
|
||||
'name' => html_entity_decode((string) $category->name, ENT_QUOTES | ENT_HTML5, 'UTF-8'),
|
||||
'slug' => (string) $category->slug,
|
||||
'content_type_slug' => (string) ($category->contentType?->slug ?? ''),
|
||||
'url' => $category->contentType ? $category->url : null,
|
||||
'is_primary' => $isPrimary,
|
||||
'parent' => $category->parent ? [
|
||||
'id' => (int) $category->parent->id,
|
||||
'slug' => (string) $category->parent->slug,
|
||||
'name' => html_entity_decode((string) $category->parent->name, ENT_QUOTES | ENT_HTML5, 'UTF-8'),
|
||||
'content_type_slug' => (string) ($category->parent->contentType?->slug ?? ''),
|
||||
'url' => $category->parent->contentType ? $category->parent->url : null,
|
||||
] : null,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<int|string> $ids
|
||||
* @return list<int>
|
||||
*/
|
||||
private function normalizeIds(array $ids): array
|
||||
{
|
||||
$normalized = [];
|
||||
|
||||
foreach ($ids as $id) {
|
||||
$value = (int) $id;
|
||||
if ($value > 0 && ! in_array($value, $normalized, true)) {
|
||||
$normalized[] = $value;
|
||||
}
|
||||
}
|
||||
|
||||
return $normalized;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<int>
|
||||
*/
|
||||
private function existingSecondaryIds(Artwork $artwork, ?int $primaryCategoryId): array
|
||||
{
|
||||
return $artwork->categories()
|
||||
->pluck('categories.id')
|
||||
->map(static fn ($id): int => (int) $id)
|
||||
->reject(static fn (int $id): bool => $primaryCategoryId !== null && $id === $primaryCategoryId)
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<int> $secondaryCategoryIds
|
||||
*/
|
||||
private function persist(
|
||||
Artwork $artwork,
|
||||
?int $primaryCategoryId,
|
||||
array $secondaryCategoryIds,
|
||||
?string $source,
|
||||
bool $reindex,
|
||||
): void {
|
||||
$membershipIds = $primaryCategoryId === null
|
||||
? []
|
||||
: array_values(array_unique([$primaryCategoryId, ...$secondaryCategoryIds]));
|
||||
|
||||
DB::transaction(function () use ($artwork, $primaryCategoryId, $membershipIds, $source): void {
|
||||
$artwork->categories()->sync($membershipIds);
|
||||
$artwork->primary_category_id = $primaryCategoryId;
|
||||
|
||||
if ($source !== null) {
|
||||
$artwork->category_source = $source;
|
||||
}
|
||||
|
||||
$artwork->save();
|
||||
});
|
||||
|
||||
$artwork->unsetRelation('categories');
|
||||
$artwork->unsetRelation('primaryCategory');
|
||||
|
||||
if (! $reindex) {
|
||||
return;
|
||||
}
|
||||
|
||||
DB::afterCommit(function () use ($artwork): void {
|
||||
$this->artworkService->clearArtworkCache($artwork->fresh() ?? $artwork);
|
||||
Cache::forget('categories.directory.v1');
|
||||
$this->searchIndexer->update($artwork);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -17,12 +17,16 @@ final class ArtworkDraftService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly GroupService $groups,
|
||||
private readonly ArtworkCategoryService $categories,
|
||||
) {
|
||||
}
|
||||
|
||||
public function createDraft(User $user, string $title, ?string $description, ?int $categoryId = null, bool $isMature = false, string|int|null $groupIdentifier = null): ArtworkDraftResult
|
||||
/**
|
||||
* @param list<int> $secondaryCategoryIds
|
||||
*/
|
||||
public function createDraft(User $user, string $title, ?string $description, ?int $categoryId = null, bool $isMature = false, string|int|null $groupIdentifier = null, array $secondaryCategoryIds = []): ArtworkDraftResult
|
||||
{
|
||||
return DB::transaction(function () use ($user, $title, $description, $categoryId, $isMature, $groupIdentifier) {
|
||||
return DB::transaction(function () use ($user, $title, $description, $categoryId, $isMature, $groupIdentifier, $secondaryCategoryIds) {
|
||||
$slug = $this->makeSlug($title);
|
||||
$group = $this->resolveGroup($user, $groupIdentifier);
|
||||
|
||||
@@ -50,9 +54,8 @@ final class ArtworkDraftService
|
||||
'artwork_status' => 'draft',
|
||||
]);
|
||||
|
||||
// Attach the selected category to the artwork pivot table
|
||||
if ($categoryId !== null && \App\Models\Category::where('id', $categoryId)->exists()) {
|
||||
$artwork->categories()->sync([$categoryId]);
|
||||
if ($categoryId !== null || $secondaryCategoryIds !== []) {
|
||||
$this->categories->sync($artwork, $categoryId, $secondaryCategoryIds, 'user', false);
|
||||
}
|
||||
|
||||
if ($group) {
|
||||
|
||||
@@ -140,7 +140,7 @@ class CollectionAiCurationService
|
||||
->first();
|
||||
|
||||
$label = $tag?->name
|
||||
?: ($artwork->categories->first()?->name)
|
||||
?: ($artwork->resolvedPrimaryCategory()?->name)
|
||||
?: ($artwork->stats?->views ? 'Popular highlights' : 'Curated picks');
|
||||
|
||||
if (! isset($themeBuckets[$label])) {
|
||||
@@ -673,7 +673,7 @@ class CollectionAiCurationService
|
||||
$artworks = $this->candidateArtworks($collection, $draft, 36);
|
||||
$themes = $this->topThemes($artworks);
|
||||
$categories = $artworks
|
||||
->map(fn (Artwork $artwork) => $artwork->categories->first()?->name)
|
||||
->map(fn (Artwork $artwork) => $artwork->resolvedPrimaryCategory()?->name)
|
||||
->filter()
|
||||
->countBy()
|
||||
->sortDesc();
|
||||
|
||||
@@ -95,7 +95,7 @@ class CollectionLinkService
|
||||
'label' => (string) $artwork->title,
|
||||
'description' => collect([
|
||||
$artwork->user?->username ? '@' . strtolower((string) $artwork->user->username) : null,
|
||||
$artwork->categories->first()?->contentType?->name,
|
||||
$artwork->resolvedPrimaryCategory()?->contentType?->name,
|
||||
])->filter()->join(' • ') ?: 'Published artwork',
|
||||
])
|
||||
->values()
|
||||
@@ -343,7 +343,7 @@ class CollectionLinkService
|
||||
'title' => (string) $entity->title,
|
||||
'subtitle' => collect([
|
||||
$entity->user?->username ? '@' . strtolower((string) $entity->user->username) : null,
|
||||
$entity->categories->first()?->contentType?->name,
|
||||
$entity->resolvedPrimaryCategory()?->contentType?->name,
|
||||
])->filter()->join(' • ') ?: 'Artwork',
|
||||
'description' => $link->relationship_type ?: 'Linked artwork',
|
||||
'url' => route('art.show', [
|
||||
|
||||
@@ -861,6 +861,14 @@ class CollectionService
|
||||
? $collections
|
||||
: new EloquentCollection(is_array($collections) ? $collections : iterator_to_array($collections));
|
||||
|
||||
// The profile producer already eager-loads these relations. Keep the
|
||||
// mapper safe for direct callers as well, so the card loop never
|
||||
// triggers relation lazy-loading one collection at a time.
|
||||
$collectionList->loadMissing([
|
||||
'user:id,username,name',
|
||||
'coverArtwork:id,user_id,title,slug,hash,thumb_ext,published_at,is_public,is_approved,deleted_at',
|
||||
]);
|
||||
|
||||
$collectionIds = $collectionList->pluck('id')->map(static fn ($id) => (int) $id)->all();
|
||||
$hideMatureCovers = ! $ownerView && $this->viewerShouldHideMature($viewer);
|
||||
|
||||
@@ -879,12 +887,16 @@ class CollectionService
|
||||
->all()
|
||||
: [];
|
||||
|
||||
return $collectionList->map(function (Collection $collection) use ($ownerView, $viewer, $firstArtworkMap, $savedCollectionIds) {
|
||||
return $collectionList->map(function (Collection $collection) use ($ownerView, $viewer, $firstArtworkMap, $savedCollectionIds, $hideMatureCovers) {
|
||||
// Manual collections have their explicit cover eager-loaded. The
|
||||
// already bulk-loaded first artwork map is the exact fallback
|
||||
// used by resolvedCoverArtwork() when that cover is absent or not
|
||||
// visible, so no per-card relation query is needed here.
|
||||
$resolvedCover = $collection->isSmart()
|
||||
? $this->smartCollections->firstArtwork($collection, $ownerView)
|
||||
: $collection->resolvedCoverArtwork(! $ownerView, ! $ownerView && $this->viewerShouldHideMature($viewer));
|
||||
: ($collection->relationLoaded('coverArtwork') ? $collection->coverArtwork : null);
|
||||
$fallbackCover = $firstArtworkMap->get((int) $collection->id);
|
||||
$cover = $this->eligibleCoverArtwork($resolvedCover, ! $ownerView, ! $ownerView && $this->viewerShouldHideMature($viewer))
|
||||
$cover = $this->eligibleCoverArtwork($resolvedCover, ! $ownerView, $hideMatureCovers)
|
||||
? $resolvedCover
|
||||
: $fallbackCover;
|
||||
$summary = $collection->summary ?? $collection->description;
|
||||
@@ -1264,7 +1276,7 @@ class CollectionService
|
||||
|
||||
private function mapArtworkPayload(Artwork $artwork, array $extra = []): array
|
||||
{
|
||||
$category = $artwork->categories->first();
|
||||
$category = $artwork->resolvedPrimaryCategory();
|
||||
$contentType = $category?->contentType;
|
||||
$stats = $artwork->stats;
|
||||
|
||||
|
||||
@@ -33,8 +33,10 @@ final class CommunityActivityService
|
||||
public function getFeed(?User $viewer, string $filter = self::FILTER_ALL, int $page = 1, int $perPage = self::DEFAULT_PER_PAGE, ?int $actorUserId = null): array
|
||||
{
|
||||
$normalizedFilter = $this->normalizeFilter($filter);
|
||||
$resolvedPage = max(1, $page);
|
||||
$resolvedPerPage = max(1, min(50, $perPage));
|
||||
$resolvedPage = $viewer ? max(1, $page) : 1;
|
||||
$resolvedPerPage = $viewer
|
||||
? max(1, min(50, $perPage))
|
||||
: 20;
|
||||
|
||||
$cacheKey = sprintf(
|
||||
'community_activity:%s:%d:%d:%d:%d',
|
||||
@@ -68,20 +70,55 @@ final class CommunityActivityService
|
||||
|
||||
private function buildFeed(?User $viewer, string $filter, int $page, int $perPage, ?int $actorUserId): array
|
||||
{
|
||||
$sourceLimit = max(80, $page * $perPage * 6);
|
||||
// Guests only receive the latest page. Since every source is ordered
|
||||
// newest-first, one page per source is sufficient to produce the
|
||||
// globally newest page after merging, while avoiding historical
|
||||
// over-fetching for anonymous traffic.
|
||||
$sourceLimit = $viewer === null
|
||||
? $perPage
|
||||
: max(80, $page * $perPage * 6);
|
||||
$followingIds = $filter === self::FILTER_FOLLOWING && $viewer
|
||||
? $viewer->following()->pluck('users.id')->map(fn ($id) => (int) $id)->all()
|
||||
: [];
|
||||
$actorIds = match ($filter) {
|
||||
self::FILTER_FOLLOWING => $followingIds,
|
||||
self::FILTER_MY => $viewer ? [(int) $viewer->id] : [],
|
||||
default => null,
|
||||
};
|
||||
|
||||
$commentModels = $this->fetchCommentModels($sourceLimit, repliesOnly: false);
|
||||
$replyModels = $this->fetchCommentModels($sourceLimit, repliesOnly: true);
|
||||
$reactionModels = $this->fetchReactionModels($sourceLimit);
|
||||
$recordedActivities = $this->fetchRecordedActivities($sourceLimit);
|
||||
if ($actorUserId !== null) {
|
||||
$actorIds = $actorIds === null
|
||||
? [(int) $actorUserId]
|
||||
: array_values(array_intersect($actorIds, [(int) $actorUserId]));
|
||||
}
|
||||
|
||||
// A comments/replies feed can only contain its matching comment type.
|
||||
// Avoid loading and hydrating the other activity sources only to discard
|
||||
// them in the merge filter below. Actor-scoped filters also constrain
|
||||
// each source query while preserving the final in-memory safety filter.
|
||||
$commentModels = in_array($filter, [self::FILTER_ALL, self::FILTER_COMMENTS, self::FILTER_FOLLOWING, self::FILTER_MY], true)
|
||||
? $this->fetchCommentModels($sourceLimit, repliesOnly: false, actorIds: $actorIds)
|
||||
: collect();
|
||||
$replyModels = in_array($filter, [self::FILTER_ALL, self::FILTER_REPLIES, self::FILTER_FOLLOWING, self::FILTER_MY], true)
|
||||
? $this->fetchCommentModels($sourceLimit, repliesOnly: true, actorIds: $actorIds)
|
||||
: collect();
|
||||
$reactionModels = in_array($filter, [self::FILTER_ALL, self::FILTER_FOLLOWING, self::FILTER_MY], true)
|
||||
? $this->fetchReactionModels($sourceLimit, actorIds: $actorIds)
|
||||
: collect();
|
||||
$recordedActivities = in_array($filter, [self::FILTER_ALL, self::FILTER_FOLLOWING, self::FILTER_MY], true)
|
||||
? $this->fetchRecordedActivities($sourceLimit, actorIds: $actorIds)
|
||||
: collect();
|
||||
|
||||
$commentActivities = $commentModels->map(fn (ArtworkComment $comment) => $this->mapCommentActivity($comment, 'comment'));
|
||||
$replyActivities = $replyModels->map(fn (ArtworkComment $comment) => $this->mapCommentActivity($comment, 'reply'));
|
||||
$reactionActivities = $reactionModels->map(fn (CommentReaction $reaction) => $this->mapReactionActivity($reaction));
|
||||
$mentionActivities = $this->fetchMentionActivities($sourceLimit);
|
||||
$mentionActivities = in_array($filter, [self::FILTER_ALL, self::FILTER_FOLLOWING, self::FILTER_MY], true)
|
||||
? $this->fetchMentionActivities(
|
||||
$sourceLimit,
|
||||
actorIds: $actorIds,
|
||||
mentionedUserId: $filter === self::FILTER_MY ? $viewer?->id : null,
|
||||
)
|
||||
: collect();
|
||||
|
||||
$merged = $recordedActivities
|
||||
->concat($commentActivities)
|
||||
@@ -140,7 +177,7 @@ final class CommunityActivityService
|
||||
];
|
||||
}
|
||||
|
||||
private function fetchRecordedActivities(int $limit): Collection
|
||||
private function fetchRecordedActivities(int $limit, ?array $actorIds = null): Collection
|
||||
{
|
||||
$events = ActivityEvent::query()
|
||||
->select(['id', 'actor_id', 'type', 'target_type', 'target_id', 'meta', 'created_at'])
|
||||
@@ -153,6 +190,7 @@ final class CommunityActivityService
|
||||
},
|
||||
])
|
||||
->whereHas('actor', fn ($query) => $query->where('is_active', true)->whereNull('deleted_at'))
|
||||
->when($actorIds !== null, fn ($query) => $query->whereIn('actor_id', $actorIds))
|
||||
->latest('created_at')
|
||||
->limit($limit)
|
||||
->get();
|
||||
@@ -223,7 +261,7 @@ final class CommunityActivityService
|
||||
->values();
|
||||
}
|
||||
|
||||
private function fetchCommentModels(int $limit, bool $repliesOnly): Collection
|
||||
private function fetchCommentModels(int $limit, bool $repliesOnly, ?array $actorIds = null): Collection
|
||||
{
|
||||
return ArtworkComment::query()
|
||||
->select([
|
||||
@@ -250,6 +288,7 @@ final class CommunityActivityService
|
||||
])
|
||||
->where('is_approved', true)
|
||||
->whereNull('deleted_at')
|
||||
->when($actorIds !== null, fn ($query) => $query->whereIn('user_id', $actorIds))
|
||||
->when($repliesOnly, fn ($query) => $query->whereNotNull('parent_id'), fn ($query) => $query->whereNull('parent_id'))
|
||||
->whereHas('user', function ($query) {
|
||||
$query->where('is_active', true)->whereNull('deleted_at');
|
||||
@@ -262,7 +301,7 @@ final class CommunityActivityService
|
||||
->get();
|
||||
}
|
||||
|
||||
private function fetchReactionModels(int $limit): Collection
|
||||
private function fetchReactionModels(int $limit, ?array $actorIds = null): Collection
|
||||
{
|
||||
return CommentReaction::query()
|
||||
->select(['id', 'comment_id', 'user_id', 'reaction', 'created_at'])
|
||||
@@ -292,6 +331,7 @@ final class CommunityActivityService
|
||||
->whereHas('user', function ($query) {
|
||||
$query->where('is_active', true)->whereNull('deleted_at');
|
||||
})
|
||||
->when($actorIds !== null, fn ($query) => $query->whereIn('user_id', $actorIds))
|
||||
->whereHas('comment', function ($query) {
|
||||
$query
|
||||
->where('is_approved', true)
|
||||
@@ -395,7 +435,7 @@ final class CommunityActivityService
|
||||
];
|
||||
}
|
||||
|
||||
private function fetchMentionActivities(int $limit): Collection
|
||||
private function fetchMentionActivities(int $limit, ?array $actorIds = null, ?int $mentionedUserId = null): Collection
|
||||
{
|
||||
if (! Schema::hasTable('user_mentions')) {
|
||||
return collect();
|
||||
@@ -403,6 +443,15 @@ final class CommunityActivityService
|
||||
|
||||
return UserMention::query()
|
||||
->select(['id', 'user_id', 'mentioned_user_id', 'artwork_id', 'comment_id', 'created_at'])
|
||||
->when($actorIds !== null, function ($query) use ($actorIds, $mentionedUserId): void {
|
||||
$query->where(function ($scope) use ($actorIds, $mentionedUserId): void {
|
||||
$scope->whereIn('user_id', $actorIds);
|
||||
|
||||
if ($mentionedUserId !== null) {
|
||||
$scope->orWhere('mentioned_user_id', $mentionedUserId);
|
||||
}
|
||||
});
|
||||
})
|
||||
->with([
|
||||
'actor' => function ($query) {
|
||||
$query
|
||||
|
||||
@@ -10,6 +10,7 @@ use App\Models\Group;
|
||||
use App\Jobs\IndexArtworkJob;
|
||||
use App\Models\Tag;
|
||||
use App\Models\User;
|
||||
use App\Services\Artworks\ArtworkCategoryService;
|
||||
use Illuminate\Pagination\LengthAwarePaginator;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
@@ -23,6 +24,7 @@ class GroupArtworkReviewService
|
||||
private readonly GroupHistoryService $history,
|
||||
private readonly NotificationService $notifications,
|
||||
private readonly GroupMembershipService $memberships,
|
||||
private readonly ArtworkCategoryService $artworkCategories,
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -108,6 +110,10 @@ class GroupArtworkReviewService
|
||||
'group_reviewed_at' => now(),
|
||||
'group_review_notes' => $notes,
|
||||
'is_approved' => true,
|
||||
'approval_source' => 'moderator',
|
||||
'moderated_at' => now(),
|
||||
'moderated_by' => $actor->id,
|
||||
'moderation_note' => $notes,
|
||||
'artwork_status' => 'published',
|
||||
'published_at' => now(),
|
||||
'publish_at' => null,
|
||||
@@ -363,9 +369,20 @@ class GroupArtworkReviewService
|
||||
$artwork->uploaded_by_user_id = $artwork->uploaded_by_user_id ?: (int) $actor->id;
|
||||
$artwork->primary_author_user_id = $artwork->primary_author_user_id ?: (int) $actor->id;
|
||||
|
||||
$categoryId = isset($validated['category']) ? (int) $validated['category'] : null;
|
||||
if ($categoryId > 0 && Category::query()->where('id', $categoryId)->exists()) {
|
||||
$artwork->categories()->sync([$categoryId]);
|
||||
$categoryId = isset($validated['primary_category_id'])
|
||||
? (int) $validated['primary_category_id']
|
||||
: (isset($validated['category']) ? (int) $validated['category'] : null);
|
||||
$secondaryCategoryIds = array_key_exists('secondary_category_ids', $validated)
|
||||
? array_values(array_map('intval', (array) $validated['secondary_category_ids']))
|
||||
: [];
|
||||
if ($categoryId > 0) {
|
||||
$this->artworkCategories->sync(
|
||||
$artwork,
|
||||
$categoryId,
|
||||
$secondaryCategoryIds,
|
||||
'user',
|
||||
false,
|
||||
);
|
||||
}
|
||||
|
||||
if (array_key_exists('tags', $validated) && is_array($validated['tags'])) {
|
||||
@@ -395,4 +412,4 @@ class GroupArtworkReviewService
|
||||
{
|
||||
IndexArtworkJob::dispatch((int) $artwork->id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -102,42 +102,48 @@ final class ArtworkHourlySnapshotWindow
|
||||
*/
|
||||
public function artworkPeriodDeltas(CarbonInterface|\DateTimeInterface $start, ?int $userId = null): Builder
|
||||
{
|
||||
$inWindow = DB::table('artwork_metric_snapshots_hourly as snapshots');
|
||||
$this->constrainOwner($inWindow, $userId);
|
||||
$inWindow
|
||||
->where('snapshots.bucket_hour', '>=', $start)
|
||||
->groupBy('snapshots.artwork_id')
|
||||
->select('snapshots.artwork_id')
|
||||
->selectRaw('MAX(snapshots.views_count) as views_latest')
|
||||
->selectRaw('MIN(snapshots.views_count) as views_min')
|
||||
->selectRaw('MAX(snapshots.downloads_count) as downloads_latest')
|
||||
->selectRaw('MIN(snapshots.downloads_count) as downloads_min')
|
||||
->selectRaw('MAX(snapshots.favourites_count) as favourites_latest')
|
||||
->selectRaw('MIN(snapshots.favourites_count) as favourites_min')
|
||||
->selectRaw('MAX(snapshots.comments_count) as comments_latest')
|
||||
->selectRaw('MIN(snapshots.comments_count) as comments_min')
|
||||
->selectRaw('MAX(snapshots.shares_count) as shares_latest')
|
||||
->selectRaw('MIN(snapshots.shares_count) as shares_min');
|
||||
$start = \Carbon\Carbon::parse($start);
|
||||
$baselineFrom = (clone $start)->subHours(self::BASELINE_LOOKBACK_HOURS);
|
||||
|
||||
$baselineFrom = \Carbon\Carbon::parse($start)->subHours(self::BASELINE_LOOKBACK_HOURS);
|
||||
$baseline = DB::table('artwork_metric_snapshots_hourly as snapshots');
|
||||
$this->constrainOwner($baseline, $userId);
|
||||
$baseline
|
||||
->where('snapshots.bucket_hour', '<', $start)
|
||||
->where('snapshots.bucket_hour', '>=', $baselineFrom)
|
||||
->groupBy('snapshots.artwork_id')
|
||||
->select('snapshots.artwork_id')
|
||||
->selectRaw('MAX(snapshots.views_count) as views_baseline')
|
||||
->selectRaw('MAX(snapshots.downloads_count) as downloads_baseline')
|
||||
->selectRaw('MAX(snapshots.favourites_count) as favourites_baseline')
|
||||
->selectRaw('MAX(snapshots.comments_count) as comments_baseline')
|
||||
->selectRaw('MAX(snapshots.shares_count) as shares_baseline');
|
||||
// Resolve the three global hourly buckets first. These are cheap indexed
|
||||
// lookups and avoid aggregating millions of cumulative snapshot rows.
|
||||
$latestBucket = DB::table('artwork_metric_snapshots_hourly')
|
||||
->where('bucket_hour', '>=', $start)
|
||||
->max('bucket_hour');
|
||||
|
||||
$firstBucket = DB::table('artwork_metric_snapshots_hourly')
|
||||
->where('bucket_hour', '>=', $start)
|
||||
->min('bucket_hour');
|
||||
|
||||
$baselineBucket = DB::table('artwork_metric_snapshots_hourly')
|
||||
->where('bucket_hour', '<', $start)
|
||||
->where('bucket_hour', '>=', $baselineFrom)
|
||||
->max('bucket_hour');
|
||||
|
||||
$latest = $this->exactBucketSnapshot(
|
||||
$latestBucket,
|
||||
$userId,
|
||||
'latest',
|
||||
);
|
||||
|
||||
$first = $this->exactBucketSnapshot(
|
||||
$firstBucket,
|
||||
$userId,
|
||||
'min',
|
||||
);
|
||||
|
||||
$baseline = $this->exactBucketSnapshot(
|
||||
$baselineBucket,
|
||||
$userId,
|
||||
'baseline',
|
||||
);
|
||||
|
||||
$bornSql = $this->quotedTimestamp($start);
|
||||
|
||||
return DB::query()
|
||||
->fromSub($inWindow, 'win')
|
||||
->fromSub($latest, 'win')
|
||||
->leftJoinSub($baseline, 'base', 'base.artwork_id', '=', 'win.artwork_id')
|
||||
->leftJoinSub($first, 'first', 'first.artwork_id', '=', 'win.artwork_id')
|
||||
->join('artworks', 'artworks.id', '=', 'win.artwork_id')
|
||||
->select('win.artwork_id')
|
||||
->selectRaw($this->deltaExpression('views', $bornSql) . ' as views_delta')
|
||||
@@ -147,6 +153,7 @@ final class ArtworkHourlySnapshotWindow
|
||||
->selectRaw($this->deltaExpression('shares', $bornSql) . ' as shares_delta');
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @return array{oldest_bucket: ?string, newest_bucket: ?string, coverage_days: float, window_complete: bool, requested_days: int}
|
||||
*/
|
||||
@@ -194,6 +201,34 @@ final class ArtworkHourlySnapshotWindow
|
||||
->whereNull('owner_artworks.deleted_at');
|
||||
}
|
||||
|
||||
private function exactBucketSnapshot(
|
||||
?string $bucket,
|
||||
?int $userId,
|
||||
string $suffix,
|
||||
): Builder {
|
||||
$query = DB::table('artwork_metric_snapshots_hourly as snapshots');
|
||||
|
||||
if (DB::connection()->getDriverName() === 'mysql') {
|
||||
$query->forceIndex('idx_bucket_artwork');
|
||||
}
|
||||
|
||||
$this->constrainOwner($query, $userId);
|
||||
|
||||
if ($bucket === null) {
|
||||
$query->whereRaw('1 = 0');
|
||||
} else {
|
||||
$query->where('snapshots.bucket_hour', $bucket);
|
||||
}
|
||||
|
||||
return $query
|
||||
->select('snapshots.artwork_id')
|
||||
->selectRaw("snapshots.views_count as views_{$suffix}")
|
||||
->selectRaw("snapshots.downloads_count as downloads_{$suffix}")
|
||||
->selectRaw("snapshots.favourites_count as favourites_{$suffix}")
|
||||
->selectRaw("snapshots.comments_count as comments_{$suffix}")
|
||||
->selectRaw("snapshots.shares_count as shares_{$suffix}");
|
||||
}
|
||||
|
||||
private function quotedTimestamp(CarbonInterface|\DateTimeInterface $start): string
|
||||
{
|
||||
return DB::getPdo()->quote(\Carbon\Carbon::parse($start)->toDateTimeString());
|
||||
@@ -202,7 +237,7 @@ final class ArtworkHourlySnapshotWindow
|
||||
private function deltaExpression(string $metric, string $bornSql): string
|
||||
{
|
||||
$latest = "COALESCE(win.{$metric}_latest, 0)";
|
||||
$min = "COALESCE(win.{$metric}_min, 0)";
|
||||
$min = "COALESCE(first.{$metric}_min, 0)";
|
||||
$base = "base.{$metric}_baseline";
|
||||
$bornInWindow = "COALESCE(artworks.published_at, artworks.created_at) >= {$bornSql}";
|
||||
$observed = $this->clampNonNegative("{$latest} - {$min}");
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Moderation;
|
||||
|
||||
use App\Models\Artwork;
|
||||
use App\Models\User;
|
||||
|
||||
final class ArtworkUploadPolicy
|
||||
{
|
||||
/** @return array{requires_review: bool, reasons: array<int, string>, approved_uploads: int} */
|
||||
public function assess(User $user, ?Artwork $artwork = null): array
|
||||
{
|
||||
if ($user->isAdmin()) {
|
||||
return ['requires_review' => false, 'reasons' => [], 'approved_uploads' => PHP_INT_MAX];
|
||||
}
|
||||
|
||||
$approvedUploads = $this->approvedArtworkCount($user, $artwork);
|
||||
$minimumApproved = max(1, (int) config('uploads.moderation.minimum_approved_uploads', 5));
|
||||
|
||||
$reasons = [];
|
||||
|
||||
if ($approvedUploads < $minimumApproved) {
|
||||
$reasons[] = 'insufficient_approved_uploads';
|
||||
|
||||
if ($user->email_verified_at === null) {
|
||||
$reasons[] = 'email_not_verified';
|
||||
}
|
||||
|
||||
$minimumAgeDays = max(0, (int) config('uploads.moderation.minimum_account_age_days', 7));
|
||||
if ($user->created_at?->gt(now()->subDays($minimumAgeDays))) {
|
||||
$reasons[] = 'new_account';
|
||||
}
|
||||
|
||||
if ((int) ($user->level ?? 1) <= (int) config('uploads.moderation.maximum_untrusted_level', 1)) {
|
||||
$reasons[] = 'low_level';
|
||||
}
|
||||
|
||||
if ((int) ($user->bot_risk_score ?? 0) >= (int) config('uploads.moderation.bot_risk_review_threshold', 40)) {
|
||||
$reasons[] = 'bot_risk';
|
||||
}
|
||||
|
||||
if ((int) ($user->spam_reports ?? 0) > 0) {
|
||||
$reasons[] = 'spam_reports';
|
||||
}
|
||||
}
|
||||
|
||||
if ($artwork && $this->looksPromotional($artwork)) {
|
||||
$reasons[] = 'promotional_content';
|
||||
}
|
||||
|
||||
return [
|
||||
'requires_review' => $reasons !== [],
|
||||
'reasons' => array_values(array_unique($reasons)),
|
||||
'approved_uploads' => $approvedUploads,
|
||||
];
|
||||
}
|
||||
|
||||
private function approvedArtworkCount(User $user, ?Artwork $artwork): int
|
||||
{
|
||||
$query = $user->artworks()
|
||||
->where('is_approved', true)
|
||||
->whereNotNull('published_at')
|
||||
->where(function ($status): void {
|
||||
$status->whereNull('artwork_status')
|
||||
->orWhereNotIn('artwork_status', ['review', 'rejected', 'scheduled', 'draft']);
|
||||
})
|
||||
->where(function ($source): void {
|
||||
$source->whereNull('approval_source')
|
||||
->orWhere('approval_source', 'moderator');
|
||||
});
|
||||
|
||||
if ($artwork?->exists) {
|
||||
$query->whereKeyNot($artwork->getKey());
|
||||
}
|
||||
|
||||
return (int) $query->count();
|
||||
}
|
||||
|
||||
private function looksPromotional(Artwork $artwork): bool
|
||||
{
|
||||
$text = strtolower(implode(' ', [
|
||||
(string) $artwork->title,
|
||||
(string) $artwork->description,
|
||||
(string) $artwork->file_name,
|
||||
]));
|
||||
|
||||
foreach ((array) config('uploads.moderation.promotional_patterns', []) as $pattern) {
|
||||
if ($pattern !== '' && str_contains($text, strtolower((string) $pattern))) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return (bool) preg_match('/(?:https?:\/\/|www\.|\b[a-z0-9-]+\.(?:com|net|org|io|co)\b)/i', $text);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Moderation;
|
||||
|
||||
use App\Contracts\Moderation\CommentSpamClassifier;
|
||||
use App\Models\ArtworkComment;
|
||||
use App\Models\CommentSpamSignature;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Throwable;
|
||||
|
||||
class CommentSpamService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ContentModerationProcessingService $processing,
|
||||
private readonly CommentSpamClassifier $ai,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Classify after the comment exists, so the existing moderation queue can
|
||||
* retain the full source context and admin review workflow.
|
||||
*
|
||||
* @return array{status:string, score:int, probability:int, source:string, reason:string}
|
||||
*/
|
||||
public function moderate(ArtworkComment $comment, User $user, ?string $mode = null): array
|
||||
{
|
||||
$mode ??= (string) config('comment_spam.mode', 'enforce');
|
||||
$content = (string) ($comment->raw_content ?? $comment->content ?? '');
|
||||
$hash = hash('sha256', $this->normalize($content));
|
||||
|
||||
if (! (bool) config('comment_spam.enabled', true) || $mode === 'off') {
|
||||
return $this->saveMetadata($comment, 0, 0, 'disabled', 'Comment spam protection disabled.');
|
||||
}
|
||||
|
||||
$signature = $this->signature($hash);
|
||||
if ($signature !== null) {
|
||||
$this->recordSignatureHit($signature);
|
||||
$isSpam = ($signature->source === 'spam' || $signature->source === 'admin_spam');
|
||||
|
||||
return $this->finish($comment, $mode, $isSpam ? 100 : 0, $isSpam ? 100 : 0, 'signature', (string) $signature->reason, $isSpam);
|
||||
}
|
||||
|
||||
$scan = $this->processing->process($content, [
|
||||
'content_type' => 'artwork_comment',
|
||||
'content_id' => (int) $comment->id,
|
||||
'artwork_id' => (int) $comment->artwork_id,
|
||||
'user_id' => (int) $user->id,
|
||||
'content_snapshot' => $content,
|
||||
'comment_spam_mode' => $mode,
|
||||
], true);
|
||||
|
||||
$local = (int) $scan['result']->score;
|
||||
$probability = $local;
|
||||
$source = 'local';
|
||||
$reason = implode(' ', array_slice($scan['result']->reasons, 0, 2));
|
||||
$isSpam = $local >= (int) config('comment_spam.local_spam_min', 70);
|
||||
$needsAi = $local > (int) config('comment_spam.local_safe_max', 29) && ! $isSpam;
|
||||
$aiPending = false;
|
||||
|
||||
// Repeated external links and explicit copy/paste promotions are
|
||||
// deterministic spam signals. Do not let an AI false negative make
|
||||
// an obvious advertisement public.
|
||||
if ($this->isObviousPromotionalSpam($content, (array) $scan['result']->matchedLinks)) {
|
||||
$local = max($local, (int) config('comment_spam.local_spam_min', 70));
|
||||
$probability = 100;
|
||||
$isSpam = true;
|
||||
$needsAi = false;
|
||||
$source = 'local_hard_rule';
|
||||
$reason = trim($reason.' Repeated promotional external link pattern.');
|
||||
}
|
||||
|
||||
if ($needsAi && (bool) config('comment_spam.ai_enabled', true)) {
|
||||
try {
|
||||
$assessment = $this->ai->classify($content);
|
||||
$probability = $assessment->spam
|
||||
? max($local, (int) round($assessment->confidence * 100))
|
||||
: $local;
|
||||
$source = 'local+ai';
|
||||
$reason = trim(implode(' ', array_filter([$reason, $assessment->reason])));
|
||||
$isSpam = $assessment->spam && $assessment->confidence >= (float) config('comment_spam.ai.spam_confidence', 0.90);
|
||||
$aiPending = $assessment->spam && ! $isSpam;
|
||||
Log::info('comment_spam_ai_classification', ['comment_id' => $comment->id, 'provider' => $assessment->provider, 'model' => $assessment->model, 'spam' => $assessment->spam, 'confidence' => $assessment->confidence, 'latency_ms' => $assessment->latencyMs, 'decision' => $isSpam ? 'spam' : ($assessment->spam ? 'pending' : 'approved')]);
|
||||
} catch (Throwable) {
|
||||
$source = 'local+ai_error';
|
||||
$aiPending = true;
|
||||
}
|
||||
}
|
||||
|
||||
$status = $mode === 'observe'
|
||||
? 'observed'
|
||||
: ($isSpam ? 'spam' : ($needsAi && $aiPending ? 'pending' : 'approved'));
|
||||
|
||||
$comment->forceFill([
|
||||
'is_approved' => $mode === 'enforce' ? ! in_array($status, ['spam', 'pending'], true) : true,
|
||||
'spam_score' => min(100, $local),
|
||||
'spam_probability' => min(100, $probability),
|
||||
'spam_reason' => $reason !== '' ? mb_substr($reason, 0, 500) : null,
|
||||
'moderation_source' => $source,
|
||||
'moderated_at' => now(),
|
||||
])->save();
|
||||
|
||||
if ($mode === 'enforce' && $status === 'spam') {
|
||||
$this->rememberSignature($hash, 'spam', $reason, $probability);
|
||||
}
|
||||
|
||||
return compact('status', 'local', 'probability', 'source', 'reason') + ['score' => $local];
|
||||
}
|
||||
|
||||
private function finish(ArtworkComment $comment, string $mode, int $score, int $probability, string $source, string $reason, ?bool $isSpam = null): array
|
||||
{
|
||||
$isSpam ??= $score >= (int) config('comment_spam.local_spam_min', 70);
|
||||
$status = $mode === 'observe' ? 'observed' : ($isSpam ? 'spam' : 'approved');
|
||||
$comment->forceFill([
|
||||
'is_approved' => $mode !== 'enforce' || ! $isSpam,
|
||||
'spam_score' => $score,
|
||||
'spam_probability' => $probability,
|
||||
'spam_reason' => mb_substr($reason, 0, 500),
|
||||
'moderation_source' => $source,
|
||||
'moderated_at' => now(),
|
||||
])->save();
|
||||
|
||||
return compact('status', 'score', 'probability', 'source', 'reason');
|
||||
}
|
||||
|
||||
private function saveMetadata(ArtworkComment $comment, int $score, int $probability, string $source, string $reason): array
|
||||
{
|
||||
$comment->forceFill(['spam_score' => $score, 'spam_probability' => $probability, 'spam_reason' => $reason, 'moderation_source' => $source, 'moderated_at' => now()])->save();
|
||||
|
||||
return ['status' => 'approved', 'score' => $score, 'probability' => $probability, 'source' => $source, 'reason' => $reason];
|
||||
}
|
||||
|
||||
private function signature(string $hash): ?CommentSpamSignature
|
||||
{
|
||||
return Cache::remember('comment-spam:'.$hash, now()->addMinutes((int) config('comment_spam.signature_cache_minutes', 1440)), fn () => CommentSpamSignature::query()->where('content_hash', $hash)->first());
|
||||
}
|
||||
|
||||
private function recordSignatureHit(CommentSpamSignature $signature): void
|
||||
{
|
||||
try {
|
||||
if (! Schema::hasColumn('comment_spam_signatures', 'hit_count')) {
|
||||
return;
|
||||
}
|
||||
|
||||
CommentSpamSignature::query()->whereKey($signature->getKey())->increment('hit_count');
|
||||
} catch (Throwable $e) {
|
||||
Log::warning('comment_spam_signature_hit_failed', [
|
||||
'signature_id' => $signature->getKey(),
|
||||
'message' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
private function rememberSignature(string $hash, string $source, string $reason, int $confidence): void
|
||||
{
|
||||
CommentSpamSignature::query()->updateOrCreate(['content_hash' => $hash], ['source' => $source, 'reason' => mb_substr($reason, 0, 500), 'confidence' => min(100, $confidence), 'created_at' => now()]);
|
||||
Cache::forget('comment-spam:'.$hash);
|
||||
}
|
||||
|
||||
private function normalize(string $content): string
|
||||
{
|
||||
return mb_strtolower((string) preg_replace('/\s+/u', ' ', trim($content)));
|
||||
}
|
||||
|
||||
private function isObviousPromotionalSpam(string $content, array $matchedLinks): bool
|
||||
{
|
||||
$links = array_values(array_unique(array_map(
|
||||
fn (string $link): string => mb_strtolower(trim($link)),
|
||||
array_filter($matchedLinks, 'is_string'),
|
||||
)));
|
||||
preg_match_all('#https?://[^\s<>\[\]"\'`\)]+#iu', $content, $matches);
|
||||
$allLinks = array_map('mb_strtolower', $matches[0] ?? []);
|
||||
$hasRepeatedLink = count($allLinks) >= 2 && count(array_unique($allLinks)) < count($allLinks);
|
||||
$hasPromotion = preg_match('/\b(copy\s*(?:&|and)\s*paste|buy\s+now|cheap\s+seo|guaranteed\s+traffic|visit\s+my\s+profile)\b/iu', $content) === 1;
|
||||
|
||||
return $hasRepeatedLink || ($hasPromotion && $links !== []);
|
||||
}
|
||||
}
|
||||
@@ -3,8 +3,8 @@
|
||||
namespace App\Services\Moderation;
|
||||
|
||||
use App\Data\Moderation\ModerationResultData;
|
||||
use App\Enums\ModerationEscalationStatus;
|
||||
use App\Enums\ModerationContentType;
|
||||
use App\Enums\ModerationEscalationStatus;
|
||||
use App\Enums\ModerationStatus;
|
||||
use App\Models\ContentModerationAiSuggestion;
|
||||
use App\Models\ContentModerationFinding;
|
||||
@@ -14,8 +14,7 @@ class ContentModerationPersistenceService
|
||||
public function __construct(
|
||||
private readonly ContentModerationReviewService $review,
|
||||
private readonly ContentModerationActionLogService $actionLogs,
|
||||
) {
|
||||
}
|
||||
) {}
|
||||
|
||||
public function shouldQueue(ModerationResultData $result): bool
|
||||
{
|
||||
@@ -33,7 +32,7 @@ class ContentModerationPersistenceService
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $context
|
||||
* @param array<string, mixed> $context
|
||||
* @return array{finding:?ContentModerationFinding, created:bool, updated:bool}
|
||||
*/
|
||||
public function persist(ModerationResultData $result, array $context): array
|
||||
@@ -56,7 +55,7 @@ class ContentModerationPersistenceService
|
||||
return ['finding' => null, 'created' => false, 'updated' => false];
|
||||
}
|
||||
|
||||
$finding = $existing ?? new ContentModerationFinding();
|
||||
$finding = $existing ?? new ContentModerationFinding;
|
||||
$isNew = ! $finding->exists;
|
||||
|
||||
$finding->fill([
|
||||
@@ -144,10 +143,16 @@ class ContentModerationPersistenceService
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $context
|
||||
* @param array<string, mixed> $context
|
||||
*/
|
||||
public function applyAutomatedActionIfNeeded(ContentModerationFinding $finding, ModerationResultData $result, array $context): bool
|
||||
{
|
||||
if (($context['comment_spam_mode'] ?? null) === 'observe') {
|
||||
$finding->forceFill(['auto_action_taken' => 'observe_only'])->save();
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
if (! $result->autoHideRecommended) {
|
||||
return false;
|
||||
}
|
||||
@@ -179,4 +184,4 @@ class ContentModerationPersistenceService
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Moderation;
|
||||
|
||||
use App\Contracts\Moderation\CommentSpamClassifier;
|
||||
use App\Data\Moderation\CommentSpamClassification;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use RuntimeException;
|
||||
|
||||
final class TogetherCommentSpamClassifier implements CommentSpamClassifier
|
||||
{
|
||||
public function classify(string $content): CommentSpamClassification
|
||||
{
|
||||
$config = (array) config('comment_spam.ai', []);
|
||||
$key = (string) ($config['api_key'] ?? '');
|
||||
$model = (string) ($config['model'] ?? '');
|
||||
if ($key === '' || $model === '') {
|
||||
throw new RuntimeException('Together AI is not configured.');
|
||||
}
|
||||
if (! in_array($model, (array) ($config['allowed_models'] ?? []), true)) {
|
||||
throw new RuntimeException('Unsupported Together AI comment spam model.');
|
||||
}
|
||||
|
||||
$started = microtime(true);
|
||||
$response = Http::timeout(min(5, max(1, (int) ($config['timeout'] ?? 5))))
|
||||
->withToken($key)
|
||||
->post(rtrim((string) ($config['base_url'] ?? 'https://api.together.xyz/v1'), '/').'/chat/completions', [
|
||||
'model' => $model,
|
||||
'temperature' => 0,
|
||||
'response_format' => ['type' => 'json_object'],
|
||||
'messages' => [
|
||||
['role' => 'system', 'content' => 'Classify the comment as spam or not spam. Return only JSON: {"spam":true|false,"confidence":0.0,"reason":"short explanation"}. Confidence must be a number from 0 to 1. Spam includes advertising, SEO promotion, unsolicited links, scams, and bot-like promotional repetition.'],
|
||||
['role' => 'user', 'content' => mb_substr($content, 0, (int) ($config['max_input_chars'] ?? 2500))],
|
||||
],
|
||||
]);
|
||||
|
||||
if ($response->failed()) {
|
||||
throw new RuntimeException('Together AI request failed with status '.$response->status().'.');
|
||||
}
|
||||
|
||||
$message = Arr::get($response->json(), 'choices.0.message.content');
|
||||
$decoded = is_string($message) ? json_decode($message, true) : null;
|
||||
if (! is_array($decoded) || ! is_bool($decoded['spam'] ?? null)
|
||||
|| ! is_numeric($decoded['confidence'] ?? null)
|
||||
|| $decoded['confidence'] < 0 || $decoded['confidence'] > 1
|
||||
|| ! is_string($decoded['reason'] ?? null)) {
|
||||
throw new RuntimeException('Together AI returned invalid moderation JSON.');
|
||||
}
|
||||
|
||||
return new CommentSpamClassification(
|
||||
$decoded['spam'],
|
||||
(float) $decoded['confidence'],
|
||||
mb_substr(trim($decoded['reason']), 0, 500),
|
||||
'together',
|
||||
$model,
|
||||
(int) round((microtime(true) - $started) * 1000),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -359,6 +359,60 @@ final class NotificationService
|
||||
]);
|
||||
}
|
||||
|
||||
public function notifyArtworkApproved(User $recipient, User $actor, \App\Models\Artwork $artwork): ?Notification
|
||||
{
|
||||
if ($recipient->id === $actor->id) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$title = (string) ($artwork->title ?: 'Untitled artwork');
|
||||
|
||||
return Notification::query()->create([
|
||||
'user_id' => (int) $recipient->id,
|
||||
'type' => 'artwork_approved',
|
||||
'data' => [
|
||||
'type' => 'artwork_approved',
|
||||
'actor_id' => (int) $actor->id,
|
||||
'actor_name' => $actor->name,
|
||||
'actor_username' => $actor->username,
|
||||
'message' => 'Your artwork "'.$title.'" was approved and is now live.',
|
||||
'url' => route('art.show', ['id' => $artwork->id, 'slug' => $artwork->slug ?: $artwork->id]),
|
||||
'artwork_id' => (int) $artwork->id,
|
||||
'artwork_title' => $title,
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public function notifyArtworkRejected(User $recipient, User $actor, \App\Models\Artwork $artwork, ?string $note = null): ?Notification
|
||||
{
|
||||
if ($recipient->id === $actor->id) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$title = (string) ($artwork->title ?: 'Untitled artwork');
|
||||
$message = 'Your artwork "'.$title.'" was not approved.';
|
||||
$note = trim((string) $note);
|
||||
if ($note !== '') {
|
||||
$message .= ' Reason: '.$note;
|
||||
}
|
||||
|
||||
return Notification::query()->create([
|
||||
'user_id' => (int) $recipient->id,
|
||||
'type' => 'artwork_rejected',
|
||||
'data' => [
|
||||
'type' => 'artwork_rejected',
|
||||
'actor_id' => (int) $actor->id,
|
||||
'actor_name' => $actor->name,
|
||||
'actor_username' => $actor->username,
|
||||
'message' => $message,
|
||||
'url' => route('studio.artworks.edit', ['id' => $artwork->id]),
|
||||
'artwork_id' => (int) $artwork->id,
|
||||
'artwork_title' => $title,
|
||||
'note' => $note !== '' ? $note : null,
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public function notifyGroupPostPublished(User $recipient, User $actor, \App\Models\Group $group, \App\Models\GroupPost $post): ?Notification
|
||||
{
|
||||
if ($recipient->id === $actor->id) {
|
||||
|
||||
@@ -87,7 +87,7 @@ final class RSSFeedBuilder
|
||||
|
||||
// Primary category from eagerly loaded relation (avoid N+1)
|
||||
$primaryCategory = ($artwork->relationLoaded('categories'))
|
||||
? $artwork->categories->first()
|
||||
? $artwork->resolvedPrimaryCategory()
|
||||
: null;
|
||||
|
||||
// Build HTML description embedded in CDATA
|
||||
|
||||
@@ -201,7 +201,7 @@ final class ArtworkStudioProvider implements CreatorStudioProvider
|
||||
? 'scheduled'
|
||||
: ((bool) $artwork->is_public ? 'published' : 'draft'));
|
||||
|
||||
$category = $artwork->categories->first();
|
||||
$category = $artwork->resolvedPrimaryCategory();
|
||||
$visibility = $artwork->visibility ?: ((bool) $artwork->is_public ? Artwork::VISIBILITY_PUBLIC : Artwork::VISIBILITY_PRIVATE);
|
||||
|
||||
return [
|
||||
|
||||
@@ -211,9 +211,13 @@ final class StudioAiAssistService
|
||||
}
|
||||
|
||||
$categoryId = $this->resolveCategoryId($payload);
|
||||
$secondaryCategoryIds = array_key_exists('secondary_category_ids', $payload)
|
||||
? array_values(array_map('intval', (array) $payload['secondary_category_ids']))
|
||||
: null;
|
||||
|
||||
if ($categoryId !== null) {
|
||||
$artwork->categories()->sync([$categoryId]);
|
||||
app(\App\Services\Artworks\ArtworkCategoryService::class)
|
||||
->sync($artwork, $categoryId, $secondaryCategoryIds, 'ai_applied', false);
|
||||
$artwork->category_source = 'ai_applied';
|
||||
$updated = true;
|
||||
$applied[] = 'category';
|
||||
@@ -294,10 +298,10 @@ final class StudioAiAssistService
|
||||
*/
|
||||
public function payloadFor(Artwork $artwork): array
|
||||
{
|
||||
$artwork->loadMissing(['artworkAiAssist', 'tags', 'categories.contentType']);
|
||||
$artwork->loadMissing(['artworkAiAssist', 'tags', 'categories.contentType', 'primaryCategory.contentType']);
|
||||
$assist = $artwork->artworkAiAssist;
|
||||
|
||||
$primaryCategory = $artwork->categories->first();
|
||||
$primaryCategory = $artwork->resolvedPrimaryCategory();
|
||||
|
||||
if (! $assist) {
|
||||
return [
|
||||
@@ -515,6 +519,13 @@ final class StudioAiAssistService
|
||||
'description' => (string) ($artwork->description ?? ''),
|
||||
'tags' => $artwork->tags->pluck('slug')->values()->all(),
|
||||
'category_id' => $primaryCategory?->id,
|
||||
'primary_category_id' => $primaryCategory?->id,
|
||||
'secondary_category_ids' => $artwork->categories
|
||||
->reject(fn ($category): bool => (int) $category->id === (int) ($primaryCategory?->id ?? 0))
|
||||
->pluck('id')
|
||||
->map(fn ($id): int => (int) $id)
|
||||
->values()
|
||||
->all(),
|
||||
'content_type_id' => $primaryCategory?->contentType?->id,
|
||||
'sources' => [
|
||||
'title' => $artwork->title_source ?: 'manual',
|
||||
@@ -530,6 +541,10 @@ final class StudioAiAssistService
|
||||
*/
|
||||
private function resolveCategoryId(array $payload): ?int
|
||||
{
|
||||
if (isset($payload['primary_category_id']) && $payload['primary_category_id'] !== null) {
|
||||
return (int) $payload['primary_category_id'];
|
||||
}
|
||||
|
||||
if (isset($payload['category_id']) && $payload['category_id'] !== null) {
|
||||
return (int) $payload['category_id'];
|
||||
}
|
||||
|
||||
@@ -49,8 +49,9 @@ final class StudioAiCategoryMapper
|
||||
$selectedCategory,
|
||||
$this->confidenceForModel($categoryScores, $selectedCategory->id),
|
||||
$categoryScores
|
||||
->reject(fn (array $row): bool => (int) $row['model']->id === (int) $selectedCategory->id)
|
||||
->take(3)
|
||||
->reject(fn (array $row): bool => (int) $row['model']->id === (int) $selectedCategory->id
|
||||
|| (int) $row['model']->content_type_id !== (int) $selectedCategory->content_type_id)
|
||||
->take(max(0, (int) config('categories.max_secondary_categories', 5)))
|
||||
->map(fn (array $row): array => $this->serializeCategory($row['model'], $row['confidence']))
|
||||
->all()
|
||||
) : null,
|
||||
@@ -245,6 +246,7 @@ final class StudioAiCategoryMapper
|
||||
'root_category_id' => (int) $rootCategory->id,
|
||||
'sub_category_id' => $category->parent_id ? (int) $category->id : null,
|
||||
'alternatives' => array_values($alternatives),
|
||||
'secondary_categories' => array_values($alternatives),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -117,7 +117,8 @@ final class StudioBulkActionService
|
||||
throw new \InvalidArgumentException('category_id required for change_category');
|
||||
}
|
||||
|
||||
$artwork->categories()->sync([(int) $params['category_id']]);
|
||||
app(\App\Services\Artworks\ArtworkCategoryService::class)
|
||||
->sync($artwork, (int) $params['category_id'], [], 'moderator');
|
||||
}
|
||||
|
||||
private function addTags(Artwork $artwork, array $params): void
|
||||
|
||||
@@ -9,6 +9,7 @@ use App\Jobs\AutoTagArtworkJob;
|
||||
use App\Jobs\DetectArtworkMaturityJob;
|
||||
use App\Jobs\GenerateArtworkEmbeddingJob;
|
||||
use App\Models\Artwork;
|
||||
use App\Services\Moderation\ArtworkUploadPolicy;
|
||||
use App\Models\UploadBatch;
|
||||
use App\Models\UploadBatchItem;
|
||||
use App\Models\User;
|
||||
@@ -392,8 +393,24 @@ final class UploadQueueService
|
||||
}
|
||||
|
||||
$artwork = $item->artwork;
|
||||
$policy = app(ArtworkUploadPolicy::class)->assess($item->user ?? $artwork->user, $artwork);
|
||||
if ($policy['requires_review']) {
|
||||
$artwork->forceFill([
|
||||
'is_public' => false,
|
||||
'is_approved' => false,
|
||||
'artwork_status' => 'review',
|
||||
'approval_source' => null,
|
||||
'moderation_note' => implode(', ', $policy['reasons']),
|
||||
])->saveQuietly();
|
||||
$item->forceFill([
|
||||
'status' => UploadBatchItem::STATUS_NEEDS_REVIEW,
|
||||
'is_ready_to_publish' => false,
|
||||
])->save();
|
||||
return;
|
||||
}
|
||||
$artwork->forceFill([
|
||||
'is_approved' => true,
|
||||
'approval_source' => 'trusted_auto',
|
||||
'visibility' => $artwork->visibility ?: Artwork::VISIBILITY_PUBLIC,
|
||||
])->saveQuietly();
|
||||
|
||||
@@ -428,7 +445,8 @@ final class UploadQueueService
|
||||
]);
|
||||
}
|
||||
|
||||
$item->artwork->categories()->sync([$categoryId]);
|
||||
app(\App\Services\Artworks\ArtworkCategoryService::class)
|
||||
->sync($item->artwork, $categoryId, [], 'user');
|
||||
$this->refreshItem((int) $item->id);
|
||||
}
|
||||
|
||||
@@ -790,4 +808,4 @@ final class UploadQueueService
|
||||
|
||||
return $normalized === '' ? null : Str::limit($normalized, 65535, '');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -95,13 +95,34 @@ final class AiArtworkVectorSearchService
|
||||
*/
|
||||
private function searchMatchesForArtwork(Artwork $artwork, int $limit): array
|
||||
{
|
||||
$this->client->assertCircuitClosed();
|
||||
|
||||
/*
|
||||
* Reuse the artwork's already-indexed Qdrant vector when available.
|
||||
* A missing point is expected for older/rebuilt indexes, so retain the
|
||||
* existing image/CLIP fallback below.
|
||||
*/
|
||||
try {
|
||||
$pointMatches = $this->client->searchByPointId((int) $artwork->id, $limit);
|
||||
|
||||
if ($pointMatches !== null) {
|
||||
return $pointMatches;
|
||||
}
|
||||
} catch (VectorGatewayException $pointFailure) {
|
||||
$this->client->tripIfCircuitWorthy(
|
||||
[$pointFailure],
|
||||
'similar_ai_point',
|
||||
['artwork_id' => (int) $artwork->id],
|
||||
);
|
||||
|
||||
throw $pointFailure;
|
||||
}
|
||||
|
||||
$url = $this->imageUrl->fromArtwork($artwork);
|
||||
if ($url === null || $url === '') {
|
||||
return [];
|
||||
}
|
||||
|
||||
$this->client->assertCircuitClosed();
|
||||
|
||||
$fileFailure = null;
|
||||
$fileGatewayAttempted = false;
|
||||
|
||||
|
||||
@@ -26,14 +26,19 @@ final class ArtworkEmbeddingClient
|
||||
$retries = (int) config('recommendations.embedding.retries', 1);
|
||||
$delay = (int) config('recommendations.embedding.retry_delay_ms', 200);
|
||||
|
||||
$apiKey = trim(
|
||||
(string) config('vision.clip.api_key', '')
|
||||
);
|
||||
|
||||
$response = Http::acceptJson()
|
||||
->withHeaders([
|
||||
'X-API-Key' => $apiKey,
|
||||
])
|
||||
->connectTimeout(max(1, $connectTimeout))
|
||||
->timeout(max(1, $timeout))
|
||||
->retry(max(0, $retries), max(0, $delay), throw: false)
|
||||
->post($url, [
|
||||
'image_url' => $imageUrl,
|
||||
'artwork_id' => $artworkId,
|
||||
'hash' => $sourceHash,
|
||||
'url' => $imageUrl,
|
||||
]);
|
||||
|
||||
if (! $response->ok()) {
|
||||
@@ -53,6 +58,8 @@ final class ArtworkEmbeddingClient
|
||||
|
||||
if (is_array($json) && $this->isNumericVector($json)) {
|
||||
$candidate = $json;
|
||||
} elseif (is_array($json) && isset($json['vector']) && is_array($json['vector'])) {
|
||||
$candidate = $json['vector'];
|
||||
} elseif (is_array($json) && isset($json['embedding']) && is_array($json['embedding'])) {
|
||||
$candidate = $json['embedding'];
|
||||
} elseif (is_array($json) && isset($json['data']['embedding']) && is_array($json['data']['embedding'])) {
|
||||
|
||||
@@ -59,6 +59,31 @@ final class ArtworkVectorIndexService
|
||||
return $payload;
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a vector that has already been generated for this artwork.
|
||||
*
|
||||
* This avoids downloading and embedding the artwork a second time.
|
||||
*
|
||||
* @param array<int, float> $vector
|
||||
* @return array{url: string, metadata: array<string, mixed>}
|
||||
*/
|
||||
public function upsertArtworkVector(Artwork $artwork, array $vector): array
|
||||
{
|
||||
$payload = $this->payloadForArtwork($artwork);
|
||||
|
||||
$this->client->upsertByVector(
|
||||
$vector,
|
||||
(int) $artwork->id,
|
||||
$payload['metadata']
|
||||
);
|
||||
|
||||
$artwork->forceFill([
|
||||
'last_vector_indexed_at' => now(),
|
||||
])->save();
|
||||
|
||||
return $payload;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{contents: string, filename: string}|null
|
||||
*/
|
||||
|
||||
@@ -74,6 +74,43 @@ final class VectorGatewayClient
|
||||
return is_array($json) ? $json : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Store an already-computed vector directly in Qdrant.
|
||||
*
|
||||
* @param array<int, float> $vector
|
||||
* @param array<string, mixed> $metadata
|
||||
*/
|
||||
public function upsertByVector(array $vector, int|string $id, array $metadata = []): array
|
||||
{
|
||||
$response = $this->postJson(
|
||||
$this->request(),
|
||||
$this->url(
|
||||
(string) config(
|
||||
'vision.vector_gateway.upsert_vector_endpoint',
|
||||
'/vectors/upsert/vector'
|
||||
)
|
||||
),
|
||||
[
|
||||
'vector' => array_values($vector),
|
||||
'id' => (string) $id,
|
||||
'metadata' => $metadata,
|
||||
]
|
||||
);
|
||||
|
||||
if ($response->failed()) {
|
||||
throw new RuntimeException(
|
||||
$this->failureMessage(
|
||||
'Vector upsert',
|
||||
$response
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
$json = $response->json();
|
||||
|
||||
return is_array($json) ? $json : [];
|
||||
}
|
||||
|
||||
public function upsertByFileContents(string $contents, string $filename, int|string $id, array $metadata = []): array
|
||||
{
|
||||
$response = $this->request()
|
||||
@@ -224,6 +261,46 @@ final class VectorGatewayClient
|
||||
return is_array($json) ? $json : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Search using an artwork vector that is already stored in Qdrant.
|
||||
*
|
||||
* Returns null when the source point does not exist so the caller may fall
|
||||
* back to the existing image/CLIP search path.
|
||||
*
|
||||
* @return list<array{id: int|string, score: float, metadata: array<string, mixed>}>|null
|
||||
*/
|
||||
public function searchByPointId(int $artworkId, int $limit = 5): ?array
|
||||
{
|
||||
try {
|
||||
$response = $this->postJson(
|
||||
$this->searchRequest(),
|
||||
$this->url(
|
||||
(string) config(
|
||||
'vision.vector_gateway.search_point_endpoint',
|
||||
'/vectors/search/point'
|
||||
)
|
||||
),
|
||||
[
|
||||
'id' => $artworkId,
|
||||
'limit' => $this->clampSearchLimit($limit),
|
||||
]
|
||||
);
|
||||
} catch (Throwable $e) {
|
||||
throw $this->classifyThrowable('search_point', $e);
|
||||
}
|
||||
|
||||
// Missing indexed vector is expected and is not a gateway failure.
|
||||
if ($response->status() === 404) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($response->failed()) {
|
||||
throw $this->classifyHttpFailure('search_point', $response);
|
||||
}
|
||||
|
||||
return $this->extractMatches($response->json());
|
||||
}
|
||||
|
||||
/**
|
||||
* Used by upsert/delete — only ever called from queued/console indexing
|
||||
* jobs, so a more generous budget is fine.
|
||||
|
||||
@@ -608,7 +608,7 @@ final class WorldSubmissionService
|
||||
'thumbnail_url' => $artwork->thumbUrl('md'),
|
||||
'creator_name' => (string) ($artwork->user?->name ?: $artwork->user?->username ?: ''),
|
||||
'meta' => array_values(array_filter([
|
||||
$artwork->categories->first()?->name,
|
||||
$artwork->resolvedPrimaryCategory()?->name,
|
||||
$views > 0 ? number_format($views) . ' views' : null,
|
||||
$artwork->visibility ? Str::headline((string) $artwork->visibility) : null,
|
||||
])),
|
||||
|
||||
@@ -60,6 +60,7 @@ return Application::configure(basePath: dirname(__DIR__))
|
||||
'ensure.onboarding.complete'=> \App\Http\Middleware\EnsureOnboardingComplete::class,
|
||||
'forum.ai.moderation' => \App\Http\Middleware\ForumAIModerationMiddleware::class,
|
||||
'forum.bot.protection' => \App\Http\Middleware\ForumBotProtectionMiddleware::class,
|
||||
'comment.captcha' => \App\Http\Middleware\CommentCaptchaMiddleware::class,
|
||||
'forum.spam.detection' => \App\Http\Middleware\ForumSpamDetectionMiddleware::class,
|
||||
'forum.security.firewall' => \App\Http\Middleware\ForumSecurityFirewallMiddleware::class,
|
||||
'forum.rate_limit' => \App\Http\Middleware\ForumRateLimitMiddleware::class,
|
||||
|
||||
@@ -4,6 +4,7 @@ return [
|
||||
App\Providers\AppServiceProvider::class,
|
||||
App\Providers\AuthServiceProvider::class,
|
||||
App\Providers\HorizonServiceProvider::class,
|
||||
App\Providers\LazyCarbonServiceProvider::class,
|
||||
Klevze\ControlPanel\ServiceProvider::class,
|
||||
cPad\Plugins\Artworks\ServiceProvider::class,
|
||||
cPad\Plugins\Forum\ServiceProvider::class,
|
||||
|
||||
+1
-1
@@ -18,7 +18,7 @@ import "./vendor-tooltip-CIQaDNlG.js";
|
||||
import "node:process";
|
||||
import "node:path";
|
||||
import "node:url";
|
||||
import "./vendor-realtime-Koiu-_pw.js";
|
||||
import "./vendor-realtime-DYEIbD6w.js";
|
||||
import "buffer";
|
||||
import "child_process";
|
||||
import "net";
|
||||
+2
-2
@@ -1,17 +1,17 @@
|
||||
import require$$0 from "util";
|
||||
import stream from "stream";
|
||||
import require$$4 from "https";
|
||||
import require$$5 from "url";
|
||||
import require$$6 from "fs";
|
||||
import require$$1 from "crypto";
|
||||
import require$$4$2 from "assert";
|
||||
import require$$1$1 from "buffer";
|
||||
import require$$2 from "child_process";
|
||||
import require$$4$1 from "events";
|
||||
import require$$8 from "net";
|
||||
import require$$10 from "tls";
|
||||
import { c as commonjsGlobal, g as getDefaultExportFromCjs } from "./vendor-tiptap-DRFaxGEb.js";
|
||||
import require$$4$1 from "events";
|
||||
import require$$3 from "http";
|
||||
import require$$4 from "https";
|
||||
class u {
|
||||
constructor() {
|
||||
this.notificationCreatedEvent = ".Illuminate\\Notifications\\Events\\BroadcastNotificationCreated";
|
||||
@@ -14,10 +14,10 @@
|
||||
"\u0000D:/Sites/Skinbase26/node_modules/nprogress/nprogress.js?commonjs-es-import": [],
|
||||
"\u0000D:/Sites/Skinbase26/node_modules/nprogress/nprogress.js?commonjs-module": [],
|
||||
"\u0000D:/Sites/Skinbase26/node_modules/pusher-js/dist/node/pusher.js?commonjs-es-import": [
|
||||
"/build/assets/vendor-realtime-Koiu-_pw.js"
|
||||
"/build/assets/vendor-realtime-DYEIbD6w.js"
|
||||
],
|
||||
"\u0000D:/Sites/Skinbase26/node_modules/pusher-js/dist/node/pusher.js?commonjs-module": [
|
||||
"/build/assets/vendor-realtime-Koiu-_pw.js"
|
||||
"/build/assets/vendor-realtime-DYEIbD6w.js"
|
||||
],
|
||||
"\u0000D:/Sites/Skinbase26/node_modules/qs/lib/index.js?commonjs-es-import": [],
|
||||
"\u0000D:/Sites/Skinbase26/node_modules/react-dom/cjs/react-dom-client.development.js?commonjs-exports": [],
|
||||
@@ -97,46 +97,46 @@
|
||||
"/build/assets/vendor-tiptap-DRFaxGEb.js"
|
||||
],
|
||||
"\u0000assert?commonjs-external": [
|
||||
"/build/assets/vendor-realtime-Koiu-_pw.js"
|
||||
"/build/assets/vendor-realtime-DYEIbD6w.js"
|
||||
],
|
||||
"\u0000buffer?commonjs-external": [
|
||||
"/build/assets/vendor-realtime-Koiu-_pw.js"
|
||||
"/build/assets/vendor-realtime-DYEIbD6w.js"
|
||||
],
|
||||
"\u0000child_process?commonjs-external": [
|
||||
"/build/assets/vendor-realtime-Koiu-_pw.js"
|
||||
"/build/assets/vendor-realtime-DYEIbD6w.js"
|
||||
],
|
||||
"\u0000commonjsHelpers.js": [
|
||||
"/build/assets/vendor-tiptap-DRFaxGEb.js"
|
||||
],
|
||||
"\u0000crypto?commonjs-external": [
|
||||
"/build/assets/vendor-realtime-Koiu-_pw.js"
|
||||
"/build/assets/vendor-realtime-DYEIbD6w.js"
|
||||
],
|
||||
"\u0000events?commonjs-external": [
|
||||
"/build/assets/vendor-realtime-Koiu-_pw.js"
|
||||
"/build/assets/vendor-realtime-DYEIbD6w.js"
|
||||
],
|
||||
"\u0000fs?commonjs-external": [
|
||||
"/build/assets/vendor-realtime-Koiu-_pw.js"
|
||||
"/build/assets/vendor-realtime-DYEIbD6w.js"
|
||||
],
|
||||
"\u0000http?commonjs-external": [
|
||||
"/build/assets/vendor-realtime-Koiu-_pw.js"
|
||||
"/build/assets/vendor-realtime-DYEIbD6w.js"
|
||||
],
|
||||
"\u0000https?commonjs-external": [
|
||||
"/build/assets/vendor-realtime-Koiu-_pw.js"
|
||||
"/build/assets/vendor-realtime-DYEIbD6w.js"
|
||||
],
|
||||
"\u0000net?commonjs-external": [
|
||||
"/build/assets/vendor-realtime-Koiu-_pw.js"
|
||||
"/build/assets/vendor-realtime-DYEIbD6w.js"
|
||||
],
|
||||
"\u0000stream?commonjs-external": [
|
||||
"/build/assets/vendor-realtime-Koiu-_pw.js"
|
||||
"/build/assets/vendor-realtime-DYEIbD6w.js"
|
||||
],
|
||||
"\u0000tls?commonjs-external": [
|
||||
"/build/assets/vendor-realtime-Koiu-_pw.js"
|
||||
"/build/assets/vendor-realtime-DYEIbD6w.js"
|
||||
],
|
||||
"\u0000url?commonjs-external": [
|
||||
"/build/assets/vendor-realtime-Koiu-_pw.js"
|
||||
"/build/assets/vendor-realtime-DYEIbD6w.js"
|
||||
],
|
||||
"\u0000util?commonjs-external": [
|
||||
"/build/assets/vendor-realtime-Koiu-_pw.js"
|
||||
"/build/assets/vendor-realtime-DYEIbD6w.js"
|
||||
],
|
||||
"node_modules/@emoji-mart/data/sets/15/native.json": [
|
||||
"/build/assets/emoji-data-4xGXbtDn.js"
|
||||
@@ -1035,7 +1035,7 @@
|
||||
"node_modules/inline-style-parser/cjs/index.js": [],
|
||||
"node_modules/is-plain-obj/index.js": [],
|
||||
"node_modules/laravel-echo/dist/echo.js": [
|
||||
"/build/assets/vendor-realtime-Koiu-_pw.js"
|
||||
"/build/assets/vendor-realtime-DYEIbD6w.js"
|
||||
],
|
||||
"node_modules/linkifyjs/dist/linkify.mjs": [
|
||||
"/build/assets/vendor-tiptap-DRFaxGEb.js"
|
||||
@@ -1935,7 +1935,7 @@
|
||||
],
|
||||
"node_modules/proxy-from-env/index.js": [],
|
||||
"node_modules/pusher-js/dist/node/pusher.js": [
|
||||
"/build/assets/vendor-realtime-Koiu-_pw.js"
|
||||
"/build/assets/vendor-realtime-DYEIbD6w.js"
|
||||
],
|
||||
"node_modules/qs/lib/formats.js": [],
|
||||
"node_modules/qs/lib/index.js": [],
|
||||
@@ -2063,6 +2063,7 @@
|
||||
"resources/js/Pages/Admin/Academy/Dashboard.jsx": [],
|
||||
"resources/js/Pages/Admin/Academy/LessonEditor.jsx": [],
|
||||
"resources/js/Pages/Admin/Academy/Submissions.jsx": [],
|
||||
"resources/js/Pages/Admin/Adsense/Index.jsx": [],
|
||||
"resources/js/Pages/Admin/AiBiography.jsx": [],
|
||||
"resources/js/Pages/Admin/Artworks.jsx": [],
|
||||
"resources/js/Pages/Admin/AuthAudit.jsx": [],
|
||||
@@ -2242,6 +2243,7 @@
|
||||
"resources/js/components/achievements/AchievementsList.jsx": [],
|
||||
"resources/js/components/admin/AdminUploadQueue.jsx": [],
|
||||
"resources/js/components/admin/AdminUsernameQueue.jsx": [],
|
||||
"resources/js/components/ads/AdSenseUnit.jsx": [],
|
||||
"resources/js/components/artwork/ArtworkActionBar.jsx": [],
|
||||
"resources/js/components/artwork/ArtworkAwards.jsx": [],
|
||||
"resources/js/components/artwork/ArtworkBreadcrumbs.jsx": [],
|
||||
@@ -2259,7 +2261,7 @@
|
||||
"resources/js/components/artwork/ArtworkRecommendationsRails.jsx": [],
|
||||
"resources/js/components/artwork/ArtworkShareButton.jsx": [],
|
||||
"resources/js/components/artwork/ArtworkShareModal.jsx": [
|
||||
"/build/assets/ArtworkShareModal-BPM8yel5.js"
|
||||
"/build/assets/ArtworkShareModal-BI8kkaqs.js"
|
||||
],
|
||||
"resources/js/components/artwork/ArtworkTags.jsx": [],
|
||||
"resources/js/components/artwork/AuthorBioPopover.jsx": [],
|
||||
@@ -2397,6 +2399,7 @@
|
||||
"resources/js/components/upload/UploadDescriptionEditor.jsx": [],
|
||||
"resources/js/components/upload/UploadDropzone.jsx": [],
|
||||
"resources/js/components/upload/UploadOverlay.jsx": [],
|
||||
"resources/js/components/upload/UploadReviewNotice.jsx": [],
|
||||
"resources/js/components/upload/UploadSidebar.jsx": [],
|
||||
"resources/js/components/upload/UploadWizard.jsx": [],
|
||||
"resources/js/components/upload/steps/Step1FileUpload.jsx": [],
|
||||
@@ -2474,8 +2477,10 @@
|
||||
"resources/js/lib/uploadNotices.js": [],
|
||||
"resources/js/lib/uploadUtils.js": [],
|
||||
"resources/js/lib/useDeferredSimilarAi.js": [],
|
||||
"resources/js/lib/useLazyProfileCollections.js": [],
|
||||
"resources/js/lib/useLazyProfileFavourites.js": [],
|
||||
"resources/js/lib/useLazyProfileFeaturedArtworks.js": [],
|
||||
"resources/js/lib/useLazyProfileWorldHistory.js": [],
|
||||
"resources/js/lib/useNavContext.js": [],
|
||||
"resources/js/lib/worldAnalytics.js": [],
|
||||
"resources/js/ssr.jsx": [],
|
||||
|
||||
+1272
-530
File diff suppressed because one or more lines are too long
+3
-1
@@ -102,7 +102,9 @@
|
||||
},
|
||||
"extra": {
|
||||
"laravel": {
|
||||
"dont-discover": []
|
||||
"dont-discover": [
|
||||
"nesbot/carbon"
|
||||
]
|
||||
}
|
||||
},
|
||||
"config": {
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
return [
|
||||
'client_id' => env('ADSENSE_CLIENT_ID'),
|
||||
'client_secret' => env('ADSENSE_CLIENT_SECRET'),
|
||||
|
||||
'redirect_uri' => env(
|
||||
'ADSENSE_REDIRECT_URI',
|
||||
rtrim((string) env('APP_URL', 'http://localhost'), '/').'/admin/adsense/oauth/callback'
|
||||
),
|
||||
|
||||
'scope' => 'https://www.googleapis.com/auth/adsense.readonly',
|
||||
|
||||
'sync_enabled' => filter_var(env('ADSENSE_SYNC_ENABLED', true), FILTER_VALIDATE_BOOLEAN),
|
||||
|
||||
'api_base' => env('ADSENSE_API_BASE', 'https://adsense.googleapis.com/v2'),
|
||||
'oauth_authorize_url' => env('ADSENSE_OAUTH_AUTHORIZE_URL', 'https://accounts.google.com/o/oauth2/v2/auth'),
|
||||
'oauth_token_url' => env('ADSENSE_OAUTH_TOKEN_URL', 'https://oauth2.googleapis.com/token'),
|
||||
'oauth_revoke_url' => env('ADSENSE_OAUTH_REVOKE_URL', 'https://oauth2.googleapis.com/revoke'),
|
||||
|
||||
'timeout' => max(5, (int) env('ADSENSE_HTTP_TIMEOUT', 20)),
|
||||
'connect_timeout' => max(1, (int) env('ADSENSE_HTTP_CONNECT_TIMEOUT', 5)),
|
||||
'retry_times' => max(1, (int) env('ADSENSE_HTTP_RETRY_TIMES', 3)),
|
||||
'retry_sleep_ms' => max(0, (int) env('ADSENSE_HTTP_RETRY_SLEEP_MS', 400)),
|
||||
|
||||
'access_token_cache_prefix' => 'adsense.access_token.',
|
||||
'access_token_skew_seconds' => 60,
|
||||
|
||||
// Ad clients whose productCode is set to something else (YouTube host, video, search)
|
||||
// are skipped. Empty productCode still syncs unless the resource name is excluded.
|
||||
'inventory_product_codes' => ['AFC'],
|
||||
'inventory_excluded_client_prefixes' => [
|
||||
'ca-yt-host-',
|
||||
],
|
||||
|
||||
'metrics' => [
|
||||
'ESTIMATED_EARNINGS',
|
||||
'PAGE_VIEWS',
|
||||
'PAGE_VIEWS_CTR',
|
||||
'PAGE_VIEWS_RPM',
|
||||
'AD_REQUESTS',
|
||||
'AD_REQUESTS_COVERAGE',
|
||||
'AD_REQUESTS_CTR',
|
||||
'AD_REQUESTS_RPM',
|
||||
'MATCHED_AD_REQUESTS',
|
||||
'IMPRESSIONS',
|
||||
'IMPRESSIONS_CTR',
|
||||
'IMPRESSIONS_RPM',
|
||||
'CLICKS',
|
||||
'COST_PER_CLICK',
|
||||
],
|
||||
|
||||
'recommended_placement_names' => [
|
||||
'Skinbase_Artwork_BeforeComments',
|
||||
'Skinbase_Home_AfterTrending',
|
||||
'Skinbase_Home_AfterFresh',
|
||||
'Skinbase_Browse_InFeed',
|
||||
'Skinbase_Search_InFeed',
|
||||
'Skinbase_News_InArticle',
|
||||
],
|
||||
];
|
||||
@@ -0,0 +1,7 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
return [
|
||||
'max_secondary_categories' => max(0, (int) env('CATEGORIES_MAX_SECONDARY', 5)),
|
||||
];
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
'enabled' => (bool) env('COMMENT_SPAM_ENABLED', true),
|
||||
'mode' => env('COMMENT_SPAM_MODE', 'enforce'), // observe|enforce|off
|
||||
'ai_enabled' => (bool) env('COMMENT_SPAM_AI_ENABLED', true),
|
||||
'ai' => [
|
||||
'provider' => env('COMMENT_SPAM_AI_PROVIDER', 'together'),
|
||||
'api_key' => env('TOGETHER_API_KEY'),
|
||||
'base_url' => env('TOGETHER_API_BASE_URL', 'https://api.together.xyz/v1'),
|
||||
'model' => env('COMMENT_SPAM_AI_MODEL', env('TOGETHER_MODEL', 'meta-llama/Llama-3.2-3B-Instruct-Turbo')),
|
||||
'allowed_models' => [
|
||||
'Prism-ML/Ternary-Bonsai-27B',
|
||||
'meta-llama/Llama-3.2-3B-Instruct-Turbo',
|
||||
],
|
||||
'timeout' => min(5, max(1, (int) env('COMMENT_SPAM_AI_TIMEOUT', 5))),
|
||||
'max_input_chars' => min(2500, max(100, (int) env('COMMENT_SPAM_AI_MAX_INPUT_CHARS', 2500))),
|
||||
'spam_confidence' => (float) env('COMMENT_SPAM_AI_SPAM_CONFIDENCE', 0.90),
|
||||
],
|
||||
'local_safe_max' => (int) env('COMMENT_SPAM_LOCAL_SAFE_MAX', 29),
|
||||
'local_spam_min' => (int) env('COMMENT_SPAM_LOCAL_SPAM_MIN', 70),
|
||||
'ai_spam_min' => (int) env('COMMENT_SPAM_AI_SPAM_MIN', 70),
|
||||
'signature_cache_minutes' => (int) env('COMMENT_SPAM_SIGNATURE_CACHE_MINUTES', 1440),
|
||||
'scanner_version' => env('COMMENT_SPAM_SCANNER_VERSION', 'comment-v1'),
|
||||
'captcha' => [
|
||||
'enabled' => (bool) env('COMMENT_TURNSTILE_ENABLED', false),
|
||||
'site_key' => env('COMMENT_TURNSTILE_SITE_KEY', ''),
|
||||
'secret_key' => env('COMMENT_TURNSTILE_SECRET_KEY', ''),
|
||||
'threshold' => (int) env('COMMENT_TURNSTILE_RISK_THRESHOLD', 40),
|
||||
'fail_open' => (bool) env('COMMENT_TURNSTILE_FAIL_OPEN', false),
|
||||
'script_url' => env('COMMENT_TURNSTILE_SCRIPT_URL', 'https://challenges.cloudflare.com/turnstile/v0/api.js'),
|
||||
'verify_url' => env('COMMENT_TURNSTILE_VERIFY_URL', 'https://challenges.cloudflare.com/turnstile/v0/siteverify'),
|
||||
],
|
||||
];
|
||||
@@ -9,6 +9,10 @@ return [
|
||||
|
||||
'readonly_backup_originals_root' => env('ARTWORKS_READONLY_BACKUP_ORIGINALS_ROOT', '/opt/www/virtual/files/cdn/artworks/original'),
|
||||
|
||||
// Optional filename-based legacy tree. The readonly backup root above is
|
||||
// hash-based and must never be treated as this legacy tree.
|
||||
'legacy_originals_root' => env('ARTWORKS_LEGACY_ORIGINALS_ROOT', ''),
|
||||
|
||||
'object_storage' => [
|
||||
'disk' => env('ARTWORKS_OBJECT_DISK', 's3'),
|
||||
'prefix' => env('ARTWORKS_OBJECT_PREFIX', 'artworks'),
|
||||
@@ -154,4 +158,16 @@ return [
|
||||
'enabled' => env('UPLOAD_SCAN_ENABLED', false),
|
||||
'command' => env('UPLOAD_SCAN_COMMAND', []),
|
||||
],
|
||||
|
||||
'moderation' => [
|
||||
'minimum_account_age_days' => (int) env('UPLOAD_MIN_ACCOUNT_AGE_DAYS', 7),
|
||||
'minimum_approved_uploads' => (int) env('UPLOAD_MIN_APPROVED_UPLOADS', 5),
|
||||
'maximum_untrusted_level' => (int) env('UPLOAD_MAX_UNTRUSTED_LEVEL', 1),
|
||||
'bot_risk_review_threshold' => (int) env('UPLOAD_BOT_RISK_REVIEW_THRESHOLD', 40),
|
||||
'notify_email' => env('UPLOAD_MODERATION_NOTIFY_EMAIL', env('SECURITY_REPORT_NOTIFY_EMAIL', env('MAIL_FROM_ADDRESS'))),
|
||||
'promotional_patterns' => [
|
||||
'buy now', 'best price', 'limited offer', 'contact us', 'whatsapp',
|
||||
'casino', 'cheap backlinks', 'seo service', 'promo code', 'discount',
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
@@ -98,7 +98,9 @@ return [
|
||||
'circuit_breaker_seconds' => (int) env('VISION_VECTOR_GATEWAY_CIRCUIT_SECONDS', 30),
|
||||
'upsert_endpoint' => env('VISION_VECTOR_GATEWAY_UPSERT_ENDPOINT', '/vectors/upsert'),
|
||||
'upsert_file_endpoint' => env('VISION_VECTOR_GATEWAY_UPSERT_FILE_ENDPOINT', '/vectors/upsert/file'),
|
||||
'upsert_vector_endpoint' => env('VISION_VECTOR_GATEWAY_UPSERT_VECTOR_ENDPOINT', '/vectors/upsert/vector'),
|
||||
'search_endpoint' => env('VISION_VECTOR_GATEWAY_SEARCH_ENDPOINT', '/vectors/search'),
|
||||
'search_point_endpoint' => env('VISION_VECTOR_GATEWAY_SEARCH_POINT_ENDPOINT', '/vectors/search/point'),
|
||||
'search_file_endpoint' => env('VISION_VECTOR_GATEWAY_SEARCH_FILE_ENDPOINT', '/vectors/search/file'),
|
||||
'delete_endpoint' => env('VISION_VECTOR_GATEWAY_DELETE_ENDPOINT', '/vectors/delete'),
|
||||
'collections_endpoint' => env('VISION_VECTOR_GATEWAY_COLLECTIONS_ENDPOINT', '/vectors/collections'),
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration {
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('artworks', function (Blueprint $table): void {
|
||||
$table->string('download_status', 16)->default('unknown')->after('file_path');
|
||||
$table->string('download_source', 16)->nullable()->after('download_status');
|
||||
$table->timestamp('download_checked_at')->nullable()->after('download_source');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('artworks', function (Blueprint $table): void {
|
||||
$table->dropColumn(['download_status', 'download_source', 'download_checked_at']);
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration {
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('artworks', function (Blueprint $table): void {
|
||||
$table->string('approval_source', 24)->nullable()->after('is_approved')->index();
|
||||
$table->timestamp('moderated_at')->nullable()->after('approval_source');
|
||||
$table->unsignedBigInteger('moderated_by')->nullable()->after('moderated_at')->index();
|
||||
$table->string('moderation_note', 1000)->nullable()->after('moderated_by');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('artworks', function (Blueprint $table): void {
|
||||
$table->dropIndex(['approval_source']);
|
||||
$table->dropIndex(['moderated_by']);
|
||||
$table->dropColumn(['approval_source', 'moderated_at', 'moderated_by', 'moderation_note']);
|
||||
});
|
||||
}
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user