Compare commits

..
19 Commits
Author SHA1 Message Date
test 7121c3ba27 Rebuild Inertia SSR assets after the upload and artwork UI changes.
Replace hashed SSR bundles so server-rendered pages match the current frontend.
2026-09-20 14:51:12 +02:00
test 0b1938edf9 Update the optimization roadmap through the latest milestones.
Keep the production optimization plan aligned with the work already landed on develop.
2026-09-20 14:51:12 +02:00
test 9c537e08d0 Wire new routes and environment keys for the recent modules.
Register AdSense, artwork review, comment captcha, lazy collections, and upload review props, and document the matching env vars and CLI commands.
2026-09-20 14:51:04 +02:00
test 39819a85cb Expand profile gender options and cover country plus activity persistence.
Allow the broader gender enum and add tests for country saves and discovery event recording.
2026-09-20 14:50:59 +02:00
test 4479d81253 Harden legacy user profile lookups.
Keep the old user controller aligned with current profile fields and safer query handling.
2026-09-20 14:50:59 +02:00
test a138a3e404 Tighten artwork hourly snapshot window queries.
Reduce snapshot work to the active hour window so metrics aggregation stays cheaper on the hot path.
2026-09-20 14:50:59 +02:00
test dae5a49c4b Limit anonymous community activity queries to the latest page.
Skip unused activity sources and historical over-fetching for guests while keeping authenticated feeds filterable.
2026-09-20 14:50:58 +02:00
test c247b8f652 Warn newer creators that uploads go through review first.
Show a level-based notice on /upload without revealing the internal approved-artwork threshold, and mention the usual 48-hour review window.
2026-09-20 14:50:39 +02:00
test 232880e01f Allow artworks to have one primary category and extra secondaries.
Keep artwork_category as membership, store a deterministic primary_category_id, and let upload plus studio pick up to five additional same-type categories without changing canonical URLs.
2026-09-20 14:50:12 +02:00
test 4d60a876af Add a read-only Google AdSense analytics module for admins.
Connect AdSense over OAuth, sync entities and daily reports locally, and show placement performance in the admin panel without calling the Management API from public pages.
2026-09-20 14:49:48 +02:00
test 17df4d0ad7 Keep AdSense rendering limited to anonymous visitors.
Show guest-only ad units on public pages and skip the AdSense script entirely for signed-in users.
2026-09-20 14:49:34 +02:00
test 37100ced3f Lazy-load profile collections until the tab is opened.
Keep the profile payload smaller on first paint and fetch collections through a dedicated API when the collections tab is needed.
2026-09-20 14:49:33 +02:00
test 1d18052fde Extract related artwork lookup into a dedicated service.
Reuse the same related-artwork ranking on similar-art pages and keep category and tag matches from duplicating results.
2026-09-20 14:49:32 +02:00
test 62aaa2f704 Harden vision vector search and embedding jobs.
Add point search, circuit-breaking, and safer gateway limits so artwork similarity lookups fail closed instead of hanging the request path.
2026-09-20 14:49:19 +02:00
test 455b514d50 Track original artwork file availability more accurately.
Resolve local and object-storage originals, persist download status, and audit missing files without guessing from a single path.
2026-09-20 14:49:19 +02:00
test 366dbf1de8 Send new artwork uploads through a trust-based review policy.
Require review for untrusted accounts, add admin artwork review APIs, and keep queued or auto-trusted publishes from counting as established history.
2026-09-20 14:49:06 +02:00
test 5437f84c4c Move model observers onto attributes and tidy provider bindings.
Register observers with ObservedBy, bind the comment spam classifier, and keep AppServiceProvider focused on application wiring.
2026-09-20 14:49:00 +02:00
test 3078319f3c Add comment spam classification and captcha checks.
Score artwork comments with local signatures plus Together AI, and optionally require Turnstile before posting.
2026-09-20 14:48:50 +02:00
test 5c705c6490 Defer Carbon package discovery until it is needed.
Keep Laravel from auto-discovering nesbot/carbon so the app can boot the lazy Carbon provider instead.
2026-09-20 14:48:37 +02:00
170 changed files with 15084 additions and 2108 deletions
+37
View File
@@ -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.
+137
View File
@@ -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] ?? [],
];
+10 -5
View File
@@ -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 [
+150 -89
View File
@@ -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,
+65 -28
View File
@@ -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'),
+242 -182
View File
@@ -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
}
});
}
}
}
+121 -103
View File
@@ -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')
+167 -13
View File
@@ -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;
+6 -8
View File
@@ -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,
+56
View File
@@ -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,
);
}
}
+80
View File
@@ -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();
}
}
+68
View File
@@ -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',
];
}
}
+33
View File
@@ -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
View File
@@ -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,
+7 -4
View File
@@ -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);
}
/**
+82 -67
View File
@@ -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);
}
}
+12 -9
View File
@@ -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 ──────────────────────────────────────────────────────────
+4
View File
@@ -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;
+8 -5
View File
@@ -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',
];
+22
View File
@@ -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',
];
}
+6 -5
View File
@@ -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, '/');
}
}
+19 -2
View File
@@ -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 -1
View File
@@ -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);
}
}
}
+28 -1
View File
@@ -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');
}
}
}
+13
View File
@@ -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.');
}
}
+248 -223
View File
@@ -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;
}
}
+350
View File
@@ -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 === [];
}
}
+358
View File
@@ -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 {}
+129 -4
View File
@@ -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;
}
}
+163
View File
@@ -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) {
+2 -2
View File
@@ -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();
+2 -2
View File
@@ -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', [
+16 -4
View File
@@ -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;
+61 -12
View File
@@ -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
+21 -4
View File
@@ -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),
);
}
}
+54
View File
@@ -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) {
+1 -1
View File
@@ -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 [
+18 -3
View File
@@ -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
+20 -2
View File
@@ -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;
+10 -3
View File
@@ -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,
])),
+1
View File
@@ -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,
+1
View File
@@ -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,
@@ -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";
@@ -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";
+23 -18
View File
@@ -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
View File
File diff suppressed because one or more lines are too long
+3 -1
View File
@@ -102,7 +102,9 @@
},
"extra": {
"laravel": {
"dont-discover": []
"dont-discover": [
"nesbot/carbon"
]
}
},
"config": {
+63
View File
@@ -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',
],
];
+7
View File
@@ -0,0 +1,7 @@
<?php
declare(strict_types=1);
return [
'max_secondary_categories' => max(0, (int) env('CATEGORIES_MAX_SECONDARY', 5)),
];
+34
View File
@@ -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'),
],
];
+16
View File
@@ -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',
],
],
];
+2
View File
@@ -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