Compare commits

...
11 Commits
Author SHA1 Message Date
klevze 771f9f4350 Document M1-M18 production optimization status.
Record what landed, what is in flight, and what remains so later deploys can pick up the plan without archaeology.
2026-08-29 12:25:50 +02:00
klevze 4c409ceb81 Rebuild Inertia SSR artifacts after deferred artwork UI.
Keep bootstrap/ssr in sync with the client Vite build shipped in this stack.
2026-08-29 12:25:50 +02:00
klevze ab8fa6d845 Fix Windows/WSL production deploy hang and permissions.
Skip slow Git worktree walks on /mnt, run Vite on Windows npm, reuse Windows SSH keys, and stop rewriting the klevze-owned public app symlink that skinbase cannot replace.
2026-08-29 12:25:50 +02:00
klevze bf9ca12469 Add a public profile followers tab.
Expose follower lists on public profiles with a dedicated tab and API path instead of only showing counts.
2026-08-29 12:25:49 +02:00
klevze 1d350c9bca Defer similar-AI recommendations until the rail is near view.
ArtworkPage already imports this hook; ship the implementation so similar-AI work stays off the initial render.
2026-08-29 12:25:49 +02:00
klevze 437d943827 Prefetch artwork prev/next navigation off the critical path.
Move neighbor lookup into a dedicated service and let the viewer load adjacent artworks after first paint instead of blocking the detail request.
2026-08-29 12:25:49 +02:00
klevze bf902f509a Batch-load comment reaction counts on artwork comments.
Avoid per-comment reaction queries on the deferred comments API by aggregating counts in one service call.
2026-08-29 12:25:37 +02:00
klevze bfcbfec357 Cache Schema::hasTable lookups on artwork JSON.
Artwork detail was paying repeated information_schema hits per request. Keep public detail edge-case behavior covered by regression tests.
2026-08-29 12:25:37 +02:00
klevze b9beb8f5c5 Speed legacy photo serving and skip tracking on download routes.
Tighten thumbnail/legacy photo handling and exclude download/photo traffic from session visitor tracking so those hot paths stay cheap.
2026-08-29 12:25:37 +02:00
klevze e9cf754b37 Enable nginx X-Accel for original artwork downloads.
Hand originals to nginx after auth so PHP is not in the byte path. Keep DOWNLOAD_ACCEL_ENABLED off until the internal location is verified.
2026-08-29 12:25:37 +02:00
klevze fb560fb7dd Add HTTP performance log analyzer (M12.6).
Parse rotated nginx JSON access logs into host/route timings so production hotspots can be measured without ad-hoc one-off scripts.
2026-08-29 12:25:27 +02:00
63 changed files with 6328 additions and 518 deletions
+5
View File
@@ -4,6 +4,11 @@ APP_KEY=
APP_DEBUG=true APP_DEBUG=true
APP_URL=http://localhost APP_URL=http://localhost
# 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
DOWNLOAD_ACCEL_PATH=/internal/originals
SECURITY_REPORT_ENABLED=true SECURITY_REPORT_ENABLED=true
SECURITY_REPORT_NOTIFY_EMAIL= SECURITY_REPORT_NOTIFY_EMAIL=
SECURITY_REPORT_SCAN_NPM=true SECURITY_REPORT_SCAN_NPM=true
+2
View File
@@ -13,6 +13,7 @@
/.deploy /.deploy
/.zed /.zed
/auth.json /auth.json
/build-info.json
/node_modules /node_modules
/public/build /public/build
/public/hot /public/hot
@@ -45,6 +46,7 @@
/storage/*.tar.xz /storage/*.tar.xz
/storage/*.tar /storage/*.tar
/storage/*.tgz /storage/*.tgz
/var/deploy/
/vendor /vendor
Homestead.json Homestead.json
Homestead.yaml Homestead.yaml
@@ -11,6 +11,7 @@ use App\Models\UserMention;
use App\Notifications\ArtworkCommentedNotification; use App\Notifications\ArtworkCommentedNotification;
use App\Notifications\ArtworkMentionedNotification; use App\Notifications\ArtworkMentionedNotification;
use App\Services\ContentSanitizer; use App\Services\ContentSanitizer;
use App\Services\CommentReactionService;
use App\Support\AvatarUrl; use App\Support\AvatarUrl;
use Carbon\Carbon; use Carbon\Carbon;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
@@ -30,6 +31,8 @@ class ArtworkCommentController extends Controller
{ {
private const MAX_LENGTH = 10_000; private const MAX_LENGTH = 10_000;
public function __construct(private readonly CommentReactionService $commentReactions) {}
// ───────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────
// List // List
// ───────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────
@@ -53,7 +56,9 @@ class ArtworkCommentController extends Controller
->paginate($perPage, ['*'], 'page', $page); ->paginate($perPage, ['*'], 'page', $page);
$userId = $request->user()?->id; $userId = $request->user()?->id;
$items = $comments->getCollection()->map(fn ($c) => $this->formatComment($c, $userId, true)); $commentIds = $this->commentIds($comments->getCollection());
$reactionTotals = $this->commentReactions->forComments($commentIds, $userId);
$items = $comments->getCollection()->map(fn ($c) => $this->formatComment($c, $userId, true, $reactionTotals));
return response()->json([ return response()->json([
'data' => $items, 'data' => $items,
@@ -138,7 +143,9 @@ class ArtworkCommentController extends Controller
); );
} catch (\Throwable) {} } catch (\Throwable) {}
return response()->json(['data' => $this->formatComment($comment, $request->user()->id, false)], 201); $reactionTotals = $this->commentReactions->forComments([$comment->id], $request->user()->id);
return response()->json(['data' => $this->formatComment($comment, $request->user()->id, false, $reactionTotals)], 201);
} }
// ───────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────
@@ -197,7 +204,7 @@ class ArtworkCommentController extends Controller
// Helpers // Helpers
// ───────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────
private function formatComment(ArtworkComment $c, ?int $currentUserId, bool $includeReplies = false): array private function formatComment(ArtworkComment $c, ?int $currentUserId, bool $includeReplies = false, array $reactionTotals = []): array
{ {
$user = $c->user; $user = $c->user;
$userId = (int) ($c->user_id ?? 0); $userId = (int) ($c->user_id ?? 0);
@@ -221,12 +228,13 @@ class ArtworkCommentController extends Controller
'level' => (int) ($user?->level ?? 1), 'level' => (int) ($user?->level ?? 1),
'rank' => (string) ($user?->rank ?? 'Newbie'), 'rank' => (string) ($user?->rank ?? 'Newbie'),
], ],
'reactions' => $reactionTotals[(int) $c->id] ?? [],
]; ];
if ($includeReplies && $c->relationLoaded('approvedReplies')) { if ($includeReplies && $c->relationLoaded('approvedReplies')) {
$data['replies'] = $c->approvedReplies->map(fn ($r) => $this->formatComment($r, $currentUserId, true))->values()->toArray(); $data['replies'] = $c->approvedReplies->map(fn ($r) => $this->formatComment($r, $currentUserId, true, $reactionTotals))->values()->toArray();
} elseif ($includeReplies && $c->relationLoaded('replies')) { } elseif ($includeReplies && $c->relationLoaded('replies')) {
$data['replies'] = $c->replies->map(fn ($r) => $this->formatComment($r, $currentUserId, true))->values()->toArray(); $data['replies'] = $c->replies->map(fn ($r) => $this->formatComment($r, $currentUserId, true, $reactionTotals))->values()->toArray();
} else { } else {
$data['replies'] = []; $data['replies'] = [];
} }
@@ -234,6 +242,24 @@ class ArtworkCommentController extends Controller
return $data; return $data;
} }
/** @param iterable<int, ArtworkComment> $comments */
private function commentIds(iterable $comments): array
{
$ids = [];
$walk = function (iterable $items) use (&$walk, &$ids): void {
foreach ($items as $comment) {
$ids[] = (int) $comment->id;
if ($comment->relationLoaded('approvedReplies')) {
$walk($comment->approvedReplies);
}
}
};
$walk($comments);
return array_values(array_unique($ids));
}
private function renderCommentContent(ArtworkComment $comment): string private function renderCommentContent(ArtworkComment $comment): string
{ {
$rawContent = (string) ($comment->raw_content ?? $comment->content ?? ''); $rawContent = (string) ($comment->raw_content ?? $comment->content ?? '');
@@ -5,11 +5,15 @@ namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Http\Resources\ArtworkResource; use App\Http\Resources\ArtworkResource;
use App\Models\Artwork; use App\Models\Artwork;
use App\Services\ArtworkNavigationService;
use App\Services\ThumbnailPresenter;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Support\Str; use Illuminate\Http\Request;
class ArtworkNavigationController extends Controller class ArtworkNavigationController extends Controller
{ {
public function __construct(private readonly ArtworkNavigationService $navigation) {}
/** /**
* GET /api/artworks/navigation/{id} * GET /api/artworks/navigation/{id}
* *
@@ -26,35 +30,11 @@ class ArtworkNavigationController extends Controller
'prev_id' => null, 'next_id' => null, 'prev_id' => null, 'next_id' => null,
'prev_url' => null, 'next_url' => null, 'prev_url' => null, 'next_url' => null,
'prev_slug' => null, 'next_slug' => null, 'prev_slug' => null, 'next_slug' => null,
'prev_preview' => null, 'next_preview' => null,
]); ]);
} }
$scope = Artwork::published() return response()->json($this->navigation->navigationFor($artwork));
->select(['id', 'title', 'slug'])
->where('user_id', $artwork->user_id);
$prev = (clone $scope)->where('id', '<', $id)->orderByDesc('id')->first();
$next = (clone $scope)->where('id', '>', $id)->orderBy('id')->first();
// Infinite loop: wrap around when reaching the first or last artwork
if (! $prev) {
$prev = (clone $scope)->where('id', '!=', $id)->orderByDesc('id')->first();
}
if (! $next) {
$next = (clone $scope)->where('id', '!=', $id)->orderBy('id')->first();
}
$prevSlug = $prev ? (Str::slug($prev->slug ?: $prev->title) ?: (string) $prev->id) : null;
$nextSlug = $next ? (Str::slug($next->slug ?: $next->title) ?: (string) $next->id) : null;
return response()->json([
'prev_id' => $prev?->id,
'next_id' => $next?->id,
'prev_url' => $prev ? url('/art/' . $prev->id . '/' . $prevSlug) : null,
'next_url' => $next ? url('/art/' . $next->id . '/' . $nextSlug) : null,
'prev_slug' => $prevSlug,
'next_slug' => $nextSlug,
]);
} }
/** /**
@@ -62,8 +42,12 @@ class ArtworkNavigationController extends Controller
* *
* Returns full artwork resource by numeric ID for client-side (no-reload) navigation. * Returns full artwork resource by numeric ID for client-side (no-reload) navigation.
*/ */
public function pageData(int $id): JsonResponse public function pageData(Request $request, int $id): JsonResponse
{ {
if ($request->boolean('prefetch')) {
return $this->prefetchData($id);
}
$artwork = Artwork::with(['user.profile', 'categories.contentType', 'categories.parent.contentType', 'tags', 'stats']) $artwork = Artwork::with(['user.profile', 'categories.contentType', 'categories.parent.contentType', 'tags', 'stats'])
->published() ->published()
->find($id); ->find($id);
@@ -73,7 +57,30 @@ class ArtworkNavigationController extends Controller
} }
$resource = (new ArtworkResource($artwork))->toArray(request()); $resource = (new ArtworkResource($artwork))->toArray(request());
$resource['navigation'] = $this->navigation->navigationFor($artwork);
return response()->json($resource); return response()->json($resource);
} }
private function prefetchData(int $id): JsonResponse
{
$artwork = Artwork::query()
->published()
->select(['id', 'title', 'slug', 'hash', 'file_path', 'file_name'])
->find($id);
if (! $artwork) {
return response()->json(['error' => 'Not found'], 404);
}
return response()->json([
'id' => (int) $artwork->id,
'title' => (string) $artwork->title,
'slug' => (string) $artwork->slug,
'thumbs' => [
'md' => ThumbnailPresenter::present($artwork, 'md'),
'lg' => ThumbnailPresenter::present($artwork, 'lg'),
],
]);
}
} }
+20 -40
View File
@@ -5,12 +5,10 @@ namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Models\User; use App\Models\User;
use App\Services\FollowService; use App\Services\FollowService;
use App\Support\AvatarUrl;
use App\Support\UsernamePolicy; use App\Support\UsernamePolicy;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
/** /**
* API endpoints for the follow system. * API endpoints for the follow system.
@@ -72,26 +70,17 @@ final class FollowController extends Controller
{ {
$target = $this->resolveUser($username); $target = $this->resolveUser($username);
$perPage = min((int) $request->query('per_page', 24), 100); $perPage = min((int) $request->query('per_page', 24), 100);
$page = max(1, (int) $request->query('page', 1));
$rows = DB::table('user_followers as uf') $rows = $this->followService
->join('users as u', 'u.id', '=', 'uf.follower_id') ->paginatedFollowers((int) $target->id, $perPage, $page)
->leftJoin('user_profiles as up', 'up.user_id', '=', 'u.id') ->through(fn (array $row) => [
->where('uf.user_id', $target->id) 'id' => $row['id'],
->whereNull('u.deleted_at') 'username' => $row['username'],
->orderByDesc('uf.created_at') 'display_name' => $row['display_name'],
->select([ 'avatar_url' => $row['avatar_url'],
'u.id', 'u.username', 'u.name', 'profile_url' => $row['profile_url'],
'up.avatar_hash', 'followed_at' => $row['followed_at'],
'uf.created_at as followed_at',
])
->paginate($perPage)
->through(fn ($row) => [
'id' => $row->id,
'username' => $row->username,
'display_name'=> $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,
]); ]);
return response()->json($rows); return response()->json($rows);
@@ -103,26 +92,17 @@ final class FollowController extends Controller
{ {
$target = $this->resolveUser($username); $target = $this->resolveUser($username);
$perPage = min((int) $request->query('per_page', 24), 100); $perPage = min((int) $request->query('per_page', 24), 100);
$page = max(1, (int) $request->query('page', 1));
$rows = DB::table('user_followers as uf') $rows = $this->followService
->join('users as u', 'u.id', '=', 'uf.user_id') ->paginatedFollowing((int) $target->id, $perPage, $page)
->leftJoin('user_profiles as up', 'up.user_id', '=', 'u.id') ->through(fn (array $row) => [
->where('uf.follower_id', $target->id) 'id' => $row['id'],
->whereNull('u.deleted_at') 'username' => $row['username'],
->orderByDesc('uf.created_at') 'display_name' => $row['display_name'],
->select([ 'avatar_url' => $row['avatar_url'],
'u.id', 'u.username', 'u.name', 'profile_url' => $row['profile_url'],
'up.avatar_hash', 'followed_at' => $row['followed_at'],
'uf.created_at as followed_at',
])
->paginate($perPage)
->through(fn ($row) => [
'id' => $row->id,
'username' => $row->username,
'display_name'=> $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,
]); ]);
return response()->json($rows); return response()->json($rows);
@@ -48,14 +48,14 @@ final class ArtworkDownloadController extends Controller
$artwork = Artwork::query()->find($id); $artwork = Artwork::query()->find($id);
if (! $artwork) { if (! $artwork) {
abort(404); return $this->notFound();
} }
$filePath = $this->originalFiles->resolveLocalPath($artwork); $filePath = $this->originalFiles->resolveLocalPath($artwork);
$ext = strtolower(ltrim((string) pathinfo($filePath, PATHINFO_EXTENSION), '.')); $ext = strtolower(ltrim((string) pathinfo($filePath, PATHINFO_EXTENSION), '.'));
if ($filePath === '' || ! in_array($ext, self::ALLOWED_EXTENSIONS, true)) { if ($filePath === '' || ! in_array($ext, self::ALLOWED_EXTENSIONS, true)) {
abort(404); return $this->notFound();
} }
if (! File::isFile($filePath)) { if (! File::isFile($filePath)) {
@@ -65,7 +65,7 @@ final class ArtworkDownloadController extends Controller
'resolved_path' => $filePath, 'resolved_path' => $filePath,
]); ]);
abort(404); return $this->notFound();
} }
$this->recordDownload($request, $artwork->id); $this->recordDownload($request, $artwork->id);
@@ -98,6 +98,14 @@ final class ArtworkDownloadController extends Controller
return response()->download($filePath, $downloadName); return response()->download($filePath, $downloadName);
} }
private function notFound(): Response
{
return response('', 404, [
'Content-Type' => 'text/plain; charset=UTF-8',
'Cache-Control' => 'no-store',
]);
}
private function resolveAccelUri(string $filePath): ?string private function resolveAccelUri(string $filePath): ?string
{ {
if (! config('app.download_accel_enabled')) { if (! config('app.download_accel_enabled')) {
@@ -8,6 +8,7 @@ use App\Http\Controllers\Controller;
use App\Models\Artwork; use App\Models\Artwork;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\Schema; use Illuminate\Support\Facades\Schema;
final class LegacyArtworkPhotoController extends Controller final class LegacyArtworkPhotoController extends Controller
@@ -26,25 +27,39 @@ final class LegacyArtworkPhotoController extends Controller
private static ?bool $hasLegacyIdColumn = null; private static ?bool $hasLegacyIdColumn = null;
public function __invoke(string $encoded, string $size, string $extension): RedirectResponse public function __invoke(string $encoded, string $size, string $extension): RedirectResponse|Response
{ {
$artworkId = $this->decodeBase62($encoded); $artworkId = $this->decodeBase62($encoded);
$sizeCode = (int) $size; $sizeCode = (int) $size;
abort_if($artworkId === null || $artworkId < 1, 404); if ($artworkId === null || $artworkId < 1) {
return $this->notFound();
}
$artwork = $this->resolveArtwork($artworkId); $artwork = $this->resolveArtwork($artworkId);
abort_unless($artwork !== null, 404); if ($artwork === null) {
return $this->notFound();
}
$targetUrl = $sizeCode === 7 $targetUrl = $sizeCode === 7
? $this->resolveOriginalUrl($artwork) ? $this->resolveOriginalUrl($artwork)
: $artwork->thumbUrl(self::THUMB_SIZE_MAP[$sizeCode] ?? 'md'); : $artwork->thumbUrl(self::THUMB_SIZE_MAP[$sizeCode] ?? 'md');
abort_if(empty($targetUrl), 404); if ($targetUrl === null || $targetUrl === '') {
return $this->notFound();
}
return redirect()->away($targetUrl, 301); return redirect()->away($targetUrl, 301);
} }
private function notFound(): Response
{
return response('', 404, [
'Content-Type' => 'text/plain; charset=UTF-8',
'Cache-Control' => 'no-store',
]);
}
private function decodeBase62(string $value): ?int private function decodeBase62(string $value): ?int
{ {
if ($value === '') { if ($value === '') {
@@ -86,11 +101,6 @@ final class LegacyArtworkPhotoController extends Controller
{ {
$cdn = rtrim((string) config('cdn.files_url', 'https://cdn.skinbase.org'), '/'); $cdn = rtrim((string) config('cdn.files_url', 'https://cdn.skinbase.org'), '/');
$filePath = trim((string) ($artwork->file_path ?? ''), '/'); $filePath = trim((string) ($artwork->file_path ?? ''), '/');
if ($filePath !== '') {
return $cdn . '/' . $filePath;
}
$hash = strtolower((string) preg_replace('/[^a-f0-9]/i', '', (string) ($artwork->hash ?? ''))); $hash = strtolower((string) preg_replace('/[^a-f0-9]/i', '', (string) ($artwork->hash ?? '')));
$ext = ltrim((string) ($artwork->file_ext ?: $artwork->thumb_ext ?: 'webp'), '.'); $ext = ltrim((string) ($artwork->file_ext ?: $artwork->thumb_ext ?: 'webp'), '.');
@@ -98,6 +108,10 @@ final class LegacyArtworkPhotoController extends Controller
return $artwork->thumbUrl('xl') ?? $artwork->thumbUrl('lg') ?? $artwork->thumbUrl('md'); return $artwork->thumbUrl('xl') ?? $artwork->thumbUrl('lg') ?? $artwork->thumbUrl('md');
} }
if ($filePath !== '') {
return $cdn . '/' . $filePath;
}
$prefix = trim((string) config('uploads.object_storage.prefix', 'artworks'), '/'); $prefix = trim((string) config('uploads.object_storage.prefix', 'artworks'), '/');
$firstDir = substr($hash, 0, 2); $firstDir = substr($hash, 0, 2);
$secondDir = substr($hash, 2, 2); $secondDir = substr($hash, 2, 2);
@@ -74,6 +74,7 @@ class ProfileController extends Controller
'stats', 'stats',
'favourites', 'favourites',
'activity', 'activity',
'followers',
]; ];
public function __construct( public function __construct(
@@ -200,6 +201,28 @@ class ProfileController extends Controller
return redirect()->route('profile.show', ['username' => UsernamePolicy::normalize($username)], 301); return redirect()->route('profile.show', ['username' => UsernamePolicy::normalize($username)], 301);
} }
/**
* Legacy "All followers" URL from old profile templates:
* GET /following/{id}/{slug?} → /@{canonical-username}/followers
*/
public function legacyFollowingById(Request $request, int $id, ?string $slug = null)
{
$user = User::query()->find($id);
if (! $user) {
abort(404);
}
$url = url('/@' . strtolower((string) $user->username) . '/followers');
$query = $request->query();
if ($query !== []) {
$url .= '?' . http_build_query($query);
}
return redirect()->to($url, 301);
}
/** Toggle follow/unfollow for the profile of $username (auth required). */ /** Toggle follow/unfollow for the profile of $username (auth required). */
public function toggleFollow(Request $request, string $username): JsonResponse public function toggleFollow(Request $request, string $username): JsonResponse
{ {
@@ -1293,9 +1316,37 @@ class ProfileController extends Controller
? ($profileTabUrls[$resolvedInitialTab] ?? $canonical) ? ($profileTabUrls[$resolvedInitialTab] ?? $canonical)
: $canonical; : $canonical;
$tabMetaLabel = $resolvedInitialTab !== null $tabMetaLabel = $resolvedInitialTab !== null
? ucfirst($resolvedInitialTab) ? ($resolvedInitialTab === 'followers' ? 'Followers' : ucfirst($resolvedInitialTab))
: null; : null;
$followersListing = null;
if (! $galleryOnly && $resolvedInitialTab === 'followers') {
$followersPage = max(1, (int) $request->query('page', 1));
$followersListing = [
'data' => [],
'current_page' => $followersPage,
'last_page' => 1,
'per_page' => 24,
'total' => 0,
'next_page_url' => null,
'prev_page_url' => null,
];
if (Schema::hasTable('user_followers')) {
$followersPaginator = $this->followService->paginatedFollowers((int) $user->id, 24, $followersPage);
$followersListing = [
'data' => array_values($followersPaginator->items()),
'current_page' => $followersPaginator->currentPage(),
'last_page' => $followersPaginator->lastPage(),
'per_page' => $followersPaginator->perPage(),
'total' => $followersPaginator->total(),
'next_page_url' => $followersPaginator->nextPageUrl(),
'prev_page_url' => $followersPaginator->previousPageUrl(),
];
}
}
$pageTitle = $galleryOnly $pageTitle = $galleryOnly
? (($user->username ?? $user->name ?? 'User') . ' Gallery on Skinbase') ? (($user->username ?? $user->name ?? 'User') . ' Gallery on Skinbase')
: ($isTabLanding : ($isTabLanding
@@ -1303,9 +1354,11 @@ class ProfileController extends Controller
: (($user->username ?? $user->name ?? 'User') . ' on Skinbase')); : (($user->username ?? $user->name ?? 'User') . ' on Skinbase'));
$pageDescription = $galleryOnly $pageDescription = $galleryOnly
? ('Browse the public gallery of ' . ($user->username ?? $user->name) . ' on Skinbase.') ? ('Browse the public gallery of ' . ($user->username ?? $user->name) . ' on Skinbase.')
: ($isTabLanding : ($resolvedInitialTab === 'followers'
? ('Explore the ' . strtolower((string) $tabMetaLabel) . ' section for ' . ($user->username ?? $user->name) . ' on Skinbase.') ? ('People who follow ' . ($user->username ?? $user->name) . ' on Skinbase.')
: ('View the profile of ' . ($user->username ?? $user->name) . ' on Skinbase — artworks, favourites and more.')); : ($isTabLanding
? ('Explore the ' . strtolower((string) $tabMetaLabel) . ' section for ' . ($user->username ?? $user->name) . ' on Skinbase.')
: ('View the profile of ' . ($user->username ?? $user->name) . ' on Skinbase — artworks, favourites and more.')));
$profileSeo = app(SeoFactory::class)->profilePage( $profileSeo = app(SeoFactory::class)->profilePage(
$pageTitle, $pageTitle,
$galleryOnly ? $galleryUrl : $activeProfileUrl, $galleryOnly ? $galleryUrl : $activeProfileUrl,
@@ -1350,6 +1403,7 @@ class ProfileController extends Controller
'socialLinks' => $socialLinks, 'socialLinks' => $socialLinks,
'followerCount' => $followerCount, 'followerCount' => $followerCount,
'recentFollowers' => $recentFollowers->values(), 'recentFollowers' => $recentFollowers->values(),
'followersListing' => $followersListing,
'followContext' => $followContext, 'followContext' => $followContext,
'followAnalytics' => $followAnalytics, 'followAnalytics' => $followAnalytics,
'suggestedUsers' => $suggestedUsers, 'suggestedUsers' => $suggestedUsers,
@@ -74,6 +74,8 @@ final class TrackOnlineVisitor
'robots.txt', 'robots.txt',
'sitemap.xml', 'sitemap.xml',
'sitemaps/*', 'sitemaps/*',
'download/artwork/*',
'photo/*',
])) { ])) {
return false; return false;
} }
+21 -13
View File
@@ -15,6 +15,9 @@ use Illuminate\Support\Facades\Schema;
class ArtworkResource extends JsonResource class ArtworkResource extends JsonResource
{ {
/** @var array<string, bool> */
private array $tablePresence = [];
/** /**
* Transform the resource into an array. * Transform the resource into an array.
*/ */
@@ -35,14 +38,14 @@ class ArtworkResource extends JsonResource
$followerCount = 0; $followerCount = 0;
if (!empty($this->user?->id)) { if (!empty($this->user?->id)) {
if (Schema::hasTable('user_statistics')) { if ($this->hasTable('user_statistics')) {
$followerCount = (int) DB::table('user_statistics') $followerCount = (int) DB::table('user_statistics')
->where('user_id', (int) $this->user->id) ->where('user_id', (int) $this->user->id)
->value('followers_count'); ->value('followers_count');
} }
// Legacy fallback for environments where new tables are unavailable. // Legacy fallback for environments where new tables are unavailable.
if (($followerCount <= 0) && Schema::hasTable('friends_list')) { if (($followerCount <= 0) && $this->hasTable('friends_list')) {
$followerCount = (int) DB::table('friends_list') $followerCount = (int) DB::table('friends_list')
->where('friend_id', (int) $this->user->id) ->where('friend_id', (int) $this->user->id)
->count(); ->count();
@@ -58,19 +61,19 @@ class ArtworkResource extends JsonResource
$viewerAward = null; $viewerAward = null;
$isOwner = $viewerId > 0 && $viewerId === (int) ($this->user?->id ?? 0); $isOwner = $viewerId > 0 && $viewerId === (int) ($this->user?->id ?? 0);
$bookmarksCount = Schema::hasTable('artwork_bookmarks') $bookmarksCount = $this->hasTable('artwork_bookmarks')
? (int) DB::table('artwork_bookmarks')->where('artwork_id', (int) $this->id)->count() ? (int) DB::table('artwork_bookmarks')->where('artwork_id', (int) $this->id)->count()
: 0; : 0;
if ($viewerId > 0) { if ($viewerId > 0) {
if (Schema::hasTable('artwork_likes')) { if ($this->hasTable('artwork_likes')) {
$isLiked = DB::table('artwork_likes') $isLiked = DB::table('artwork_likes')
->where('user_id', $viewerId) ->where('user_id', $viewerId)
->where('artwork_id', (int) $this->id) ->where('artwork_id', (int) $this->id)
->exists(); ->exists();
} }
if (Schema::hasTable('artwork_bookmarks')) { if ($this->hasTable('artwork_bookmarks')) {
$isBookmarked = DB::table('artwork_bookmarks') $isBookmarked = DB::table('artwork_bookmarks')
->where('user_id', $viewerId) ->where('user_id', $viewerId)
->where('artwork_id', (int) $this->id) ->where('artwork_id', (int) $this->id)
@@ -83,12 +86,12 @@ class ArtworkResource extends JsonResource
->exists(); ->exists();
if (!empty($this->user?->id)) { if (!empty($this->user?->id)) {
if (Schema::hasTable('user_followers')) { if ($this->hasTable('user_followers')) {
$isFollowing = DB::table('user_followers') $isFollowing = DB::table('user_followers')
->where('user_id', (int) $this->user->id) ->where('user_id', (int) $this->user->id)
->where('follower_id', $viewerId) ->where('follower_id', $viewerId)
->exists(); ->exists();
} elseif (Schema::hasTable('friends_list')) { } elseif ($this->hasTable('friends_list')) {
// Legacy fallback only. // Legacy fallback only.
$isFollowing = DB::table('friends_list') $isFollowing = DB::table('friends_list')
->where('user_id', $viewerId) ->where('user_id', $viewerId)
@@ -97,14 +100,14 @@ class ArtworkResource extends JsonResource
} }
} }
if (!empty($this->group?->id) && Schema::hasTable('group_follows')) { if (!empty($this->group?->id) && $this->hasTable('group_follows')) {
$isFollowingGroup = DB::table('group_follows') $isFollowingGroup = DB::table('group_follows')
->where('group_id', (int) $this->group->id) ->where('group_id', (int) $this->group->id)
->where('user_id', $viewerId) ->where('user_id', $viewerId)
->exists(); ->exists();
} }
if (Schema::hasTable('artwork_medals')) { if ($this->hasTable('artwork_medals')) {
$viewerAward = DB::table('artwork_medals') $viewerAward = DB::table('artwork_medals')
->where('user_id', $viewerId) ->where('user_id', $viewerId)
->where('artwork_id', (int) $this->id) ->where('artwork_id', (int) $this->id)
@@ -279,7 +282,7 @@ class ArtworkResource extends JsonResource
private function resolveScreenshotAssets(): array private function resolveScreenshotAssets(): array
{ {
if (! Schema::hasTable('artwork_files')) { if (! $this->hasTable('artwork_files')) {
return []; return [];
} }
@@ -339,7 +342,7 @@ class ArtworkResource extends JsonResource
$items = collect(); $items = collect();
$participationWorlds = collect(); $participationWorlds = collect();
if (Schema::hasTable('world_relations') && Schema::hasTable('worlds')) { if ($this->hasTable('world_relations') && $this->hasTable('worlds')) {
$relations = WorldRelation::query() $relations = WorldRelation::query()
->with('world') ->with('world')
->where('related_type', WorldRelation::TYPE_ARTWORK) ->where('related_type', WorldRelation::TYPE_ARTWORK)
@@ -369,7 +372,7 @@ class ArtworkResource extends JsonResource
); );
} }
if (Schema::hasTable('world_submissions')) { if ($this->hasTable('world_submissions')) {
$liveSubmissions = $this->worldSubmissions $liveSubmissions = $this->worldSubmissions
->filter(function (WorldSubmission $submission): bool { ->filter(function (WorldSubmission $submission): bool {
return (string) $submission->status === WorldSubmission::STATUS_LIVE return (string) $submission->status === WorldSubmission::STATUS_LIVE
@@ -403,7 +406,7 @@ class ArtworkResource extends JsonResource
World::primeCanonicalEditionIds($participationWorlds->pluck('recurrence_key')->all()); World::primeCanonicalEditionIds($participationWorlds->pluck('recurrence_key')->all());
} }
if (Schema::hasTable('world_reward_grants')) { if ($this->hasTable('world_reward_grants')) {
$items = $items->concat(app(WorldRewardService::class)->artworkRewardBadges($this->resource)); $items = $items->concat(app(WorldRewardService::class)->artworkRewardBadges($this->resource));
} }
@@ -421,6 +424,11 @@ class ArtworkResource extends JsonResource
->all(); ->all();
} }
private function hasTable(string $table): bool
{
return $this->tablePresence[$table] ??= Schema::hasTable($table);
}
private function authorCanPublishLinks(): bool private function authorCanPublishLinks(): bool
{ {
$level = (int) ($this->user?->level ?? 1); $level = (int) ($this->user?->level ?? 1);
+70
View File
@@ -0,0 +1,70 @@
<?php
namespace App\Services;
use App\Models\Artwork;
use Illuminate\Support\Str;
final class ArtworkNavigationService
{
/**
* Build the public previous/next navigation payload for an already loaded
* current artwork. No current-artwork lookup is performed here.
*/
public function navigationFor(Artwork $artwork): array
{
$scope = Artwork::published()
->select(['id', 'title', 'slug', 'hash', 'file_path', 'file_name'])
->where('user_id', $artwork->user_id);
$prev = (clone $scope)->where('id', '<', $artwork->id)->orderByDesc('id')->first();
$next = (clone $scope)->where('id', '>', $artwork->id)->orderBy('id')->first();
if (! $prev) {
$prev = (clone $scope)->where('id', '!=', $artwork->id)->orderByDesc('id')->first();
}
if (! $next) {
$next = (clone $scope)->where('id', '!=', $artwork->id)->orderBy('id')->first();
}
$prevSlug = $this->canonicalSlug($prev);
$nextSlug = $this->canonicalSlug($next);
return [
'prev_id' => $prev?->id,
'next_id' => $next?->id,
'prev_url' => $prev ? url('/art/'.$prev->id.'/'.$prevSlug) : null,
'next_url' => $next ? url('/art/'.$next->id.'/'.$nextSlug) : null,
'prev_slug' => $prevSlug,
'next_slug' => $nextSlug,
'prev_preview' => $this->preview($prev),
'next_preview' => $this->preview($next),
];
}
private function canonicalSlug(?Artwork $artwork): ?string
{
if (! $artwork) {
return null;
}
return Str::slug($artwork->slug ?: $artwork->title) ?: (string) $artwork->id;
}
private function preview(?Artwork $artwork): ?array
{
if (! $artwork) {
return null;
}
return [
'id' => (int) $artwork->id,
'title' => (string) $artwork->title,
'slug' => (string) $artwork->slug,
'thumbs' => [
'md' => ThumbnailPresenter::present($artwork, 'md'),
'lg' => ThumbnailPresenter::present($artwork, 'lg'),
],
];
}
}
+72
View File
@@ -0,0 +1,72 @@
<?php
declare(strict_types=1);
namespace App\Services;
use App\Enums\ReactionType;
use Illuminate\Support\Facades\DB;
/**
* Bulk reaction read model for artwork comment trees.
*
* The returned value intentionally matches ReactionController's `totals`
* contract so SSR and API comment payloads can hydrate ReactionBar directly.
*/
final class CommentReactionService
{
/**
* @param iterable<int|string> $commentIds
* @return array<int, array<string, array{emoji:string, label:string, count:int, mine:bool}>>
*/
public function forComments(iterable $commentIds, ?int $userId = null): array
{
$ids = collect($commentIds)
->map(static fn ($id): int => (int) $id)
->filter(static fn (int $id): bool => $id > 0)
->unique()
->values()
->all();
if ($ids === []) {
return [];
}
$counts = DB::table('comment_reactions')
->whereIn('comment_id', $ids)
->selectRaw('comment_id, reaction, COUNT(*) as total')
->groupBy('comment_id', 'reaction')
->get()
->groupBy('comment_id');
$mine = collect();
if ($userId !== null) {
$mine = DB::table('comment_reactions')
->whereIn('comment_id', $ids)
->where('user_id', $userId)
->get(['comment_id', 'reaction'])
->groupBy('comment_id');
}
$result = [];
foreach ($ids as $commentId) {
$commentCounts = $counts->get($commentId, collect())->keyBy('reaction');
$commentMine = $mine->get($commentId, collect())->pluck('reaction')->flip();
$totals = [];
foreach (ReactionType::cases() as $type) {
$slug = $type->value;
$totals[$slug] = [
'emoji' => $type->emoji(),
'label' => $type->label(),
'count' => (int) ($commentCounts->get($slug)->total ?? 0),
'mine' => $commentMine->has($slug),
];
}
$result[$commentId] = $totals;
}
return $result;
}
}
+85
View File
@@ -7,6 +7,7 @@ use App\Services\Activity\UserActivityService;
use App\Events\Achievements\AchievementCheckRequested; use App\Events\Achievements\AchievementCheckRequested;
use App\Services\FollowAnalyticsService; use App\Services\FollowAnalyticsService;
use App\Support\AvatarUrl; use App\Support\AvatarUrl;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
@@ -160,6 +161,38 @@ final class FollowService
->value('followers_count'); ->value('followers_count');
} }
/**
* People who follow $userId (user_id = the profile owner being followed).
*
* @return LengthAwarePaginator<int, array<string, mixed>>
*/
public function paginatedFollowers(int $userId, int $perPage = 24, int $page = 1): LengthAwarePaginator
{
return $this->paginateFollowRelationship(
ownerColumn: 'uf.user_id',
relatedUserColumn: 'uf.follower_id',
ownerId: $userId,
perPage: $perPage,
page: $page,
);
}
/**
* People $userId follows (follower_id = the profile owner).
*
* @return LengthAwarePaginator<int, array<string, mixed>>
*/
public function paginatedFollowing(int $userId, int $perPage = 24, int $page = 1): LengthAwarePaginator
{
return $this->paginateFollowRelationship(
ownerColumn: 'uf.follower_id',
relatedUserColumn: 'uf.user_id',
ownerId: $userId,
perPage: $perPage,
page: $page,
);
}
public function followingCount(int $userId): int public function followingCount(int $userId): int
{ {
return (int) DB::table('user_statistics') return (int) DB::table('user_statistics')
@@ -216,6 +249,58 @@ final class FollowService
// ─── Private helpers ───────────────────────────────────────────────────── // ─── Private helpers ─────────────────────────────────────────────────────
/**
* @return LengthAwarePaginator<int, array<string, mixed>>
*/
private function paginateFollowRelationship(
string $ownerColumn,
string $relatedUserColumn,
int $ownerId,
int $perPage,
int $page,
): LengthAwarePaginator {
$perPage = max(1, min($perPage, 100));
$page = max(1, $page);
return DB::table('user_followers as uf')
->join('users as u', 'u.id', '=', $relatedUserColumn)
->leftJoin('user_profiles as up', 'up.user_id', '=', 'u.id')
->where($ownerColumn, $ownerId)
->whereNull('u.deleted_at')
->orderByDesc('uf.created_at')
->select([
'u.id',
'u.username',
'u.name',
'u.level',
'u.rank',
'up.avatar_hash',
'uf.created_at as followed_at',
])
->paginate($perPage, ['*'], 'page', $page)
->through(fn ($row) => $this->mapPublicFollowUser($row));
}
/**
* @return array<string, mixed>
*/
private function mapPublicFollowUser(object $row): array
{
$username = (string) ($row->username ?? '');
return [
'id' => (int) $row->id,
'username' => $username,
'name' => (string) ($row->name ?? $username),
'display_name' => $username !== '' ? $username : (string) ($row->name ?? ''),
'avatar_url' => AvatarUrl::forUser((int) $row->id, $row->avatar_hash ?? null, 50),
'profile_url' => '/@' . strtolower($username !== '' ? $username : (string) $row->id),
'followed_at' => $row->followed_at,
'level' => (int) ($row->level ?? 1),
'rank' => (string) ($row->rank ?? 'Newbie'),
];
}
private function incrementCounter(int $userId, string $column): void private function incrementCounter(int $userId, string $column): void
{ {
DB::table('user_statistics')->updateOrInsert( DB::table('user_statistics')->updateOrInsert(
+1 -1
View File
@@ -59,7 +59,7 @@ class ThumbnailService
try { try {
$artClass = '\\App\\Models\\Artwork'; $artClass = '\\App\\Models\\Artwork';
if (class_exists($artClass)) { if (class_exists($artClass)) {
$art = $artClass::where('id', $id)->orWhere('legacy_id', $id)->first(); $art = $artClass::query()->find($id);
if ($art) { if ($art) {
$hash = $art->hash ?? null; $hash = $art->hash ?? null;
$extToUse = $ext ?? ($art->thumb_ext ?? null); $extToUse = $ext ?? ($art->thumb_ext ?? null);
+363 -121
View File
@@ -10,16 +10,23 @@ namespace App\Support\Http;
* Multi-value upstream timings (retries): use the **sum** of numeric parts * Multi-value upstream timings (retries): use the **sum** of numeric parts
* as the upstream total for that request. "-" and empty → null. * as the upstream total for that request. "-" and empty → null.
* Percentiles use nearest-rank (ceil(p * n), 1-indexed). * Percentiles use nearest-rank (ceil(p * n), 1-indexed).
*
* Exact --status=404 means HTTP 404 only (not 4xx). Classes are 4xx/5xx.
*/ */
final class HttpPerformanceLogAnalyzer final class HttpPerformanceLogAnalyzer
{ {
/** Ignore gaps shorter than this when judging 24h source coverage. */
public const COVERAGE_TOLERANCE_SECONDS = 900;
public function __construct(private readonly HttpUriNormalizer $uris) public function __construct(private readonly HttpUriNormalizer $uris)
{ {
} }
/** /**
* @param string|list<string> $input
* @param array{ * @param array{
* since?: ?string, * since?: ?string,
* until?: ?string,
* min_requests?: int, * min_requests?: int,
* top?: int, * top?: int,
* status?: ?string, * status?: ?string,
@@ -30,139 +37,178 @@ final class HttpPerformanceLogAnalyzer
* } $options * } $options
* @return array<string, mixed> * @return array<string, mixed>
*/ */
public function analyze(string $file, array $options = []): array public function analyze(string|array $input, array $options = []): array
{ {
$since = $this->sinceCutoff($options['since'] ?? null, $options['now'] ?? new \DateTimeImmutable('now')); $files = $this->normalizeInputList($input);
$now = $options['now'] ?? new \DateTimeImmutable('now');
$requestedStart = $this->sinceCutoff($options['since'] ?? null, $now);
$requestedEnd = $this->untilInstant($options['until'] ?? null, $now);
$minRequests = max(1, (int) ($options['min_requests'] ?? 1)); $minRequests = max(1, (int) ($options['min_requests'] ?? 1));
$top = max(1, (int) ($options['top'] ?? 20)); $top = max(1, (int) ($options['top'] ?? 20));
$statusFilter = isset($options['status']) ? strtolower((string) $options['status']) : null; $statusFilter = $this->parseStatusFilter($options['status'] ?? null);
$uriFilter = $options['uri'] ?? null; $uriFilter = $options['uri'] ?? null;
$hostFilter = isset($options['host']) ? (string) $options['host'] : ''; $hostFilter = isset($options['host']) ? (string) $options['host'] : '';
$slowMs = isset($options['slow_ms']) ? (float) $options['slow_ms'] / 1000 : null; $slowMs = isset($options['slow_ms']) && $options['slow_ms'] !== null
? (float) $options['slow_ms'] / 1000
: null;
$skipped = 0; $skipped = 0;
$linesScanned = 0;
$parseable = 0;
$families = []; $families = [];
$slowest = []; $slowest = [];
$statusCounts = ['4xx' => 0, '5xx' => 0, '499' => 0, 'total' => 0]; $statusCounts = ['4xx' => 0, '5xx' => 0, '499' => 0, 'total' => 0];
$sources = [];
$earliestParseable = null;
$latestParseable = null;
$earliestMatching = null;
$latestMatching = null;
$handle = fopen($file, 'r'); foreach ($files as $file) {
if ($handle === false) { $source = [
throw new \RuntimeException('Unable to open performance log: '.$file); 'path' => $file,
} 'gzip' => str_ends_with(strtolower($file), '.gz'),
'lines_scanned' => 0,
'parseable' => 0,
'in_window' => 0,
'error' => null,
];
try { $handle = $this->openStream($file);
while (($line = fgets($handle)) !== false) { if ($handle === false) {
$line = trim($line); $source['error'] = 'unreadable';
if ($line === '') { $sources[] = $source;
continue; continue;
}
$row = json_decode($line, true);
if (! is_array($row)) {
$skipped++;
continue;
}
$time = $this->parseTime((string) ($row['time'] ?? ''));
if ($since !== null && ($time === null || $time < $since)) {
continue;
}
$requestTime = $this->parseFloat($row['request_time'] ?? null);
if ($requestTime === null) {
$skipped++;
continue;
}
$status = (int) ($row['status'] ?? 0);
if ($statusFilter === '5xx' && ($status < 500 || $status > 599)) {
continue;
}
if ($statusFilter === '4xx' && ($status < 400 || $status > 499)) {
continue;
}
if ($statusFilter === '499' && $status !== 499) {
continue;
}
$host = (string) ($row['host'] ?? '');
if ($hostFilter !== '' && $host !== $hostFilter) {
continue;
}
$uri = $this->uris->normalize((string) ($row['uri'] ?? '/'));
if (is_string($uriFilter) && $uriFilter !== '' && ! str_starts_with($uri, $uriFilter)) {
continue;
}
if ($slowMs !== null && $requestTime < $slowMs) {
continue;
}
$upstreamResponse = $this->parseUpstream($row['upstream_response_time'] ?? null);
$upstreamHeader = $this->parseUpstream($row['upstream_header_time'] ?? null);
$bytes = (int) ($row['bytes'] ?? 0);
$method = (string) ($row['method'] ?? '');
$statusCounts['total']++;
if ($status === 499) {
$statusCounts['499']++;
} elseif ($status >= 500) {
$statusCounts['5xx']++;
} elseif ($status >= 400) {
$statusCounts['4xx']++;
}
$familyKey = $host."\n".$uri;
$families[$familyKey] ??= [
'host' => $host,
'uri' => $uri,
'count' => 0,
'times' => [],
'upstream_sum' => 0.0,
'upstream_n' => 0,
'header_sum' => 0.0,
'header_n' => 0,
'bytes_sum' => 0,
'4xx' => 0,
'5xx' => 0,
'499' => 0,
'404' => 0,
];
$families[$familyKey]['count']++;
$families[$familyKey]['times'][] = $requestTime;
$families[$familyKey]['bytes_sum'] += $bytes;
if ($upstreamResponse !== null) {
$families[$familyKey]['upstream_sum'] += $upstreamResponse;
$families[$familyKey]['upstream_n']++;
}
if ($upstreamHeader !== null) {
$families[$familyKey]['header_sum'] += $upstreamHeader;
$families[$familyKey]['header_n']++;
}
if ($status === 499) {
$families[$familyKey]['499']++;
} elseif ($status >= 500) {
$families[$familyKey]['5xx']++;
} elseif ($status >= 400) {
$families[$familyKey]['4xx']++;
if ($status === 404) {
$families[$familyKey]['404']++;
}
}
$slowest[] = [
'time' => $row['time'] ?? null,
'host' => $host,
'uri' => $uri,
'method' => $method,
'status' => $status,
'request_time' => $requestTime,
'upstream_response_time' => $upstreamResponse,
];
} }
} finally {
fclose($handle); try {
while (($line = fgets($handle)) !== false) {
$linesScanned++;
$source['lines_scanned']++;
$line = trim($line);
if ($line === '') {
continue;
}
$row = json_decode($line, true);
if (! is_array($row)) {
$skipped++;
continue;
}
$time = $this->parseTime((string) ($row['time'] ?? ''));
$requestTime = $this->parseFloat($row['request_time'] ?? null);
if ($requestTime === null) {
$skipped++;
continue;
}
$parseable++;
$source['parseable']++;
if ($time instanceof \DateTimeImmutable) {
$earliestParseable = $this->minTime($earliestParseable, $time);
$latestParseable = $this->maxTime($latestParseable, $time);
}
if ($requestedStart !== null && ($time === null || $time < $requestedStart)) {
continue;
}
if ($requestedEnd !== null && ($time === null || $time > $requestedEnd)) {
continue;
}
$status = (int) ($row['status'] ?? 0);
if (! $this->statusMatches($status, $statusFilter)) {
continue;
}
$host = (string) ($row['host'] ?? '');
if ($hostFilter !== '' && $host !== $hostFilter) {
continue;
}
$uri = $this->uris->normalize((string) ($row['uri'] ?? '/'));
if (is_string($uriFilter) && $uriFilter !== '' && ! str_starts_with($uri, $uriFilter)) {
continue;
}
if ($slowMs !== null && $requestTime < $slowMs) {
continue;
}
$source['in_window']++;
if ($time instanceof \DateTimeImmutable) {
$earliestMatching = $this->minTime($earliestMatching, $time);
$latestMatching = $this->maxTime($latestMatching, $time);
}
$upstreamResponse = $this->parseUpstream($row['upstream_response_time'] ?? null);
$upstreamHeader = $this->parseUpstream($row['upstream_header_time'] ?? null);
$bytes = (int) ($row['bytes'] ?? 0);
$method = (string) ($row['method'] ?? '');
$statusCounts['total']++;
if ($status === 499) {
$statusCounts['499']++;
} elseif ($status >= 500) {
$statusCounts['5xx']++;
} elseif ($status >= 400) {
$statusCounts['4xx']++;
}
$familyKey = $host."\n".$uri;
$families[$familyKey] ??= [
'host' => $host,
'uri' => $uri,
'count' => 0,
'times' => [],
'upstream_sum' => 0.0,
'upstream_n' => 0,
'header_sum' => 0.0,
'header_n' => 0,
'bytes_sum' => 0,
'4xx' => 0,
'5xx' => 0,
'499' => 0,
'404' => 0,
];
$families[$familyKey]['count']++;
$families[$familyKey]['times'][] = $requestTime;
$families[$familyKey]['bytes_sum'] += $bytes;
if ($upstreamResponse !== null) {
$families[$familyKey]['upstream_sum'] += $upstreamResponse;
$families[$familyKey]['upstream_n']++;
}
if ($upstreamHeader !== null) {
$families[$familyKey]['header_sum'] += $upstreamHeader;
$families[$familyKey]['header_n']++;
}
if ($status === 499) {
$families[$familyKey]['499']++;
} elseif ($status >= 500) {
$families[$familyKey]['5xx']++;
} elseif ($status >= 400) {
$families[$familyKey]['4xx']++;
if ($status === 404) {
$families[$familyKey]['404']++;
}
}
$slowest[] = [
'time' => $row['time'] ?? null,
'host' => $host,
'uri' => $uri,
'method' => $method,
'status' => $status,
'request_time' => $requestTime,
];
$slowest[array_key_last($slowest)]['upstream_response_time'] = $upstreamResponse;
}
} finally {
fclose($handle);
}
$sources[] = $source;
} }
usort($slowest, static fn (array $a, array $b): int => $b['request_time'] <=> $a['request_time']); usort($slowest, static fn (array $a, array $b): int => $b['request_time'] <=> $a['request_time']);
@@ -213,8 +259,24 @@ final class HttpPerformanceLogAnalyzer
$by404 = array_values(array_filter($summaries, static fn (array $row): bool => ($row['404'] ?? 0) > 0)); $by404 = array_values(array_filter($summaries, static fn (array $row): bool => ($row['404'] ?? 0) > 0));
usort($by404, static fn (array $a, array $b): int => $b['404'] <=> $a['404']); usort($by404, static fn (array $a, array $b): int => $b['404'] <=> $a['404']);
$coverage = $this->coverageReport(
$requestedStart,
$requestedEnd,
$earliestParseable,
$latestParseable,
$earliestMatching,
$latestMatching,
);
return [ return [
'skipped' => $skipped, 'skipped' => $skipped,
'lines_scanned' => $linesScanned,
'parseable' => $parseable,
'source_files' => count($files),
'sources' => $sources,
'coverage' => $coverage,
'warnings' => $coverage['warnings'],
'sample_empty' => $statusCounts['total'] === 0,
'totals' => $statusCounts, 'totals' => $statusCounts,
'by_p95' => array_slice($byP95, 0, $top), 'by_p95' => array_slice($byP95, 0, $top),
'by_p99' => array_slice($byP99, 0, $top), 'by_p99' => array_slice($byP99, 0, $top),
@@ -229,6 +291,48 @@ final class HttpPerformanceLogAnalyzer
]; ];
} }
/**
* @param list<string> $paths
* @return list<string>
*/
public function deduplicatePaths(array $paths): array
{
$seen = [];
$out = [];
foreach ($paths as $path) {
$path = str_replace('\\', '/', (string) $path);
if ($path === '') {
continue;
}
$resolved = realpath($path) ?: $path;
$key = strtolower($resolved);
if (isset($seen[$key])) {
continue;
}
$seen[$key] = true;
$out[] = $path;
}
return array_values($out);
}
/**
* @return list<string>
*/
public function expandGlob(string $pattern): array
{
$matches = glob($pattern, GLOB_NOSORT) ?: [];
$files = [];
foreach ($matches as $match) {
if (is_file($match)) {
$files[] = $match;
}
}
natsort($files);
return array_values($files);
}
/** /**
* Nearest-rank: rank = ceil(p * n), 1-indexed. * Nearest-rank: rank = ceil(p * n), 1-indexed.
* *
@@ -274,6 +378,135 @@ final class HttpPerformanceLogAnalyzer
return $found ? $sum : null; return $found ? $sum : null;
} }
/**
* @param string|list<string> $input
* @return list<string>
*/
private function normalizeInputList(string|array $input): array
{
$files = is_array($input) ? $input : [$input];
$files = $this->deduplicatePaths(array_map('strval', $files));
if ($files === []) {
throw new \InvalidArgumentException('No performance log files were provided.');
}
return $files;
}
/**
* @return resource|false
*/
private function openStream(string $file)
{
if (str_ends_with(strtolower($file), '.gz')) {
return @gzopen($file, 'rb');
}
return @fopen($file, 'r');
}
/**
* @return array{type: string, code?: int, class?: string}
*/
public function parseStatusFilter(?string $raw): array
{
if ($raw === null || $raw === '') {
return ['type' => 'none'];
}
$value = strtolower(trim($raw));
if ($value === '4xx') {
return ['type' => 'class', 'class' => '4xx'];
}
if ($value === '5xx') {
return ['type' => 'class', 'class' => '5xx'];
}
if (preg_match('/^[1-5][0-9]{2}$/', $value) === 1) {
return ['type' => 'exact', 'code' => (int) $value];
}
throw new \InvalidArgumentException('Invalid --status value: '.$raw);
}
/**
* @param array{type: string, code?: int, class?: string} $filter
*/
private function statusMatches(int $status, array $filter): bool
{
return match ($filter['type']) {
'none' => true,
'exact' => $status === (int) ($filter['code'] ?? 0),
'class' => ($filter['class'] ?? '') === '5xx'
? ($status >= 500 && $status <= 599)
: ($status >= 400 && $status <= 499),
default => true,
};
}
/**
* @return array<string, mixed>
*/
private function coverageReport(
?\DateTimeImmutable $requestedStart,
?\DateTimeImmutable $requestedEnd,
?\DateTimeImmutable $earliestParseable,
?\DateTimeImmutable $latestParseable,
?\DateTimeImmutable $earliestMatching,
?\DateTimeImmutable $latestMatching,
): array {
$warnings = [];
$complete = true;
if ($requestedStart instanceof \DateTimeImmutable && $requestedEnd instanceof \DateTimeImmutable) {
if (! $earliestParseable instanceof \DateTimeImmutable || ! $latestParseable instanceof \DateTimeImmutable) {
$complete = false;
$warnings[] = 'Observed records do not demonstrate full requested coverage.';
} else {
$startGap = $earliestParseable->getTimestamp() - $requestedStart->getTimestamp();
$endGap = $requestedEnd->getTimestamp() - $latestParseable->getTimestamp();
if ($startGap > self::COVERAGE_TOLERANCE_SECONDS || $endGap > self::COVERAGE_TOLERANCE_SECONDS) {
$complete = false;
$warnings[] = sprintf(
'WARNING: Requested window is not fully covered by supplied logs. Requested: %s → %s Observed: %s → %s',
$requestedStart->format(\DateTimeInterface::ATOM),
$requestedEnd->format(\DateTimeInterface::ATOM),
$earliestParseable->format(\DateTimeInterface::ATOM),
$latestParseable->format(\DateTimeInterface::ATOM),
);
}
}
}
return [
'requested_start' => $requestedStart?->format(\DateTimeInterface::ATOM),
'requested_end' => $requestedEnd?->format(\DateTimeInterface::ATOM),
'observed_earliest_parseable' => $earliestParseable?->format(\DateTimeInterface::ATOM),
'observed_latest_parseable' => $latestParseable?->format(\DateTimeInterface::ATOM),
'observed_earliest_matching' => $earliestMatching?->format(\DateTimeInterface::ATOM),
'observed_latest_matching' => $latestMatching?->format(\DateTimeInterface::ATOM),
'tolerance_seconds' => self::COVERAGE_TOLERANCE_SECONDS,
'complete' => $complete,
'warnings' => $warnings,
];
}
private function minTime(?\DateTimeImmutable $current, \DateTimeImmutable $candidate): \DateTimeImmutable
{
if ($current === null || $candidate < $current) {
return $candidate;
}
return $current;
}
private function maxTime(?\DateTimeImmutable $current, \DateTimeImmutable $candidate): \DateTimeImmutable
{
if ($current === null || $candidate > $current) {
return $candidate;
}
return $current;
}
private function parseFloat(mixed $value): ?float private function parseFloat(mixed $value): ?float
{ {
if ($value === null || $value === '' || $value === '-') { if ($value === null || $value === '' || $value === '-') {
@@ -306,7 +539,7 @@ final class HttpPerformanceLogAnalyzer
} }
if (preg_match('/^(\d+)(h|d|m)$/', strtolower($since), $matches) !== 1) { if (preg_match('/^(\d+)(h|d|m)$/', strtolower($since), $matches) !== 1) {
return null; return $this->parseTime($since);
} }
$n = (int) $matches[1]; $n = (int) $matches[1];
@@ -319,4 +552,13 @@ final class HttpPerformanceLogAnalyzer
return $now->sub($interval); return $now->sub($interval);
} }
private function untilInstant(?string $until, \DateTimeImmutable $now): \DateTimeImmutable
{
if ($until === null || $until === '') {
return $now;
}
return $this->parseTime($until) ?? $now;
}
} }
+2
View File
@@ -2354,6 +2354,7 @@
"resources/js/components/profile/tabs/TabArtworks.jsx": [], "resources/js/components/profile/tabs/TabArtworks.jsx": [],
"resources/js/components/profile/tabs/TabCollections.jsx": [], "resources/js/components/profile/tabs/TabCollections.jsx": [],
"resources/js/components/profile/tabs/TabFavourites.jsx": [], "resources/js/components/profile/tabs/TabFavourites.jsx": [],
"resources/js/components/profile/tabs/TabFollowers.jsx": [],
"resources/js/components/profile/tabs/TabPosts.jsx": [], "resources/js/components/profile/tabs/TabPosts.jsx": [],
"resources/js/components/profile/tabs/TabStats.jsx": [], "resources/js/components/profile/tabs/TabStats.jsx": [],
"resources/js/components/profile/tabs/TabStories.jsx": [], "resources/js/components/profile/tabs/TabStories.jsx": [],
@@ -2471,6 +2472,7 @@
"resources/js/lib/uploadEndpoints.js": [], "resources/js/lib/uploadEndpoints.js": [],
"resources/js/lib/uploadNotices.js": [], "resources/js/lib/uploadNotices.js": [],
"resources/js/lib/uploadUtils.js": [], "resources/js/lib/uploadUtils.js": [],
"resources/js/lib/useDeferredSimilarAi.js": [],
"resources/js/lib/useNavContext.js": [], "resources/js/lib/useNavContext.js": [],
"resources/js/lib/worldAnalytics.js": [], "resources/js/lib/worldAnalytics.js": [],
"resources/js/ssr.jsx": [], "resources/js/ssr.jsx": [],
+339 -83
View File
@@ -41436,49 +41436,91 @@ function Skeleton$1() {
} }
function ArtworkComments({ function ArtworkComments({
artworkId, artworkId,
comments: initialComments = [],
isLoggedIn = false, isLoggedIn = false,
loginUrl = "/login" loginUrl = "/login"
}) { }) {
const [comments, setComments] = reactExports.useState(initialComments); const [comments, setComments2] = reactExports.useState([]);
const [loading, setLoading] = reactExports.useState(false); const [loading, setLoading] = reactExports.useState(false);
const [error, setError] = reactExports.useState(false);
const [page, setPage] = reactExports.useState(1); const [page, setPage] = reactExports.useState(1);
const [lastPage, setLastPage] = reactExports.useState(1); const [lastPage, setLastPage] = reactExports.useState(1);
const [total, setTotal] = reactExports.useState(initialComments.length); const [total, setTotal] = reactExports.useState(0);
const initialized = reactExports.useRef(false); const sectionRef = reactExports.useRef(null);
const requestRef = reactExports.useRef(null);
const generationRef = reactExports.useRef(0);
const loadComments = reactExports.useCallback( const loadComments = reactExports.useCallback(
async (p = 1) => { async (p = 1) => {
if (!artworkId) return; if (!artworkId || requestRef.current) return;
const generation = generationRef.current;
const controller = new AbortController();
requestRef.current = controller;
setLoading(true); setLoading(true);
setError(false);
try { try {
const { data } = await axios.get(`/api/artworks/${artworkId}/comments?page=${p}`); const { data } = await axios.get(`/api/artworks/${artworkId}/comments?page=${p}`, { signal: controller.signal });
if (generation !== generationRef.current) return;
if (p === 1) { if (p === 1) {
setComments(data.data ?? []); setComments2(data.data ?? []);
} else { } else {
setComments((prev) => [...prev, ...data.data ?? []]); setComments2((prev) => [...prev, ...data.data ?? []]);
} }
setPage(data.meta?.current_page ?? p); setPage(data.meta?.current_page ?? p);
setLastPage(data.meta?.last_page ?? 1); setLastPage(data.meta?.last_page ?? 1);
setTotal(data.meta?.total ?? 0); setTotal(data.meta?.total ?? 0);
} catch { } catch {
if (generation === generationRef.current && !controller.signal.aborted) setError(true);
} finally { } finally {
setLoading(false); if (requestRef.current === controller) {
requestRef.current = null;
if (generation === generationRef.current) setLoading(false);
}
} }
}, },
[artworkId] [artworkId]
); );
reactExports.useEffect(() => { reactExports.useEffect(() => {
if (initialized.current) return; generationRef.current += 1;
initialized.current = true; requestRef.current?.abort();
if (artworkId && initialComments.length === 0) { requestRef.current = null;
loadComments(1); setComments2([]);
} else { setLoading(false);
setTotal(initialComments.length); setError(false);
setPage(1);
setLastPage(1);
setTotal(0);
const section = sectionRef.current;
if (!section || !artworkId) return void 0;
let fallbackTimer = null;
let observer = null;
let established = false;
const trigger = () => {
observer?.disconnect();
if (!requestRef.current) loadComments(1);
};
if (typeof window !== "undefined" && typeof window.IntersectionObserver === "function") {
try {
observer = new window.IntersectionObserver((entries) => {
if (entries.some((entry) => entry.isIntersecting)) trigger();
}, { rootMargin: "1000px 0px" });
observer.observe(section);
established = true;
} catch {
observer?.disconnect();
}
} }
}, [artworkId, initialComments.length, loadComments]); if (!established && typeof window !== "undefined") {
fallbackTimer = window.setTimeout(trigger, 1200);
}
return () => {
observer?.disconnect();
if (fallbackTimer !== null && typeof window !== "undefined") window.clearTimeout(fallbackTimer);
requestRef.current?.abort();
requestRef.current = null;
};
}, [artworkId, loadComments]);
const handlePosted = reactExports.useCallback((newComment) => { const handlePosted = reactExports.useCallback((newComment) => {
const comment = { ...newComment, replies: newComment.replies || [] }; const comment = { ...newComment, replies: newComment.replies || [] };
setComments((prev) => [comment, ...prev]); setComments2((prev) => [comment, ...prev]);
setTotal((t) => t + 1); setTotal((t) => t + 1);
}, []); }, []);
const handleReplyPosted = reactExports.useCallback((parentId, newReply) => { const handleReplyPosted = reactExports.useCallback((parentId, newReply) => {
@@ -41491,10 +41533,10 @@ function ArtworkComments({
} }
return c; return c;
}); });
setComments((prev) => insertReply(prev)); setComments2((prev) => insertReply(prev));
setTotal((t) => t + 1); setTotal((t) => t + 1);
}, []); }, []);
return /* @__PURE__ */ React.createElement("section", { "aria-label": "Comments", className: "space-y-6" }, /* @__PURE__ */ React.createElement("div", { className: "flex items-center gap-3" }, /* @__PURE__ */ React.createElement("h2", { className: "text-lg font-semibold tracking-tight text-white sm:text-xl" }, "Comments"), total > 0 && /* @__PURE__ */ React.createElement("span", { className: "inline-flex items-center rounded-full bg-white/[0.06] px-2.5 py-0.5 text-xs font-medium tabular-nums text-white/50" }, total)), loading && comments.length === 0 ? /* @__PURE__ */ React.createElement(Skeleton$1, null) : comments.length === 0 ? /* @__PURE__ */ React.createElement("div", { className: "flex flex-col items-center gap-3 rounded-2xl border border-dashed border-white/[0.08] bg-white/[0.015] px-6 py-10 text-center" }, /* @__PURE__ */ React.createElement(ChatBubbleIcon$1, null), /* @__PURE__ */ React.createElement("p", { className: "text-sm font-medium text-white/40" }, "No comments yet"), /* @__PURE__ */ React.createElement("p", { className: "text-xs text-white/25" }, "Be the first to share your thoughts.")) : /* @__PURE__ */ React.createElement(React.Fragment, null, /* @__PURE__ */ React.createElement("ul", { className: "space-y-3 sm:space-y-4" }, comments.map((comment) => /* @__PURE__ */ React.createElement( return /* @__PURE__ */ React.createElement("section", { ref: sectionRef, "aria-label": "Comments", className: "space-y-6" }, /* @__PURE__ */ React.createElement("div", { className: "flex items-center gap-3" }, /* @__PURE__ */ React.createElement("h2", { className: "text-lg font-semibold tracking-tight text-white sm:text-xl" }, "Comments"), total > 0 && /* @__PURE__ */ React.createElement("span", { className: "inline-flex items-center rounded-full bg-white/[0.06] px-2.5 py-0.5 text-xs font-medium tabular-nums text-white/50" }, total)), loading && comments.length === 0 ? /* @__PURE__ */ React.createElement(Skeleton$1, null) : error && comments.length === 0 ? /* @__PURE__ */ React.createElement("div", { className: "rounded-2xl border border-dashed border-white/[0.08] bg-white/[0.015] px-6 py-8 text-center" }, /* @__PURE__ */ React.createElement("p", { className: "text-sm font-medium text-white/45" }, "Comments could not be loaded."), /* @__PURE__ */ React.createElement("button", { type: "button", onClick: () => loadComments(1), className: "mt-3 rounded-full border border-white/[0.1] px-4 py-2 text-xs font-medium text-white/65 hover:bg-white/[0.06]" }, "Try again")) : comments.length === 0 ? /* @__PURE__ */ React.createElement("div", { className: "flex flex-col items-center gap-3 rounded-2xl border border-dashed border-white/[0.08] bg-white/[0.015] px-6 py-10 text-center" }, /* @__PURE__ */ React.createElement(ChatBubbleIcon$1, null), /* @__PURE__ */ React.createElement("p", { className: "text-sm font-medium text-white/40" }, "No comments yet"), /* @__PURE__ */ React.createElement("p", { className: "text-xs text-white/25" }, "Be the first to share your thoughts.")) : /* @__PURE__ */ React.createElement(React.Fragment, null, /* @__PURE__ */ React.createElement("ul", { className: "space-y-3 sm:space-y-4" }, comments.map((comment) => /* @__PURE__ */ React.createElement(
CommentItem$3, CommentItem$3,
{ {
key: comment.id, key: comment.id,
@@ -42906,6 +42948,20 @@ function ArtworkRecommendationsRails({ artwork, related = [], similarApiData = [
return /* @__PURE__ */ React.createElement("div", { className: "space-y-14" }, /* @__PURE__ */ React.createElement(Rail, { title: "Similar Artworks", emoji: "✨", items: similarItems, seeAllHref: similarHref }), /* @__PURE__ */ React.createElement(Rail, { title: trendingLabel, emoji: "🔥", items: trendingRailItems, seeAllHref: trendingHref })); return /* @__PURE__ */ React.createElement("div", { className: "space-y-14" }, /* @__PURE__ */ React.createElement(Rail, { title: "Similar Artworks", emoji: "✨", items: similarItems, seeAllHref: similarHref }), /* @__PURE__ */ React.createElement(Rail, { title: trendingLabel, emoji: "🔥", items: trendingRailItems, seeAllHref: trendingHref }));
} }
const fallbackCache = /* @__PURE__ */ new Map(); const fallbackCache = /* @__PURE__ */ new Map();
function normalizeNavigation(data) {
if (!data || typeof data !== "object") return null;
const hasPrev = data.prev_id == null || data.prev_url && data.prev_slug;
const hasNext = data.next_id == null || data.next_url && data.next_slug;
if (!hasPrev || !hasNext) return null;
return {
prevId: data.prev_id ?? null,
nextId: data.next_id ?? null,
prevUrl: data.prev_url ?? null,
nextUrl: data.next_url ?? null,
prevPreview: data.prev_preview ?? null,
nextPreview: data.next_preview ?? null
};
}
async function fetchFallback(artworkId) { async function fetchFallback(artworkId) {
const key = String(artworkId); const key = String(artworkId);
if (fallbackCache.has(key)) return fallbackCache.get(key); if (fallbackCache.has(key)) return fallbackCache.get(key);
@@ -42913,27 +42969,25 @@ async function fetchFallback(artworkId) {
const res = await fetch(`/api/artworks/navigation/${artworkId}`, { const res = await fetch(`/api/artworks/navigation/${artworkId}`, {
headers: { Accept: "application/json" } headers: { Accept: "application/json" }
}); });
if (!res.ok) return { prevId: null, nextId: null, prevUrl: null, nextUrl: null }; if (!res.ok) return { prevId: null, nextId: null, prevUrl: null, nextUrl: null, prevPreview: null, nextPreview: null };
const data = await res.json(); const data = await res.json();
const result = { const result = normalizeNavigation(data) || { prevId: null, nextId: null, prevUrl: null, nextUrl: null, prevPreview: null, nextPreview: null };
prevId: data.prev_id ?? null,
nextId: data.next_id ?? null,
prevUrl: data.prev_url ?? null,
nextUrl: data.next_url ?? null
};
fallbackCache.set(key, result); fallbackCache.set(key, result);
return result; return result;
} catch { } catch {
return { prevId: null, nextId: null, prevUrl: null, nextUrl: null }; return { prevId: null, nextId: null, prevUrl: null, nextUrl: null, prevPreview: null, nextPreview: null };
} }
} }
function useNavContext(currentArtworkId) { function useNavContext(currentArtworkId, embeddedNavigation = null) {
const getNeighbors = reactExports.useCallback(async () => { const getNeighbors = reactExports.useCallback(async () => {
const embedded = normalizeNavigation(embeddedNavigation);
if (embedded) return embedded;
return fetchFallback(currentArtworkId); return fetchFallback(currentArtworkId);
}, [currentArtworkId]); }, [currentArtworkId, embeddedNavigation]);
return { getNeighbors }; return { getNeighbors };
} }
const preloadCache = /* @__PURE__ */ new Set(); const preloadCache = /* @__PURE__ */ new Set();
const prefetchCache = /* @__PURE__ */ new Set();
function scheduleIdleTask(callback, delay = 1200) { function scheduleIdleTask(callback, delay = 1200) {
if (typeof window === "undefined") { if (typeof window === "undefined") {
callback(); callback();
@@ -42953,8 +43007,8 @@ function preloadImage(src2) {
const img = new Image(); const img = new Image();
img.src = src2; img.src = src2;
} }
function ArtworkNavigator({ artworkId, onNavigate, onOpenViewer, onReady }) { function ArtworkNavigator({ artworkId, navigation: navigation2 = null, onNavigate, onOpenViewer, onReady }) {
const { getNeighbors } = useNavContext(artworkId); const { getNeighbors } = useNavContext(artworkId, navigation2);
const [neighbors, setNeighbors] = reactExports.useState({ prevId: null, nextId: null, prevUrl: null, nextUrl: null }); const [neighbors, setNeighbors] = reactExports.useState({ prevId: null, nextId: null, prevUrl: null, nextUrl: null });
const navigatingRef = reactExports.useRef(false); const navigatingRef = reactExports.useRef(false);
const neighborsRef = reactExports.useRef(neighbors); const neighborsRef = reactExports.useRef(neighbors);
@@ -42989,22 +43043,45 @@ function ArtworkNavigator({ artworkId, onNavigate, onOpenViewer, onReady }) {
reactExports.useEffect(() => { reactExports.useEffect(() => {
const ids = [neighbors.prevId, neighbors.nextId].filter(Boolean); const ids = [neighbors.prevId, neighbors.nextId].filter(Boolean);
if (ids.length === 0) return void 0; if (ids.length === 0) return void 0;
const previews = /* @__PURE__ */ new Map([
[neighbors.prevId, neighbors.prevPreview],
[neighbors.nextId, neighbors.nextPreview]
]);
let cancelled = false; let cancelled = false;
const cancelIdleTask = scheduleIdleTask(() => { const cancelIdleTask = scheduleIdleTask(() => {
ids.forEach((id) => { ids.forEach((id) => {
fetch(`/api/artworks/${id}/page`, { headers: { Accept: "application/json" } }).then((r) => r.ok ? r.json() : null).then((data) => { if (prefetchCache.has(id)) return;
if (cancelled || !data) return; prefetchCache.add(id);
const preview = previews.get(id);
if (preview) {
if (!cancelled) {
const imgUrl = preview.thumbs?.lg?.url || preview.thumbs?.md?.url;
if (imgUrl) preloadImage(imgUrl);
}
return;
}
fetch(`/api/artworks/${id}/page?prefetch=1`, { headers: { Accept: "application/json" } }).then((r) => {
if (!r.ok) {
prefetchCache.delete(id);
return null;
}
return r.json();
}).then((data) => {
if (!data) {
prefetchCache.delete(id);
return;
}
if (cancelled) return;
const imgUrl = data.thumbs?.lg?.url || data.thumbs?.md?.url; const imgUrl = data.thumbs?.lg?.url || data.thumbs?.md?.url;
if (imgUrl) preloadImage(imgUrl); if (imgUrl) preloadImage(imgUrl);
}).catch(() => { }).catch(() => prefetchCache.delete(id));
});
}); });
}); });
return () => { return () => {
cancelled = true; cancelled = true;
cancelIdleTask(); cancelIdleTask();
}; };
}, [neighbors.prevId, neighbors.nextId]); }, [neighbors.prevId, neighbors.nextId, neighbors.prevPreview, neighbors.nextPreview]);
const navigate = reactExports.useCallback(async (targetId, targetUrl) => { const navigate = reactExports.useCallback(async (targetId, targetUrl) => {
if (!targetId && !targetUrl) return; if (!targetId && !targetUrl) return;
if (navigatingRef.current) return; if (navigatingRef.current) return;
@@ -43194,6 +43271,140 @@ function GroupSummaryPanel({ group, artwork }) {
if (!group) return null; if (!group) return null;
return /* @__PURE__ */ React.createElement("section", { className: "overflow-hidden rounded-[28px] border border-white/[0.08] bg-[radial-gradient(circle_at_top_left,rgba(56,189,248,0.14),transparent_36%),linear-gradient(180deg,rgba(255,255,255,0.06),rgba(255,255,255,0.02))] px-5 py-5 shadow-[0_22px_55px_rgba(0,0,0,0.26)] backdrop-blur-xl sm:px-6" }, /* @__PURE__ */ React.createElement("div", { className: "flex items-start gap-4" }, /* @__PURE__ */ React.createElement("a", { href: group.urls?.public || "/groups", className: "flex h-16 w-16 shrink-0 items-center justify-center overflow-hidden rounded-2xl border border-white/10 bg-white/[0.04]" }, group.avatar_url ? /* @__PURE__ */ React.createElement("img", { src: group.avatar_url, alt: group.name, className: "h-full w-full object-cover", loading: "lazy" }) : /* @__PURE__ */ React.createElement("i", { className: "fa-solid fa-people-group text-slate-300" })), /* @__PURE__ */ React.createElement("div", { className: "min-w-0 flex-1" }, /* @__PURE__ */ React.createElement("div", { className: "text-[11px] font-semibold uppercase tracking-[0.22em] text-sky-200/80" }, "Published as a group"), /* @__PURE__ */ React.createElement("a", { href: group.urls?.public || "/groups", className: "mt-2 block text-xl font-semibold tracking-[-0.02em] text-white transition hover:text-sky-300" }, group.name), /* @__PURE__ */ React.createElement("p", { className: "mt-2 text-sm leading-6 text-white/65" }, group.headline || group.bio_excerpt || `${artwork?.title || "This artwork"} is credited to a collaborative group identity on Skinbase.`))), /* @__PURE__ */ React.createElement("div", { className: "mt-4 flex flex-wrap gap-2" }, (Array.isArray(group.trust_signals) ? group.trust_signals : []).slice(0, 3).map((signal) => /* @__PURE__ */ React.createElement(GroupBadgePill, { key: signal.key, label: signal.label, tone: signal.tone })), group.is_recruiting ? /* @__PURE__ */ React.createElement(GroupBadgePill, { label: "Recruiting", tone: "emerald" }) : null), /* @__PURE__ */ React.createElement("div", { className: "mt-5 grid grid-cols-3 gap-2 rounded-2xl border border-white/10 bg-black/20 p-3 text-center" }, /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("div", { className: "text-lg font-semibold text-white" }, NUMBER_FORMATTER.format(Number(group.counts?.artworks || 0))), /* @__PURE__ */ React.createElement("div", { className: "text-[11px] uppercase tracking-[0.16em] text-slate-500" }, "Artworks")), /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("div", { className: "text-lg font-semibold text-white" }, NUMBER_FORMATTER.format(Number(group.counts?.members || 0))), /* @__PURE__ */ React.createElement("div", { className: "text-[11px] uppercase tracking-[0.16em] text-slate-500" }, "Members")), /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("div", { className: "text-lg font-semibold text-white" }, NUMBER_FORMATTER.format(Number(group.counts?.followers || 0))), /* @__PURE__ */ React.createElement("div", { className: "text-[11px] uppercase tracking-[0.16em] text-slate-500" }, "Followers"))), /* @__PURE__ */ React.createElement("div", { className: "mt-5 flex flex-wrap gap-2" }, /* @__PURE__ */ React.createElement("a", { href: group.urls?.public || "/groups", className: "rounded-full border border-sky-300/20 bg-sky-300/10 px-4 py-2 text-sm font-semibold text-sky-100 transition hover:border-sky-300/35 hover:bg-sky-300/15" }, "Open group page"), group.urls?.releases ? /* @__PURE__ */ React.createElement("a", { href: group.urls.releases, className: "rounded-full border border-white/10 bg-white/[0.04] px-4 py-2 text-sm font-semibold text-white transition hover:border-white/20 hover:bg-white/[0.07]" }, "Browse releases") : null)); return /* @__PURE__ */ React.createElement("section", { className: "overflow-hidden rounded-[28px] border border-white/[0.08] bg-[radial-gradient(circle_at_top_left,rgba(56,189,248,0.14),transparent_36%),linear-gradient(180deg,rgba(255,255,255,0.06),rgba(255,255,255,0.02))] px-5 py-5 shadow-[0_22px_55px_rgba(0,0,0,0.26)] backdrop-blur-xl sm:px-6" }, /* @__PURE__ */ React.createElement("div", { className: "flex items-start gap-4" }, /* @__PURE__ */ React.createElement("a", { href: group.urls?.public || "/groups", className: "flex h-16 w-16 shrink-0 items-center justify-center overflow-hidden rounded-2xl border border-white/10 bg-white/[0.04]" }, group.avatar_url ? /* @__PURE__ */ React.createElement("img", { src: group.avatar_url, alt: group.name, className: "h-full w-full object-cover", loading: "lazy" }) : /* @__PURE__ */ React.createElement("i", { className: "fa-solid fa-people-group text-slate-300" })), /* @__PURE__ */ React.createElement("div", { className: "min-w-0 flex-1" }, /* @__PURE__ */ React.createElement("div", { className: "text-[11px] font-semibold uppercase tracking-[0.22em] text-sky-200/80" }, "Published as a group"), /* @__PURE__ */ React.createElement("a", { href: group.urls?.public || "/groups", className: "mt-2 block text-xl font-semibold tracking-[-0.02em] text-white transition hover:text-sky-300" }, group.name), /* @__PURE__ */ React.createElement("p", { className: "mt-2 text-sm leading-6 text-white/65" }, group.headline || group.bio_excerpt || `${artwork?.title || "This artwork"} is credited to a collaborative group identity on Skinbase.`))), /* @__PURE__ */ React.createElement("div", { className: "mt-4 flex flex-wrap gap-2" }, (Array.isArray(group.trust_signals) ? group.trust_signals : []).slice(0, 3).map((signal) => /* @__PURE__ */ React.createElement(GroupBadgePill, { key: signal.key, label: signal.label, tone: signal.tone })), group.is_recruiting ? /* @__PURE__ */ React.createElement(GroupBadgePill, { label: "Recruiting", tone: "emerald" }) : null), /* @__PURE__ */ React.createElement("div", { className: "mt-5 grid grid-cols-3 gap-2 rounded-2xl border border-white/10 bg-black/20 p-3 text-center" }, /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("div", { className: "text-lg font-semibold text-white" }, NUMBER_FORMATTER.format(Number(group.counts?.artworks || 0))), /* @__PURE__ */ React.createElement("div", { className: "text-[11px] uppercase tracking-[0.16em] text-slate-500" }, "Artworks")), /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("div", { className: "text-lg font-semibold text-white" }, NUMBER_FORMATTER.format(Number(group.counts?.members || 0))), /* @__PURE__ */ React.createElement("div", { className: "text-[11px] uppercase tracking-[0.16em] text-slate-500" }, "Members")), /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("div", { className: "text-lg font-semibold text-white" }, NUMBER_FORMATTER.format(Number(group.counts?.followers || 0))), /* @__PURE__ */ React.createElement("div", { className: "text-[11px] uppercase tracking-[0.16em] text-slate-500" }, "Followers"))), /* @__PURE__ */ React.createElement("div", { className: "mt-5 flex flex-wrap gap-2" }, /* @__PURE__ */ React.createElement("a", { href: group.urls?.public || "/groups", className: "rounded-full border border-sky-300/20 bg-sky-300/10 px-4 py-2 text-sm font-semibold text-sky-100 transition hover:border-sky-300/35 hover:bg-sky-300/15" }, "Open group page"), group.urls?.releases ? /* @__PURE__ */ React.createElement("a", { href: group.urls.releases, className: "rounded-full border border-white/10 bg-white/[0.04] px-4 py-2 text-sm font-semibold text-white transition hover:border-white/20 hover:bg-white/[0.07]" }, "Browse releases") : null));
} }
const REGISTRY = /* @__PURE__ */ new Map();
const ROOT_MARGIN = "800px 0px";
const IDLE_TIMEOUT = 3e3;
function scheduleIdle(callback) {
if (typeof window.requestIdleCallback === "function") {
const handle2 = window.requestIdleCallback(callback, { timeout: IDLE_TIMEOUT });
return () => window.cancelIdleCallback?.(handle2);
}
const handle = window.setTimeout(callback, IDLE_TIMEOUT);
return () => window.clearTimeout(handle);
}
function useDeferredSimilarAi(artworkId, anchorRef) {
const [state, setState] = reactExports.useState(() => ({
id: artworkId,
items: REGISTRY.get(Number(artworkId || 0))?.items || []
}));
const [loading, setLoading] = reactExports.useState(false);
const [error, setError] = reactExports.useState(false);
reactExports.useEffect(() => {
let cancelled = false;
const id = Number(artworkId || 0);
const controller = new AbortController();
let attachedEntry = null;
let triggered = false;
const currentEntry = REGISTRY.get(id);
const cached = currentEntry?.status === "completed" ? currentEntry.items : null;
setState({ id, items: cached || [] });
setLoading(false);
setError(false);
if (!id || cached) {
return () => controller.abort();
}
let stopIdle = () => {
};
let observer = null;
let observationEstablished = false;
const detach = () => {
if (!attachedEntry) return;
attachedEntry.subscribers -= 1;
const entry = attachedEntry;
attachedEntry = null;
if (entry.subscribers === 0 && entry.status === "in-flight") {
entry.controller.abort();
}
};
const attach = (entry) => {
if (cancelled || attachedEntry === entry) return;
attachedEntry = entry;
entry.subscribers += 1;
setLoading(true);
entry.promise.then((nextItems) => {
if (!cancelled) setState({ id, items: nextItems });
}).catch((requestError) => {
if (!cancelled && requestError?.name !== "AbortError") {
setState({ id, items: [] });
setError(true);
}
}).finally(() => {
if (!cancelled) setLoading(false);
if (attachedEntry === entry) {
attachedEntry = null;
entry.subscribers -= 1;
}
});
};
const load = async () => {
if (triggered || cancelled) return;
triggered = true;
observer?.disconnect();
stopIdle();
let entry = REGISTRY.get(id);
if (entry?.status === "completed") {
if (!cancelled) setState({ id, items: entry.items });
return;
}
if (entry?.status !== "in-flight" || entry.controller.signal.aborted) {
const requestController = new AbortController();
entry = {
status: "in-flight",
controller: requestController,
promise: null,
subscribers: 0
};
REGISTRY.set(id, entry);
entry.promise = Promise.resolve().then(() => fetch(`/api/art/${id}/similar-ai`, {
credentials: "same-origin",
signal: requestController.signal
})).then((response) => {
if (!response.ok) throw new Error("similar fetch failed");
return response.json();
}).then((payload) => {
const nextItems = Array.isArray(payload?.data) ? payload.data : [];
if (REGISTRY.get(id) === entry) {
REGISTRY.set(id, { status: "completed", items: nextItems });
}
return nextItems;
}).catch((requestError) => {
if (REGISTRY.get(id) === entry) REGISTRY.delete(id);
throw requestError;
});
entry.promise.catch(() => {
});
}
attach(entry);
};
if (typeof window.IntersectionObserver === "function" && anchorRef?.current) {
try {
observer = new window.IntersectionObserver((entries) => {
if (entries.some((entry) => entry.isIntersecting)) load();
}, { rootMargin: ROOT_MARGIN });
observer.observe(anchorRef.current);
observationEstablished = true;
} catch {
observer?.disconnect();
observer = null;
}
}
if (!observationEstablished) {
stopIdle = scheduleIdle(load);
}
return () => {
cancelled = true;
observer?.disconnect();
stopIdle();
detach();
controller.abort();
};
}, [artworkId, anchorRef]);
return {
items: Number(state.id || 0) === Number(artworkId || 0) ? state.items : [],
loading: Number(state.id || 0) === Number(artworkId || 0) ? loading : false,
error: Number(state.id || 0) === Number(artworkId || 0) ? error : false
};
}
function publisherToGroupSummary(publisher) { function publisherToGroupSummary(publisher) {
if (!publisher || publisher.type !== "group") return null; if (!publisher || publisher.type !== "group") return null;
return { return {
@@ -43215,7 +43426,7 @@ function publisherToGroupSummary(publisher) {
} }
}; };
} }
function ArtworkPage({ artwork: initialArtwork, related: initialRelated, presentMd: initialMd, presentLg: initialLg, presentXl: initialXl, presentSq: initialSq, canonicalUrl: initialCanonical, isAuthenticated = false, comments: initialComments = [], groupSummary: initialGroupSummary = null, reactionTotals: initialReactionTotals = {}, seo = null }) { function ArtworkPage({ artwork: initialArtwork, navigation: initialNavigation = null, related: initialRelated, presentMd: initialMd, presentLg: initialLg, presentXl: initialXl, presentSq: initialSq, canonicalUrl: initialCanonical, isAuthenticated = false, groupSummary: initialGroupSummary = null, reactionTotals: initialReactionTotals = {}, seo = null }) {
const [viewerOpen, setViewerOpen] = reactExports.useState(false); const [viewerOpen, setViewerOpen] = reactExports.useState(false);
const [showMatureArtwork, setShowMatureArtwork] = reactExports.useState(false); const [showMatureArtwork, setShowMatureArtwork] = reactExports.useState(false);
const openViewer = reactExports.useCallback(() => setViewerOpen(true), []); const openViewer = reactExports.useCallback(() => setViewerOpen(true), []);
@@ -43236,12 +43447,12 @@ function ArtworkPage({ artwork: initialArtwork, related: initialRelated, present
const [presentXl, setPresentXl] = reactExports.useState(initialXl); const [presentXl, setPresentXl] = reactExports.useState(initialXl);
const [presentSq, setPresentSq] = reactExports.useState(initialSq); const [presentSq, setPresentSq] = reactExports.useState(initialSq);
const [related, setRelated] = reactExports.useState(initialRelated); const [related, setRelated] = reactExports.useState(initialRelated);
const [comments, setComments] = reactExports.useState(initialComments);
const [canonicalUrl, setCanonicalUrl] = reactExports.useState(initialCanonical); const [canonicalUrl, setCanonicalUrl] = reactExports.useState(initialCanonical);
const [groupSummary, setGroupSummary] = reactExports.useState(initialGroupSummary || publisherToGroupSummary(initialArtwork?.publisher)); const [groupSummary, setGroupSummary] = reactExports.useState(initialGroupSummary || publisherToGroupSummary(initialArtwork?.publisher));
const [selectedMediaId, setSelectedMediaId] = reactExports.useState("cover"); const [selectedMediaId, setSelectedMediaId] = reactExports.useState("cover");
const [similarRecommendations, setSimilarRecommendations] = reactExports.useState([]);
const [trendingRecommendations, setTrendingRecommendations] = reactExports.useState([]); const [trendingRecommendations, setTrendingRecommendations] = reactExports.useState([]);
const similarAiAnchorRef = reactExports.useRef(null);
const { items: deferredSimilarRecommendations } = useDeferredSimilarAi(artwork?.id, similarAiAnchorRef);
const [navState, setNavState] = reactExports.useState({ hasPrev: false, hasNext: false, navigatePrev: null, navigateNext: null }); const [navState, setNavState] = reactExports.useState({ hasPrev: false, hasNext: false, navigatePrev: null, navigateNext: null });
const initialArtworkIdRef = reactExports.useRef(initialArtwork?.id); const initialArtworkIdRef = reactExports.useRef(initialArtwork?.id);
const [reactionTotals, setReactionTotals] = reactExports.useState(initialReactionTotals ?? {}); const [reactionTotals, setReactionTotals] = reactExports.useState(initialReactionTotals ?? {});
@@ -43250,27 +43461,6 @@ function ArtworkPage({ artwork: initialArtwork, related: initialRelated, present
if (artwork.id === initialArtworkIdRef.current) return; if (artwork.id === initialArtworkIdRef.current) return;
axios.get(`/api/artworks/${artwork.id}/reactions`).then(({ data }) => setReactionTotals(data.totals ?? {})).catch(() => setReactionTotals({})); axios.get(`/api/artworks/${artwork.id}/reactions`).then(({ data }) => setReactionTotals(data.totals ?? {})).catch(() => setReactionTotals({}));
}, [artwork?.id]); }, [artwork?.id]);
reactExports.useEffect(() => {
let isCancelled = false;
const loadSimilarRecommendations = async () => {
if (!artwork?.id) {
setSimilarRecommendations([]);
return;
}
try {
const response = await fetch(`/api/art/${artwork.id}/similar-ai`, { credentials: "same-origin" });
if (!response.ok) throw new Error("similar fetch failed");
const payload = await response.json();
if (!isCancelled) setSimilarRecommendations(payload?.data || []);
} catch {
if (!isCancelled) setSimilarRecommendations([]);
}
};
loadSimilarRecommendations();
return () => {
isCancelled = true;
};
}, [artwork?.id]);
reactExports.useEffect(() => { reactExports.useEffect(() => {
let isCancelled = false; let isCancelled = false;
const loadTrendingRecommendations = async () => { const loadTrendingRecommendations = async () => {
@@ -43308,7 +43498,6 @@ function ArtworkPage({ artwork: initialArtwork, related: initialRelated, present
setCanonicalUrl(data.canonical_url ?? window.location.href); setCanonicalUrl(data.canonical_url ?? window.location.href);
setGroupSummary(data.group_summary ?? publisherToGroupSummary(data.publisher)); setGroupSummary(data.group_summary ?? publisherToGroupSummary(data.publisher));
setSelectedMediaId("cover"); setSelectedMediaId("cover");
setSimilarRecommendations([]);
setTrendingRecommendations([]); setTrendingRecommendations([]);
setViewerOpen(false); setViewerOpen(false);
setShowMatureArtwork(false); setShowMatureArtwork(false);
@@ -43424,22 +43613,22 @@ function ArtworkPage({ artwork: initialArtwork, related: initialRelated, present
ArtworkComments, ArtworkComments,
{ {
artworkId: artwork.id, artworkId: artwork.id,
comments,
isLoggedIn: isAuthenticated, isLoggedIn: isAuthenticated,
loginUrl: "/login" loginUrl: "/login"
} }
)), /* @__PURE__ */ React.createElement("aside", { className: "space-y-5 lg:sticky lg:top-6 lg:self-start" }, /* @__PURE__ */ React.createElement(CreatorSpotlight, { artwork, presentSq, related }), groupSummary ? /* @__PURE__ */ React.createElement(GroupSummaryPanel, { group: groupSummary, artwork }) : null, /* @__PURE__ */ React.createElement(ArtworkDetailsPanel, { artwork, stats: liveStats }), /* @__PURE__ */ React.createElement(ArtworkAwards, { artwork, initialAwards, isAuthenticated })))), /* @__PURE__ */ React.createElement("div", { className: "mt-14 w-full max-w-screen-2xl mx-auto min-h-[640px]" }, /* @__PURE__ */ React.createElement( )), /* @__PURE__ */ React.createElement("aside", { className: "space-y-5 lg:sticky lg:top-6 lg:self-start" }, /* @__PURE__ */ React.createElement(CreatorSpotlight, { artwork, presentSq, related }), groupSummary ? /* @__PURE__ */ React.createElement(GroupSummaryPanel, { group: groupSummary, artwork }) : null, /* @__PURE__ */ React.createElement(ArtworkDetailsPanel, { artwork, stats: liveStats }), /* @__PURE__ */ React.createElement(ArtworkAwards, { artwork, initialAwards, isAuthenticated })))), /* @__PURE__ */ React.createElement("div", { ref: similarAiAnchorRef, className: "mt-14 w-full max-w-screen-2xl mx-auto min-h-[640px]" }, /* @__PURE__ */ React.createElement(
ArtworkRecommendationsRails, ArtworkRecommendationsRails,
{ {
artwork, artwork,
related, related,
similarApiData: similarRecommendations, similarApiData: deferredSimilarRecommendations,
trendingData: trendingRecommendations trendingData: trendingRecommendations
} }
))), /* @__PURE__ */ React.createElement( ))), /* @__PURE__ */ React.createElement(
ArtworkNavigator, ArtworkNavigator,
{ {
artworkId: artwork.id, artworkId: artwork.id,
navigation: artwork.navigation || initialNavigation,
onNavigate: handleNavigate, onNavigate: handleNavigate,
onOpenViewer: openViewer, onOpenViewer: openViewer,
onReady: setNavState onReady: setNavState
@@ -79881,7 +80070,7 @@ function CollectionShow() {
reportEndpoint reportEndpoint
} = props; } = props;
const [collection, setCollection] = React.useState(initialCollection); const [collection, setCollection] = React.useState(initialCollection);
const [comments, setComments] = React.useState(initialComments || []); const [comments, setComments2] = React.useState(initialComments || []);
const [submissions, setSubmissions] = React.useState(initialSubmissions || []); const [submissions, setSubmissions] = React.useState(initialSubmissions || []);
const [selectedArtworkId, setSelectedArtworkId] = React.useState(submissionArtworkOptions?.[0]?.id || ""); const [selectedArtworkId, setSelectedArtworkId] = React.useState(submissionArtworkOptions?.[0]?.id || "");
const [state, setState] = React.useState({ const [state, setState] = React.useState({
@@ -80148,12 +80337,12 @@ function CollectionShow() {
method: "POST", method: "POST",
body: { body: body2 } body: { body: body2 }
}); });
setComments(payload?.comments || []); setComments2(payload?.comments || []);
setCollection((current) => ({ ...current, comments_count: payload?.comments_count ?? current.comments_count })); setCollection((current) => ({ ...current, comments_count: payload?.comments_count ?? current.comments_count }));
} }
async function handleDeleteComment(commentId) { async function handleDeleteComment(commentId) {
const payload = await requestJson$l(`${commentsEndpoint}/${commentId}`, { method: "DELETE" }); const payload = await requestJson$l(`${commentsEndpoint}/${commentId}`, { method: "DELETE" });
setComments(payload?.comments || []); setComments2(payload?.comments || []);
setCollection((current) => ({ ...current, comments_count: payload?.comments_count ?? current.comments_count })); setCollection((current) => ({ ...current, comments_count: payload?.comments_count ?? current.comments_count }));
} }
async function handleSubmitArtwork() { async function handleSubmitArtwork() {
@@ -82717,7 +82906,7 @@ const FILTER_TABS = [
]; ];
function LatestCommentsPage({ initialComments = [], initialMeta = {}, isAuthenticated = false }) { function LatestCommentsPage({ initialComments = [], initialMeta = {}, isAuthenticated = false }) {
const [activeFilter, setActiveFilter] = reactExports.useState("all"); const [activeFilter, setActiveFilter] = reactExports.useState("all");
const [comments, setComments] = reactExports.useState(initialComments); const [comments, setComments2] = reactExports.useState(initialComments);
const [meta, setMeta] = reactExports.useState(initialMeta); const [meta, setMeta] = reactExports.useState(initialMeta);
const [loading, setLoading] = reactExports.useState(false); const [loading, setLoading] = reactExports.useState(false);
const [error, setError] = reactExports.useState(null); const [error, setError] = reactExports.useState(null);
@@ -82733,7 +82922,7 @@ function LatestCommentsPage({ initialComments = [], initialMeta = {}, isAuthenti
}); });
if (res.status === 401) { if (res.status === 401) {
setError("Please log in to view this feed."); setError("Please log in to view this feed.");
setComments([]); setComments2([]);
setMeta({}); setMeta({});
return; return;
} }
@@ -82742,7 +82931,7 @@ function LatestCommentsPage({ initialComments = [], initialMeta = {}, isAuthenti
return; return;
} }
const json = await res.json(); const json = await res.json();
setComments(json.data ?? []); setComments2(json.data ?? []);
setMeta(json.meta ?? {}); setMeta(json.meta ?? {});
} catch { } catch {
setError("Network error. Please try again."); setError("Network error. Please try again.");
@@ -83131,7 +83320,7 @@ function formatRelative$1(isoString) {
return `${d2}d ago`; return `${d2}d ago`;
} }
function PostComments({ postId, isLoggedIn, isOwn = false, initialCount = 0 }) { function PostComments({ postId, isLoggedIn, isOwn = false, initialCount = 0 }) {
const [comments, setComments] = reactExports.useState([]); const [comments, setComments2] = reactExports.useState([]);
const [loading, setLoading] = reactExports.useState(false); const [loading, setLoading] = reactExports.useState(false);
const [submitting, setSubmitting] = reactExports.useState(false); const [submitting, setSubmitting] = reactExports.useState(false);
const [body2, setBody] = reactExports.useState(""); const [body2, setBody] = reactExports.useState("");
@@ -83144,7 +83333,7 @@ function PostComments({ postId, isLoggedIn, isOwn = false, initialCount = 0 }) {
setLoading(true); setLoading(true);
try { try {
const { data } = await axios.get(`/api/posts/${postId}/comments`, { params: { page: p } }); const { data } = await axios.get(`/api/posts/${postId}/comments`, { params: { page: p } });
setComments((prev) => p === 1 ? data.data : [...prev, ...data.data]); setComments2((prev) => p === 1 ? data.data : [...prev, ...data.data]);
setHasMore(data.meta.current_page < data.meta.last_page); setHasMore(data.meta.current_page < data.meta.last_page);
setPage(p); setPage(p);
} catch { } catch {
@@ -83163,7 +83352,7 @@ function PostComments({ postId, isLoggedIn, isOwn = false, initialCount = 0 }) {
setError(null); setError(null);
try { try {
const { data } = await axios.post(`/api/posts/${postId}/comments`, { body: body2 }); const { data } = await axios.post(`/api/posts/${postId}/comments`, { body: body2 });
setComments((prev) => [...prev, data.comment]); setComments2((prev) => [...prev, data.comment]);
setBody(""); setBody("");
} catch (err) { } catch (err) {
setError(err.response?.data?.message ?? "Failed to post comment."); setError(err.response?.data?.message ?? "Failed to post comment.");
@@ -83175,7 +83364,7 @@ function PostComments({ postId, isLoggedIn, isOwn = false, initialCount = 0 }) {
if (!window.confirm("Delete this comment?")) return; if (!window.confirm("Delete this comment?")) return;
try { try {
await axios.delete(`/api/posts/${postId}/comments/${commentId}`); await axios.delete(`/api/posts/${postId}/comments/${commentId}`);
setComments((prev) => prev.filter((c) => c.id !== commentId)); setComments2((prev) => prev.filter((c) => c.id !== commentId));
} catch { } catch {
} }
}; };
@@ -83183,12 +83372,12 @@ function PostComments({ postId, isLoggedIn, isOwn = false, initialCount = 0 }) {
try { try {
if (comment.is_highlighted) { if (comment.is_highlighted) {
await axios.delete(`/api/posts/${postId}/comments/${comment.id}/highlight`); await axios.delete(`/api/posts/${postId}/comments/${comment.id}/highlight`);
setComments( setComments2(
(prev) => prev.map((c) => c.id === comment.id ? { ...c, is_highlighted: false } : c) (prev) => prev.map((c) => c.id === comment.id ? { ...c, is_highlighted: false } : c)
); );
} else { } else {
await axios.post(`/api/posts/${postId}/comments/${comment.id}/highlight`); await axios.post(`/api/posts/${postId}/comments/${comment.id}/highlight`);
setComments( setComments2(
(prev) => prev.map((c) => ({ ...c, is_highlighted: c.id === comment.id })) (prev) => prev.map((c) => ({ ...c, is_highlighted: c.id === comment.id }))
); );
} }
@@ -94339,7 +94528,7 @@ function Skeleton() {
))); )));
} }
function NewsComments({ articleId, isLoggedIn = false, loginUrl = "/login" }) { function NewsComments({ articleId, isLoggedIn = false, loginUrl = "/login" }) {
const [comments, setComments] = reactExports.useState([]); const [comments, setComments2] = reactExports.useState([]);
const [loading, setLoading] = reactExports.useState(false); const [loading, setLoading] = reactExports.useState(false);
const [page, setPage] = reactExports.useState(1); const [page, setPage] = reactExports.useState(1);
const [lastPage, setLastPage] = reactExports.useState(1); const [lastPage, setLastPage] = reactExports.useState(1);
@@ -94352,9 +94541,9 @@ function NewsComments({ articleId, isLoggedIn = false, loginUrl = "/login" }) {
try { try {
const { data } = await axios.get(`/api/news/articles/${articleId}/comments?page=${nextPage}`); const { data } = await axios.get(`/api/news/articles/${articleId}/comments?page=${nextPage}`);
if (nextPage === 1) { if (nextPage === 1) {
setComments(data.data ?? []); setComments2(data.data ?? []);
} else { } else {
setComments((current) => [...current, ...data.data ?? []]); setComments2((current) => [...current, ...data.data ?? []]);
} }
setPage(data.meta?.current_page ?? nextPage); setPage(data.meta?.current_page ?? nextPage);
setLastPage(data.meta?.last_page ?? 1); setLastPage(data.meta?.last_page ?? 1);
@@ -94371,7 +94560,7 @@ function NewsComments({ articleId, isLoggedIn = false, loginUrl = "/login" }) {
}, [loadComments]); }, [loadComments]);
const handlePosted = reactExports.useCallback((newComment) => { const handlePosted = reactExports.useCallback((newComment) => {
const comment = { ...newComment, replies: newComment.replies || [] }; const comment = { ...newComment, replies: newComment.replies || [] };
setComments((current) => [comment, ...current]); setComments2((current) => [comment, ...current]);
setTotal((current) => current + 1); setTotal((current) => current + 1);
}, []); }, []);
const handleReplyPosted = reactExports.useCallback((parentId, newReply) => { const handleReplyPosted = reactExports.useCallback((parentId, newReply) => {
@@ -94384,7 +94573,7 @@ function NewsComments({ articleId, isLoggedIn = false, loginUrl = "/login" }) {
} }
return comment; return comment;
}); });
setComments((current) => insertReply(current)); setComments2((current) => insertReply(current));
setTotal((current) => current + 1); setTotal((current) => current + 1);
}, []); }, []);
const handleDelete = reactExports.useCallback(async (commentId) => { const handleDelete = reactExports.useCallback(async (commentId) => {
@@ -95510,7 +95699,8 @@ const TABS$1 = [
{ id: "about", label: "About", icon: "fa-id-card" }, { id: "about", label: "About", icon: "fa-id-card" },
{ id: "stats", label: "Stats", icon: "fa-chart-bar" }, { id: "stats", label: "Stats", icon: "fa-chart-bar" },
{ id: "favourites", label: "Favourites", icon: "fa-heart" }, { id: "favourites", label: "Favourites", icon: "fa-heart" },
{ id: "activity", label: "Activity", icon: "fa-bolt" } { id: "activity", label: "Activity", icon: "fa-bolt" },
{ id: "followers", label: "Followers", icon: "fa-users" }
]; ];
function ProfileTabs({ activeTab, onTabChange }) { function ProfileTabs({ activeTab, onTabChange }) {
const navRef = reactExports.useRef(null); const navRef = reactExports.useRef(null);
@@ -98120,12 +98310,65 @@ function TabWorlds({ worldHistory, isOwner }) {
/* @__PURE__ */ React.createElement(ProfileWorldHistorySection, { history: worldHistory, isOwner }) /* @__PURE__ */ React.createElement(ProfileWorldHistorySection, { history: worldHistory, isOwner })
); );
} }
function formatFollowedAt(value) {
if (!value) return null;
try {
const date = new Date(value);
if (Number.isNaN(date.getTime())) return null;
return date.toLocaleDateString(void 0, {
year: "numeric",
month: "short",
day: "numeric"
});
} catch {
return null;
}
}
function TabFollowers({ listing, username, followerCount }) {
const items = Array.isArray(listing?.data) ? listing.data : [];
const total = Number(listing?.total ?? followerCount ?? 0);
const currentPage = Number(listing?.current_page ?? 1);
const lastPage = Number(listing?.last_page ?? 1);
const prevUrl = listing?.prev_page_url ?? null;
const nextUrl = listing?.next_page_url ?? null;
return /* @__PURE__ */ React.createElement(
"div",
{
id: "tabpanel-followers",
role: "tabpanel",
"aria-labelledby": "tab-followers",
className: "pt-6"
},
/* @__PURE__ */ React.createElement("h2", { className: "mb-4 flex items-center gap-2 text-xs font-semibold uppercase tracking-widest text-slate-500" }, /* @__PURE__ */ React.createElement("i", { className: "fa-solid fa-users text-sky-400 fa-fw" }), "Followers", /* @__PURE__ */ React.createElement("span", { className: "rounded bg-white/5 px-1.5 py-0.5 text-[10px] font-medium normal-case tracking-normal text-slate-400" }, total.toLocaleString())),
items.length === 0 ? /* @__PURE__ */ React.createElement("p", { className: "rounded-2xl border border-white/8 bg-white/[0.03] px-4 py-8 text-center text-sm text-slate-400" }, username ? `${username} has no public followers yet.` : "No public followers yet.") : /* @__PURE__ */ React.createElement("ul", { className: "grid gap-3 sm:grid-cols-2" }, items.map((follower) => {
const handle = follower.username || follower.display_name || follower.name;
const followedAt = formatFollowedAt(follower.followed_at);
return /* @__PURE__ */ React.createElement("li", { key: follower.id }, /* @__PURE__ */ React.createElement(
"a",
{
href: follower.profile_url,
className: "flex items-center gap-3 rounded-2xl border border-white/8 bg-white/[0.03] px-3 py-3 transition-colors hover:border-sky-300/25 hover:bg-white/[0.06]"
},
follower.avatar_url ? /* @__PURE__ */ React.createElement(
"img",
{
src: follower.avatar_url,
alt: "",
className: "h-12 w-12 rounded-xl object-cover"
}
) : /* @__PURE__ */ React.createElement("span", { className: "flex h-12 w-12 items-center justify-center rounded-xl bg-white/5 text-slate-500" }, /* @__PURE__ */ React.createElement("i", { className: "fa-solid fa-user" })),
/* @__PURE__ */ React.createElement("span", { className: "min-w-0" }, /* @__PURE__ */ React.createElement("span", { className: "block truncate text-sm font-semibold text-white" }, handle), follower.username ? /* @__PURE__ */ React.createElement("span", { className: "block truncate text-xs text-slate-400" }, "@", follower.username) : null, /* @__PURE__ */ React.createElement("span", { className: "mt-1 block text-[11px] text-slate-500" }, [follower.rank, followedAt ? `Followed ${followedAt}` : null].filter(Boolean).join(" · ")))
));
})),
lastPage > 1 ? /* @__PURE__ */ React.createElement("nav", { className: "mt-6 flex items-center justify-center gap-3 text-sm", "aria-label": "Followers pagination" }, prevUrl ? /* @__PURE__ */ React.createElement("a", { href: prevUrl, className: "rounded-xl border border-white/10 px-3 py-2 text-slate-300 hover:bg-white/5" }, "Previous") : /* @__PURE__ */ React.createElement("span", { className: "rounded-xl border border-white/5 px-3 py-2 text-slate-600" }, "Previous"), /* @__PURE__ */ React.createElement("span", { className: "text-slate-500" }, "Page ", currentPage, " of ", lastPage), nextUrl ? /* @__PURE__ */ React.createElement("a", { href: nextUrl, className: "rounded-xl border border-white/10 px-3 py-2 text-slate-300 hover:bg-white/5" }, "Next") : /* @__PURE__ */ React.createElement("span", { className: "rounded-xl border border-white/5 px-3 py-2 text-slate-600" }, "Next")) : null
);
}
function GroupProfileSummary({ contributions = [], href = null }) { function GroupProfileSummary({ contributions = [], href = null }) {
if (!Array.isArray(contributions) || contributions.length === 0) return null; if (!Array.isArray(contributions) || contributions.length === 0) return null;
const featured = contributions.slice(0, 3); const featured = contributions.slice(0, 3);
return /* @__PURE__ */ React.createElement("section", { className: "mx-auto mt-8 max-w-6xl px-4 sm:px-6 lg:px-8" }, /* @__PURE__ */ React.createElement("div", { className: "rounded-[30px] border border-white/10 bg-[radial-gradient(circle_at_top_left,rgba(14,165,233,0.12),transparent_34%),linear-gradient(180deg,rgba(255,255,255,0.05),rgba(255,255,255,0.02))] p-5 shadow-[0_18px_55px_rgba(2,6,23,0.28)] backdrop-blur-xl sm:p-6" }, /* @__PURE__ */ React.createElement("div", { className: "flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between" }, /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("div", { className: "text-[11px] font-semibold uppercase tracking-[0.24em] text-sky-200/80" }, "Group footprint"), /* @__PURE__ */ React.createElement("h2", { className: "mt-2 text-2xl font-semibold text-white" }, "Collaborative work across public groups"), /* @__PURE__ */ React.createElement("p", { className: "mt-2 max-w-3xl text-sm leading-6 text-slate-400" }, "See the groups this creator contributes to through releases, credited artworks, and shared publishing activity.")), href ? /* @__PURE__ */ React.createElement("a", { href, className: "text-sm font-semibold text-sky-200 transition hover:text-white" }, "View full contribution history") : null), /* @__PURE__ */ React.createElement("div", { className: "mt-5 grid gap-4 lg:grid-cols-3" }, featured.map((entry) => /* @__PURE__ */ React.createElement("a", { key: entry.group?.slug || entry.group?.id, href: entry.group?.profile_url || "/groups", className: "rounded-[24px] border border-white/10 bg-black/20 p-4 transition hover:border-white/20 hover:bg-black/30" }, /* @__PURE__ */ React.createElement("div", { className: "flex items-start gap-4" }, /* @__PURE__ */ React.createElement("div", { className: "flex h-12 w-12 shrink-0 items-center justify-center overflow-hidden rounded-2xl border border-white/10 bg-white/[0.04]" }, entry.group?.avatar_url ? /* @__PURE__ */ React.createElement("img", { src: entry.group.avatar_url, alt: entry.group?.name, className: "h-full w-full object-cover", loading: "lazy" }) : /* @__PURE__ */ React.createElement("i", { className: "fa-solid fa-people-group text-slate-300" })), /* @__PURE__ */ React.createElement("div", { className: "min-w-0 flex-1" }, /* @__PURE__ */ React.createElement("div", { className: "truncate text-base font-semibold text-white" }, entry.group?.name), entry.role?.label ? /* @__PURE__ */ React.createElement("div", { className: "mt-1 text-sm text-slate-400" }, entry.role.label) : null, entry.summary ? /* @__PURE__ */ React.createElement("div", { className: "mt-2 text-sm text-slate-300" }, entry.summary) : null)), /* @__PURE__ */ React.createElement("div", { className: "mt-4 flex flex-wrap gap-2" }, entry.trusted ? /* @__PURE__ */ React.createElement(GroupBadgePill, { label: "Trusted contributor", tone: "sky" }) : null, entry.recent_release_titles?.length ? /* @__PURE__ */ React.createElement(GroupBadgePill, { label: "Recent releases", tone: "amber" }) : null), /* @__PURE__ */ React.createElement("div", { className: "mt-4 flex flex-wrap gap-4 text-xs text-slate-400" }, /* @__PURE__ */ React.createElement("span", null, Number(entry.counts?.artworks || 0).toLocaleString("en-US"), " artworks"), /* @__PURE__ */ React.createElement("span", null, Number(entry.counts?.releases || 0).toLocaleString("en-US"), " releases"), /* @__PURE__ */ React.createElement("span", null, Number(entry.counts?.projects || 0).toLocaleString("en-US"), " projects"))))))); return /* @__PURE__ */ React.createElement("section", { className: "mx-auto mt-8 max-w-6xl px-4 sm:px-6 lg:px-8" }, /* @__PURE__ */ React.createElement("div", { className: "rounded-[30px] border border-white/10 bg-[radial-gradient(circle_at_top_left,rgba(14,165,233,0.12),transparent_34%),linear-gradient(180deg,rgba(255,255,255,0.05),rgba(255,255,255,0.02))] p-5 shadow-[0_18px_55px_rgba(2,6,23,0.28)] backdrop-blur-xl sm:p-6" }, /* @__PURE__ */ React.createElement("div", { className: "flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between" }, /* @__PURE__ */ React.createElement("div", null, /* @__PURE__ */ React.createElement("div", { className: "text-[11px] font-semibold uppercase tracking-[0.24em] text-sky-200/80" }, "Group footprint"), /* @__PURE__ */ React.createElement("h2", { className: "mt-2 text-2xl font-semibold text-white" }, "Collaborative work across public groups"), /* @__PURE__ */ React.createElement("p", { className: "mt-2 max-w-3xl text-sm leading-6 text-slate-400" }, "See the groups this creator contributes to through releases, credited artworks, and shared publishing activity.")), href ? /* @__PURE__ */ React.createElement("a", { href, className: "text-sm font-semibold text-sky-200 transition hover:text-white" }, "View full contribution history") : null), /* @__PURE__ */ React.createElement("div", { className: "mt-5 grid gap-4 lg:grid-cols-3" }, featured.map((entry) => /* @__PURE__ */ React.createElement("a", { key: entry.group?.slug || entry.group?.id, href: entry.group?.profile_url || "/groups", className: "rounded-[24px] border border-white/10 bg-black/20 p-4 transition hover:border-white/20 hover:bg-black/30" }, /* @__PURE__ */ React.createElement("div", { className: "flex items-start gap-4" }, /* @__PURE__ */ React.createElement("div", { className: "flex h-12 w-12 shrink-0 items-center justify-center overflow-hidden rounded-2xl border border-white/10 bg-white/[0.04]" }, entry.group?.avatar_url ? /* @__PURE__ */ React.createElement("img", { src: entry.group.avatar_url, alt: entry.group?.name, className: "h-full w-full object-cover", loading: "lazy" }) : /* @__PURE__ */ React.createElement("i", { className: "fa-solid fa-people-group text-slate-300" })), /* @__PURE__ */ React.createElement("div", { className: "min-w-0 flex-1" }, /* @__PURE__ */ React.createElement("div", { className: "truncate text-base font-semibold text-white" }, entry.group?.name), entry.role?.label ? /* @__PURE__ */ React.createElement("div", { className: "mt-1 text-sm text-slate-400" }, entry.role.label) : null, entry.summary ? /* @__PURE__ */ React.createElement("div", { className: "mt-2 text-sm text-slate-300" }, entry.summary) : null)), /* @__PURE__ */ React.createElement("div", { className: "mt-4 flex flex-wrap gap-2" }, entry.trusted ? /* @__PURE__ */ React.createElement(GroupBadgePill, { label: "Trusted contributor", tone: "sky" }) : null, entry.recent_release_titles?.length ? /* @__PURE__ */ React.createElement(GroupBadgePill, { label: "Recent releases", tone: "amber" }) : null), /* @__PURE__ */ React.createElement("div", { className: "mt-4 flex flex-wrap gap-4 text-xs text-slate-400" }, /* @__PURE__ */ React.createElement("span", null, Number(entry.counts?.artworks || 0).toLocaleString("en-US"), " artworks"), /* @__PURE__ */ React.createElement("span", null, Number(entry.counts?.releases || 0).toLocaleString("en-US"), " releases"), /* @__PURE__ */ React.createElement("span", null, Number(entry.counts?.projects || 0).toLocaleString("en-US"), " projects")))))));
} }
const VALID_TABS = ["posts", "artworks", "stories", "achievements", "worlds", "collections", "about", "stats", "favourites", "activity"]; const VALID_TABS = ["posts", "artworks", "stories", "achievements", "worlds", "collections", "about", "stats", "favourites", "activity", "followers"];
function getInitialTab(initialTab = "posts") { function getInitialTab(initialTab = "posts") {
if (typeof window === "undefined") { if (typeof window === "undefined") {
return VALID_TABS.includes(initialTab) ? initialTab : "posts"; return VALID_TABS.includes(initialTab) ? initialTab : "posts";
@@ -98154,6 +98397,7 @@ function ProfileShow() {
socialLinks, socialLinks,
followerCount, followerCount,
recentFollowers, recentFollowers,
followersListing,
followContext, followContext,
followAnalytics, followAnalytics,
suggestedUsers, suggestedUsers,
@@ -98184,6 +98428,11 @@ function ProfileShow() {
const [activeTab, setActiveTab] = reactExports.useState(() => getInitialTab(initialTab)); const [activeTab, setActiveTab] = reactExports.useState(() => getInitialTab(initialTab));
const handleTabChange = reactExports.useCallback((tab2) => { const handleTabChange = reactExports.useCallback((tab2) => {
if (!VALID_TABS.includes(tab2)) return; if (!VALID_TABS.includes(tab2)) return;
if (tab2 === "followers") {
const targetBase = profileTabUrls?.followers || `${profileUrl || ""}/followers`;
window.location.assign(targetBase);
return;
}
setActiveTab(tab2); setActiveTab(tab2);
try { try {
const currentUrl = new URL(window.location.href); const currentUrl = new URL(window.location.href);
@@ -98344,6 +98593,13 @@ function ProfileShow() {
followerCount, followerCount,
creatorStories creatorStories
} }
), activeTab === "followers" && /* @__PURE__ */ React.createElement(
TabFollowers,
{
listing: followersListing,
username: user.username || user.name,
followerCount
}
)))); ))));
} }
const __vite_glob_0_110 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ const __vite_glob_0_110 = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({
+2
View File
@@ -52,6 +52,8 @@ return [
'leaderboard', 'leaderboard',
'art', 'art',
'art/*', 'art/*',
'download/artwork/*',
'photo/*',
'sitemap.xml', 'sitemap.xml',
'sitemaps/*', 'sitemaps/*',
'robots.txt', 'robots.txt',
+4
View File
@@ -0,0 +1,4 @@
@echo off
rem Convenience alias so "deploy" works from Command Prompt as deploy.bat
call "%~dp0deploy.cmd" %*
exit /b %ERRORLEVEL%
+68
View File
@@ -0,0 +1,68 @@
@echo off
setlocal EnableExtensions
rem Canonical Windows entrypoint for production deploy.
rem Vite/npm run on Windows (this process). rsync/ssh still run in WSL bash.
rem This distro cannot execute Windows PE binaries from Linux (Exec format error),
rem so powershell.exe/npm.cmd cannot be launched from inside WSL.
set "ROOT=%~dp0"
if "%ROOT:~-1%"=="\" set "ROOT=%ROOT:~0,-1%"
where wsl.exe >nul 2>&1
if errorlevel 1 (
echo ERROR: wsl.exe was not found on PATH.
echo Install WSL or run from an existing WSL shell: bash deploy.sh
exit /b 1
)
rem Local WSL is Ubuntu. Remote production server is Debian 13 (unrelated to this choice).
rem Override only with a non-empty DEPLOY_WSL_DISTRO.
set "DEPLOY_WSL_DISTRO_EFFECTIVE=Ubuntu"
if defined DEPLOY_WSL_DISTRO if not "%DEPLOY_WSL_DISTRO%"=="" set "DEPLOY_WSL_DISTRO_EFFECTIVE=%DEPLOY_WSL_DISTRO%"
set "SKIP_WINDOWS_BUILD=0"
set "SHOW_HELP=0"
for %%A in (%*) do (
if /I "%%~A"=="--skip-build" set "SKIP_WINDOWS_BUILD=1"
if /I "%%~A"=="--help" set "SHOW_HELP=1"
if /I "%%~A"=="-h" set "SHOW_HELP=1"
)
if defined LOCAL_BUILD_COMMAND set "SKIP_WINDOWS_BUILD=1"
set "WINDOWS_FRONTEND_BUILT=0"
if "%SHOW_HELP%"=="1" goto run_wsl
if "%SKIP_WINDOWS_BUILD%"=="1" goto run_wsl
echo.
echo Building frontend assets with Windows npm.cmd...
echo.
pushd "%ROOT%"
where npm.cmd >nul 2>&1
if errorlevel 1 (
echo ERROR: npm.cmd was not found on PATH.
echo Install Node.js or add Laragon's node directory to PATH, then retry.
popd
exit /b 1
)
call npm.cmd run build
set "NPM_EXIT=%ERRORLEVEL%"
popd
if not "%NPM_EXIT%"=="0" (
echo ERROR: Windows npm run build failed with exit code %NPM_EXIT%.
exit /b %NPM_EXIT%
)
set "WINDOWS_FRONTEND_BUILT=1"
:run_wsl
if defined WSLENV (
set "WSLENV=WINDOWS_FRONTEND_BUILT/u:%WSLENV%"
) else (
set "WSLENV=WINDOWS_FRONTEND_BUILT/u"
)
rem --cd keeps the working directory on the Windows checkout without fragile path munging.
wsl.exe -d %DEPLOY_WSL_DISTRO_EFFECTIVE% --cd "%ROOT%" -e bash "./deploy.sh" %*
set "EXIT_CODE=%ERRORLEVEL%"
exit /b %EXIT_CODE%
+9
View File
@@ -0,0 +1,9 @@
#!/usr/bin/env bash
set -euo pipefail
# Canonical production deploy entrypoint.
# Windows users should prefer deploy.cmd, which switches into WSL and runs this script.
script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
exec "$script_dir/scripts/deploy-production.sh" "$@"
+12 -30
View File
@@ -1,36 +1,18 @@
# ----------------------------------------------------------------------- # M13 — nginx X-Accel-Redirect for GET /download/artwork/{id}
# nginx X-Accel-Redirect for artwork original file downloads
# #
# Problem: PHP streams the file body through FPM → nginx, causing # Production vhost is NOT in this repository (live file:
# "[warn] upstream response is buffered to a temporary file" # /etc/nginx/sites-enabled/skinbase.org.conf).
# for large downloads because FastCGI buffers are too small. # Insert this location inside the HTTPS server { } block, before the
# catch-all `location /` and any regex locations that could steal the URI.
# #
# Solution: PHP sets X-Accel-Redirect, nginx serves the file body # Trailing slashes are required: location prefix and alias both end with /.
# directly from disk — FPM is only used for the header response. # Use the canonical shared storage tree so release switches do not break nginx.
# Do not use `root` here.
# #
# Setup: # Direct GET /internal/originals/... must 404 (internal;).
# 1. Set DOWNLOAD_ACCEL_PATH=/internal/originals in .env (production). # Laravel still authorizes/counts; only the file body is offloaded.
# 2. Include this file inside your server {} block:
# include /etc/nginx/conf.d/download-accel.conf;
# 3. Make sure the nginx worker has read access to the originals path.
#
# The /internal/originals prefix MUST match DOWNLOAD_ACCEL_PATH in .env.
# The alias path MUST match ARTWORKS_LOCAL_ORIGINALS_ROOT on the server.
# -----------------------------------------------------------------------
location /internal/originals/ { location ^~ /internal/originals/ {
# Block direct client access — only X-Accel-Redirect headers trigger this.
internal; internal;
alias /opt/www/virtual/SkinbaseNova.releases/shared/storage/app/originals/artworks/;
# Replace this with the actual absolute path to artwork originals on disk.
# Typically: /var/www/skinbase/storage/originals or /var/www/skinbase/public/files/originals
alias /var/www/skinbase/public/files/originals/;
# Let nginx send the file efficiently with sendfile + tcp_nopush.
sendfile on;
tcp_nopush on;
tcp_nodelay on;
# No nginx-level caching for download responses (private files).
add_header Cache-Control "private, no-store";
} }
+13 -2
View File
@@ -1,10 +1,21 @@
; Install once on the app host:
; sudo cp deploy/supervisor/skinbase-ssr.conf /etc/supervisor/conf.d/skinbase-ssr.conf
; sudo supervisorctl reread && sudo supervisorctl update
; sudo supervisorctl start skinbase-ssr
;
; Path uses the stable app symlink (REMOTE_FOLDER), not a versioned release dir.
; Override node path if needed. Deploy restarts this via SSR_SUPERVISOR_PROGRAM=skinbase-ssr.
[program:skinbase-ssr] [program:skinbase-ssr]
command=/usr/local/bin/node /opt/www/virtual/SkinbaseNova/bootstrap/ssr/ssr.js command=/usr/bin/env node /opt/www/virtual/SkinbaseNova/bootstrap/ssr/ssr.js
directory=/opt/www/virtual/SkinbaseNova
process_name=%(program_name)s process_name=%(program_name)s
numprocs=1 numprocs=1
autostart=true autostart=true
autorestart=true autorestart=true
user=www-data user=skinbase
redirect_stderr=true redirect_stderr=true
stdout_logfile=/var/log/skinbase_ssr.log stdout_logfile=/var/log/skinbase_ssr.log
stdout_logfile_maxbytes=10MB
stopwaitsecs=10 stopwaitsecs=10
stopasgroup=true
killasgroup=true
+3 -1
View File
@@ -20,7 +20,9 @@ Examples below are representative. For the full option list of any Artisan comma
| Entry point | Why it is used | Example | | Entry point | Why it is used | Example |
| --- | --- | --- | | --- | --- | --- |
| `php artisan` | Main Laravel CLI for all custom app commands listed below | `php artisan list --raw` | | `php artisan` | Main Laravel CLI for all custom app commands listed below | `php artisan list --raw` |
| `bash sync.sh` | Main production deploy wrapper; delegates to the safe release-based production deploy script | `bash sync.sh` | | `deploy.cmd` / `deploy.bat` | Windows Command Prompt production deploy entrypoint; switches into WSL and runs `deploy.sh` | `deploy.cmd` |
| `bash deploy.sh` | Canonical bash production deploy entrypoint; stages versioned releases and switches `current` | `bash deploy.sh` |
| `bash sync.sh` | Legacy alias for `bash deploy.sh` | `bash sync.sh` |
| `bash sync_dev.sh` | Push the development environment to the configured remote dev host | `bash sync_dev.sh` | | `bash sync_dev.sh` | Push the development environment to the configured remote dev host | `bash sync_dev.sh` |
## Maintained Standalone Scripts ## Maintained Standalone Scripts
+77 -39
View File
@@ -1,18 +1,25 @@
# Deployment # Deployment
This repository uses a Bash-based production deploy flow. This repository uses a Bash-based production deploy flow with a Windows Command Prompt wrapper.
## Normal deploy ## Normal deploy
Run the existing entrypoint: Preferred entrypoints:
```bash ```bat
bash sync.sh deploy.cmd
``` ```
`bash sync.sh` delegates to the safe production deploy wrapper, which stages each deploy into a versioned release directory and switches traffic by updating the server-side `current` symlink. ```bash
bash deploy.sh
```
If you launch `bash sync.sh` from WSL against this Windows checkout, the script will automatically run the frontend build with `npm.cmd` on Windows so Rollup/Vite use the correct optional native package set. `deploy.cmd` is the Windows Command Prompt entrypoint. It switches into WSL and runs `deploy.sh`.
`deploy.sh` is the canonical bash entrypoint and delegates to the safe production deploy wrapper, which stages each deploy into a versioned release directory and switches traffic by updating the server-side `current` symlink.
`bash sync.sh` remains as a legacy alias for the same flow.
`deploy.cmd` runs `npm.cmd run build` on Windows first, then enters WSL for rsync/ssh. That is required when the Ubuntu distro cannot execute Windows `.exe` files (`Exec format error` on `powershell.exe`). If WSL interop does work, `bash deploy.sh` can still launch `npm.cmd` through PowerShell. Local Linux `php`/`composer` are not required for a normal deploy; Artisan and Composer run on the production server. `--with-tests` uses WSL `php` when present, otherwise Windows `php.exe`.
This will: This will:
@@ -28,7 +35,16 @@ This will:
This is now the low-downtime default path for normal code and feature deploys. This is now the low-downtime default path for normal code and feature deploys.
Each deploy generates a release ID automatically from UTC time and the local Git revision. Releases are retained under `REMOTE_RELEASE_ROOT/releases/<release-id>`, and production switches between them on the server by updating `REMOTE_RELEASE_ROOT/current`. The public/runtime path stays fixed at `REMOTE_FOLDER`, which is now treated as a stable symlink to the active release. Each deploy generates:
- a monotonic local **build number** (stored in `var/deploy/build-number`)
- a **release ID** from UTC time + build number + Git revision, for example `20260829-141522-b42-a1b2c3d`
- local deploy history under `var/deploy/` (`latest.json`, `history.jsonl`, and per-run logs in `var/deploy/logs/`)
- remote metadata under `REMOTE_RELEASE_ROOT/deployments/<release-id>.json` and `current-release.json`
Console output includes step progress with elapsed time, rsync transfer progress, and a final duration summary. Override the build number with `--build-number N` or `BUILD_NUMBER=N` when needed. Dry-runs preview the next build number without consuming it.
Releases are retained under `REMOTE_RELEASE_ROOT/releases/<release-id>`, and production switches between them on the server by updating `REMOTE_RELEASE_ROOT/current`. The public/runtime path stays fixed at `REMOTE_FOLDER`, which is now treated as a stable symlink to the active release.
On the first deploy with this layout, the existing live folder is adopted into the release archive automatically and `REMOTE_FOLDER` is converted into that stable symlink path. After that, switching back to an older release does not require any local re-upload. On the first deploy with this layout, the existing live folder is adopted into the release archive automatically and `REMOTE_FOLDER` is converted into that stable symlink path. After that, switching back to an older release does not require any local re-upload.
@@ -36,8 +52,12 @@ On the first deploy with this layout, the existing live folder is adopted into t
Use a full upgrade when the release also needs broad Meilisearch work or non-code service operations. Use a full upgrade when the release also needs broad Meilisearch work or non-code service operations.
```bat
deploy.cmd --full-upgrade
```
```bash ```bash
bash sync.sh --full-upgrade bash deploy.sh --full-upgrade
``` ```
Full-upgrade mode: Full-upgrade mode:
@@ -49,7 +69,7 @@ Full-upgrade mode:
Example with service hooks: Example with service hooks:
```bash ```bash
bash sync.sh --full-upgrade \ bash deploy.sh --full-upgrade \
--upgrade-pre-hook='sudo systemctl stop reverb' \ --upgrade-pre-hook='sudo systemctl stop reverb' \
--upgrade-post-hook='sudo systemctl restart reverb meilisearch' --upgrade-post-hook='sudo systemctl restart reverb meilisearch'
``` ```
@@ -59,39 +79,52 @@ You can also provide those hooks through environment variables instead of CLI fl
```bash ```bash
FULL_UPGRADE_PRE_HOOK='sudo systemctl stop reverb' \ FULL_UPGRADE_PRE_HOOK='sudo systemctl stop reverb' \
FULL_UPGRADE_POST_HOOK='sudo systemctl restart reverb meilisearch' \ FULL_UPGRADE_POST_HOOK='sudo systemctl restart reverb meilisearch' \
bash sync.sh --full-upgrade bash deploy.sh --full-upgrade
``` ```
## Deploy options ## Deploy options
```bat
deploy.cmd --skip-build
deploy.cmd --skip-migrate
deploy.cmd --no-maintenance
deploy.cmd --mode=full-upgrade
deploy.cmd --keep-releases=8
deploy.cmd --release-id=release-2026-04-25
deploy.cmd --build-number=100
deploy.cmd --no-rsync-progress
```
```bash ```bash
bash sync.sh --skip-build bash deploy.sh --skip-build
bash sync.sh --skip-migrate bash deploy.sh --skip-migrate
bash sync.sh --no-maintenance bash deploy.sh --no-maintenance
bash sync.sh --mode=full-upgrade bash deploy.sh --mode=full-upgrade
bash sync.sh --keep-releases=8 bash deploy.sh --keep-releases=8
bash sync.sh --release-id=release-2026-04-25 bash deploy.sh --release-id=release-2026-04-25
bash deploy.sh --build-number=100
bash deploy.sh --no-rsync-progress
``` ```
Environment overrides: Environment overrides:
```bash ```bash
REMOTE_SERVER=user@example.com REMOTE_FOLDER=/var/www/app bash sync.sh REMOTE_SERVER=user@example.com REMOTE_FOLDER=/var/www/app bash deploy.sh
REMOTE_RELEASE_ROOT=/var/www/app.releases RELEASE_RETENTION=8 bash sync.sh REMOTE_RELEASE_ROOT=/var/www/app.releases RELEASE_RETENTION=8 bash deploy.sh
``` ```
You can also override the local build command explicitly: You can also override the local build command explicitly:
```bash ```bash
LOCAL_BUILD_COMMAND='npm run build' bash sync.sh LOCAL_BUILD_COMMAND='npm run build' bash deploy.sh
LOCAL_BUILD_COMMAND='pnpm build' bash sync.sh LOCAL_BUILD_COMMAND='pnpm build' bash deploy.sh
``` ```
Upgrade hooks can also be supplied via environment variables: Upgrade hooks can also be supplied via environment variables:
```bash ```bash
FULL_UPGRADE_PRE_HOOK='sudo systemctl stop reverb' bash sync.sh --full-upgrade FULL_UPGRADE_PRE_HOOK='sudo systemctl stop reverb' bash deploy.sh --full-upgrade
FULL_UPGRADE_POST_HOOK='sudo systemctl restart reverb meilisearch' bash sync.sh --full-upgrade FULL_UPGRADE_POST_HOOK='sudo systemctl restart reverb meilisearch' bash deploy.sh --full-upgrade
``` ```
## Rollback and release history ## Rollback and release history
@@ -126,26 +159,26 @@ Operational notes:
- Each retained release already contains its own code and vendor tree, so rollback is primarily a symlink switch plus cache refresh and `queue:restart`. - Each retained release already contains its own code and vendor tree, so rollback is primarily a symlink switch plus cache refresh and `queue:restart`.
- Rollback does not reverse database migrations. If a release includes incompatible schema changes, handle the database separately. - Rollback does not reverse database migrations. If a release includes incompatible schema changes, handle the database separately.
- Release retention defaults to 5 releases and can be changed with `--keep-releases` or `RELEASE_RETENTION`. - Release retention defaults to 5 releases and can be changed with `--keep-releases` or `RELEASE_RETENTION`.
- Release data lives outside the active app path by default at `REMOTE_FOLDER.releases`, so switching releases happens entirely on the production server. - Reldeploy.sh --with-db-from=local
## Replace production database from local
This is intentionally separate from a normal deploy because it overwrites production data.
```bash
bash scripts/push-db-to-prod.sh --force
```
Or combine it with deploy:
```bash
bash sync.sh --with-db-from=local
``` ```
When run interactively, the deploy script will ask you to confirm the exact remote server and type a confirmation phrase before replacing production data. When run interactively, the deploy script will ask you to confirm the exact remote server and type a confirmation phrase before replacing production data.
For non-interactive use, pass both confirmations explicitly: For non-interactive use, pass both confirmations explicitly:
```bash
bash deploy.sh --with-db-from=local \
--confirm-db-sync-target=klevze@server3.klevze.si \
--confirm-db-sync-phrase='replace production db from local'
```
Legacy compatibility still exists for:
```bash
bash deployinteractively, the deploy script will ask you to confirm the exact remote server and type a confirmation phrase before replacing production data.
For non-interactive use, pass both confirmations explicitly:
```bash ```bash
bash sync.sh --with-db-from=local \ bash sync.sh --with-db-from=local \
--confirm-db-sync-target=klevze@server3.klevze.si \ --confirm-db-sync-target=klevze@server3.klevze.si \
@@ -179,11 +212,16 @@ LOCAL_MYSQLDUMP_COMMAND='mysqldump --host=10.0.0.5 --port=3306 --user=app dbname
## Safety notes ## Safety notes
- Normal deployments should use `bash sync.sh` without `--with-db`. - Normal deployments should use `deploy.cmd` or `bash deploy.sh` without `--with-db`.
- Use `bash sync.sh --full-upgrade` only when the release also includes Meilisearch-wide refreshes or remote service changes. - Use `--full-upgrade` only when the release also includes Meilisearch-wide refreshes or remote service changes.
- Use database replacement only for first-time bootstrap, staging, or an intentional full production reset. - Use database replacement only for first-time bootstrap, staging, or an intentional full production reset.
- Use `bash scripts/rollback-production.sh --previous` for a fast server-side release switch when the last deploy needs to be reverted. - Use `bash scripts/rollback-production.sh --previous` for a fast server-side release switch when the last deploy needs to be reverted.
- Route caching now runs through `php artisan optimize` in deploy automation; if that starts failing again, fix the route definitions instead of dropping route caching from deploy. - Route caching now runs through `php artisan optimize` in deploy automation; if that starts failing again, fix the route definitions instead of dropping route caching from deploy.
- On Windows, prefer `deploy.cmd` so the process always enters WSL before rsync/ssh/php. It defaults to the local `Ubuntu` WSL distro. The production server itself runs Debian 13; that is separate from the local WSL choice. Override with `DEPLOY_WSL_DISTRO` only if needed. If WSL has no usable `~/.ssh` keys (common when the distro runs as root and keys live in `C:\Users\<you>\.ssh`), the script copies those Windows identities into a 0600 temp dir and uses them for BatchMode SSH. Override the source with `WINDOWS_SSH_DIR`.
- SSH still authenticates as `REMOTE_SERVER` (for example `klevze@host`), but remote rsync/composer/artisan/release work runs as `REMOTE_APP_USER` (default `skinbase`) via `sudo -n -u skinbase`. This keeps release files and runtime dirs owned by the app user and avoids prune blockers like `owner=skinbase mode=2700 .config`. Set `REMOTE_APP_USER=-` only to disable that and run as the SSH login user.
- Remote app-user shells start in `/tmp` (not the SSH user's home). The stable `REMOTE_FOLDER` symlink is left alone once it already points at `.../current`; only `current` is rewritten each deploy. That avoids `Permission denied` on root-owned parents like `/opt/www/virtual`.
- The deploy script refuses missing Vite/SSR build artifacts by default (`REQUIRE_BUILD_MANIFEST=1`), verifies SSH non-interactively first, and takes a local deploy lock so two overlapping deploys do not race.
- Inertia SSR restart is owned exclusively by Supervisor program `skinbase-ssr` (`deploy/supervisor/skinbase-ssr.conf`). The remote application phase runs as `skinbase`, while the restart is issued separately over the SSH deployment session as the privileged login account with `sudo -n /usr/bin/supervisorctl`. The deploy fails if privileged Supervisor access or the configured program is unavailable, and verifies that the program reaches `RUNNING`; it never starts or stops SSR through Artisan.
## Nginx upstream error pages ## Nginx upstream error pages
@@ -202,4 +240,4 @@ On the current `skinbase.org` vhost, the required FastCGI locations are:
- `location ^~ /api/uploads/` - `location ^~ /api/uploads/`
- `location = /index.php` - `location = /index.php`
This intentionally intercepts only `502` and `504`, so Laravel remains responsible for normal `404`, `419`, `429`, `500`, and `503` rendering when the application is actually running. This intentionally intercepts only `502` and `504`, so Laravel remains responsible for normal `404`, `419`, `429`, `500`, and `503` rendering when the application is actually running.
@@ -0,0 +1,49 @@
# M12.6 — Performance Analyzer Correctness + Rotation-Safe 24h Reporting
Analyzer-only. No nginx, FPM, Redis, or request-path changes.
## Status filters
| Value | Meaning |
| --- | --- |
| `--status=404` | **exact** HTTP 404 only |
| `--status=500` | exact 500 |
| `--status=4xx` | 400–499 (includes 404, 422, 499) |
| `--status=5xx` | 500–599 |
`--status=404` is **not** a 4xx class. Malformed values (`foo`, `40`, `9999`) exit 2.
**Bug:** the old analyzer only special-cased `4xx` / `5xx` / `499`. `--status=404` was ignored, so almost every row was returned.
## Inputs
- `--file=path` (repeatable)
- `--glob='…/skinbase-performance.log*'`
- default single file remains `/var/log/nginx/skinbase-performance.log`
- `.gz` streamed with `gzopen` (no `zcat`, no full-file decompress)
- duplicate **paths** are collapsed via `realpath`; duplicate **events** are kept
## Time / coverage
- `--since=24h` (or `30m` / `7d`) from `--until` or now
- optional `--until=` ISO-8601, `--hours=24` alias for `--since=24h`
- Coverage uses parseable timestamps in the supplied files (15-minute tolerance)
- Incomplete sources emit: `Observed records do not demonstrate full requested coverage.`
## Backward compatible
```bash
php scripts/analyze-http-performance.php --file=/var/log/nginx/skinbase-performance.log --since=24h --host=skinbase.org
```
## Rotation-aware 24h
```bash
sudo php /opt/www/virtual/SkinbaseNova/scripts/analyze-http-performance.php \
--glob='/var/log/nginx/skinbase-performance.log*' \
--since=24h \
--host=skinbase.org \
--top=20
```
`skinbase` may not be able to read `/var/log/nginx`; use the same sudo model as today. The analyzer is read-only.
@@ -0,0 +1,80 @@
# M13 — Artwork Download X-Accel-Redirect Offload
Application tests and nginx snippet only. **Do not enable in production until Stage A nginx is verified.**
## Pre-M13 evidence
`/download/artwork/{id}` (M12 analyzer, skinbase.org):
| | |
| --- | --- |
| count | 2363 |
| p50 / p95 / p99 / max | 0.535 / 1.526 / 3.335 / **16.037** s |
| avg request_time | 0.7175 s |
| avg upstream_response_time | 0.3762 s |
| avg bytes | ~528 KB |
Slow requests show PHP finishing in ~0.3–0.5 s while `request_time` stays large: FPM is stuck sending the body.
After X-Accel: `upstream_response_time` is still Laravel work; `request_time` can still include slow clients. The win is **freeing PHP-FPM**, not always a tiny nginx `request_time`.
## Existing application
- Route: `GET /download/artwork/{id}` → `ArtworkDownloadController`
- Flag: `DOWNLOAD_ACCEL_ENABLED` default **false**
- Path: `DOWNLOAD_ACCEL_PATH=/internal/originals`
- Laravel originals root: `uploads.local_originals_root` (symlink `SkinbaseNova/storage` → shared)
- Fallback: `response()->download()` — **must stay**
Controller already authorizes, checks file, records analytics, then optionally sets `X-Accel-Redirect`.
## nginx mapping
Production vhost is **not** in this repo: `/etc/nginx/sites-enabled/skinbase.org.conf`.
Snippet: `deploy/nginx/download-accel.conf`
```nginx
location ^~ /internal/originals/ {
internal;
alias /opt/www/virtual/SkinbaseNova.releases/shared/storage/app/originals/artworks/;
}
```
Alias uses the **canonical shared tree** so a release switch cannot point nginx at a missing release directory. Laravel still uses `local_originals_root`; the X-Accel URI is only the relative hash path (`/27/21/….zip`).
`internal;` is mandatory: a browser GET to `/internal/originals/...` must 404.
No sendfile/tcp/buffering changes in M13.
## Rollout (operator — not executed here)
### Stage A — nginx only (`DOWNLOAD_ACCEL_ENABLED=false`)
1. Backup: `sudo cp /etc/nginx/sites-enabled/skinbase.org.conf /etc/nginx/sites-enabled/skinbase.org.conf.bak-$(date +%Y%m%d-%H%M%S)`
2. Insert the location inside the HTTPS `server { }` (before `location /`).
3. `sudo nginx -t` — stop if it fails.
4. `sudo systemctl reload nginx` — **not** restart.
5. Direct access: `curl -I https://skinbase.org/internal/originals/<relative-path>` → **404**.
6. Optional: `sudo -u www-data test -r <absolute-original> && echo READABLE`
### Stage B — Laravel flag
7. Set `DOWNLOAD_ACCEL_ENABLED=true` (path already `/internal/originals`).
8. `sudo -u skinbase php artisan config:cache` if config is cached.
9. Verify with tinker as `skinbase` (flag true, path `/internal/originals`).
10. Public: `curl -fL https://skinbase.org/download/artwork/<ID>` → 200, attachment, size matches original.
11. Range: `curl -H 'Range: bytes=0-99'` → **206**, `Content-Range: bytes 0-99/<size>`, 100-byte body.
12. Repeat direct internal URL → still 404.
Application deploy: `bash ./sync.sh`. Never `git pull` on production.
## Rollback
First: `DOWNLOAD_ACCEL_ENABLED=false` then `sudo -u skinbase php artisan config:cache` if needed. PHP fallback returns immediately. Nginx location can stay.
If nginx must be reverted: restore the `.bak-*` vhost, `nginx -t`, reload.
## Analyzer
Keep M12 `skinbase-performance.log`. After activation, `upstream_response_time` should stay near Laravel work; do not expect `request_time` to collapse for slow clients.
File diff suppressed because it is too large Load Diff
+19 -1
View File
@@ -13,9 +13,10 @@ import TabActivity from '../../components/profile/tabs/TabActivity'
import TabPosts from '../../components/profile/tabs/TabPosts' import TabPosts from '../../components/profile/tabs/TabPosts'
import TabStories from '../../components/profile/tabs/TabStories' import TabStories from '../../components/profile/tabs/TabStories'
import TabWorlds from '../../components/profile/tabs/TabWorlds' import TabWorlds from '../../components/profile/tabs/TabWorlds'
import TabFollowers from '../../components/profile/tabs/TabFollowers'
import GroupProfileSummary from '../../components/groups/GroupProfileSummary' import GroupProfileSummary from '../../components/groups/GroupProfileSummary'
const VALID_TABS = ['posts', 'artworks', 'stories', 'achievements', 'worlds', 'collections', 'about', 'stats', 'favourites', 'activity'] const VALID_TABS = ['posts', 'artworks', 'stories', 'achievements', 'worlds', 'collections', 'about', 'stats', 'favourites', 'activity', 'followers']
function getInitialTab(initialTab = 'posts') { function getInitialTab(initialTab = 'posts') {
if (typeof window === 'undefined') { if (typeof window === 'undefined') {
@@ -55,6 +56,7 @@ export default function ProfileShow() {
socialLinks, socialLinks,
followerCount, followerCount,
recentFollowers, recentFollowers,
followersListing,
followContext, followContext,
followAnalytics, followAnalytics,
suggestedUsers, suggestedUsers,
@@ -87,6 +89,15 @@ export default function ProfileShow() {
const handleTabChange = useCallback((tab) => { const handleTabChange = useCallback((tab) => {
if (!VALID_TABS.includes(tab)) return if (!VALID_TABS.includes(tab)) return
// Followers listing is loaded only on the followers tab GET. A client-only
// pushState would render an empty list, so navigate for a real page load.
if (tab === 'followers') {
const targetBase = profileTabUrls?.followers || `${profileUrl || ''}/followers`
window.location.assign(targetBase)
return
}
setActiveTab(tab) setActiveTab(tab)
try { try {
@@ -274,6 +285,13 @@ export default function ProfileShow() {
creatorStories={creatorStories} creatorStories={creatorStories}
/> />
)} )}
{activeTab === 'followers' && (
<TabFollowers
listing={followersListing}
username={user.username || user.name}
followerCount={followerCount}
/>
)}
</div> </div>
</div> </div>
</> </>
@@ -11,6 +11,7 @@ export const TABS = [
{ id: 'stats', label: 'Stats', icon: 'fa-chart-bar' }, { id: 'stats', label: 'Stats', icon: 'fa-chart-bar' },
{ id: 'favourites', label: 'Favourites', icon: 'fa-heart' }, { id: 'favourites', label: 'Favourites', icon: 'fa-heart' },
{ id: 'activity', label: 'Activity', icon: 'fa-bolt' }, { id: 'activity', label: 'Activity', icon: 'fa-bolt' },
{ id: 'followers', label: 'Followers', icon: 'fa-users' },
] ]
/** /**
@@ -0,0 +1,113 @@
import React from 'react'
function formatFollowedAt(value) {
if (!value) return null
try {
const date = new Date(value)
if (Number.isNaN(date.getTime())) return null
return date.toLocaleDateString(undefined, {
year: 'numeric',
month: 'short',
day: 'numeric',
})
} catch {
return null
}
}
export default function TabFollowers({ listing, username, followerCount }) {
const items = Array.isArray(listing?.data) ? listing.data : []
const total = Number(listing?.total ?? followerCount ?? 0)
const currentPage = Number(listing?.current_page ?? 1)
const lastPage = Number(listing?.last_page ?? 1)
const prevUrl = listing?.prev_page_url ?? null
const nextUrl = listing?.next_page_url ?? null
return (
<div
id="tabpanel-followers"
role="tabpanel"
aria-labelledby="tab-followers"
className="pt-6"
>
<h2 className="mb-4 flex items-center gap-2 text-xs font-semibold uppercase tracking-widest text-slate-500">
<i className="fa-solid fa-users text-sky-400 fa-fw" />
Followers
<span className="rounded bg-white/5 px-1.5 py-0.5 text-[10px] font-medium normal-case tracking-normal text-slate-400">
{total.toLocaleString()}
</span>
</h2>
{items.length === 0 ? (
<p className="rounded-2xl border border-white/8 bg-white/[0.03] px-4 py-8 text-center text-sm text-slate-400">
{username ? `${username} has no public followers yet.` : 'No public followers yet.'}
</p>
) : (
<ul className="grid gap-3 sm:grid-cols-2">
{items.map((follower) => {
const handle = follower.username || follower.display_name || follower.name
const followedAt = formatFollowedAt(follower.followed_at)
return (
<li key={follower.id}>
<a
href={follower.profile_url}
className="flex items-center gap-3 rounded-2xl border border-white/8 bg-white/[0.03] px-3 py-3 transition-colors hover:border-sky-300/25 hover:bg-white/[0.06]"
>
{follower.avatar_url ? (
<img
src={follower.avatar_url}
alt=""
className="h-12 w-12 rounded-xl object-cover"
/>
) : (
<span className="flex h-12 w-12 items-center justify-center rounded-xl bg-white/5 text-slate-500">
<i className="fa-solid fa-user" />
</span>
)}
<span className="min-w-0">
<span className="block truncate text-sm font-semibold text-white">
{handle}
</span>
{follower.username ? (
<span className="block truncate text-xs text-slate-400">@{follower.username}</span>
) : null}
<span className="mt-1 block text-[11px] text-slate-500">
{[follower.rank, followedAt ? `Followed ${followedAt}` : null]
.filter(Boolean)
.join(' · ')}
</span>
</span>
</a>
</li>
)
})}
</ul>
)}
{lastPage > 1 ? (
<nav className="mt-6 flex items-center justify-center gap-3 text-sm" aria-label="Followers pagination">
{prevUrl ? (
<a href={prevUrl} className="rounded-xl border border-white/10 px-3 py-2 text-slate-300 hover:bg-white/5">
Previous
</a>
) : (
<span className="rounded-xl border border-white/5 px-3 py-2 text-slate-600">Previous</span>
)}
<span className="text-slate-500">
Page {currentPage} of {lastPage}
</span>
{nextUrl ? (
<a href={nextUrl} className="rounded-xl border border-white/10 px-3 py-2 text-slate-300 hover:bg-white/5">
Next
</a>
) : (
<span className="rounded-xl border border-white/5 px-3 py-2 text-slate-600">Next</span>
)}
</nav>
) : null}
</div>
)
}
@@ -9,6 +9,7 @@ import { useState, useEffect, useCallback, useRef } from 'react';
import { useNavContext } from '../../lib/useNavContext'; import { useNavContext } from '../../lib/useNavContext';
const preloadCache = new Set(); const preloadCache = new Set();
const prefetchCache = new Set();
function scheduleIdleTask(callback, delay = 1200) { function scheduleIdleTask(callback, delay = 1200) {
if (typeof window === 'undefined') { if (typeof window === 'undefined') {
@@ -32,8 +33,8 @@ function preloadImage(src) {
img.src = src; img.src = src;
} }
export default function ArtworkNavigator({ artworkId, onNavigate, onOpenViewer, onReady }) { export default function ArtworkNavigator({ artworkId, navigation = null, onNavigate, onOpenViewer, onReady }) {
const { getNeighbors } = useNavContext(artworkId); const { getNeighbors } = useNavContext(artworkId, navigation);
const [neighbors, setNeighbors] = useState({ prevId: null, nextId: null, prevUrl: null, nextUrl: null }); const [neighbors, setNeighbors] = useState({ prevId: null, nextId: null, prevUrl: null, nextUrl: null });
// Refs so navigate/keyboard/swipe callbacks are stable (no dep on state values) // Refs so navigate/keyboard/swipe callbacks are stable (no dep on state values)
@@ -67,17 +68,42 @@ export default function ArtworkNavigator({ artworkId, onNavigate, onOpenViewer,
const ids = [neighbors.prevId, neighbors.nextId].filter(Boolean); const ids = [neighbors.prevId, neighbors.nextId].filter(Boolean);
if (ids.length === 0) return undefined; if (ids.length === 0) return undefined;
const previews = new Map([
[neighbors.prevId, neighbors.prevPreview],
[neighbors.nextId, neighbors.nextPreview],
]);
let cancelled = false; let cancelled = false;
const cancelIdleTask = scheduleIdleTask(() => { const cancelIdleTask = scheduleIdleTask(() => {
ids.forEach((id) => { ids.forEach((id) => {
fetch(`/api/artworks/${id}/page`, { headers: { Accept: 'application/json' } }) if (prefetchCache.has(id)) return;
.then((r) => r.ok ? r.json() : null) prefetchCache.add(id);
const preview = previews.get(id);
if (preview) {
if (!cancelled) {
const imgUrl = preview.thumbs?.lg?.url || preview.thumbs?.md?.url;
if (imgUrl) preloadImage(imgUrl);
}
return;
}
fetch(`/api/artworks/${id}/page?prefetch=1`, { headers: { Accept: 'application/json' } })
.then((r) => {
if (!r.ok) {
prefetchCache.delete(id);
return null;
}
return r.json();
})
.then((data) => { .then((data) => {
if (cancelled || !data) return; if (!data) {
prefetchCache.delete(id);
return;
}
if (cancelled) return;
const imgUrl = data.thumbs?.lg?.url || data.thumbs?.md?.url; const imgUrl = data.thumbs?.lg?.url || data.thumbs?.md?.url;
if (imgUrl) preloadImage(imgUrl); if (imgUrl) preloadImage(imgUrl);
}) })
.catch(() => {}); .catch(() => prefetchCache.delete(id));
}); });
}); });
@@ -85,7 +111,7 @@ export default function ArtworkNavigator({ artworkId, onNavigate, onOpenViewer,
cancelled = true; cancelled = true;
cancelIdleTask(); cancelIdleTask();
}; };
}, [neighbors.prevId, neighbors.nextId]); }, [neighbors.prevId, neighbors.nextId, neighbors.prevPreview, neighbors.nextPreview]);
// Stable navigate — reads state via refs, never recreated // Stable navigate — reads state via refs, never recreated
const navigate = useCallback(async (targetId, targetUrl) => { const navigate = useCallback(async (targetId, targetUrl) => {
@@ -0,0 +1,79 @@
import React from 'react'
import { act, cleanup, render, waitFor } from '@testing-library/react'
import ArtworkNavigator from './ArtworkNavigator'
describe('ArtworkNavigator preview prefetch', () => {
afterEach(() => {
cleanup()
vi.restoreAllMocks()
delete window.requestIdleCallback
delete window.cancelIdleCallback
})
it('seeds neighbor prefetch from navigation and keeps full navigation separate', async () => {
let idleCallback
let ready
window.requestIdleCallback = vi.fn((callback) => { idleCallback = callback; return 1 })
window.cancelIdleCallback = vi.fn()
global.fetch = vi.fn((url) => {
if (String(url).includes('/api/artworks/navigation/777')) {
return Promise.resolve({
ok: true,
json: async () => ({
prev_id: 776,
next_id: 778,
prev_url: '/art/776/previous',
next_url: '/art/778/next',
prev_slug: 'previous',
next_slug: 'next',
prev_preview: { id: 776, title: 'Previous', slug: 'previous', thumbs: { md: { url: '/md-776' }, lg: { url: '/lg-776' } } },
next_preview: { id: 778, title: 'Next', slug: 'next', thumbs: { md: { url: '/md-778' }, lg: { url: '/lg-778' } } },
}),
})
}
return Promise.resolve({ ok: true, json: async () => ({ id: 778, title: 'Next', slug: 'next' }) })
})
render(<ArtworkNavigator artworkId={777} onNavigate={vi.fn()} onReady={(next) => { if (next.hasNext) ready = next }} />)
await waitFor(() => expect(ready?.hasNext).toBe(true))
await act(async () => idleCallback())
expect(global.fetch.mock.calls.filter(([url]) => String(url).includes('?prefetch=1'))).toHaveLength(0)
await act(async () => ready.navigateNext())
expect(global.fetch).toHaveBeenCalledWith('/api/artworks/778/page', { headers: { Accept: 'application/json' } })
})
it('uses embedded initial navigation without a standalone or prefetch request', async () => {
let idleCallback
window.requestIdleCallback = vi.fn((callback) => { idleCallback = callback; return 1 })
window.cancelIdleCallback = vi.fn()
global.fetch = vi.fn()
const navigation = {
prev_id: 880,
next_id: 882,
prev_url: '/art/880/previous',
next_url: '/art/882/next',
prev_slug: 'previous',
next_slug: 'next',
prev_preview: { id: 880, title: 'Previous', slug: 'previous', thumbs: { md: { url: '/md-880' }, lg: { url: '/lg-880' } } },
next_preview: { id: 882, title: 'Next', slug: 'next', thumbs: { md: { url: '/md-882' }, lg: { url: '/lg-882' } } },
}
render(<ArtworkNavigator artworkId={881} navigation={navigation} onNavigate={vi.fn()} onReady={vi.fn()} />)
await waitFor(() => expect(window.requestIdleCallback).toHaveBeenCalledTimes(1))
await act(async () => idleCallback())
expect(global.fetch).not.toHaveBeenCalled()
})
it('falls back to standalone navigation for malformed embedded data', async () => {
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ prev_id: 889, next_id: 891, prev_url: '/art/889/p', next_url: '/art/891/n', prev_slug: 'p', next_slug: 'n' }),
})
render(<ArtworkNavigator artworkId={890} navigation={{ prev_id: 889 }} onNavigate={vi.fn()} onReady={vi.fn()} />)
await waitFor(() => expect(global.fetch).toHaveBeenCalledWith('/api/artworks/navigation/890', { headers: { Accept: 'application/json' } }))
})
})
+166
View File
@@ -0,0 +1,166 @@
import { useEffect, useState } from 'react'
const REGISTRY = new Map()
const ROOT_MARGIN = '800px 0px'
const IDLE_TIMEOUT = 3000
export function __resetDeferredSimilarAiCache() {
for (const entry of REGISTRY.values()) {
entry.controller?.abort()
}
REGISTRY.clear()
}
function scheduleIdle(callback) {
if (typeof window.requestIdleCallback === 'function') {
const handle = window.requestIdleCallback(callback, { timeout: IDLE_TIMEOUT })
return () => window.cancelIdleCallback?.(handle)
}
const handle = window.setTimeout(callback, IDLE_TIMEOUT)
return () => window.clearTimeout(handle)
}
export function useDeferredSimilarAi(artworkId, anchorRef) {
const [state, setState] = useState(() => ({
id: artworkId,
items: REGISTRY.get(Number(artworkId || 0))?.items || [],
}))
const [loading, setLoading] = useState(false)
const [error, setError] = useState(false)
useEffect(() => {
let cancelled = false
const id = Number(artworkId || 0)
const controller = new AbortController()
let attachedEntry = null
let triggered = false
const currentEntry = REGISTRY.get(id)
const cached = currentEntry?.status === 'completed' ? currentEntry.items : null
setState({ id, items: cached || [] })
setLoading(false)
setError(false)
if (!id || cached) {
return () => controller.abort()
}
let stopIdle = () => {}
let observer = null
let observationEstablished = false
const detach = () => {
if (!attachedEntry) return
attachedEntry.subscribers -= 1
const entry = attachedEntry
attachedEntry = null
if (entry.subscribers === 0 && entry.status === 'in-flight') {
entry.controller.abort()
}
}
const attach = (entry) => {
if (cancelled || attachedEntry === entry) return
attachedEntry = entry
entry.subscribers += 1
setLoading(true)
entry.promise
.then((nextItems) => {
if (!cancelled) setState({ id, items: nextItems })
})
.catch((requestError) => {
if (!cancelled && requestError?.name !== 'AbortError') {
setState({ id, items: [] })
setError(true)
}
})
.finally(() => {
if (!cancelled) setLoading(false)
if (attachedEntry === entry) {
attachedEntry = null
entry.subscribers -= 1
}
})
}
const load = async () => {
if (triggered || cancelled) return
triggered = true
observer?.disconnect()
stopIdle()
let entry = REGISTRY.get(id)
if (entry?.status === 'completed') {
if (!cancelled) setState({ id, items: entry.items })
return
}
if (entry?.status !== 'in-flight' || entry.controller.signal.aborted) {
const requestController = new AbortController()
entry = {
status: 'in-flight',
controller: requestController,
promise: null,
subscribers: 0,
}
REGISTRY.set(id, entry)
entry.promise = Promise.resolve()
.then(() => fetch(`/api/art/${id}/similar-ai`, {
credentials: 'same-origin',
signal: requestController.signal,
}))
.then((response) => {
if (!response.ok) throw new Error('similar fetch failed')
return response.json()
})
.then((payload) => {
const nextItems = Array.isArray(payload?.data) ? payload.data : []
if (REGISTRY.get(id) === entry) {
REGISTRY.set(id, { status: 'completed', items: nextItems })
}
return nextItems
})
.catch((requestError) => {
if (REGISTRY.get(id) === entry) REGISTRY.delete(id)
throw requestError
})
entry.promise.catch(() => {})
}
attach(entry)
}
if (typeof window.IntersectionObserver === 'function' && anchorRef?.current) {
try {
observer = new window.IntersectionObserver((entries) => {
if (entries.some((entry) => entry.isIntersecting)) load()
}, { rootMargin: ROOT_MARGIN })
observer.observe(anchorRef.current)
observationEstablished = true
} catch {
observer?.disconnect()
observer = null
}
}
if (!observationEstablished) {
stopIdle = scheduleIdle(load)
}
return () => {
cancelled = true
observer?.disconnect()
stopIdle()
detach()
controller.abort()
}
}, [artworkId, anchorRef])
return {
items: Number(state.id || 0) === Number(artworkId || 0) ? state.items : [],
loading: Number(state.id || 0) === Number(artworkId || 0) ? loading : false,
error: Number(state.id || 0) === Number(artworkId || 0) ? error : false,
}
}
@@ -0,0 +1,288 @@
import React, { useRef } from 'react'
import { act, cleanup, render, screen, waitFor } from '@testing-library/react'
import {
__resetDeferredSimilarAiCache,
useDeferredSimilarAi,
} from './useDeferredSimilarAi'
function Harness({ artworkId, instance = '' }) {
const anchorRef = useRef(null)
const { items, loading, error } = useDeferredSimilarAi(artworkId, anchorRef)
const suffix = instance ? `-${instance}` : ''
return (
<div ref={anchorRef}>
<output data-testid={`state${suffix}`}>{loading ? 'loading' : error ? 'error' : 'ready'}</output>
<output data-testid={`items${suffix}`}>{items.map((item) => item.title).join('|')}</output>
</div>
)
}
function response(data, ok = true) {
return { ok, json: async () => ({ data }) }
}
describe('useDeferredSimilarAi', () => {
afterEach(() => {
cleanup()
vi.restoreAllMocks()
delete window.IntersectionObserver
delete window.requestIdleCallback
delete window.cancelIdleCallback
__resetDeferredSimilarAiCache()
})
it('does not fetch during initial render', async () => {
global.fetch = vi.fn()
window.IntersectionObserver = vi.fn(() => ({ observe: vi.fn(), disconnect: vi.fn() }))
render(<Harness artworkId={10} />)
await act(async () => {})
expect(global.fetch).not.toHaveBeenCalled()
})
it('loads once when the section approaches the viewport', async () => {
let callback
window.IntersectionObserver = vi.fn((next) => {
callback = next
return { observe: vi.fn(), disconnect: vi.fn() }
})
global.fetch = vi.fn().mockResolvedValue(response([{ title: 'Near rail' }]))
render(<Harness artworkId={11} />)
await act(async () => callback([{ isIntersecting: true }]))
await waitFor(() => expect(screen.getByTestId('items').textContent).toBe('Near rail'))
expect(global.fetch).toHaveBeenCalledTimes(1)
expect(window.IntersectionObserver).toHaveBeenCalledWith(expect.any(Function), { rootMargin: '800px 0px' })
expect(global.fetch).toHaveBeenCalledWith('/api/art/11/similar-ai', expect.objectContaining({ signal: expect.any(AbortSignal) }))
})
it('does not use the idle fallback when observation is established', async () => {
const observe = vi.fn()
window.IntersectionObserver = vi.fn(() => ({ observe, disconnect: vi.fn() }))
window.requestIdleCallback = vi.fn()
window.cancelIdleCallback = vi.fn()
global.fetch = vi.fn()
vi.useFakeTimers()
try {
render(<Harness artworkId={12} />)
await act(async () => vi.advanceTimersByTime(3100))
expect(observe).toHaveBeenCalledTimes(1)
expect(window.requestIdleCallback).not.toHaveBeenCalled()
expect(global.fetch).not.toHaveBeenCalled()
} finally {
vi.useRealTimers()
}
})
it('uses the idle fallback once when IntersectionObserver is unavailable', async () => {
let idleCallback
window.requestIdleCallback = vi.fn((callback) => { idleCallback = callback; return 1 })
window.cancelIdleCallback = vi.fn()
global.fetch = vi.fn().mockResolvedValue(response([{ title: 'Idle rail' }]))
render(<Harness artworkId={12} />)
expect(global.fetch).not.toHaveBeenCalled()
await act(async () => idleCallback())
await act(async () => idleCallback())
await waitFor(() => expect(screen.getByTestId('items').textContent).toBe('Idle rail'))
expect(global.fetch).toHaveBeenCalledTimes(1)
})
it('ignores repeated observer callbacks and preserves error behavior', async () => {
let callback
window.IntersectionObserver = vi.fn((next) => {
callback = next
return { observe: vi.fn(), disconnect: vi.fn() }
})
global.fetch = vi.fn().mockResolvedValue({ ok: false, json: async () => ({}) })
render(<Harness artworkId={13} />)
await act(async () => {
callback([{ isIntersecting: true }])
callback([{ isIntersecting: true }])
})
await waitFor(() => expect(screen.getByTestId('state').textContent).toBe('error'))
expect(global.fetch).toHaveBeenCalledTimes(1)
})
it('does not request the same artwork again after unmount and remount', async () => {
let callback
window.IntersectionObserver = vi.fn((next) => {
callback = next
return { observe: vi.fn(), disconnect: vi.fn() }
})
global.fetch = vi.fn().mockResolvedValue(response([{ title: 'Persisted rail' }]))
const first = render(<Harness artworkId={16} />)
await act(async () => callback([{ isIntersecting: true }]))
await waitFor(() => expect(screen.getByTestId('items').textContent).toBe('Persisted rail'))
first.unmount()
render(<Harness artworkId={16} />)
expect(screen.getByTestId('items').textContent).toBe('Persisted rail')
expect(global.fetch).toHaveBeenCalledTimes(1)
})
it('reuses artwork A when navigating A to B and back to A', async () => {
const callbacks = []
window.IntersectionObserver = vi.fn((next) => {
callbacks.push(next)
return { observe: vi.fn(), disconnect: vi.fn() }
})
global.fetch = vi.fn()
.mockResolvedValueOnce(response([{ title: 'Artwork A' }]))
.mockResolvedValueOnce(response([{ title: 'Artwork B' }]))
const view = render(<Harness artworkId={17} />)
await act(async () => callbacks[0]([{ isIntersecting: true }]))
await waitFor(() => expect(screen.getByTestId('items').textContent).toBe('Artwork A'))
view.rerender(<Harness artworkId={18} />)
await act(async () => callbacks[1]([{ isIntersecting: true }]))
await waitFor(() => expect(screen.getByTestId('items').textContent).toBe('Artwork B'))
view.rerender(<Harness artworkId={17} />)
expect(screen.getByTestId('items').textContent).toBe('Artwork A')
expect(global.fetch).toHaveBeenCalledTimes(2)
})
it('shares one in-flight request between hook instances', async () => {
const callbacks = []
window.IntersectionObserver = vi.fn((next) => {
callbacks.push(next)
return { observe: vi.fn(), disconnect: vi.fn() }
})
global.fetch = vi.fn().mockResolvedValue(response([{ title: 'Shared rail' }]))
render(<><Harness artworkId={19} /><Harness artworkId={19} /></>)
await act(async () => {
callbacks[0]([{ isIntersecting: true }])
callbacks[1]([{ isIntersecting: true }])
})
await waitFor(() => expect(global.fetch).toHaveBeenCalledTimes(1))
})
it('keeps the shared request alive when one consumer unmounts', async () => {
const callbacks = []
let resolveRequest
window.IntersectionObserver = vi.fn((next) => {
callbacks.push(next)
return { observe: vi.fn(), disconnect: vi.fn() }
})
global.fetch = vi.fn().mockReturnValue(new Promise((resolve) => { resolveRequest = resolve }))
const first = render(<Harness artworkId={22} instance="a" />)
render(<Harness artworkId={22} instance="b" />)
await act(async () => {
callbacks[0]([{ isIntersecting: true }])
callbacks[1]([{ isIntersecting: true }])
})
expect(global.fetch).toHaveBeenCalledTimes(1)
first.unmount()
expect(global.fetch.mock.calls[0][1].signal.aborted).toBe(false)
await act(async () => resolveRequest(response([{ title: 'Surviving rail' }])))
await waitFor(() => expect(screen.getByTestId('items-b').textContent).toBe('Surviving rail'))
expect(global.fetch).toHaveBeenCalledTimes(1)
})
it('aborts the shared request after the final consumer unmounts', async () => {
const callbacks = []
let requestOptions
window.IntersectionObserver = vi.fn((next) => {
callbacks.push(next)
return { observe: vi.fn(), disconnect: vi.fn() }
})
global.fetch = vi.fn().mockImplementation((url, options) => {
requestOptions = options
return new Promise(() => {})
})
const first = render(<Harness artworkId={23} instance="a" />)
const second = render(<Harness artworkId={23} instance="b" />)
await act(async () => {
callbacks[0]([{ isIntersecting: true }])
callbacks[1]([{ isIntersecting: true }])
})
expect(global.fetch).toHaveBeenCalledTimes(1)
first.unmount()
expect(requestOptions.signal.aborted).toBe(false)
second.unmount()
expect(requestOptions.signal.aborted).toBe(true)
})
it('allows an aborted request to be fetched again later', async () => {
let callback
window.IntersectionObserver = vi.fn((next) => {
callback = next
return { observe: vi.fn(), disconnect: vi.fn() }
})
global.fetch = vi.fn()
.mockImplementationOnce((url, { signal }) => new Promise((resolve, reject) => {
signal.addEventListener('abort', () => reject(Object.assign(new Error('aborted'), { name: 'AbortError' })))
}))
.mockResolvedValueOnce(response([{ title: 'Retried rail' }]))
const first = render(<Harness artworkId={20} />)
await act(async () => callback([{ isIntersecting: true }]))
first.unmount()
await act(async () => {})
render(<Harness artworkId={20} />)
await act(async () => callback([{ isIntersecting: true }]))
await waitFor(() => expect(screen.getByTestId('items').textContent).toBe('Retried rail'))
expect(global.fetch).toHaveBeenCalledTimes(2)
})
it('allows a failed request to be retried after remount', async () => {
let callback
window.IntersectionObserver = vi.fn((next) => {
callback = next
return { observe: vi.fn(), disconnect: vi.fn() }
})
global.fetch = vi.fn()
.mockResolvedValueOnce({ ok: false, json: async () => ({}) })
.mockResolvedValueOnce(response([{ title: 'Recovered rail' }]))
const first = render(<Harness artworkId={21} />)
await act(async () => callback([{ isIntersecting: true }]))
await waitFor(() => expect(screen.getByTestId('state').textContent).toBe('error'))
first.unmount()
render(<Harness artworkId={21} />)
await act(async () => callback([{ isIntersecting: true }]))
await waitFor(() => expect(screen.getByTestId('items').textContent).toBe('Recovered rail'))
expect(global.fetch).toHaveBeenCalledTimes(2)
})
it('resets for a new artwork ID and ignores the stale response', async () => {
let callback
let resolveOld
window.IntersectionObserver = vi.fn((next) => {
callback = next
return { observe: vi.fn(), disconnect: vi.fn() }
})
global.fetch = vi.fn()
.mockReturnValueOnce(new Promise((resolve) => { resolveOld = resolve }))
.mockResolvedValueOnce(response([{ title: 'New rail' }]))
const view = render(<Harness artworkId={14} />)
await act(async () => callback([{ isIntersecting: true }]))
view.rerender(<Harness artworkId={15} />)
await act(async () => callback([{ isIntersecting: true }]))
await waitFor(() => expect(screen.getByTestId('items').textContent).toBe('New rail'))
await act(async () => resolveOld(response([{ title: 'Old rail' }])))
expect(screen.getByTestId('items').textContent).toBe('New rail')
expect(global.fetch).toHaveBeenCalledTimes(2)
})
})
+23 -10
View File
@@ -8,6 +8,22 @@ import { useCallback } from 'react';
// Module-level cache for API calls // Module-level cache for API calls
const fallbackCache = new Map(); const fallbackCache = new Map();
function normalizeNavigation(data) {
if (!data || typeof data !== 'object') return null;
const hasPrev = data.prev_id == null || (data.prev_url && data.prev_slug);
const hasNext = data.next_id == null || (data.next_url && data.next_slug);
if (!hasPrev || !hasNext) return null;
return {
prevId: data.prev_id ?? null,
nextId: data.next_id ?? null,
prevUrl: data.prev_url ?? null,
nextUrl: data.next_url ?? null,
prevPreview: data.prev_preview ?? null,
nextPreview: data.next_preview ?? null,
};
}
async function fetchFallback(artworkId) { async function fetchFallback(artworkId) {
const key = String(artworkId); const key = String(artworkId);
if (fallbackCache.has(key)) return fallbackCache.get(key); if (fallbackCache.has(key)) return fallbackCache.get(key);
@@ -16,28 +32,25 @@ async function fetchFallback(artworkId) {
const res = await fetch(`/api/artworks/navigation/${artworkId}`, { const res = await fetch(`/api/artworks/navigation/${artworkId}`, {
headers: { Accept: 'application/json' }, headers: { Accept: 'application/json' },
}); });
if (!res.ok) return { prevId: null, nextId: null, prevUrl: null, nextUrl: null }; if (!res.ok) return { prevId: null, nextId: null, prevUrl: null, nextUrl: null, prevPreview: null, nextPreview: null };
const data = await res.json(); const data = await res.json();
const result = { const result = normalizeNavigation(data) || { prevId: null, nextId: null, prevUrl: null, nextUrl: null, prevPreview: null, nextPreview: null };
prevId: data.prev_id ?? null,
nextId: data.next_id ?? null,
prevUrl: data.prev_url ?? null,
nextUrl: data.next_url ?? null,
};
fallbackCache.set(key, result); fallbackCache.set(key, result);
return result; return result;
} catch { } catch {
return { prevId: null, nextId: null, prevUrl: null, nextUrl: null }; return { prevId: null, nextId: null, prevUrl: null, nextUrl: null, prevPreview: null, nextPreview: null };
} }
} }
export function useNavContext(currentArtworkId) { export function useNavContext(currentArtworkId, embeddedNavigation = null) {
/** /**
* Always resolve via API to guarantee same-user navigation. * Always resolve via API to guarantee same-user navigation.
*/ */
const getNeighbors = useCallback(async () => { const getNeighbors = useCallback(async () => {
const embedded = normalizeNavigation(embeddedNavigation);
if (embedded) return embedded;
return fetchFallback(currentArtworkId); return fetchFallback(currentArtworkId);
}, [currentArtworkId]); }, [currentArtworkId, embeddedNavigation]);
return { getNeighbors }; return { getNeighbors };
} }
+1 -1
View File
@@ -556,7 +556,7 @@
<span class="ml-1 px-1.5 py-0.5 rounded text-xs bg-white/5 text-[--sb-muted]"> <span class="ml-1 px-1.5 py-0.5 rounded text-xs bg-white/5 text-[--sb-muted]">
{{ number_format($followerCount) }} {{ number_format($followerCount) }}
</span> </span>
<a href="/following/{{ $user->id }}/{{ \Illuminate\Support\Str::slug($uname) }}" <a href="{{ url('/@' . strtolower((string) ($user->username ?? $uname)) . '/followers') }}"
class="ml-auto text-xs text-[--sb-blue] hover:underline normal-case tracking-normal font-normal"> class="ml-auto text-xs text-[--sb-blue] hover:underline normal-case tracking-normal font-normal">
All All
</a> </a>
+6
View File
@@ -129,6 +129,12 @@ Route::get('/profile.php', function () {
// ── PROFILE (legacy URL patterns) ──────────────────────────────────────────── // ── PROFILE (legacy URL patterns) ────────────────────────────────────────────
Route::get('/user/{username}', [ProfileController::class, 'legacyByUsername'])->where('username', '[A-Za-z0-9_-]{3,20}')->name('legacy.user.profile'); Route::get('/user/{username}', [ProfileController::class, 'legacyByUsername'])->where('username', '[A-Za-z0-9_-]{3,20}')->name('legacy.user.profile');
Route::get('/profile/{id}/{username?}', [ProfileController::class, 'legacyById'])->where('id', '\d+')->name('legacy.profile.id'); Route::get('/profile/{id}/{username?}', [ProfileController::class, 'legacyById'])->where('id', '\d+')->name('legacy.profile.id');
// Legacy profile template "Followers → All" used /following/{id}/{slug}.
// Canonical public surface is /@{username}/followers (people who follow the owner).
Route::get('/following/{id}/{slug?}', [ProfileController::class, 'legacyFollowingById'])
->where('id', '\d+')
->name('legacy.following.user');
Route::get('/profile/{username}', [ProfileController::class, 'legacyByUsername'])->where('username', '[A-Za-z0-9_-]{3,20}')->name('legacy.profile'); Route::get('/profile/{username}', [ProfileController::class, 'legacyByUsername'])->where('username', '[A-Za-z0-9_-]{3,20}')->name('legacy.profile');
// Keep legacy `/user` as a permanent redirect to the canonical dashboard path. // Keep legacy `/user` as a permanent redirect to the canonical dashboard path.
+1 -1
View File
@@ -493,7 +493,7 @@ Route::get('/@{username}/collections/{slug}', [ProfileCollectionController::clas
Route::get('/@{username}/{tab}', [ProfileController::class, 'showTabByUsername']) Route::get('/@{username}/{tab}', [ProfileController::class, 'showTabByUsername'])
->where('username', '[A-Za-z0-9_-]{3,20}') ->where('username', '[A-Za-z0-9_-]{3,20}')
->where('tab', 'posts|artworks|stories|achievements|worlds|collections|about|stats|favourites|activity') ->where('tab', 'posts|artworks|stories|achievements|worlds|collections|about|stats|favourites|activity|followers')
->name('profile.tab'); ->name('profile.tab');
Route::get('/@{username}', [ProfileController::class, 'showByUsername']) Route::get('/@{username}', [ProfileController::class, 'showByUsername'])
+68 -18
View File
@@ -3,10 +3,18 @@
declare(strict_types=1); declare(strict_types=1);
/** /**
* Read-only analyzer for /var/log/nginx/skinbase-performance.log * Read-only analyzer for nginx skinbase-performance JSON logs.
* *
* Usage: * Single file (backward compatible):
* php scripts/analyze-http-performance.php --file=/var/log/nginx/skinbase-performance.log --since=24h --top=20 --host=skinbase.org * php scripts/analyze-http-performance.php --file=/var/log/nginx/skinbase-performance.log --since=24h --host=skinbase.org
*
* Rotation-aware 24h:
* php scripts/analyze-http-performance.php --glob='/var/log/nginx/skinbase-performance.log*' --since=24h --host=skinbase.org
*
* Status:
* --status=404 exact HTTP 404
* --status=4xx 400-499
* --status=5xx 500-599
*/ */
use App\Support\Http\HttpPerformanceLogAnalyzer; use App\Support\Http\HttpPerformanceLogAnalyzer;
@@ -14,9 +22,11 @@ use App\Support\Http\HttpUriNormalizer;
require __DIR__.'/../vendor/autoload.php'; require __DIR__.'/../vendor/autoload.php';
$files = [];
$globs = [];
$options = [ $options = [
'file' => '/var/log/nginx/skinbase-performance.log',
'since' => '24h', 'since' => '24h',
'until' => null,
'min_requests' => 1, 'min_requests' => 1,
'top' => 20, 'top' => 20,
'status' => null, 'status' => null,
@@ -31,9 +41,18 @@ foreach (array_slice($argv, 1) as $arg) {
exit(2); exit(2);
} }
[$key, $value] = array_pad(explode('=', substr($arg, 2), 2), 2, '1'); [$key, $value] = array_pad(explode('=', substr($arg, 2), 2), 2, '1');
if ($key === 'file') {
$files[] = $value;
continue;
}
if ($key === 'glob') {
$globs[] = $value;
continue;
}
$map = [ $map = [
'file' => 'file',
'since' => 'since', 'since' => 'since',
'until' => 'until',
'hours' => 'hours',
'min-requests' => 'min_requests', 'min-requests' => 'min_requests',
'top' => 'top', 'top' => 'top',
'status' => 'status', 'status' => 'status',
@@ -48,21 +67,52 @@ foreach (array_slice($argv, 1) as $arg) {
$options[$map[$key]] = $value; $options[$map[$key]] = $value;
} }
$file = (string) $options['file']; if (isset($options['hours']) && is_numeric($options['hours'])) {
if (! is_readable($file)) { $options['since'] = ((int) $options['hours']).'h';
fwrite(STDERR, "Cannot read {$file}\n");
exit(1);
} }
$analyzer = new HttpPerformanceLogAnalyzer(new HttpUriNormalizer()); $analyzer = new HttpPerformanceLogAnalyzer(new HttpUriNormalizer());
$result = $analyzer->analyze($file, [
'since' => $options['since'], $discovered = $files;
'min_requests' => (int) $options['min_requests'], foreach ($globs as $pattern) {
'top' => (int) $options['top'], $discovered = array_merge($discovered, $analyzer->expandGlob($pattern));
'status' => $options['status'], }
'uri' => $options['uri'],
'host' => $options['host'], if ($discovered === [] && $globs === []) {
'slow_ms' => $options['slow_ms'] !== null ? (float) $options['slow_ms'] : null, $discovered[] = '/var/log/nginx/skinbase-performance.log';
]); }
$discovered = $analyzer->deduplicatePaths($discovered);
if ($discovered === []) {
fwrite(STDERR, "No matching performance log files.\n");
exit(1);
}
foreach ($discovered as $path) {
if (! is_readable($path)) {
fwrite(STDERR, "Cannot read {$path}\n");
exit(1);
}
}
try {
$result = $analyzer->analyze($discovered, [
'since' => $options['since'],
'until' => $options['until'],
'min_requests' => (int) $options['min_requests'],
'top' => (int) $options['top'],
'status' => $options['status'],
'uri' => $options['uri'],
'host' => $options['host'],
'slow_ms' => $options['slow_ms'] !== null ? (float) $options['slow_ms'] : null,
]);
} catch (InvalidArgumentException $e) {
fwrite(STDERR, $e->getMessage()."\n");
exit(2);
}
foreach ($result['warnings'] ?? [] as $warning) {
fwrite(STDERR, $warning."\n");
}
echo json_encode($result, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES), PHP_EOL; echo json_encode($result, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES), PHP_EOL;
+459
View File
@@ -0,0 +1,459 @@
#!/usr/bin/env bash
# Shared deploy observability helpers sourced by scripts/sync-safe-updated.sh.
# Keeps build numbers, local history, and progress logging out of the main script body.
: "${root_dir:?root_dir must be set before sourcing deploy-observability.sh}"
: "${local_folder:?local_folder must be set before sourcing deploy-observability.sh}"
build_number="${BUILD_NUMBER:-${build_number:-}}"
local_deploy_dir="${LOCAL_DEPLOY_DIR:-$root_dir/var/deploy}"
local_deploy_log_dir="${local_deploy_dir}/logs"
local_build_number_file="${local_deploy_dir}/build-number"
local_history_file="${local_deploy_dir}/history.jsonl"
local_latest_file="${local_deploy_dir}/latest.json"
local_deploy_log_file="${local_deploy_log_file:-}"
build_info_file="${build_info_file:-}"
git_sha="${git_sha:-unknown}"
git_branch="${git_branch:-unknown}"
git_dirty="${git_dirty:-0}"
deploy_started_epoch="${deploy_started_epoch:-$(date +%s)}"
deploy_started_utc="${deploy_started_utc:-$(date -u +%Y-%m-%dT%H:%M:%SZ)}"
deploy_started_local="${deploy_started_local:-$(date +%Y-%m-%d\ %H:%M:%S\ %Z)}"
deploy_finished_epoch="${deploy_finished_epoch:-}"
deploy_status="${deploy_status:-running}"
deploy_phase_current="${deploy_phase_current:-0}"
phase_started_epoch="${phase_started_epoch:-$deploy_started_epoch}"
previous_release_meta="${previous_release_meta:-}"
rsync_show_progress="${rsync_show_progress:-1}"
declare -a deploy_phase_timings=("${deploy_phase_timings[@]+"${deploy_phase_timings[@]}"}")
format_duration() {
local total_seconds="${1:-0}"
local hours minutes seconds
if ! [[ "$total_seconds" =~ ^[0-9]+$ ]]; then
total_seconds=0
fi
hours=$((total_seconds / 3600))
minutes=$(((total_seconds % 3600) / 60))
seconds=$((total_seconds % 60))
if (( hours > 0 )); then
printf '%dh %02dm %02ds' "$hours" "$minutes" "$seconds"
elif (( minutes > 0 )); then
printf '%dm %02ds' "$minutes" "$seconds"
else
printf '%ds' "$seconds"
fi
}
elapsed_since_start() {
local now
now="$(date +%s)"
format_duration "$((now - deploy_started_epoch))"
}
json_escape() {
local value="${1-}"
value="${value//\\/\\\\}"
value="${value//\"/\\\"}"
value="${value//$'\n'/\\n}"
value="${value//$'\r'/\\r}"
value="${value//$'\t'/\\t}"
printf '%s' "$value"
}
append_deploy_log() {
local line="$1"
[[ -n "${local_deploy_log_file:-}" ]] || return 0
printf '%s\n' "$line" >> "$local_deploy_log_file" 2>/dev/null || true
}
obs_log_step() {
local message="$1"
local now
local line
now="$(date +%s)"
deploy_phase_current=$((deploy_phase_current + 1))
phase_started_epoch="$now"
line="$(printf '[step %s] (+%s) %s' "$deploy_phase_current" "$(elapsed_since_start)" "$message")"
printf '\n%s\n' "$line"
append_deploy_log "$(date -u +%Y-%m-%dT%H:%M:%SZ) STEP ${line}"
}
obs_log_info() {
local message="$1"
printf ' -> %s\n' "$message"
append_deploy_log "$(date -u +%Y-%m-%dT%H:%M:%SZ) INFO ${message}"
}
obs_log_warn() {
local message="$1"
printf 'WARN: %s\n' "$message" >&2
append_deploy_log "$(date -u +%Y-%m-%dT%H:%M:%SZ) WARN ${message}"
}
obs_die() {
printf 'ERROR: %s\n' "$1" >&2
append_deploy_log "$(date -u +%Y-%m-%dT%H:%M:%SZ) ERROR $1"
exit 1
}
mark_phase_complete() {
local label="${1:-phase}"
local now
local elapsed
now="$(date +%s)"
elapsed=$((now - phase_started_epoch))
deploy_phase_timings+=("${label}=$(format_duration "$elapsed")")
obs_log_info "Phase finished in $(format_duration "$elapsed") (total $(elapsed_since_start))"
}
ensure_local_deploy_dirs() {
mkdir -p "$local_deploy_dir" "$local_deploy_log_dir"
}
# Full-tree dirty checks (git diff / status / describe --dirty) can take minutes
# on WSL /mnt/<drive> checkouts because every file is stat'd over 9p. Keep deploy
# metadata fast: short SHA/branch always; unstaged dirty scans are skipped on
# slow filesystems and otherwise hard-timeout'd.
git_metadata_timeout_seconds="${GIT_METADATA_TIMEOUT_SECONDS:-8}"
git_workdir_is_slow() {
case "$local_folder" in
/mnt/[a-zA-Z]/*|/mnt/[a-zA-Z])
return 0
;;
esac
local fstype=""
if command -v findmnt >/dev/null 2>&1; then
fstype="$(findmnt -n -o FSTYPE --target "$local_folder" 2>/dev/null || true)"
case "$fstype" in
9p|drvfs|cifs|nfs|nfs4|fuse|fuseblk|fuse.*)
return 0
;;
esac
fi
return 1
}
run_git_with_timeout() {
local timeout_seconds="${1:-$git_metadata_timeout_seconds}"
shift
# Do not use timeout --foreground: that leaves git children unkilled.
# -k 2 sends SIGKILL if SIGTERM cannot interrupt a stuck 9p stat.
if command -v timeout >/dev/null 2>&1; then
timeout -k 2 "$timeout_seconds" git -C "$local_folder" "$@"
return $?
fi
git -C "$local_folder" "$@"
}
# Returns: 0 dirty, 1 clean, 2 timeout/unknown, 3 skipped unstaged scan (slow FS).
detect_git_dirty_fast() {
local status_output=""
local rc=0
# Staged changes are cheap (index vs HEAD, no worktree walk).
run_git_with_timeout 3 diff --cached --quiet >/dev/null 2>&1 || rc=$?
if [[ "$rc" -eq 1 ]]; then
return 0
fi
if [[ "$rc" -eq 124 || "$rc" -eq 137 ]]; then
return 2
fi
if [[ "$rc" -ne 0 ]]; then
return 2
fi
if git_workdir_is_slow; then
return 3
fi
# Porcelain still stats the worktree; only run it on native filesystems.
rc=0
status_output="$(run_git_with_timeout "$git_metadata_timeout_seconds" -c core.untrackedCache=false status --porcelain=v1 -uno 2>/dev/null)" || rc=$?
if [[ "$rc" -eq 0 ]]; then
if [[ -n "${status_output//[[:space:]]/}" ]]; then
return 0
fi
return 1
fi
return 2
}
collect_git_metadata() {
local dirty_rc=1
git_sha="unknown"
git_branch="unknown"
git_dirty=0
if ! command -v git >/dev/null 2>&1 || ! git -C "$local_folder" rev-parse --is-inside-work-tree >/dev/null 2>&1; then
return 0
fi
printf ' -> Collecting local Git metadata (SHA/branch; dirty check is bounded)...\n'
git_sha="$(git -C "$local_folder" rev-parse --short HEAD 2>/dev/null || printf 'unknown')"
git_branch="$(git -C "$local_folder" branch --show-current 2>/dev/null || printf 'DETACHED')"
# Capture without tripping set -e (clean/timeout/skip are non-zero).
dirty_rc=1
detect_git_dirty_fast && dirty_rc=0 || dirty_rc=$?
if [[ "$dirty_rc" -eq 0 ]]; then
git_dirty=1
elif [[ "$dirty_rc" -eq 3 ]]; then
git_dirty=0
printf ' -> Skipping unstaged Git dirty scan on this filesystem (WSL /mnt or network mount); using staged-only check.\n' >&2
elif [[ "$dirty_rc" -eq 2 ]]; then
git_dirty=0
printf ' -> Git dirty check timed out after %ss on this filesystem; continuing without dirty marker.\n' \
"$git_metadata_timeout_seconds" >&2
else
git_dirty=0
fi
}
peek_next_build_number() {
local existing=""
local next=1
if [[ -f "$local_build_number_file" ]]; then
existing="$(tr -d '[:space:]' < "$local_build_number_file" 2>/dev/null || true)"
if [[ "$existing" =~ ^[0-9]+$ ]]; then
next=$((existing + 1))
fi
fi
printf '%s' "$next"
}
allocate_build_number() {
local existing=""
local persist=1
ensure_local_deploy_dirs
# Dry-runs never consume the counter unless an explicit override is provided for labeling only.
if [[ "${dry_run:-0}" -eq 1 ]]; then
persist=0
fi
if [[ -n "$build_number" ]]; then
[[ "$build_number" =~ ^[1-9][0-9]*$ ]] || obs_die "Build number must be a positive integer. Received: $build_number"
else
build_number="$(peek_next_build_number)"
fi
[[ "$build_number" =~ ^[1-9][0-9]*$ ]] || obs_die "Build number must be a positive integer. Received: $build_number"
if [[ "$persist" -eq 1 ]]; then
if [[ -f "$local_build_number_file" ]]; then
existing="$(tr -d '[:space:]' < "$local_build_number_file" 2>/dev/null || true)"
if [[ "$existing" =~ ^[0-9]+$ ]] && (( build_number < existing )); then
:
else
printf '%s\n' "$build_number" > "$local_build_number_file"
fi
else
printf '%s\n' "$build_number" > "$local_build_number_file"
fi
fi
}
setup_local_deploy_log_file() {
local stamp
ensure_local_deploy_dirs
stamp="$(date -u +%Y%m%d-%H%M%S)"
local_deploy_log_file="${local_deploy_log_dir}/deploy-${stamp}-b${build_number}.log"
{
printf 'Skinbase production deploy log\n'
printf 'started_at_utc=%s\n' "$deploy_started_utc"
printf 'started_at_local=%s\n' "$deploy_started_local"
printf 'build_number=%s\n' "$build_number"
printf 'release_id=%s\n' "${release_id:-pending}"
printf 'mode=%s\n' "${deploy_mode:-normal}"
printf 'source=%s\n' "$local_folder"
printf 'target=%s:%s\n' "${remote_server:-}" "${remote_folder:-}"
printf 'git_sha=%s\n' "$git_sha"
printf 'git_branch=%s\n' "$git_branch"
printf 'git_dirty=%s\n' "$git_dirty"
printf '%s\n' '---'
} > "$local_deploy_log_file"
}
write_build_info_file() {
build_info_file="${local_folder}/build-info.json"
cat > "$build_info_file" <<EOF
{
"build_number": ${build_number},
"release_id": "$(json_escape "${release_id:-}")",
"deployed_at_utc": "$(json_escape "$deploy_started_utc")",
"git_sha": "$(json_escape "$git_sha")",
"git_branch": "$(json_escape "$git_branch")",
"git_dirty": ${git_dirty},
"deployment_mode": "$(json_escape "${deploy_mode:-normal}")",
"remote_server": "$(json_escape "${remote_server:-}")",
"remote_folder": "$(json_escape "${remote_folder:-}")"
}
EOF
obs_log_info "Wrote build metadata: $build_info_file"
}
write_local_deploy_history() {
local status="${1:-unknown}"
local previous_release="${2-}"
local finished_utc finished_local duration_seconds duration_human
local timings_json="[]"
local timing
local first=1
deploy_finished_epoch="$(date +%s)"
finished_utc="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
finished_local="$(date +%Y-%m-%d\ %H:%M:%S\ %Z)"
duration_seconds=$((deploy_finished_epoch - deploy_started_epoch))
duration_human="$(format_duration "$duration_seconds")"
deploy_status="$status"
ensure_local_deploy_dirs
if (( ${#deploy_phase_timings[@]} > 0 )); then
timings_json="["
first=1
for timing in "${deploy_phase_timings[@]}"; do
if (( first )); then
first=0
else
timings_json+=","
fi
timings_json+="\"$(json_escape "$timing")\""
done
timings_json+="]"
fi
cat > "$local_latest_file" <<EOF
{
"status": "$(json_escape "$status")",
"build_number": ${build_number:-0},
"release_id": "$(json_escape "${release_id:-}")",
"previous_release_id": "$(json_escape "$previous_release")",
"started_at_utc": "$(json_escape "$deploy_started_utc")",
"started_at_local": "$(json_escape "$deploy_started_local")",
"finished_at_utc": "$(json_escape "$finished_utc")",
"finished_at_local": "$(json_escape "$finished_local")",
"duration_seconds": ${duration_seconds},
"duration_human": "$(json_escape "$duration_human")",
"deployment_mode": "$(json_escape "${deploy_mode:-normal}")",
"dry_run": ${dry_run:-0},
"git_sha": "$(json_escape "$git_sha")",
"git_branch": "$(json_escape "$git_branch")",
"git_dirty": ${git_dirty},
"remote_server": "$(json_escape "${remote_server:-}")",
"remote_folder": "$(json_escape "${remote_folder:-}")",
"log_file": "$(json_escape "${local_deploy_log_file:-}")",
"phase_timings": ${timings_json}
}
EOF
cat "$local_latest_file" >> "$local_history_file"
printf '\n' >> "$local_history_file"
}
print_deploy_banner() {
local dirty_marker=""
local dry_marker=""
if [[ "${git_dirty:-0}" -eq 1 ]]; then
dirty_marker=" [dirty]"
fi
if [[ "${dry_run:-0}" -eq 1 ]]; then
dry_marker=" (dry-run preview)"
fi
printf '\n'
printf '============================================================\n'
printf ' Skinbase production deploy\n'
printf '============================================================\n'
printf ' Build number : %s%s\n' "${build_number:-pending}" "$dry_marker"
printf ' Release : %s\n' "${release_id:-pending}"
printf ' Mode : %s\n' "${deploy_mode:-normal}"
printf ' Started : %s\n' "$deploy_started_local"
printf ' Source : %s\n' "$local_folder"
printf ' Target : %s:%s\n' "${remote_server:-}" "${remote_folder:-}"
printf ' Git : %s (%s)%s\n' "$git_sha" "$git_branch" "$dirty_marker"
printf ' Local log : %s\n' "${local_deploy_log_file:-n/a}"
printf '============================================================\n'
}
finalize_local_deploy_observability() {
local exit_code="${1:-0}"
local final_status="success"
if [[ "$exit_code" -ne 0 ]]; then
final_status="failed"
elif [[ "${dry_run:-0}" -eq 1 ]]; then
final_status="dry-run"
fi
if [[ -n "${build_number:-}" && "$deploy_status" == "running" ]]; then
write_local_deploy_history "$final_status" "${previous_release_meta:-}"
if [[ -n "${local_deploy_log_file:-}" ]]; then
{
printf '%s\n' '---'
printf 'status=%s\n' "$final_status"
printf 'exit_code=%s\n' "$exit_code"
printf 'finished_at_utc=%s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)"
printf 'duration=%s\n' "$(elapsed_since_start)"
printf 'release_id=%s\n' "${release_id:-}"
printf 'build_number=%s\n' "${build_number:-}"
} >> "$local_deploy_log_file" 2>/dev/null || true
fi
fi
}
release_id_with_build() {
local timestamp
local vcs_fragment="manual"
if [[ -n "${release_id:-}" ]]; then
printf '%s' "$release_id"
return 0
fi
timestamp="$(date -u +%Y%m%d-%H%M%S)"
# Never use `git describe --dirty` here: --dirty walks the whole work tree and
# can hang for minutes on WSL /mnt/* checkouts. Prefer already-collected SHA.
if [[ -n "${git_sha:-}" && "$git_sha" != "unknown" ]]; then
vcs_fragment="$git_sha"
if [[ "${git_dirty:-0}" -eq 1 ]]; then
vcs_fragment="${vcs_fragment}-dirty"
fi
elif command -v git >/dev/null 2>&1 && git -C "$local_folder" rev-parse --is-inside-work-tree >/dev/null 2>&1; then
vcs_fragment="$(git -C "$local_folder" rev-parse --short HEAD 2>/dev/null || printf 'manual')"
fi
vcs_fragment="$(sanitize_release_fragment "$vcs_fragment")"
[[ -n "$vcs_fragment" ]] || vcs_fragment="manual"
if [[ -n "${build_number:-}" ]]; then
printf '%s-b%s-%s' "$timestamp" "$build_number" "$vcs_fragment"
else
printf '%s-%s' "$timestamp" "$vcs_fragment"
fi
}
+2 -1
View File
@@ -1,6 +1,7 @@
#!/bin/bash #!/usr/bin/env bash
set -euo pipefail set -euo pipefail
# Production deploy implementation entry used by deploy.sh / deploy.cmd.
script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
exec "$script_dir/sync-safe-updated.sh" "$@" exec "$script_dir/sync-safe-updated.sh" "$@"
+14 -1
View File
@@ -316,7 +316,20 @@ fi
log_step "Switching current release to ${target_release}" log_step "Switching current release to ${target_release}"
ln -sfn "$target_release_path" "$current_link" ln -sfn "$target_release_path" "$current_link"
ln -sfn "$current_link" "$REMOTE_FOLDER" # The public app path is a klevze-owned symlink to .../releases/current. Rewriting
# current is enough; ln on REMOTE_FOLDER fails with Permission denied for skinbase.
if [[ -L "$REMOTE_FOLDER" ]]; then
literal="$(readlink -n "$REMOTE_FOLDER" || true)"
if [[ "$literal" != "$current_link" ]]; then
resolved="$(readlink -f "$REMOTE_FOLDER" 2>/dev/null || true)"
expected="$(readlink -f "$current_link" 2>/dev/null || true)"
if [[ -z "$resolved" || -z "$expected" || "$resolved" != "$expected" ]]; then
ln -sfn "$current_link" "$REMOTE_FOLDER" || die "Cannot retarget ${REMOTE_FOLDER} to ${current_link} (parent directory not writable by $(id -un)). The current symlink was updated; fix the public app symlink as the SSH login user if needed."
fi
fi
elif [[ ! -e "$REMOTE_FOLDER" ]]; then
ln -sfn "$current_link" "$REMOTE_FOLDER" || die "Cannot create ${REMOTE_FOLDER} -> ${current_link}. Create that symlink as the SSH login user."
fi
cd "$REMOTE_FOLDER" cd "$REMOTE_FOLDER"
File diff suppressed because it is too large Load Diff
+3 -2
View File
@@ -1,6 +1,7 @@
#!/bin/bash #!/usr/bin/env bash
set -euo pipefail set -euo pipefail
# Legacy alias. Prefer: deploy.cmd (Windows) or bash deploy.sh (WSL/Linux).
script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
exec "$script_dir/scripts/deploy-production.sh" "$@" exec "$script_dir/deploy.sh" "$@"
+170
View File
@@ -0,0 +1,170 @@
<?php
declare(strict_types=1);
use App\Http\Controllers\ArtworkDownloadController;
use App\Models\Artwork;
use App\Services\ArtworkStatsService;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\File;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
beforeEach(function () {
$root = storage_path('framework/testing/artwork-downloads-accel');
config([
'uploads.storage_root' => $root,
'uploads.local_originals_root' => $root,
'app.download_accel_enabled' => false,
'app.download_accel_path' => '/internal/originals',
'app.url' => 'https://skinbase.test',
]);
if (File::exists($root)) {
File::deleteDirectory($root);
}
File::makeDirectory($root, 0755, true);
});
afterEach(function () {
$root = storage_path('framework/testing/artwork-downloads-accel');
if (File::exists($root)) {
File::deleteDirectory($root);
}
});
function accelOriginal(string $hash, string $ext, string $content = 'original-bytes'): string
{
$root = rtrim((string) config('uploads.local_originals_root'), DIRECTORY_SEPARATOR);
$dir = $root.DIRECTORY_SEPARATOR.substr($hash, 0, 2).DIRECTORY_SEPARATOR.substr($hash, 2, 2);
File::makeDirectory($dir, 0755, true, true);
$path = $dir.DIRECTORY_SEPARATOR.$hash.'.'.$ext;
File::put($path, $content);
return $path;
}
function resolveAccelUri(string $filePath): ?string
{
$controller = app(ArtworkDownloadController::class);
$method = new ReflectionMethod(ArtworkDownloadController::class, 'resolveAccelUri');
return $method->invoke($controller, $filePath);
}
it('uses the PHP download fallback when acceleration is disabled', function () {
$hash = '2721722cb2f407cc53539ee8718cc89c4cdfc579';
accelOriginal($hash, 'zip', 'zip-body');
$artwork = Artwork::factory()->create([
'file_name' => 'Nested Original',
'hash' => $hash,
'file_ext' => 'zip',
]);
$response = $this->get('/download/artwork/'.$artwork->id);
$response->assertOk();
expect($response->headers->get('X-Accel-Redirect'))->toBeNull()
->and($response->baseResponse)->toBeInstanceOf(BinaryFileResponse::class);
});
it('returns X-Accel-Redirect for a nested original when acceleration is enabled', function () {
config(['app.download_accel_enabled' => true]);
$hash = '2721722cb2f407cc53539ee8718cc89c4cdfc579';
accelOriginal($hash, 'zip', 'zip-body');
$artwork = Artwork::factory()->create([
'file_name' => 'Nested Original',
'hash' => $hash,
'file_ext' => 'zip',
]);
$this->mock(ArtworkStatsService::class, function ($mock) use ($artwork): void {
$mock->shouldReceive('incrementDownloads')->once()->with($artwork->id, 1, false);
});
$response = $this->get('/download/artwork/'.$artwork->id);
$response->assertOk()
->assertHeader('X-Accel-Redirect', '/internal/originals/27/21/2721722cb2f407cc53539ee8718cc89c4cdfc579.zip')
->assertHeader('Content-Type', 'application/octet-stream')
->assertHeader('X-Content-Type-Options', 'nosniff');
$disposition = (string) $response->headers->get('Content-Disposition');
expect($disposition)->toContain('attachment')
->and($disposition)->toContain('Nested Original')
->and($disposition)->toContain('.zip')
->and($response->baseResponse)->not->toBeInstanceOf(BinaryFileResponse::class);
expect(DB::table('artwork_downloads')->where('artwork_id', $artwork->id)->count())->toBe(1);
});
it('maps nested originals under the accel prefix without duplicate slashes', function () {
config(['app.download_accel_enabled' => true]);
$root = rtrim((string) config('uploads.local_originals_root'), DIRECTORY_SEPARATOR);
$relative = '27'.DIRECTORY_SEPARATOR.'21'.DIRECTORY_SEPARATOR.'2721722cb2f407cc53539ee8718cc89c4cdfc579.zip';
$path = $root.DIRECTORY_SEPARATOR.$relative;
expect(resolveAccelUri($path))->toBe('/internal/originals/27/21/2721722cb2f407cc53539ee8718cc89c4cdfc579.zip');
});
it('does not emit an accel URI for a file outside the originals root', function () {
config(['app.download_accel_enabled' => true]);
$outside = sys_get_temp_dir().DIRECTORY_SEPARATOR.'not-original.zip';
File::put($outside, 'nope');
expect(resolveAccelUri($outside))->toBeNull();
File::delete($outside);
});
it('returns 404 for an unsupported original extension', function () {
$hash = 'aa11bb22cc33dd44ee55ff6677889900aabbccdd';
accelOriginal($hash, 'exe', 'not-allowed');
$artwork = Artwork::factory()->create([
'hash' => $hash,
'file_ext' => 'exe',
'file_name' => 'bad.exe',
]);
$this->mock(ArtworkStatsService::class, function ($mock): void {
$mock->shouldReceive('incrementDownloads')->never();
});
$this->get('/download/artwork/'.$artwork->id)->assertNotFound();
expect(DB::table('artwork_downloads')->where('artwork_id', $artwork->id)->count())->toBe(0);
});
it('still records download analytics when acceleration is enabled', function () {
config(['app.download_accel_enabled' => true]);
$hash = 'bb22cc33dd44ee55ff6677889900aabbccddeeff';
accelOriginal($hash, 'jpg', 'jpeg-bytes');
$artwork = Artwork::factory()->create([
'hash' => $hash,
'file_ext' => 'jpg',
'file_name' => 'Photo',
]);
DB::table('artwork_stats')->insertOrIgnore([
'artwork_id' => $artwork->id,
'views' => 0,
'views_24h' => 0,
'views_7d' => 0,
'downloads' => 0,
'downloads_24h' => 0,
'downloads_7d' => 0,
'favorites' => 0,
'rating_avg' => 0,
'rating_count' => 0,
]);
$this->get('/download/artwork/'.$artwork->id)
->assertOk()
->assertHeader('X-Accel-Redirect');
expect(DB::table('artwork_downloads')->where('artwork_id', $artwork->id)->count())->toBe(1)
->and((int) DB::table('artwork_stats')->where('artwork_id', $artwork->id)->value('downloads'))->toBe(1);
});
+2 -2
View File
@@ -57,7 +57,7 @@ it('downloads an existing artwork file', function () {
$response = $this->get("/download/artwork/{$artwork->id}"); $response = $this->get("/download/artwork/{$artwork->id}");
$response->assertOk(); $response->assertOk();
$response->assertDownload('Sky Sunset.png'); $response->assertDownload('Sky Sunset (skinbase.org).png');
}); });
it('forces the download filename using file_name and extension', function () { it('forces the download filename using file_name and extension', function () {
@@ -74,7 +74,7 @@ it('forces the download filename using file_name and extension', function () {
$response = $this->get("/download/artwork/{$artwork->id}"); $response = $this->get("/download/artwork/{$artwork->id}");
$response->assertOk(); $response->assertOk();
$response->assertDownload('My Original Name.jpg'); $response->assertDownload('My Original Name (skinbase.org).jpg');
}); });
it('returns 404 for a missing artwork', function () { it('returns 404 for a missing artwork', function () {
@@ -0,0 +1,146 @@
<?php
declare(strict_types=1);
use App\Enums\ReactionType;
use App\Models\Artwork;
use App\Models\ArtworkComment;
use App\Models\CommentReaction;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
uses(RefreshDatabase::class);
function m18b1ArtworkCommentTree(int $topLevelCount = 2): array
{
$artwork = Artwork::factory()->create();
$author = User::factory()->create();
$viewer = User::factory()->create();
$other = User::factory()->create();
$comments = [];
for ($i = 0; $i < $topLevelCount; $i++) {
$comment = ArtworkComment::factory()->create([
'artwork_id' => $artwork->id,
'user_id' => $author->id,
'parent_id' => null,
]);
$reply = ArtworkComment::factory()->create([
'artwork_id' => $artwork->id,
'user_id' => $other->id,
'parent_id' => $comment->id,
]);
CommentReaction::create([
'comment_id' => $comment->id,
'user_id' => $other->id,
'reaction' => ReactionType::Heart->value,
]);
CommentReaction::create([
'comment_id' => $reply->id,
'user_id' => $viewer->id,
'reaction' => ReactionType::Fire->value,
]);
CommentReaction::create([
'comment_id' => $reply->id,
'user_id' => $other->id,
'reaction' => ReactionType::Clap->value,
]);
$comments[] = [$comment, $reply];
}
return [$artwork, $viewer, $comments];
}
function m18b1ReactionQueryCount(array $queries): int
{
return collect($queries)
->filter(fn (array $query): bool => str_contains(strtolower((string) $query['query']), 'comment_reactions'))
->count();
}
it('includes bulk reaction totals for guests with one reaction query', function (): void {
[$artwork, , $comments] = m18b1ArtworkCommentTree();
DB::enableQueryLog();
DB::flushQueryLog();
$response = $this->getJson("/api/artworks/{$artwork->id}/comments")->assertOk();
$payload = $response->json('data');
$withoutReactions = json_decode(json_encode($payload, JSON_THROW_ON_ERROR), true, 512, JSON_THROW_ON_ERROR);
$stripReactions = function (array &$items) use (&$stripReactions): void {
foreach ($items as &$item) {
unset($item['reactions']);
if (isset($item['replies']) && is_array($item['replies'])) {
$stripReactions($item['replies']);
}
}
};
$stripReactions($withoutReactions);
$commentCount = 0;
$countComments = function (array $items) use (&$countComments, &$commentCount): void {
foreach ($items as $item) {
$commentCount++;
if (isset($item['replies']) && is_array($item['replies'])) {
$countComments($item['replies']);
}
}
};
$countComments($payload);
fwrite(STDERR, sprintf(
"M18B1 guest payload bytes: before=%d after=%d added=%d comments/replies=%d\n",
strlen(json_encode($withoutReactions, JSON_THROW_ON_ERROR)),
strlen(json_encode($payload, JSON_THROW_ON_ERROR)),
strlen(json_encode($payload, JSON_THROW_ON_ERROR)) - strlen(json_encode($withoutReactions, JSON_THROW_ON_ERROR)),
$commentCount,
));
expect(m18b1ReactionQueryCount(DB::getQueryLog()))->toBe(1)
->and($payload[0])->toHaveKey('reactions')
->and($payload[0]['reactions'][ReactionType::Heart->value]['count'])->toBe(1)
->and($payload[0]['reactions'][ReactionType::Heart->value]['mine'])->toBeFalse()
->and($payload[0]['replies'][0]['reactions'][ReactionType::Fire->value]['count'])->toBe(1)
->and(count($comments))->toBe(2);
DB::disableQueryLog();
});
it('includes viewer reaction state for comments and replies with one extra query', function (): void {
[$artwork, $viewer] = m18b1ArtworkCommentTree();
DB::enableQueryLog();
DB::flushQueryLog();
$response = $this->actingAs($viewer)
->getJson("/api/artworks/{$artwork->id}/comments")
->assertOk();
$payload = $response->json('data');
expect(m18b1ReactionQueryCount(DB::getQueryLog()))->toBe(2)
->and($payload[0]['reactions'][ReactionType::Heart->value]['mine'])->toBeFalse()
->and($payload[0]['replies'][0]['reactions'][ReactionType::Fire->value]['mine'])->toBeTrue();
DB::disableQueryLog();
});
it('keeps reaction query count constant as the comment tree grows', function (): void {
[$smallArtwork] = m18b1ArtworkCommentTree(2);
[$largeArtwork] = m18b1ArtworkCommentTree(20);
DB::enableQueryLog();
DB::flushQueryLog();
$this->getJson("/api/artworks/{$smallArtwork->id}/comments")->assertOk();
$smallCount = m18b1ReactionQueryCount(DB::getQueryLog());
DB::flushQueryLog();
$this->getJson("/api/artworks/{$largeArtwork->id}/comments")->assertOk();
$largeCount = m18b1ReactionQueryCount(DB::getQueryLog());
DB::disableQueryLog();
expect($smallCount)->toBe(1)->and($largeCount)->toBe(1);
});
@@ -0,0 +1,75 @@
<?php
use App\Models\Artwork;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
uses(RefreshDatabase::class);
it('keeps the artwork detail edge-case responses intact', function (): void {
$artwork = Artwork::factory()->create([
'title' => 'M18B2 Artwork',
'slug' => 'm18b2-artwork',
]);
$this->get(route('art.show', ['id' => $artwork->id, 'slug' => 'm18b2-artwork']))
->assertOk();
$this->get(route('art.show', ['id' => $artwork->id, 'slug' => 'wrong-slug']))
->assertRedirect(route('art.show', ['id' => $artwork->id, 'slug' => 'm18b2-artwork']))
->assertHeader('Location', route('art.show', ['id' => $artwork->id, 'slug' => 'm18b2-artwork']));
$deleted = Artwork::factory()->create();
$deleted->delete();
$this->get(route('art.show', ['id' => $deleted->id, 'slug' => $deleted->slug]))
->assertStatus(410);
$private = Artwork::factory()->private()->create();
$this->get(route('art.show', ['id' => $private->id, 'slug' => $private->slug]))
->assertForbidden();
$this->get(route('art.show', ['id' => 999999, 'slug' => 'missing']))
->assertNotFound();
});
it('performs one primary artwork lookup on the public detail path', function (): void {
$artwork = Artwork::factory()->create([
'slug' => 'm18b2-query-regression',
]);
$primaryLookups = 0;
DB::listen(function ($query) use (&$primaryLookups): void {
$sql = strtolower(preg_replace('/\s+/', ' ', $query->sql));
if (str_contains($sql, 'from "artworks"')
&& str_contains($sql, 'where "id" = ?')
&& str_contains($sql, 'limit 1')) {
$primaryLookups++;
}
});
$this->get(route('art.show', ['id' => $artwork->id, 'slug' => $artwork->slug]))
->assertOk();
expect($primaryLookups)->toBe(1);
});
it('keeps the authenticated artwork detail query count bounded', function (): void {
$artwork = Artwork::factory()->create([
'slug' => 'm18b2-auth-query-regression',
]);
$viewer = User::factory()->create();
$queryCount = 0;
DB::listen(function () use (&$queryCount): void {
$queryCount++;
});
$this->actingAs($viewer)
->get(route('art.show', ['id' => $artwork->id, 'slug' => $artwork->slug]))
->assertOk();
expect($queryCount)->toBeLessThanOrEqual(50);
});
@@ -0,0 +1,98 @@
<?php
use App\Models\Artwork;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
uses(RefreshDatabase::class);
it('returns only the image contract for navigation prefetch', function (): void {
$artwork = Artwork::factory()->create([
'title' => 'Prefetch Artwork',
'slug' => 'prefetch-artwork',
'hash' => 'aabbccddeeff00112233445566778899',
'file_path' => 'uploads/prefetch.jpg',
]);
$queries = 0;
DB::listen(function () use (&$queries): void {
$queries++;
});
$response = $this->getJson('/api/artworks/'.$artwork->id.'/page?prefetch=1')
->assertOk();
expect($queries)->toBe(1)
->and($response->json())->toHaveKeys(['id', 'title', 'slug', 'thumbs'])
->and($response->json('thumbs'))->toHaveKeys(['md', 'lg'])
->and($response->json('thumbs.xl'))->toBeNull()
->and($response->json('stats'))->toBeNull()
->and($response->json('thumbs.lg.url'))->toBe(
'https://cdn.skinbase.org/artworks/lg/aa/bb/aabbccddeeff00112233445566778899.webp'
);
});
it('does not prefetch missing or unpublished artwork', function (): void {
$unpublished = Artwork::factory()->unpublished()->create();
$this->getJson('/api/artworks/'.$unpublished->id.'/page?prefetch=1')
->assertNotFound();
$this->getJson('/api/artworks/999999/page?prefetch=1')
->assertNotFound();
});
it('keeps full page navigation data available separately from prefetch', function (): void {
$artwork = Artwork::factory()->create([
'slug' => 'full-navigation-artwork',
]);
$this->getJson('/api/artworks/'.$artwork->id.'/page')
->assertOk()
->assertJsonStructure([
'id', 'title', 'thumbs', 'user', 'categories', 'tags', 'stats', 'viewer',
]);
});
it('measures the lightweight prefetch contract for guest and authenticated viewers', function (): void {
$artwork = Artwork::factory()->create([
'slug' => 'prefetch-measurement-artwork',
'hash' => 'aabbccddeeff00112233445566778899',
]);
DB::enableQueryLog();
DB::flushQueryLog();
$guestPrefetch = $this->getJson('/api/artworks/'.$artwork->id.'/page?prefetch=1')->assertOk();
$guestPrefetchQueries = count(DB::getQueryLog());
DB::flushQueryLog();
$guestFull = $this->getJson('/api/artworks/'.$artwork->id.'/page')->assertOk();
$guestFullQueries = count(DB::getQueryLog());
DB::flushQueryLog();
$authPrefetch = $this->actingAs(User::factory()->create())
->getJson('/api/artworks/'.$artwork->id.'/page?prefetch=1')
->assertOk();
$authPrefetchQueries = count(DB::getQueryLog());
DB::flushQueryLog();
$authFull = $this->getJson('/api/artworks/'.$artwork->id.'/page')->assertOk();
$authFullQueries = count(DB::getQueryLog());
DB::disableQueryLog();
fwrite(STDERR, sprintf(
"M18B3 prefetch bytes/queries: guest=%d/%d auth=%d/%d; full guest=%d/%d auth=%d/%d\n",
strlen((string) $guestPrefetch->getContent()),
$guestPrefetchQueries,
strlen((string) $authPrefetch->getContent()),
$authPrefetchQueries,
strlen((string) $guestFull->getContent()),
$guestFullQueries,
strlen((string) $authFull->getContent()),
$authFullQueries,
));
expect($guestPrefetchQueries)->toBe(1)
->and($authPrefetchQueries)->toBeLessThanOrEqual(2)
->and($guestPrefetch->json())->toEqual($authPrefetch->json());
});
@@ -0,0 +1,92 @@
<?php
use App\Models\Artwork;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
function m18b6PublishedArtwork(User $user, string $slug): Artwork
{
return Artwork::factory()->for($user)->create([
'slug' => $slug,
'published_at' => now()->subDay(),
'is_public' => true,
'is_approved' => true,
]);
}
it('returns the correct previous and next artwork for a middle item', function (): void {
$user = User::factory()->create();
$first = m18b6PublishedArtwork($user, 'first');
$middle = m18b6PublishedArtwork($user, 'middle');
$last = m18b6PublishedArtwork($user, 'last');
$this->getJson('/api/artworks/navigation/'.$middle->id)
->assertOk()
->assertJson([
'prev_id' => $first->id,
'next_id' => $last->id,
'prev_url' => url('/art/'.$first->id.'/first'),
'next_url' => url('/art/'.$last->id.'/last'),
'prev_slug' => 'first',
'next_slug' => 'last',
])
->assertJsonStructure([
'prev_preview' => ['id', 'title', 'slug', 'thumbs' => ['md', 'lg']],
'next_preview' => ['id', 'title', 'slug', 'thumbs' => ['md', 'lg']],
]);
});
it('wraps the first item to the last item', function (): void {
$user = User::factory()->create();
$first = m18b6PublishedArtwork($user, 'first');
$last = m18b6PublishedArtwork($user, 'last');
$this->getJson('/api/artworks/navigation/'.$first->id)
->assertOk()
->assertJsonPath('prev_id', $last->id)
->assertJsonPath('next_id', $last->id)
->assertJsonPath('prev_preview.id', $last->id)
->assertJsonPath('next_preview.id', $last->id);
});
it('wraps the last item to the first item', function (): void {
$user = User::factory()->create();
$first = m18b6PublishedArtwork($user, 'first');
$last = m18b6PublishedArtwork($user, 'last');
$this->getJson('/api/artworks/navigation/'.$last->id)
->assertOk()
->assertJsonPath('prev_id', $first->id)
->assertJsonPath('next_id', $first->id)
->assertJsonPath('prev_preview.id', $first->id)
->assertJsonPath('next_preview.id', $first->id);
});
it('returns the empty contract for a missing or hidden artwork', function (): void {
$empty = [
'prev_id' => null,
'next_id' => null,
'prev_url' => null,
'next_url' => null,
'prev_slug' => null,
'next_slug' => null,
'prev_preview' => null,
'next_preview' => null,
];
$this->getJson('/api/artworks/navigation/999999')
->assertOk()
->assertExactJson($empty);
$private = Artwork::factory()->private()->create();
$this->getJson('/api/artworks/navigation/'.$private->id)
->assertOk()
->assertExactJson($empty);
$this->actingAs(User::factory()->create())
->getJson('/api/artworks/navigation/'.$private->id)
->assertOk()
->assertExactJson($empty);
});
@@ -0,0 +1,88 @@
<?php
declare(strict_types=1);
use App\Models\Artwork;
use App\Services\ThumbnailService;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
beforeEach(function (): void {
config([
'cdn.files_url' => 'https://cdn.example.test',
'uploads.object_storage.prefix' => 'artworks',
]);
});
it('resolves an existing artwork id to the same CDN URL as fromHash', function (): void {
$hash = 'aabbccddeeff00112233445566778899';
$artwork = Artwork::factory()->create([
'hash' => $hash,
'thumb_ext' => 'webp',
]);
$expected = ThumbnailService::fromHash($hash, 'webp', 'md');
expect(ThumbnailService::url(null, (int) $artwork->id, null, 6))->toBe($expected);
expect($expected)->toBe('https://cdn.example.test/artworks/md/aa/bb/aabbccddeeff00112233445566778899.webp');
});
it('returns the empty-string fallback for a missing artwork id', function (): void {
expect(ThumbnailService::url(null, 99999999, null, 6))->toBe('');
});
it('falls back to Storage::url when a file path is supplied and id lookup misses', function (): void {
$path = 'uploads/artworks/fallback.jpg';
expect(ThumbnailService::url($path, 99999999, null, 6))->toBe(Storage::url($path));
});
it('builds a CDN URL from a direct hash and extension without querying artworks', function (): void {
DB::flushQueryLog();
DB::enableQueryLog();
$url = ThumbnailService::url('11223344556677889900aabbccddeeff', null, 'webp', 'xl');
expect($url)->toBe('https://cdn.example.test/artworks/xl/11/22/11223344556677889900aabbccddeeff.webp');
$legacyIdQueries = collect(DB::getQueryLog())
->filter(fn (array $query): bool => str_contains(strtolower((string) $query['query']), 'legacy_id'));
expect($legacyIdQueries)->toBeEmpty();
DB::disableQueryLog();
});
it('maps numeric size 4 to the thumb/sm CDN path', function (): void {
$hash = 'deadbeefcafebabe0123456789abcdef';
$artwork = Artwork::factory()->create([
'hash' => $hash,
'thumb_ext' => 'jpg',
]);
$url = ThumbnailService::url(null, (int) $artwork->id, null, 4);
$expected = ThumbnailService::fromHash($hash, 'jpg', 'thumb');
expect($url)->toBe($expected);
expect($url)->toBe('https://cdn.example.test/artworks/sm/de/ad/deadbeefcafebabe0123456789abcdef.jpg');
});
it('does not query artworks.legacy_id when resolving a thumbnail by artwork id', function (): void {
$artwork = Artwork::factory()->create([
'hash' => '00112233445566778899aabbccddeeff',
'thumb_ext' => 'webp',
]);
DB::flushQueryLog();
DB::enableQueryLog();
ThumbnailService::url(null, (int) $artwork->id, null, 'md');
$queries = collect(DB::getQueryLog())->map(fn (array $query): string => strtolower((string) $query['query']));
expect($queries)->not->toBeEmpty();
expect($queries->filter(fn (string $sql): bool => str_contains($sql, 'legacy_id')))->toBeEmpty();
expect($queries->filter(fn (string $sql): bool => str_contains($sql, 'artworks')))->not->toBeEmpty();
DB::disableQueryLog();
});
@@ -0,0 +1,131 @@
<?php
declare(strict_types=1);
use App\Models\Artwork;
use App\Models\User;
use App\Services\ArtworkStatsService;
use App\Services\Traffic\OnlineVisitorRepository;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\File;
beforeEach(function () {
$root = storage_path('framework/testing/artwork-downloads-m15c');
config([
'uploads.storage_root' => $root,
'uploads.local_originals_root' => $root,
'app.download_accel_enabled' => false,
'app.url' => 'https://skinbase.test',
'skinbase-sessions.enabled' => true,
'skinbase-sessions.debug_header' => true,
'skinbase-sessions.skip_anonymous_public_get' => true,
]);
if (File::exists($root)) {
File::deleteDirectory($root);
}
File::makeDirectory($root, 0755, true);
});
afterEach(function () {
$root = storage_path('framework/testing/artwork-downloads-m15c');
if (File::exists($root)) {
File::deleteDirectory($root);
}
});
function m15cOriginal(string $hash, string $ext, string $content = 'bytes'): string
{
$root = rtrim((string) config('uploads.local_originals_root'), DIRECTORY_SEPARATOR);
$dir = $root.DIRECTORY_SEPARATOR.substr($hash, 0, 2).DIRECTORY_SEPARATOR.substr($hash, 2, 2);
File::makeDirectory($dir, 0755, true, true);
$path = $dir.DIRECTORY_SEPARATOR.$hash.'.'.$ext;
File::put($path, $content);
return $path;
}
it('returns a cheap 404 for a nonexistent artwork without analytics', function () {
$this->mock(ArtworkStatsService::class, function ($mock): void {
$mock->shouldReceive('incrementDownloads')->never();
});
$this->get('/download/artwork/999999')->assertNotFound();
expect(DB::table('artwork_downloads')->count())->toBe(0);
});
it('returns a cheap 404 for a missing original without analytics', function () {
$artwork = Artwork::factory()->create([
'hash' => 'c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f6a7b8c9d0',
'file_ext' => 'webp',
'file_name' => 'gone.webp',
]);
$this->mock(ArtworkStatsService::class, function ($mock): void {
$mock->shouldReceive('incrementDownloads')->never();
});
$this->get('/download/artwork/'.$artwork->id)->assertNotFound();
expect(DB::table('artwork_downloads')->where('artwork_id', $artwork->id)->count())->toBe(0);
});
it('keeps valid anonymous downloads and X-Accel-Redirect when enabled', function () {
config(['app.download_accel_enabled' => true, 'app.download_accel_path' => '/internal/originals']);
$hash = '2721722cb2f407cc53539ee8718cc89c4cdfc579';
m15cOriginal($hash, 'zip');
$artwork = Artwork::factory()->create([
'hash' => $hash,
'file_ext' => 'zip',
'file_name' => 'Anon File',
]);
$this->get('/download/artwork/'.$artwork->id)
->assertOk()
->assertHeader('X-Accel-Redirect', '/internal/originals/27/21/2721722cb2f407cc53539ee8718cc89c4cdfc579.zip');
});
it('still records ArtworkDownload.user_id for authenticated downloads', function () {
$hash = 'aa11bb22cc33dd44ee55ff6677889900aabbccdd';
m15cOriginal($hash, 'png');
$user = User::factory()->create();
$artwork = Artwork::factory()->create([
'hash' => $hash,
'file_ext' => 'png',
'file_name' => 'Auth File',
]);
$this->actingAs($user)->get('/download/artwork/'.$artwork->id)->assertOk();
$this->assertDatabaseHas('artwork_downloads', [
'artwork_id' => $artwork->id,
'user_id' => $user->id,
]);
});
it('skips session for anonymous download GET without a session cookie', function () {
$this->get('/download/artwork/999999')
->assertNotFound()
->assertHeader('X-Skinbase-Session', 'skipped');
});
it('starts session for download GET when a session cookie is already present', function () {
$cookie = (string) config('session.cookie');
$this->withCookie($cookie, 'existing-session-cookie')
->get('/download/artwork/999999')
->assertNotFound()
->assertHeader('X-Skinbase-Session', 'started');
});
it('does not record presence for artwork download requests', function () {
$this->mock(OnlineVisitorRepository::class, function ($mock): void {
$mock->shouldReceive('track')->never();
});
$this->get('/download/artwork/999999')->assertNotFound();
});
@@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
use App\Services\Traffic\OnlineVisitorRepository;
use Illuminate\Support\Facades\Config;
beforeEach(function (): void {
Config::set('skinbase-sessions.enabled', true);
Config::set('skinbase-sessions.debug_header', true);
Config::set('skinbase-sessions.skip_anonymous_public_get', true);
});
it('skips session for anonymous GET and HEAD /photo/* without a session cookie', function (): void {
$this->get('/photo/0_6.png')
->assertNotFound()
->assertHeader('X-Skinbase-Session', 'skipped');
$this->head('/photo/0_6.png')
->assertNotFound()
->assertHeader('X-Skinbase-Session', 'skipped');
});
it('starts session for /photo/* when a session cookie is already present', function (): void {
$cookie = (string) config('session.cookie');
$this->withCookie($cookie, 'existing-session-cookie')
->get('/photo/0_6.png')
->assertNotFound()
->assertHeader('X-Skinbase-Session', 'started');
});
it('does not record presence for /photo/* requests', function (): void {
$this->mock(OnlineVisitorRepository::class, function ($mock): void {
$mock->shouldReceive('track')->never();
});
$this->get('/photo/0_6.png')->assertNotFound();
$this->head('/photo/0_6.png')->assertNotFound();
});
+73 -1
View File
@@ -58,4 +58,76 @@ it('redirects legacy full-size photo urls to the original CDN asset', function (
$response $response
->assertStatus(301) ->assertStatus(301)
->assertRedirect('https://cdn.example.test/artworks/original/11/22/1122334455667788.png'); ->assertRedirect('https://cdn.example.test/artworks/original/11/22/1122334455667788.png');
}); });
it('returns a cheap 404 for zero or invalid decoded photo ids', function (): void {
$this->get('/photo/0_6.png')->assertNotFound();
$this->head('/photo/0_6.png')->assertNotFound();
});
it('returns 404 for a nonexistent artwork', function (): void {
$this->get('/photo/' . encodeLegacyPhotoId(999999) . '_6.png')->assertNotFound();
});
it('returns 404 for private, unapproved, or unpublished artworks', function (): void {
$private = Artwork::factory()->create(['is_public' => false, 'hash' => 'aabbccddeeff0011', 'thumb_ext' => 'webp']);
$unapproved = Artwork::factory()->create(['is_approved' => false, 'hash' => 'bbccddeeff001122', 'thumb_ext' => 'webp']);
$unpublished = Artwork::factory()->create(['published_at' => null, 'hash' => 'ccddeeff00112233', 'thumb_ext' => 'webp']);
$this->get('/photo/' . encodeLegacyPhotoId($private->id) . '_6.png')->assertNotFound();
$this->get('/photo/' . encodeLegacyPhotoId($unapproved->id) . '_6.png')->assertNotFound();
$this->get('/photo/' . encodeLegacyPhotoId($unpublished->id) . '_6.png')->assertNotFound();
});
it('returns 404 for eligible legacy artwork with no usable media instead of redirecting to a dead CDN path', function (): void {
config(['cdn.files_url' => 'https://cdn.example.test']);
$artwork = Artwork::factory()->create([
'hash' => null,
'thumb_ext' => null,
'file_ext' => null,
'file_path' => 'legacy/uploads/old-photo.jpg',
'is_public' => true,
'is_approved' => true,
'published_at' => now()->subDay(),
]);
$this->get('/photo/' . encodeLegacyPhotoId($artwork->id) . '_6.png')->assertNotFound();
$this->get('/photo/' . encodeLegacyPhotoId($artwork->id) . '_7.png')->assertNotFound();
});
it('maps size 3 to the sm thumbnail variant', function (): void {
config([
'cdn.files_url' => 'https://cdn.example.test',
'uploads.object_storage.prefix' => 'artworks',
]);
$artwork = Artwork::factory()->create([
'hash' => 'aabbccddeeff0011',
'thumb_ext' => 'webp',
'file_ext' => 'jpg',
'file_path' => '',
]);
$this->get('/photo/' . encodeLegacyPhotoId($artwork->id) . '_3.png')
->assertStatus(301)
->assertRedirect('https://cdn.example.test/artworks/sm/aa/bb/aabbccddeeff0011.webp');
});
it('preserves HEAD redirects for valid modern hash-based photos', function (): void {
config([
'cdn.files_url' => 'https://cdn.example.test',
'uploads.object_storage.prefix' => 'artworks',
]);
$artwork = Artwork::factory()->create([
'hash' => 'aabbccddeeff0011',
'thumb_ext' => 'webp',
'file_ext' => 'jpg',
'file_path' => '',
]);
$this->head('/photo/' . encodeLegacyPhotoId($artwork->id) . '_6.png')
->assertStatus(301)
->assertRedirect('https://cdn.example.test/artworks/md/aa/bb/aabbccddeeff0011.webp');
});
@@ -0,0 +1,308 @@
<?php
declare(strict_types=1);
use App\Models\User;
use Illuminate\Support\Facades\DB;
use Inertia\Testing\AssertableInertia;
function insertFollow(int $ownerId, int $followerId, ?\DateTimeInterface $at = null): void
{
DB::table('user_followers')->insert([
'user_id' => $ownerId,
'follower_id' => $followerId,
'created_at' => $at ?? now(),
]);
}
it('renders the public profile followers page for an existing username', function () {
$owner = User::factory()->create(['username' => 'followowner']);
$follower = User::factory()->create(['username' => 'realfollower']);
$ownerFollows = User::factory()->create(['username' => 'ownerfollows']);
insertFollow((int) $owner->id, (int) $follower->id);
insertFollow((int) $ownerFollows->id, (int) $owner->id);
$this->get('/@followowner/followers')
->assertOk()
->assertSee('http://skinbase26.test/@followowner/followers', false)
->assertSee('followowner Followers on Skinbase', false)
->assertSee('People who follow followowner on Skinbase.', false)
->assertInertia(fn (AssertableInertia $page) => $page
->component('Profile/ProfileShow')
->where('initialTab', 'followers')
->where('profileTabUrls.followers', url('/@followowner/followers'))
->where('followersListing.total', 1)
->where('followersListing.data.0.username', 'realfollower')
->where('followersListing.data.0.profile_url', '/@realfollower')
->missing('followersListing.data.0.email')
->has('profileTabUrls.posts')
->has('profileTabUrls.artworks'));
});
it('allows anonymous viewers to load public followers without authentication', function () {
$owner = User::factory()->create(['username' => 'anonowner']);
$this->get('/@anonowner/followers')->assertOk();
$this->assertGuest();
});
it('does not return people the owner follows as followers', function () {
$owner = User::factory()->create(['username' => 'semanticowner']);
$follower = User::factory()->create(['username' => 'semanticfollower']);
$following = User::factory()->create(['username' => 'semanticfollowing']);
insertFollow((int) $owner->id, (int) $follower->id);
insertFollow((int) $following->id, (int) $owner->id);
$this->get('/@semanticowner/followers')
->assertOk()
->assertInertia(fn (AssertableInertia $page) => $page
->where('followersListing.total', 1)
->where('followersListing.data.0.username', 'semanticfollower')
->where('followersListing.data.0.username', fn ($username) => $username !== 'semanticfollowing'));
});
it('normalizes mixed-case usernames to the lowercase canonical followers URL', function () {
User::factory()->create(['username' => 'CaseOwner']);
$this->get('/@CaseOwner/followers')
->assertStatus(301)
->assertRedirect('/@caseowner/followers');
});
it('follows username_redirects to the current followers URL', function () {
$user = User::factory()->create(['username' => 'newfollowuser']);
DB::table('username_redirects')->insert([
'old_username' => 'oldfollowuser',
'new_username' => 'newfollowuser',
'user_id' => $user->id,
'created_at' => now(),
'updated_at' => now(),
]);
$this->get('/@oldfollowuser/followers')
->assertStatus(301)
->assertRedirect('/@newfollowuser/followers');
});
it('returns 404 for a nonexistent public followers username', function () {
$this->get('/@missinguserzz/followers')->assertNotFound();
});
it('paginates public followers', function () {
$owner = User::factory()->create(['username' => 'pageowner']);
$followers = User::factory()->count(25)->create();
foreach ($followers as $index => $follower) {
insertFollow((int) $owner->id, (int) $follower->id, now()->subMinutes($index));
}
$this->get('/@pageowner/followers')
->assertOk()
->assertInertia(fn (AssertableInertia $page) => $page
->where('followersListing.per_page', 24)
->where('followersListing.current_page', 1)
->where('followersListing.last_page', 2)
->where('followersListing.total', 25)
->has('followersListing.data', 24));
$this->get('/@pageowner/followers?page=2')
->assertOk()
->assertInertia(fn (AssertableInertia $page) => $page
->where('followersListing.current_page', 2)
->has('followersListing.data', 1));
});
it('redirects legacy following id and slug to the canonical followers URL', function () {
$owner = User::factory()->create(['username' => 'legacyfollow']);
$this->get('/following/' . $owner->id . '/old-outdated-slug')
->assertStatus(301)
->assertRedirect('/@legacyfollow/followers');
});
it('ignores a mismatched legacy slug and uses the current database username', function () {
$owner = User::factory()->create(['username' => 'canonicalname']);
$this->get('/following/' . $owner->id . '/wrong-slug')
->assertStatus(301)
->assertRedirect('/@canonicalname/followers');
});
it('returns 404 for a legacy following URL when the user id does not exist', function () {
$this->get('/following/99999999/whatever')->assertNotFound();
});
it('preserves query strings when redirecting legacy following URLs', function () {
$owner = User::factory()->create(['username' => 'qsowner']);
$this->get('/following/' . $owner->id . '/old-slug?page=2&utm_source=legacy')
->assertStatus(301)
->assertRedirect('/@qsowner/followers?page=2&utm_source=legacy');
});
it('no longer emits the broken legacy following URL from the old profile template', function () {
$blade = file_get_contents(resource_path('views/_legacy/profile.blade.php'));
expect($blade)->not->toContain('/following/{{ $user->id }}');
expect($blade)->toContain("/@' . strtolower((string) (\$user->username ?? \$uname)) . '/followers");
});
it('leaves authenticated /following root behavior unchanged', function () {
$user = User::factory()->create();
$this->actingAs($user)
->get('/following')
->assertRedirect(route('dashboard.following'));
});
it('leaves dashboard followers as the authenticated owner surface', function () {
$user = User::factory()->create();
$this->actingAs($user)
->get('/dashboard/followers')
->assertOk();
});
it('leaves studio followers as the creator-owner surface', function () {
$user = User::factory()->create();
$this->actingAs($user)
->get('/studio/followers')
->assertOk()
->assertInertia(fn (AssertableInertia $page) => $page->component('Studio/StudioFollowers'));
});
it('leaves the public followers API payload shape unchanged', function () {
$owner = User::factory()->create(['username' => 'apiowner']);
$follower = User::factory()->create(['username' => 'apifollower']);
$following = User::factory()->create(['username' => 'apifollowing']);
insertFollow((int) $owner->id, (int) $follower->id);
insertFollow((int) $following->id, (int) $owner->id);
$this->getJson('/api/user/apiowner/followers')
->assertOk()
->assertJsonPath('data.0.username', 'apifollower')
->assertJsonPath('data.0.profile_url', '/@apifollower')
->assertJsonMissingPath('data.0.email')
->assertJsonMissing(['username' => 'apifollowing']);
});
it('leaves other public profile tabs on their canonical routes', function () {
$owner = User::factory()->create(['username' => 'tabowner']);
$this->get('/@tabowner/artworks')
->assertOk()
->assertInertia(fn (AssertableInertia $page) => $page
->where('initialTab', 'artworks')
->where('profileTabUrls.followers', url('/@tabowner/followers'))
->where('profileTabUrls.artworks', url('/@tabowner/artworks')));
});
function profileSqlLooksLikePaginatedFollowers(string $sql): bool
{
$normalized = strtolower($sql);
if (! str_contains($normalized, 'user_followers')) {
return false;
}
if (! str_contains($normalized, ' as uf') && ! str_contains($normalized, ' as "uf"')) {
return false;
}
$isPaginatorCount = str_contains($normalized, 'aggregate')
&& str_contains($normalized, 'join');
$isPageRows = str_contains($normalized, 'limit')
&& (str_contains($normalized, 'limit 24') || str_contains($normalized, 'limit ?'));
return $isPaginatorCount || $isPageRows;
}
it('does not load paginated followers on the base profile, artworks, or about tabs', function () {
$owner = User::factory()->create(['username' => 'lazyowner']);
$followers = User::factory()->count(3)->create();
foreach ($followers as $index => $follower) {
insertFollow((int) $owner->id, (int) $follower->id, now()->subMinutes($index));
}
foreach (['/@lazyowner', '/@lazyowner/artworks', '/@lazyowner/about'] as $url) {
DB::flushQueryLog();
DB::enableQueryLog();
$this->get($url)
->assertOk()
->assertInertia(fn (AssertableInertia $page) => $page
->where('followersListing', null)
->has('followerCount')
->has('recentFollowers')
->has('viewerIsFollowing')
->has('followAnalytics'));
$paginatedQueries = collect(DB::getQueryLog())
->filter(fn (array $query): bool => profileSqlLooksLikePaginatedFollowers((string) $query['query']))
->values();
expect($paginatedQueries)->toBeEmpty('paginated followers query ran on '.$url);
}
DB::disableQueryLog();
});
it('loads paginated followers only on the followers tab including page=2', function () {
$owner = User::factory()->create(['username' => 'lazyfollow']);
$followers = User::factory()->count(25)->create();
foreach ($followers as $index => $follower) {
insertFollow((int) $owner->id, (int) $follower->id, now()->subMinutes($index));
}
DB::flushQueryLog();
DB::enableQueryLog();
$this->get('/@lazyfollow/followers?page=2')
->assertOk()
->assertInertia(fn (AssertableInertia $page) => $page
->where('initialTab', 'followers')
->where('followersListing.current_page', 2)
->where('followersListing.per_page', 24)
->where('followersListing.total', 25)
->has('followersListing.data', 1)
->has('followerCount')
->has('recentFollowers'));
$paginatedQueries = collect(DB::getQueryLog())
->filter(fn (array $query): bool => profileSqlLooksLikePaginatedFollowers((string) $query['query']))
->values();
expect($paginatedQueries)->not->toBeEmpty();
DB::disableQueryLog();
});
it('ignores ?page= on unrelated profile tabs and still skips followers pagination', function () {
$owner = User::factory()->create(['username' => 'pageignore']);
insertFollow((int) $owner->id, (int) User::factory()->create()->id);
DB::flushQueryLog();
DB::enableQueryLog();
$this->get('/@pageignore/artworks?page=2')
->assertOk()
->assertInertia(fn (AssertableInertia $page) => $page
->where('initialTab', 'artworks')
->where('followersListing', null));
$paginatedQueries = collect(DB::getQueryLog())
->filter(fn (array $query): bool => profileSqlLooksLikePaginatedFollowers((string) $query['query']))
->values();
expect($paginatedQueries)->toBeEmpty();
DB::disableQueryLog();
});
@@ -0,0 +1,223 @@
<?php
use App\Support\Http\HttpPerformanceLogAnalyzer;
use App\Support\Http\HttpUriNormalizer;
function rotationDir(): string
{
return dirname(__DIR__, 2).'/fixtures/http-performance/rotation';
}
function analyzerNow(): DateTimeImmutable
{
return new DateTimeImmutable('2026-08-25T08:00:00+02:00');
}
function writeGzFixture(): string
{
$plain = rotationDir().'/oldest.log';
$gz = rotationDir().'/oldest.log.2.gz';
$lines = <<<'JSON'
{"time":"2026-08-24T07:50:00+02:00","host":"skinbase.org","method":"GET","uri":"/","status":200,"request_time":0.100,"upstream_response_time":"0.090","upstream_connect_time":"0.000","upstream_header_time":"0.080","bytes":1000,"request_length":80,"content_type":"text/html","cache":""}
{"time":"2026-08-24T08:10:00+02:00","host":"skinbase.org","method":"GET","uri":"/art/53867/light-mark-docks","status":200,"request_time":0.400,"upstream_response_time":"0.380","upstream_connect_time":"0.000","upstream_header_time":"0.200","bytes":10000,"request_length":100,"content_type":"text/html","cache":""}
{"time":"2026-08-24T08:11:00+02:00","host":"skinbase.org","method":"GET","uri":"/missing-old","status":404,"request_time":0.030,"upstream_response_time":"0.020","upstream_connect_time":"0.000","upstream_header_time":"0.010","bytes":700,"request_length":60,"content_type":"text/html","cache":""}
{"time":"2026-08-24T08:12:00+02:00","host":"skinbase.org","method":"GET","uri":"/broken","status":500,"request_time":0.500,"upstream_response_time":"0.480","upstream_connect_time":"0.000","upstream_header_time":"0.200","bytes":200,"request_length":70,"content_type":"text/html","cache":""}
JSON;
file_put_contents($plain, $lines."\n");
$gzHandle = gzopen($gz, 'wb');
fwrite($gzHandle, $lines."\n");
gzclose($gzHandle);
return $gz;
}
function rotationFiles(): array
{
return [
rotationDir().'/active.log',
rotationDir().'/rotated.1',
writeGzFixture(),
];
}
function analyzeRotation(array $extra = []): array
{
$analyzer = new HttpPerformanceLogAnalyzer(new HttpUriNormalizer());
return $analyzer->analyze(rotationFiles(), $extra + [
'since' => '24h',
'now' => analyzerNow(),
'top' => 50,
'min_requests' => 1,
]);
}
it('filters exact status 404 and does not leak other codes', function () {
$result = analyzeRotation(['status' => '404', 'host' => 'skinbase.org']);
$statuses = collect($result['slowest'])->pluck('status')->unique()->values()->all();
expect($result['totals']['total'])->toBe(2)
->and($statuses)->toBe([404])
->and($result['totals']['5xx'])->toBe(0)
->and($result['totals']['499'])->toBe(0);
});
it('filters exact 422 and 500 separately', function () {
$s422 = analyzeRotation(['status' => '422', 'host' => 'skinbase.org']);
$s500 = analyzeRotation(['status' => '500', 'host' => 'skinbase.org']);
expect($s422['totals']['total'])->toBe(1)
->and(collect($s422['slowest'])->pluck('status')->all())->toBe([422])
->and($s500['totals']['total'])->toBe(1)
->and(collect($s500['slowest'])->pluck('status')->all())->toBe([500]);
});
it('supports 4xx and 5xx classes without treating 404 as a class', function () {
$four = analyzeRotation(['status' => '4xx', 'host' => 'skinbase.org']);
$five = analyzeRotation(['status' => '5xx', 'host' => 'skinbase.org']);
$fourStatuses = collect($four['slowest'])->pluck('status')->sort()->values()->all();
$fiveStatuses = collect($five['slowest'])->pluck('status')->sort()->values()->all();
expect($fourStatuses)->toBe([404, 404, 422, 499])
->and($fiveStatuses)->toBe([500, 502]);
});
it('rejects malformed status filters', function () {
$analyzer = new HttpPerformanceLogAnalyzer(new HttpUriNormalizer());
expect(fn () => $analyzer->parseStatusFilter('foo'))->toThrow(InvalidArgumentException::class)
->and(fn () => $analyzer->parseStatusFilter('40'))->toThrow(InvalidArgumentException::class)
->and(fn () => $analyzer->parseStatusFilter('9999'))->toThrow(InvalidArgumentException::class);
});
it('includes active, rotated, and gzip files in one window', function () {
$result = analyzeRotation(['host' => 'skinbase.org']);
$paths = collect($result['sources'])->pluck('path')->implode(' ');
expect($result['source_files'])->toBe(3)
->and($paths)->toContain('active.log')
->and($paths)->toContain('rotated.1')
->and($paths)->toContain('.gz')
->and($result['totals']['total'])->toBeGreaterThan(5);
$gz = collect($result['sources'])->first(fn (array $row): bool => str_ends_with($row['path'], '.gz'));
expect($gz['in_window'])->toBeGreaterThan(0);
});
it('deduplicates the same file passed twice without dropping distinct events', function () {
$analyzer = new HttpPerformanceLogAnalyzer(new HttpUriNormalizer());
$active = rotationDir().'/active.log';
$once = $analyzer->analyze($active, [
'since' => '24h',
'now' => analyzerNow(),
'host' => 'skinbase.org',
'min_requests' => 1,
'top' => 20,
]);
$twice = $analyzer->analyze([$active, $active], [
'since' => '24h',
'now' => analyzerNow(),
'host' => 'skinbase.org',
'min_requests' => 1,
'top' => 20,
]);
expect($twice['source_files'])->toBe(1)
->and($twice['totals']['total'])->toBe($once['totals']['total']);
});
it('does not event-deduplicate identical-looking rows from different files', function () {
$dir = sys_get_temp_dir().'/m126-dup-'.uniqid();
mkdir($dir);
$line = '{"time":"2026-08-25T07:00:00+02:00","host":"skinbase.org","method":"GET","uri":"/","status":200,"request_time":0.1,"upstream_response_time":"0.1","upstream_connect_time":"0.0","upstream_header_time":"0.1","bytes":1,"request_length":1,"content_type":"text/html","cache":""}'."\n";
file_put_contents($dir.'/a.log', $line);
file_put_contents($dir.'/b.log', $line);
$result = (new HttpPerformanceLogAnalyzer(new HttpUriNormalizer()))->analyze(
[$dir.'/a.log', $dir.'/b.log'],
['since' => '24h', 'now' => analyzerNow(), 'host' => 'skinbase.org', 'min_requests' => 1, 'top' => 10],
);
expect($result['totals']['total'])->toBe(2);
});
it('applies the time window across rotation and gzip files', function () {
$result = analyzeRotation(['host' => 'skinbase.org']);
$times = collect($result['slowest'])->pluck('time');
expect($times->contains('2026-08-24T07:50:00+02:00'))->toBeFalse()
->and($times->contains('2026-08-25T08:10:00+02:00'))->toBeFalse()
->and($times->contains('2026-08-24T08:10:00+02:00'))->toBeTrue()
->and($times->contains('2026-08-24T23:50:00+02:00'))->toBeTrue()
->and($times->contains('2026-08-25T07:50:00+02:00'))->toBeTrue();
});
it('keeps p50/p95/p99 identical when the same events are split across files', function () {
$analyzer = new HttpPerformanceLogAnalyzer(new HttpUriNormalizer());
$opts = ['since' => '24h', 'now' => analyzerNow(), 'host' => 'skinbase.org', 'min_requests' => 1, 'top' => 20];
$files = rotationFiles();
$combined = sys_get_temp_dir().'/m126-combined-'.uniqid().'.log';
$blob = '';
foreach ($files as $file) {
if (str_ends_with($file, '.gz')) {
$blob .= gzdecode(file_get_contents($file));
} else {
$blob .= file_get_contents($file);
}
if (! str_ends_with($blob, "\n")) {
$blob .= "\n";
}
}
file_put_contents($combined, $blob);
$split = $analyzer->analyze($files, $opts);
$single = $analyzer->analyze($combined, $opts);
$splitHome = collect($split['by_count'])->firstWhere('uri', '/');
$singleHome = collect($single['by_count'])->firstWhere('uri', '/');
expect($split['totals']['total'])->toBe($single['totals']['total'])
->and($splitHome['count'])->toBe($singleHome['count'])
->and($splitHome['p50'])->toBe($singleHome['p50'])
->and($splitHome['p95'])->toBe($singleHome['p95'])
->and($splitHome['p99'])->toBe($singleHome['p99'])
->and($splitHome['avg'])->toBe($singleHome['avg'])
->and($splitHome['max'])->toBe($singleHome['max']);
});
it('warns when observed records start many hours after the requested window', function () {
$analyzer = new HttpPerformanceLogAnalyzer(new HttpUriNormalizer());
$result = $analyzer->analyze(rotationDir().'/active.log', [
'since' => '24h',
'now' => analyzerNow(),
'host' => 'skinbase.org',
'min_requests' => 1,
'top' => 10,
]);
expect($result['coverage']['complete'])->toBeFalse()
->and($result['warnings'])->not->toBeEmpty();
});
it('does not warn when parseable records span the requested 24h window', function () {
$result = analyzeRotation();
expect($result['coverage']['complete'])->toBeTrue()
->and($result['warnings'])->toBeEmpty()
->and($result['coverage']['requested_start'])->toBe('2026-08-24T08:00:00+02:00')
->and($result['coverage']['requested_end'])->toBe('2026-08-25T08:00:00+02:00');
});
it('reports an empty sample when the host matches nothing', function () {
$result = analyzeRotation(['host' => 'no-such.example']);
expect($result['sample_empty'])->toBeTrue()
->and($result['totals']['total'])->toBe(0)
->and($result['by_count'])->toBe([]);
});
it('keeps exact host filtering on mixed rotation files', function () {
$result = analyzeRotation(['host' => 'skinbase.org']);
$hosts = array_unique(array_column($result['by_count'], 'host'));
expect($hosts)->toBe(['skinbase.org']);
});
+12
View File
@@ -0,0 +1,12 @@
{"time":"2026-08-24T15:20:00+02:00","host":"skinbase.org","method":"GET","uri":"/art/53867/light-mark-docks","status":200,"request_time":0.400,"upstream_response_time":"0.380","upstream_connect_time":"0.000","upstream_header_time":"0.200","bytes":10000,"request_length":100,"content_type":"text/html","cache":""}
{"time":"2026-08-24T15:21:00+02:00","host":"www.skinbase.org","method":"GET","uri":"/art/53867/light-mark-docks","status":200,"request_time":0.900,"upstream_response_time":"0.880","upstream_connect_time":"0.000","upstream_header_time":"0.200","bytes":10000,"request_length":100,"content_type":"text/html","cache":""}
{"time":"2026-08-24T15:22:00+02:00","host":"thumb.skinbase.org","method":"GET","uri":"/art/1/x","status":200,"request_time":0.050,"upstream_response_time":"-","upstream_connect_time":"-","upstream_header_time":"-","bytes":500,"request_length":80,"content_type":"image/webp","cache":""}
{"time":"2026-08-24T15:23:00+02:00","host":"hub.skinbase.org","method":"GET","uri":"/art/2/y","status":200,"request_time":0.060,"upstream_response_time":"0.040","upstream_connect_time":"0.000","upstream_header_time":"0.030","bytes":800,"request_length":80,"content_type":"text/html","cache":""}
{"time":"2026-08-24T15:24:00+02:00","host":"skinbase.org","method":"GET","uri":"/art/3858/similar","status":200,"request_time":0.220,"upstream_response_time":"0.200","upstream_connect_time":"0.000","upstream_header_time":"0.180","bytes":8000,"request_length":90,"content_type":"text/html","cache":""}
{"time":"2026-08-24T15:25:00+02:00","host":"skinbase.org","method":"GET","uri":"/download/artwork/42026","status":200,"request_time":0.150,"upstream_response_time":"0.140","upstream_connect_time":"0.000","upstream_header_time":"0.100","bytes":200,"request_length":90,"content_type":"application/octet-stream","cache":""}
{"time":"2026-08-24T15:26:00+02:00","host":"skinbase.org","method":"GET","uri":"/api/art/69876/view","status":204,"request_time":0.030,"upstream_response_time":"0.020","upstream_connect_time":"0.000","upstream_header_time":"0.015","bytes":0,"request_length":70,"content_type":"","cache":""}
{"time":"2026-08-24T15:27:00+02:00","host":"skinbase.org","method":"GET","uri":"/api/rank/category/583","status":200,"request_time":0.080,"upstream_response_time":"0.070","upstream_connect_time":"0.000","upstream_header_time":"0.060","bytes":400,"request_length":80,"content_type":"application/json","cache":""}
{"time":"2026-08-24T15:28:00+02:00","host":"skinbase.org","method":"GET","uri":"/academy/prompts/123","status":200,"request_time":0.500,"upstream_response_time":"0.480","upstream_connect_time":"0.000","upstream_header_time":"0.200","bytes":12000,"request_length":90,"content_type":"text/html","cache":""}
{"time":"2026-08-24T15:29:00+02:00","host":"skinbase.org","method":"GET","uri":"/@killua","status":200,"request_time":0.970,"upstream_response_time":"0.950","upstream_connect_time":"0.000","upstream_header_time":"0.300","bytes":249000,"request_length":100,"content_type":"text/html","cache":""}
{"time":"2026-08-24T15:30:00+02:00","host":"skinbase.org","method":"GET","uri":"/search","status":200,"request_time":0.200,"upstream_response_time":"0.180","upstream_connect_time":"0.000","upstream_header_time":"0.150","bytes":1000,"request_length":80,"content_type":"text/html","cache":""}
not-json
+4
View File
@@ -0,0 +1,4 @@
{"time":"2026-08-25T07:50:00+02:00","host":"skinbase.org","method":"GET","uri":"/","status":200,"request_time":0.110,"upstream_response_time":"0.090","upstream_connect_time":"0.000","upstream_header_time":"0.080","bytes":1000,"request_length":80,"content_type":"text/html","cache":""}
{"time":"2026-08-25T07:51:00+02:00","host":"skinbase.org","method":"GET","uri":"/missing","status":404,"request_time":0.040,"upstream_response_time":"0.030","upstream_connect_time":"0.000","upstream_header_time":"0.020","bytes":800,"request_length":70,"content_type":"text/html","cache":""}
{"time":"2026-08-25T07:52:00+02:00","host":"www.skinbase.org","method":"GET","uri":"/","status":200,"request_time":0.200,"upstream_response_time":"0.180","upstream_connect_time":"0.000","upstream_header_time":"0.100","bytes":1000,"request_length":80,"content_type":"text/html","cache":""}
{"time":"2026-08-25T08:10:00+02:00","host":"skinbase.org","method":"GET","uri":"/","status":200,"request_time":0.090,"upstream_response_time":"0.080","upstream_connect_time":"0.000","upstream_header_time":"0.070","bytes":1000,"request_length":80,"content_type":"text/html","cache":""}
+4
View File
@@ -0,0 +1,4 @@
{"time":"2026-08-24T07:50:00+02:00","host":"skinbase.org","method":"GET","uri":"/","status":200,"request_time":0.100,"upstream_response_time":"0.090","upstream_connect_time":"0.000","upstream_header_time":"0.080","bytes":1000,"request_length":80,"content_type":"text/html","cache":""}
{"time":"2026-08-24T08:10:00+02:00","host":"skinbase.org","method":"GET","uri":"/art/53867/light-mark-docks","status":200,"request_time":0.400,"upstream_response_time":"0.380","upstream_connect_time":"0.000","upstream_header_time":"0.200","bytes":10000,"request_length":100,"content_type":"text/html","cache":""}
{"time":"2026-08-24T08:11:00+02:00","host":"skinbase.org","method":"GET","uri":"/missing-old","status":404,"request_time":0.030,"upstream_response_time":"0.020","upstream_connect_time":"0.000","upstream_header_time":"0.010","bytes":700,"request_length":60,"content_type":"text/html","cache":""}
{"time":"2026-08-24T08:12:00+02:00","host":"skinbase.org","method":"GET","uri":"/broken","status":500,"request_time":0.500,"upstream_response_time":"0.480","upstream_connect_time":"0.000","upstream_header_time":"0.200","bytes":200,"request_length":70,"content_type":"text/html","cache":""}
Binary file not shown.
+4
View File
@@ -0,0 +1,4 @@
{"time":"2026-08-24T23:50:00+02:00","host":"skinbase.org","method":"GET","uri":"/art/3858/similar","status":200,"request_time":0.220,"upstream_response_time":"0.200","upstream_connect_time":"0.000","upstream_header_time":"0.150","bytes":8000,"request_length":90,"content_type":"text/html","cache":""}
{"time":"2026-08-24T23:51:00+02:00","host":"skinbase.org","method":"GET","uri":"/api/art/1/similar-ai","status":502,"request_time":0.250,"upstream_response_time":"0.230","upstream_connect_time":"0.000","upstream_header_time":"0.100","bytes":40,"request_length":80,"content_type":"application/json","cache":""}
{"time":"2026-08-24T23:52:00+02:00","host":"skinbase.org","method":"GET","uri":"/download/artwork/42026","status":499,"request_time":1.800,"upstream_response_time":"1.700","upstream_connect_time":"0.000","upstream_header_time":"0.100","bytes":0,"request_length":90,"content_type":"","cache":""}
{"time":"2026-08-24T23:53:00+02:00","host":"skinbase.org","method":"POST","uri":"/vectors/search","status":422,"request_time":0.050,"upstream_response_time":"0.040","upstream_connect_time":"0.000","upstream_header_time":"0.030","bytes":80,"request_length":100,"content_type":"application/json","cache":""}
+10
View File
@@ -0,0 +1,10 @@
{"time":"2026-08-24T15:10:00+02:00","host":"skinbase.org","method":"GET","uri":"/","status":200,"request_time":0.110,"upstream_response_time":"0.095","upstream_connect_time":"0.000","upstream_header_time":"0.090","bytes":22000,"request_length":120,"content_type":"text/html; charset=utf-8","cache":""}
{"time":"2026-08-24T15:11:00+02:00","host":"skinbase.org","method":"GET","uri":"/academy","status":200,"request_time":0.630,"upstream_response_time":"0.610","upstream_connect_time":"0.000","upstream_header_time":"0.400","bytes":24000,"request_length":140,"content_type":"text/html; charset=utf-8","cache":""}
{"time":"2026-08-24T15:12:00+02:00","host":"skinbase.org","method":"GET","uri":"/academy","status":200,"request_time":0.690,"upstream_response_time":"0.670","upstream_connect_time":"0.000","upstream_header_time":"0.410","bytes":24000,"request_length":140,"content_type":"text/html; charset=utf-8","cache":""}
{"time":"2026-08-24T15:13:00+02:00","host":"skinbase.org","method":"GET","uri":"/favicon.ico","status":200,"request_time":0.004,"upstream_response_time":"-","upstream_connect_time":"-","upstream_header_time":"-","bytes":1500,"request_length":80,"content_type":"image/x-icon","cache":""}
{"time":"2026-08-24T15:14:00+02:00","host":"skinbase.org","method":"GET","uri":"/download/artwork/17776","status":302,"request_time":0.340,"upstream_response_time":"0.100, 0.220","upstream_connect_time":"0.000","upstream_header_time":"0.090, 0.200","bytes":0,"request_length":200,"content_type":"text/html","cache":""}
{"time":"2026-08-24T15:15:00+02:00","host":"skinbase.org","method":"GET","uri":"/missing-page","status":404,"request_time":0.080,"upstream_response_time":"0.070","upstream_connect_time":"0.000","upstream_header_time":"0.060","bytes":8000,"request_length":90,"content_type":"text/html","cache":""}
{"time":"2026-08-24T15:16:00+02:00","host":"skinbase.org","method":"GET","uri":"/@killua","status":200,"request_time":0.970,"upstream_response_time":"0.950","upstream_connect_time":"0.000","upstream_header_time":"0.200","bytes":249000,"request_length":110,"content_type":"text/html","cache":""}
{"time":"2026-08-24T15:17:00+02:00","host":"skinbase.org","method":"GET","uri":"/academy/prompts","status":499,"request_time":1.800,"upstream_response_time":"1.790","upstream_connect_time":"0.000","upstream_header_time":"0.100","bytes":0,"request_length":130,"content_type":"","cache":""}
{"time":"2026-08-24T15:18:00+02:00","host":"skinbase.org","method":"GET","uri":"/explore","status":500,"request_time":0.500,"upstream_response_time":"0.480","upstream_connect_time":"0.000","upstream_header_time":"0.200","bytes":1200,"request_length":100,"content_type":"text/html","cache":""}
this is not json