Compare commits
11
Commits
4ab315e9d7
...
771f9f4350
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
771f9f4350 | ||
|
|
4c409ceb81 | ||
|
|
ab8fa6d845 | ||
|
|
bf9ca12469 | ||
|
|
1d350c9bca | ||
|
|
437d943827 | ||
|
|
bf902f509a | ||
|
|
bfcbfec357 | ||
|
|
b9beb8f5c5 | ||
|
|
e9cf754b37 | ||
|
|
fb560fb7dd |
@@ -4,6 +4,11 @@ APP_KEY=
|
||||
APP_DEBUG=true
|
||||
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_NOTIFY_EMAIL=
|
||||
SECURITY_REPORT_SCAN_NPM=true
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
/.deploy
|
||||
/.zed
|
||||
/auth.json
|
||||
/build-info.json
|
||||
/node_modules
|
||||
/public/build
|
||||
/public/hot
|
||||
@@ -45,6 +46,7 @@
|
||||
/storage/*.tar.xz
|
||||
/storage/*.tar
|
||||
/storage/*.tgz
|
||||
/var/deploy/
|
||||
/vendor
|
||||
Homestead.json
|
||||
Homestead.yaml
|
||||
|
||||
@@ -11,6 +11,7 @@ use App\Models\UserMention;
|
||||
use App\Notifications\ArtworkCommentedNotification;
|
||||
use App\Notifications\ArtworkMentionedNotification;
|
||||
use App\Services\ContentSanitizer;
|
||||
use App\Services\CommentReactionService;
|
||||
use App\Support\AvatarUrl;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
@@ -30,6 +31,8 @@ class ArtworkCommentController extends Controller
|
||||
{
|
||||
private const MAX_LENGTH = 10_000;
|
||||
|
||||
public function __construct(private readonly CommentReactionService $commentReactions) {}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// List
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
@@ -53,7 +56,9 @@ class ArtworkCommentController extends Controller
|
||||
->paginate($perPage, ['*'], 'page', $page);
|
||||
|
||||
$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([
|
||||
'data' => $items,
|
||||
@@ -138,7 +143,9 @@ class ArtworkCommentController extends Controller
|
||||
);
|
||||
} 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
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
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;
|
||||
$userId = (int) ($c->user_id ?? 0);
|
||||
@@ -221,12 +228,13 @@ class ArtworkCommentController extends Controller
|
||||
'level' => (int) ($user?->level ?? 1),
|
||||
'rank' => (string) ($user?->rank ?? 'Newbie'),
|
||||
],
|
||||
'reactions' => $reactionTotals[(int) $c->id] ?? [],
|
||||
];
|
||||
|
||||
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')) {
|
||||
$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 {
|
||||
$data['replies'] = [];
|
||||
}
|
||||
@@ -234,6 +242,24 @@ class ArtworkCommentController extends Controller
|
||||
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
|
||||
{
|
||||
$rawContent = (string) ($comment->raw_content ?? $comment->content ?? '');
|
||||
|
||||
@@ -5,11 +5,15 @@ namespace App\Http\Controllers\Api;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Resources\ArtworkResource;
|
||||
use App\Models\Artwork;
|
||||
use App\Services\ArtworkNavigationService;
|
||||
use App\Services\ThumbnailPresenter;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class ArtworkNavigationController extends Controller
|
||||
{
|
||||
public function __construct(private readonly ArtworkNavigationService $navigation) {}
|
||||
|
||||
/**
|
||||
* GET /api/artworks/navigation/{id}
|
||||
*
|
||||
@@ -26,35 +30,11 @@ class ArtworkNavigationController extends Controller
|
||||
'prev_id' => null, 'next_id' => null,
|
||||
'prev_url' => null, 'next_url' => null,
|
||||
'prev_slug' => null, 'next_slug' => null,
|
||||
'prev_preview' => null, 'next_preview' => null,
|
||||
]);
|
||||
}
|
||||
|
||||
$scope = Artwork::published()
|
||||
->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,
|
||||
]);
|
||||
return response()->json($this->navigation->navigationFor($artwork));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -62,8 +42,12 @@ class ArtworkNavigationController extends Controller
|
||||
*
|
||||
* 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'])
|
||||
->published()
|
||||
->find($id);
|
||||
@@ -73,7 +57,30 @@ class ArtworkNavigationController extends Controller
|
||||
}
|
||||
|
||||
$resource = (new ArtworkResource($artwork))->toArray(request());
|
||||
$resource['navigation'] = $this->navigation->navigationFor($artwork);
|
||||
|
||||
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'),
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,12 +5,10 @@ namespace App\Http\Controllers\Api;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\User;
|
||||
use App\Services\FollowService;
|
||||
use App\Support\AvatarUrl;
|
||||
use App\Support\UsernamePolicy;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* API endpoints for the follow system.
|
||||
@@ -72,26 +70,17 @@ final class FollowController extends Controller
|
||||
{
|
||||
$target = $this->resolveUser($username);
|
||||
$perPage = min((int) $request->query('per_page', 24), 100);
|
||||
$page = max(1, (int) $request->query('page', 1));
|
||||
|
||||
$rows = DB::table('user_followers as uf')
|
||||
->join('users as u', 'u.id', '=', 'uf.follower_id')
|
||||
->leftJoin('user_profiles as up', 'up.user_id', '=', 'u.id')
|
||||
->where('uf.user_id', $target->id)
|
||||
->whereNull('u.deleted_at')
|
||||
->orderByDesc('uf.created_at')
|
||||
->select([
|
||||
'u.id', 'u.username', 'u.name',
|
||||
'up.avatar_hash',
|
||||
'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,
|
||||
$rows = $this->followService
|
||||
->paginatedFollowers((int) $target->id, $perPage, $page)
|
||||
->through(fn (array $row) => [
|
||||
'id' => $row['id'],
|
||||
'username' => $row['username'],
|
||||
'display_name' => $row['display_name'],
|
||||
'avatar_url' => $row['avatar_url'],
|
||||
'profile_url' => $row['profile_url'],
|
||||
'followed_at' => $row['followed_at'],
|
||||
]);
|
||||
|
||||
return response()->json($rows);
|
||||
@@ -103,26 +92,17 @@ final class FollowController extends Controller
|
||||
{
|
||||
$target = $this->resolveUser($username);
|
||||
$perPage = min((int) $request->query('per_page', 24), 100);
|
||||
$page = max(1, (int) $request->query('page', 1));
|
||||
|
||||
$rows = DB::table('user_followers as uf')
|
||||
->join('users as u', 'u.id', '=', 'uf.user_id')
|
||||
->leftJoin('user_profiles as up', 'up.user_id', '=', 'u.id')
|
||||
->where('uf.follower_id', $target->id)
|
||||
->whereNull('u.deleted_at')
|
||||
->orderByDesc('uf.created_at')
|
||||
->select([
|
||||
'u.id', 'u.username', 'u.name',
|
||||
'up.avatar_hash',
|
||||
'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,
|
||||
$rows = $this->followService
|
||||
->paginatedFollowing((int) $target->id, $perPage, $page)
|
||||
->through(fn (array $row) => [
|
||||
'id' => $row['id'],
|
||||
'username' => $row['username'],
|
||||
'display_name' => $row['display_name'],
|
||||
'avatar_url' => $row['avatar_url'],
|
||||
'profile_url' => $row['profile_url'],
|
||||
'followed_at' => $row['followed_at'],
|
||||
]);
|
||||
|
||||
return response()->json($rows);
|
||||
|
||||
@@ -48,14 +48,14 @@ final class ArtworkDownloadController extends Controller
|
||||
$artwork = Artwork::query()->find($id);
|
||||
|
||||
if (! $artwork) {
|
||||
abort(404);
|
||||
return $this->notFound();
|
||||
}
|
||||
|
||||
$filePath = $this->originalFiles->resolveLocalPath($artwork);
|
||||
$ext = strtolower(ltrim((string) pathinfo($filePath, PATHINFO_EXTENSION), '.'));
|
||||
|
||||
if ($filePath === '' || ! in_array($ext, self::ALLOWED_EXTENSIONS, true)) {
|
||||
abort(404);
|
||||
return $this->notFound();
|
||||
}
|
||||
|
||||
if (! File::isFile($filePath)) {
|
||||
@@ -65,7 +65,7 @@ final class ArtworkDownloadController extends Controller
|
||||
'resolved_path' => $filePath,
|
||||
]);
|
||||
|
||||
abort(404);
|
||||
return $this->notFound();
|
||||
}
|
||||
|
||||
$this->recordDownload($request, $artwork->id);
|
||||
@@ -98,6 +98,14 @@ final class ArtworkDownloadController extends Controller
|
||||
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
|
||||
{
|
||||
if (! config('app.download_accel_enabled')) {
|
||||
|
||||
@@ -8,6 +8,7 @@ use App\Http\Controllers\Controller;
|
||||
use App\Models\Artwork;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Response;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
final class LegacyArtworkPhotoController extends Controller
|
||||
@@ -26,25 +27,39 @@ final class LegacyArtworkPhotoController extends Controller
|
||||
|
||||
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);
|
||||
$sizeCode = (int) $size;
|
||||
|
||||
abort_if($artworkId === null || $artworkId < 1, 404);
|
||||
if ($artworkId === null || $artworkId < 1) {
|
||||
return $this->notFound();
|
||||
}
|
||||
|
||||
$artwork = $this->resolveArtwork($artworkId);
|
||||
abort_unless($artwork !== null, 404);
|
||||
if ($artwork === null) {
|
||||
return $this->notFound();
|
||||
}
|
||||
|
||||
$targetUrl = $sizeCode === 7
|
||||
? $this->resolveOriginalUrl($artwork)
|
||||
: $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);
|
||||
}
|
||||
|
||||
private function notFound(): Response
|
||||
{
|
||||
return response('', 404, [
|
||||
'Content-Type' => 'text/plain; charset=UTF-8',
|
||||
'Cache-Control' => 'no-store',
|
||||
]);
|
||||
}
|
||||
|
||||
private function decodeBase62(string $value): ?int
|
||||
{
|
||||
if ($value === '') {
|
||||
@@ -86,11 +101,6 @@ final class LegacyArtworkPhotoController extends Controller
|
||||
{
|
||||
$cdn = rtrim((string) config('cdn.files_url', 'https://cdn.skinbase.org'), '/');
|
||||
$filePath = trim((string) ($artwork->file_path ?? ''), '/');
|
||||
|
||||
if ($filePath !== '') {
|
||||
return $cdn . '/' . $filePath;
|
||||
}
|
||||
|
||||
$hash = strtolower((string) preg_replace('/[^a-f0-9]/i', '', (string) ($artwork->hash ?? '')));
|
||||
$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');
|
||||
}
|
||||
|
||||
if ($filePath !== '') {
|
||||
return $cdn . '/' . $filePath;
|
||||
}
|
||||
|
||||
$prefix = trim((string) config('uploads.object_storage.prefix', 'artworks'), '/');
|
||||
$firstDir = substr($hash, 0, 2);
|
||||
$secondDir = substr($hash, 2, 2);
|
||||
|
||||
@@ -74,6 +74,7 @@ class ProfileController extends Controller
|
||||
'stats',
|
||||
'favourites',
|
||||
'activity',
|
||||
'followers',
|
||||
];
|
||||
|
||||
public function __construct(
|
||||
@@ -200,6 +201,28 @@ class ProfileController extends Controller
|
||||
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). */
|
||||
public function toggleFollow(Request $request, string $username): JsonResponse
|
||||
{
|
||||
@@ -1293,9 +1316,37 @@ class ProfileController extends Controller
|
||||
? ($profileTabUrls[$resolvedInitialTab] ?? $canonical)
|
||||
: $canonical;
|
||||
$tabMetaLabel = $resolvedInitialTab !== null
|
||||
? ucfirst($resolvedInitialTab)
|
||||
? ($resolvedInitialTab === 'followers' ? 'Followers' : ucfirst($resolvedInitialTab))
|
||||
: 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
|
||||
? (($user->username ?? $user->name ?? 'User') . ' Gallery on Skinbase')
|
||||
: ($isTabLanding
|
||||
@@ -1303,9 +1354,11 @@ class ProfileController extends Controller
|
||||
: (($user->username ?? $user->name ?? 'User') . ' on Skinbase'));
|
||||
$pageDescription = $galleryOnly
|
||||
? ('Browse the public gallery of ' . ($user->username ?? $user->name) . ' on Skinbase.')
|
||||
: ($resolvedInitialTab === 'followers'
|
||||
? ('People who follow ' . ($user->username ?? $user->name) . ' on Skinbase.')
|
||||
: ($isTabLanding
|
||||
? ('Explore the ' . strtolower((string) $tabMetaLabel) . ' section for ' . ($user->username ?? $user->name) . ' on Skinbase.')
|
||||
: ('View the profile of ' . ($user->username ?? $user->name) . ' on Skinbase — artworks, favourites and more.'));
|
||||
: ('View the profile of ' . ($user->username ?? $user->name) . ' on Skinbase — artworks, favourites and more.')));
|
||||
$profileSeo = app(SeoFactory::class)->profilePage(
|
||||
$pageTitle,
|
||||
$galleryOnly ? $galleryUrl : $activeProfileUrl,
|
||||
@@ -1350,6 +1403,7 @@ class ProfileController extends Controller
|
||||
'socialLinks' => $socialLinks,
|
||||
'followerCount' => $followerCount,
|
||||
'recentFollowers' => $recentFollowers->values(),
|
||||
'followersListing' => $followersListing,
|
||||
'followContext' => $followContext,
|
||||
'followAnalytics' => $followAnalytics,
|
||||
'suggestedUsers' => $suggestedUsers,
|
||||
|
||||
@@ -74,6 +74,8 @@ final class TrackOnlineVisitor
|
||||
'robots.txt',
|
||||
'sitemap.xml',
|
||||
'sitemaps/*',
|
||||
'download/artwork/*',
|
||||
'photo/*',
|
||||
])) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -15,6 +15,9 @@ use Illuminate\Support\Facades\Schema;
|
||||
|
||||
class ArtworkResource extends JsonResource
|
||||
{
|
||||
/** @var array<string, bool> */
|
||||
private array $tablePresence = [];
|
||||
|
||||
/**
|
||||
* Transform the resource into an array.
|
||||
*/
|
||||
@@ -35,14 +38,14 @@ class ArtworkResource extends JsonResource
|
||||
|
||||
$followerCount = 0;
|
||||
if (!empty($this->user?->id)) {
|
||||
if (Schema::hasTable('user_statistics')) {
|
||||
if ($this->hasTable('user_statistics')) {
|
||||
$followerCount = (int) DB::table('user_statistics')
|
||||
->where('user_id', (int) $this->user->id)
|
||||
->value('followers_count');
|
||||
}
|
||||
|
||||
// Legacy fallback for environments where new tables are unavailable.
|
||||
if (($followerCount <= 0) && Schema::hasTable('friends_list')) {
|
||||
if (($followerCount <= 0) && $this->hasTable('friends_list')) {
|
||||
$followerCount = (int) DB::table('friends_list')
|
||||
->where('friend_id', (int) $this->user->id)
|
||||
->count();
|
||||
@@ -58,19 +61,19 @@ class ArtworkResource extends JsonResource
|
||||
$viewerAward = null;
|
||||
$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()
|
||||
: 0;
|
||||
|
||||
if ($viewerId > 0) {
|
||||
if (Schema::hasTable('artwork_likes')) {
|
||||
if ($this->hasTable('artwork_likes')) {
|
||||
$isLiked = DB::table('artwork_likes')
|
||||
->where('user_id', $viewerId)
|
||||
->where('artwork_id', (int) $this->id)
|
||||
->exists();
|
||||
}
|
||||
|
||||
if (Schema::hasTable('artwork_bookmarks')) {
|
||||
if ($this->hasTable('artwork_bookmarks')) {
|
||||
$isBookmarked = DB::table('artwork_bookmarks')
|
||||
->where('user_id', $viewerId)
|
||||
->where('artwork_id', (int) $this->id)
|
||||
@@ -83,12 +86,12 @@ class ArtworkResource extends JsonResource
|
||||
->exists();
|
||||
|
||||
if (!empty($this->user?->id)) {
|
||||
if (Schema::hasTable('user_followers')) {
|
||||
if ($this->hasTable('user_followers')) {
|
||||
$isFollowing = DB::table('user_followers')
|
||||
->where('user_id', (int) $this->user->id)
|
||||
->where('follower_id', $viewerId)
|
||||
->exists();
|
||||
} elseif (Schema::hasTable('friends_list')) {
|
||||
} elseif ($this->hasTable('friends_list')) {
|
||||
// Legacy fallback only.
|
||||
$isFollowing = DB::table('friends_list')
|
||||
->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')
|
||||
->where('group_id', (int) $this->group->id)
|
||||
->where('user_id', $viewerId)
|
||||
->exists();
|
||||
}
|
||||
|
||||
if (Schema::hasTable('artwork_medals')) {
|
||||
if ($this->hasTable('artwork_medals')) {
|
||||
$viewerAward = DB::table('artwork_medals')
|
||||
->where('user_id', $viewerId)
|
||||
->where('artwork_id', (int) $this->id)
|
||||
@@ -279,7 +282,7 @@ class ArtworkResource extends JsonResource
|
||||
|
||||
private function resolveScreenshotAssets(): array
|
||||
{
|
||||
if (! Schema::hasTable('artwork_files')) {
|
||||
if (! $this->hasTable('artwork_files')) {
|
||||
return [];
|
||||
}
|
||||
|
||||
@@ -339,7 +342,7 @@ class ArtworkResource extends JsonResource
|
||||
$items = collect();
|
||||
$participationWorlds = collect();
|
||||
|
||||
if (Schema::hasTable('world_relations') && Schema::hasTable('worlds')) {
|
||||
if ($this->hasTable('world_relations') && $this->hasTable('worlds')) {
|
||||
$relations = WorldRelation::query()
|
||||
->with('world')
|
||||
->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
|
||||
->filter(function (WorldSubmission $submission): bool {
|
||||
return (string) $submission->status === WorldSubmission::STATUS_LIVE
|
||||
@@ -403,7 +406,7 @@ class ArtworkResource extends JsonResource
|
||||
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));
|
||||
}
|
||||
|
||||
@@ -421,6 +424,11 @@ class ArtworkResource extends JsonResource
|
||||
->all();
|
||||
}
|
||||
|
||||
private function hasTable(string $table): bool
|
||||
{
|
||||
return $this->tablePresence[$table] ??= Schema::hasTable($table);
|
||||
}
|
||||
|
||||
private function authorCanPublishLinks(): bool
|
||||
{
|
||||
$level = (int) ($this->user?->level ?? 1);
|
||||
|
||||
@@ -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'),
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ use App\Services\Activity\UserActivityService;
|
||||
use App\Events\Achievements\AchievementCheckRequested;
|
||||
use App\Services\FollowAnalyticsService;
|
||||
use App\Support\AvatarUrl;
|
||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
@@ -160,6 +161,38 @@ final class FollowService
|
||||
->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
|
||||
{
|
||||
return (int) DB::table('user_statistics')
|
||||
@@ -216,6 +249,58 @@ final class FollowService
|
||||
|
||||
// ─── 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
|
||||
{
|
||||
DB::table('user_statistics')->updateOrInsert(
|
||||
|
||||
@@ -59,7 +59,7 @@ class ThumbnailService
|
||||
try {
|
||||
$artClass = '\\App\\Models\\Artwork';
|
||||
if (class_exists($artClass)) {
|
||||
$art = $artClass::where('id', $id)->orWhere('legacy_id', $id)->first();
|
||||
$art = $artClass::query()->find($id);
|
||||
if ($art) {
|
||||
$hash = $art->hash ?? null;
|
||||
$extToUse = $ext ?? ($art->thumb_ext ?? null);
|
||||
|
||||
@@ -10,16 +10,23 @@ namespace App\Support\Http;
|
||||
* Multi-value upstream timings (retries): use the **sum** of numeric parts
|
||||
* as the upstream total for that request. "-" and empty → null.
|
||||
* 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
|
||||
{
|
||||
/** Ignore gaps shorter than this when judging 24h source coverage. */
|
||||
public const COVERAGE_TOLERANCE_SECONDS = 900;
|
||||
|
||||
public function __construct(private readonly HttpUriNormalizer $uris)
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string|list<string> $input
|
||||
* @param array{
|
||||
* since?: ?string,
|
||||
* until?: ?string,
|
||||
* min_requests?: int,
|
||||
* top?: int,
|
||||
* status?: ?string,
|
||||
@@ -30,28 +37,54 @@ final class HttpPerformanceLogAnalyzer
|
||||
* } $options
|
||||
* @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));
|
||||
$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;
|
||||
$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;
|
||||
$linesScanned = 0;
|
||||
$parseable = 0;
|
||||
$families = [];
|
||||
$slowest = [];
|
||||
$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) {
|
||||
$source = [
|
||||
'path' => $file,
|
||||
'gzip' => str_ends_with(strtolower($file), '.gz'),
|
||||
'lines_scanned' => 0,
|
||||
'parseable' => 0,
|
||||
'in_window' => 0,
|
||||
'error' => null,
|
||||
];
|
||||
|
||||
$handle = $this->openStream($file);
|
||||
if ($handle === false) {
|
||||
throw new \RuntimeException('Unable to open performance log: '.$file);
|
||||
$source['error'] = 'unreadable';
|
||||
$sources[] = $source;
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
while (($line = fgets($handle)) !== false) {
|
||||
$linesScanned++;
|
||||
$source['lines_scanned']++;
|
||||
$line = trim($line);
|
||||
if ($line === '') {
|
||||
continue;
|
||||
@@ -64,24 +97,28 @@ final class HttpPerformanceLogAnalyzer
|
||||
}
|
||||
|
||||
$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;
|
||||
}
|
||||
|
||||
$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 ($statusFilter === '5xx' && ($status < 500 || $status > 599)) {
|
||||
continue;
|
||||
}
|
||||
if ($statusFilter === '4xx' && ($status < 400 || $status > 499)) {
|
||||
continue;
|
||||
}
|
||||
if ($statusFilter === '499' && $status !== 499) {
|
||||
if (! $this->statusMatches($status, $statusFilter)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -99,6 +136,12 @@ final class HttpPerformanceLogAnalyzer
|
||||
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);
|
||||
@@ -158,13 +201,16 @@ final class HttpPerformanceLogAnalyzer
|
||||
'method' => $method,
|
||||
'status' => $status,
|
||||
'request_time' => $requestTime,
|
||||
'upstream_response_time' => $upstreamResponse,
|
||||
];
|
||||
$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']);
|
||||
$slowest = array_slice($slowest, 0, $top);
|
||||
|
||||
@@ -213,8 +259,24 @@ final class HttpPerformanceLogAnalyzer
|
||||
$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']);
|
||||
|
||||
$coverage = $this->coverageReport(
|
||||
$requestedStart,
|
||||
$requestedEnd,
|
||||
$earliestParseable,
|
||||
$latestParseable,
|
||||
$earliestMatching,
|
||||
$latestMatching,
|
||||
);
|
||||
|
||||
return [
|
||||
'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,
|
||||
'by_p95' => array_slice($byP95, 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.
|
||||
*
|
||||
@@ -274,6 +378,135 @@ final class HttpPerformanceLogAnalyzer
|
||||
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
|
||||
{
|
||||
if ($value === null || $value === '' || $value === '-') {
|
||||
@@ -306,7 +539,7 @@ final class HttpPerformanceLogAnalyzer
|
||||
}
|
||||
|
||||
if (preg_match('/^(\d+)(h|d|m)$/', strtolower($since), $matches) !== 1) {
|
||||
return null;
|
||||
return $this->parseTime($since);
|
||||
}
|
||||
|
||||
$n = (int) $matches[1];
|
||||
@@ -319,4 +552,13 @@ final class HttpPerformanceLogAnalyzer
|
||||
|
||||
return $now->sub($interval);
|
||||
}
|
||||
|
||||
private function untilInstant(?string $until, \DateTimeImmutable $now): \DateTimeImmutable
|
||||
{
|
||||
if ($until === null || $until === '') {
|
||||
return $now;
|
||||
}
|
||||
|
||||
return $this->parseTime($until) ?? $now;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2354,6 +2354,7 @@
|
||||
"resources/js/components/profile/tabs/TabArtworks.jsx": [],
|
||||
"resources/js/components/profile/tabs/TabCollections.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/TabStats.jsx": [],
|
||||
"resources/js/components/profile/tabs/TabStories.jsx": [],
|
||||
@@ -2471,6 +2472,7 @@
|
||||
"resources/js/lib/uploadEndpoints.js": [],
|
||||
"resources/js/lib/uploadNotices.js": [],
|
||||
"resources/js/lib/uploadUtils.js": [],
|
||||
"resources/js/lib/useDeferredSimilarAi.js": [],
|
||||
"resources/js/lib/useNavContext.js": [],
|
||||
"resources/js/lib/worldAnalytics.js": [],
|
||||
"resources/js/ssr.jsx": [],
|
||||
|
||||
+339
-83
@@ -41436,49 +41436,91 @@ function Skeleton$1() {
|
||||
}
|
||||
function ArtworkComments({
|
||||
artworkId,
|
||||
comments: initialComments = [],
|
||||
isLoggedIn = false,
|
||||
loginUrl = "/login"
|
||||
}) {
|
||||
const [comments, setComments] = reactExports.useState(initialComments);
|
||||
const [comments, setComments2] = reactExports.useState([]);
|
||||
const [loading, setLoading] = reactExports.useState(false);
|
||||
const [error, setError] = reactExports.useState(false);
|
||||
const [page, setPage] = reactExports.useState(1);
|
||||
const [lastPage, setLastPage] = reactExports.useState(1);
|
||||
const [total, setTotal] = reactExports.useState(initialComments.length);
|
||||
const initialized = reactExports.useRef(false);
|
||||
const [total, setTotal] = reactExports.useState(0);
|
||||
const sectionRef = reactExports.useRef(null);
|
||||
const requestRef = reactExports.useRef(null);
|
||||
const generationRef = reactExports.useRef(0);
|
||||
const loadComments = reactExports.useCallback(
|
||||
async (p = 1) => {
|
||||
if (!artworkId) return;
|
||||
if (!artworkId || requestRef.current) return;
|
||||
const generation = generationRef.current;
|
||||
const controller = new AbortController();
|
||||
requestRef.current = controller;
|
||||
setLoading(true);
|
||||
setError(false);
|
||||
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) {
|
||||
setComments(data.data ?? []);
|
||||
setComments2(data.data ?? []);
|
||||
} else {
|
||||
setComments((prev) => [...prev, ...data.data ?? []]);
|
||||
setComments2((prev) => [...prev, ...data.data ?? []]);
|
||||
}
|
||||
setPage(data.meta?.current_page ?? p);
|
||||
setLastPage(data.meta?.last_page ?? 1);
|
||||
setTotal(data.meta?.total ?? 0);
|
||||
} catch {
|
||||
if (generation === generationRef.current && !controller.signal.aborted) setError(true);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
if (requestRef.current === controller) {
|
||||
requestRef.current = null;
|
||||
if (generation === generationRef.current) setLoading(false);
|
||||
}
|
||||
}
|
||||
},
|
||||
[artworkId]
|
||||
);
|
||||
reactExports.useEffect(() => {
|
||||
if (initialized.current) return;
|
||||
initialized.current = true;
|
||||
if (artworkId && initialComments.length === 0) {
|
||||
loadComments(1);
|
||||
} else {
|
||||
setTotal(initialComments.length);
|
||||
generationRef.current += 1;
|
||||
requestRef.current?.abort();
|
||||
requestRef.current = null;
|
||||
setComments2([]);
|
||||
setLoading(false);
|
||||
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 comment = { ...newComment, replies: newComment.replies || [] };
|
||||
setComments((prev) => [comment, ...prev]);
|
||||
setComments2((prev) => [comment, ...prev]);
|
||||
setTotal((t) => t + 1);
|
||||
}, []);
|
||||
const handleReplyPosted = reactExports.useCallback((parentId, newReply) => {
|
||||
@@ -41491,10 +41533,10 @@ function ArtworkComments({
|
||||
}
|
||||
return c;
|
||||
});
|
||||
setComments((prev) => insertReply(prev));
|
||||
setComments2((prev) => insertReply(prev));
|
||||
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,
|
||||
{
|
||||
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 }));
|
||||
}
|
||||
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) {
|
||||
const key = String(artworkId);
|
||||
if (fallbackCache.has(key)) return fallbackCache.get(key);
|
||||
@@ -42913,27 +42969,25 @@ async function fetchFallback(artworkId) {
|
||||
const res = await fetch(`/api/artworks/navigation/${artworkId}`, {
|
||||
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 result = {
|
||||
prevId: data.prev_id ?? null,
|
||||
nextId: data.next_id ?? null,
|
||||
prevUrl: data.prev_url ?? null,
|
||||
nextUrl: data.next_url ?? null
|
||||
};
|
||||
const result = normalizeNavigation(data) || { prevId: null, nextId: null, prevUrl: null, nextUrl: null, prevPreview: null, nextPreview: null };
|
||||
fallbackCache.set(key, result);
|
||||
return result;
|
||||
} 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 embedded = normalizeNavigation(embeddedNavigation);
|
||||
if (embedded) return embedded;
|
||||
return fetchFallback(currentArtworkId);
|
||||
}, [currentArtworkId]);
|
||||
}, [currentArtworkId, embeddedNavigation]);
|
||||
return { getNeighbors };
|
||||
}
|
||||
const preloadCache = /* @__PURE__ */ new Set();
|
||||
const prefetchCache = /* @__PURE__ */ new Set();
|
||||
function scheduleIdleTask(callback, delay = 1200) {
|
||||
if (typeof window === "undefined") {
|
||||
callback();
|
||||
@@ -42953,8 +43007,8 @@ function preloadImage(src2) {
|
||||
const img = new Image();
|
||||
img.src = src2;
|
||||
}
|
||||
function ArtworkNavigator({ artworkId, onNavigate, onOpenViewer, onReady }) {
|
||||
const { getNeighbors } = useNavContext(artworkId);
|
||||
function ArtworkNavigator({ artworkId, navigation: navigation2 = null, onNavigate, onOpenViewer, onReady }) {
|
||||
const { getNeighbors } = useNavContext(artworkId, navigation2);
|
||||
const [neighbors, setNeighbors] = reactExports.useState({ prevId: null, nextId: null, prevUrl: null, nextUrl: null });
|
||||
const navigatingRef = reactExports.useRef(false);
|
||||
const neighborsRef = reactExports.useRef(neighbors);
|
||||
@@ -42989,22 +43043,45 @@ function ArtworkNavigator({ artworkId, onNavigate, onOpenViewer, onReady }) {
|
||||
reactExports.useEffect(() => {
|
||||
const ids = [neighbors.prevId, neighbors.nextId].filter(Boolean);
|
||||
if (ids.length === 0) return void 0;
|
||||
const previews = /* @__PURE__ */ new Map([
|
||||
[neighbors.prevId, neighbors.prevPreview],
|
||||
[neighbors.nextId, neighbors.nextPreview]
|
||||
]);
|
||||
let cancelled = false;
|
||||
const cancelIdleTask = scheduleIdleTask(() => {
|
||||
ids.forEach((id) => {
|
||||
fetch(`/api/artworks/${id}/page`, { headers: { Accept: "application/json" } }).then((r) => r.ok ? r.json() : null).then((data) => {
|
||||
if (cancelled || !data) return;
|
||||
if (prefetchCache.has(id)) 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;
|
||||
if (imgUrl) preloadImage(imgUrl);
|
||||
}).catch(() => {
|
||||
});
|
||||
}).catch(() => prefetchCache.delete(id));
|
||||
});
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
cancelIdleTask();
|
||||
};
|
||||
}, [neighbors.prevId, neighbors.nextId]);
|
||||
}, [neighbors.prevId, neighbors.nextId, neighbors.prevPreview, neighbors.nextPreview]);
|
||||
const navigate = reactExports.useCallback(async (targetId, targetUrl) => {
|
||||
if (!targetId && !targetUrl) return;
|
||||
if (navigatingRef.current) return;
|
||||
@@ -43194,6 +43271,140 @@ function GroupSummaryPanel({ group, artwork }) {
|
||||
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));
|
||||
}
|
||||
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) {
|
||||
if (!publisher || publisher.type !== "group") return null;
|
||||
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 [showMatureArtwork, setShowMatureArtwork] = reactExports.useState(false);
|
||||
const openViewer = reactExports.useCallback(() => setViewerOpen(true), []);
|
||||
@@ -43236,12 +43447,12 @@ function ArtworkPage({ artwork: initialArtwork, related: initialRelated, present
|
||||
const [presentXl, setPresentXl] = reactExports.useState(initialXl);
|
||||
const [presentSq, setPresentSq] = reactExports.useState(initialSq);
|
||||
const [related, setRelated] = reactExports.useState(initialRelated);
|
||||
const [comments, setComments] = reactExports.useState(initialComments);
|
||||
const [canonicalUrl, setCanonicalUrl] = reactExports.useState(initialCanonical);
|
||||
const [groupSummary, setGroupSummary] = reactExports.useState(initialGroupSummary || publisherToGroupSummary(initialArtwork?.publisher));
|
||||
const [selectedMediaId, setSelectedMediaId] = reactExports.useState("cover");
|
||||
const [similarRecommendations, setSimilarRecommendations] = 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 initialArtworkIdRef = reactExports.useRef(initialArtwork?.id);
|
||||
const [reactionTotals, setReactionTotals] = reactExports.useState(initialReactionTotals ?? {});
|
||||
@@ -43250,27 +43461,6 @@ function ArtworkPage({ artwork: initialArtwork, related: initialRelated, present
|
||||
if (artwork.id === initialArtworkIdRef.current) return;
|
||||
axios.get(`/api/artworks/${artwork.id}/reactions`).then(({ data }) => setReactionTotals(data.totals ?? {})).catch(() => setReactionTotals({}));
|
||||
}, [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(() => {
|
||||
let isCancelled = false;
|
||||
const loadTrendingRecommendations = async () => {
|
||||
@@ -43308,7 +43498,6 @@ function ArtworkPage({ artwork: initialArtwork, related: initialRelated, present
|
||||
setCanonicalUrl(data.canonical_url ?? window.location.href);
|
||||
setGroupSummary(data.group_summary ?? publisherToGroupSummary(data.publisher));
|
||||
setSelectedMediaId("cover");
|
||||
setSimilarRecommendations([]);
|
||||
setTrendingRecommendations([]);
|
||||
setViewerOpen(false);
|
||||
setShowMatureArtwork(false);
|
||||
@@ -43424,22 +43613,22 @@ function ArtworkPage({ artwork: initialArtwork, related: initialRelated, present
|
||||
ArtworkComments,
|
||||
{
|
||||
artworkId: artwork.id,
|
||||
comments,
|
||||
isLoggedIn: isAuthenticated,
|
||||
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,
|
||||
{
|
||||
artwork,
|
||||
related,
|
||||
similarApiData: similarRecommendations,
|
||||
similarApiData: deferredSimilarRecommendations,
|
||||
trendingData: trendingRecommendations
|
||||
}
|
||||
))), /* @__PURE__ */ React.createElement(
|
||||
ArtworkNavigator,
|
||||
{
|
||||
artworkId: artwork.id,
|
||||
navigation: artwork.navigation || initialNavigation,
|
||||
onNavigate: handleNavigate,
|
||||
onOpenViewer: openViewer,
|
||||
onReady: setNavState
|
||||
@@ -79881,7 +80070,7 @@ function CollectionShow() {
|
||||
reportEndpoint
|
||||
} = props;
|
||||
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 [selectedArtworkId, setSelectedArtworkId] = React.useState(submissionArtworkOptions?.[0]?.id || "");
|
||||
const [state, setState] = React.useState({
|
||||
@@ -80148,12 +80337,12 @@ function CollectionShow() {
|
||||
method: "POST",
|
||||
body: { body: body2 }
|
||||
});
|
||||
setComments(payload?.comments || []);
|
||||
setComments2(payload?.comments || []);
|
||||
setCollection((current) => ({ ...current, comments_count: payload?.comments_count ?? current.comments_count }));
|
||||
}
|
||||
async function handleDeleteComment(commentId) {
|
||||
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 }));
|
||||
}
|
||||
async function handleSubmitArtwork() {
|
||||
@@ -82717,7 +82906,7 @@ const FILTER_TABS = [
|
||||
];
|
||||
function LatestCommentsPage({ initialComments = [], initialMeta = {}, isAuthenticated = false }) {
|
||||
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 [loading, setLoading] = reactExports.useState(false);
|
||||
const [error, setError] = reactExports.useState(null);
|
||||
@@ -82733,7 +82922,7 @@ function LatestCommentsPage({ initialComments = [], initialMeta = {}, isAuthenti
|
||||
});
|
||||
if (res.status === 401) {
|
||||
setError("Please log in to view this feed.");
|
||||
setComments([]);
|
||||
setComments2([]);
|
||||
setMeta({});
|
||||
return;
|
||||
}
|
||||
@@ -82742,7 +82931,7 @@ function LatestCommentsPage({ initialComments = [], initialMeta = {}, isAuthenti
|
||||
return;
|
||||
}
|
||||
const json = await res.json();
|
||||
setComments(json.data ?? []);
|
||||
setComments2(json.data ?? []);
|
||||
setMeta(json.meta ?? {});
|
||||
} catch {
|
||||
setError("Network error. Please try again.");
|
||||
@@ -83131,7 +83320,7 @@ function formatRelative$1(isoString) {
|
||||
return `${d2}d ago`;
|
||||
}
|
||||
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 [submitting, setSubmitting] = reactExports.useState(false);
|
||||
const [body2, setBody] = reactExports.useState("");
|
||||
@@ -83144,7 +83333,7 @@ function PostComments({ postId, isLoggedIn, isOwn = false, initialCount = 0 }) {
|
||||
setLoading(true);
|
||||
try {
|
||||
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);
|
||||
setPage(p);
|
||||
} catch {
|
||||
@@ -83163,7 +83352,7 @@ function PostComments({ postId, isLoggedIn, isOwn = false, initialCount = 0 }) {
|
||||
setError(null);
|
||||
try {
|
||||
const { data } = await axios.post(`/api/posts/${postId}/comments`, { body: body2 });
|
||||
setComments((prev) => [...prev, data.comment]);
|
||||
setComments2((prev) => [...prev, data.comment]);
|
||||
setBody("");
|
||||
} catch (err) {
|
||||
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;
|
||||
try {
|
||||
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 {
|
||||
}
|
||||
};
|
||||
@@ -83183,12 +83372,12 @@ function PostComments({ postId, isLoggedIn, isOwn = false, initialCount = 0 }) {
|
||||
try {
|
||||
if (comment.is_highlighted) {
|
||||
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)
|
||||
);
|
||||
} else {
|
||||
await axios.post(`/api/posts/${postId}/comments/${comment.id}/highlight`);
|
||||
setComments(
|
||||
setComments2(
|
||||
(prev) => prev.map((c) => ({ ...c, is_highlighted: c.id === comment.id }))
|
||||
);
|
||||
}
|
||||
@@ -94339,7 +94528,7 @@ function Skeleton() {
|
||||
)));
|
||||
}
|
||||
function NewsComments({ articleId, isLoggedIn = false, loginUrl = "/login" }) {
|
||||
const [comments, setComments] = reactExports.useState([]);
|
||||
const [comments, setComments2] = reactExports.useState([]);
|
||||
const [loading, setLoading] = reactExports.useState(false);
|
||||
const [page, setPage] = reactExports.useState(1);
|
||||
const [lastPage, setLastPage] = reactExports.useState(1);
|
||||
@@ -94352,9 +94541,9 @@ function NewsComments({ articleId, isLoggedIn = false, loginUrl = "/login" }) {
|
||||
try {
|
||||
const { data } = await axios.get(`/api/news/articles/${articleId}/comments?page=${nextPage}`);
|
||||
if (nextPage === 1) {
|
||||
setComments(data.data ?? []);
|
||||
setComments2(data.data ?? []);
|
||||
} else {
|
||||
setComments((current) => [...current, ...data.data ?? []]);
|
||||
setComments2((current) => [...current, ...data.data ?? []]);
|
||||
}
|
||||
setPage(data.meta?.current_page ?? nextPage);
|
||||
setLastPage(data.meta?.last_page ?? 1);
|
||||
@@ -94371,7 +94560,7 @@ function NewsComments({ articleId, isLoggedIn = false, loginUrl = "/login" }) {
|
||||
}, [loadComments]);
|
||||
const handlePosted = reactExports.useCallback((newComment) => {
|
||||
const comment = { ...newComment, replies: newComment.replies || [] };
|
||||
setComments((current) => [comment, ...current]);
|
||||
setComments2((current) => [comment, ...current]);
|
||||
setTotal((current) => current + 1);
|
||||
}, []);
|
||||
const handleReplyPosted = reactExports.useCallback((parentId, newReply) => {
|
||||
@@ -94384,7 +94573,7 @@ function NewsComments({ articleId, isLoggedIn = false, loginUrl = "/login" }) {
|
||||
}
|
||||
return comment;
|
||||
});
|
||||
setComments((current) => insertReply(current));
|
||||
setComments2((current) => insertReply(current));
|
||||
setTotal((current) => current + 1);
|
||||
}, []);
|
||||
const handleDelete = reactExports.useCallback(async (commentId) => {
|
||||
@@ -95510,7 +95699,8 @@ const TABS$1 = [
|
||||
{ id: "about", label: "About", icon: "fa-id-card" },
|
||||
{ id: "stats", label: "Stats", icon: "fa-chart-bar" },
|
||||
{ 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 }) {
|
||||
const navRef = reactExports.useRef(null);
|
||||
@@ -98120,12 +98310,65 @@ function TabWorlds({ 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 }) {
|
||||
if (!Array.isArray(contributions) || contributions.length === 0) return null;
|
||||
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")))))));
|
||||
}
|
||||
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") {
|
||||
if (typeof window === "undefined") {
|
||||
return VALID_TABS.includes(initialTab) ? initialTab : "posts";
|
||||
@@ -98154,6 +98397,7 @@ function ProfileShow() {
|
||||
socialLinks,
|
||||
followerCount,
|
||||
recentFollowers,
|
||||
followersListing,
|
||||
followContext,
|
||||
followAnalytics,
|
||||
suggestedUsers,
|
||||
@@ -98184,6 +98428,11 @@ function ProfileShow() {
|
||||
const [activeTab, setActiveTab] = reactExports.useState(() => getInitialTab(initialTab));
|
||||
const handleTabChange = reactExports.useCallback((tab2) => {
|
||||
if (!VALID_TABS.includes(tab2)) return;
|
||||
if (tab2 === "followers") {
|
||||
const targetBase = profileTabUrls?.followers || `${profileUrl || ""}/followers`;
|
||||
window.location.assign(targetBase);
|
||||
return;
|
||||
}
|
||||
setActiveTab(tab2);
|
||||
try {
|
||||
const currentUrl = new URL(window.location.href);
|
||||
@@ -98344,6 +98593,13 @@ function ProfileShow() {
|
||||
followerCount,
|
||||
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({
|
||||
|
||||
@@ -52,6 +52,8 @@ return [
|
||||
'leaderboard',
|
||||
'art',
|
||||
'art/*',
|
||||
'download/artwork/*',
|
||||
'photo/*',
|
||||
'sitemap.xml',
|
||||
'sitemaps/*',
|
||||
'robots.txt',
|
||||
|
||||
@@ -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
@@ -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%
|
||||
@@ -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" "$@"
|
||||
@@ -1,36 +1,18 @@
|
||||
# -----------------------------------------------------------------------
|
||||
# nginx X-Accel-Redirect for artwork original file downloads
|
||||
# M13 — nginx X-Accel-Redirect for GET /download/artwork/{id}
|
||||
#
|
||||
# Problem: PHP streams the file body through FPM → nginx, causing
|
||||
# "[warn] upstream response is buffered to a temporary file"
|
||||
# for large downloads because FastCGI buffers are too small.
|
||||
# Production vhost is NOT in this repository (live file:
|
||||
# /etc/nginx/sites-enabled/skinbase.org.conf).
|
||||
# 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
|
||||
# directly from disk — FPM is only used for the header response.
|
||||
# Trailing slashes are required: location prefix and alias both end with /.
|
||||
# Use the canonical shared storage tree so release switches do not break nginx.
|
||||
# Do not use `root` here.
|
||||
#
|
||||
# Setup:
|
||||
# 1. Set DOWNLOAD_ACCEL_PATH=/internal/originals in .env (production).
|
||||
# 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.
|
||||
# -----------------------------------------------------------------------
|
||||
# Direct GET /internal/originals/... must 404 (internal;).
|
||||
# Laravel still authorizes/counts; only the file body is offloaded.
|
||||
|
||||
location /internal/originals/ {
|
||||
# Block direct client access — only X-Accel-Redirect headers trigger this.
|
||||
location ^~ /internal/originals/ {
|
||||
internal;
|
||||
|
||||
# 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";
|
||||
alias /opt/www/virtual/SkinbaseNova.releases/shared/storage/app/originals/artworks/;
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
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
|
||||
numprocs=1
|
||||
autostart=true
|
||||
autorestart=true
|
||||
user=www-data
|
||||
user=skinbase
|
||||
redirect_stderr=true
|
||||
stdout_logfile=/var/log/skinbase_ssr.log
|
||||
stdout_logfile_maxbytes=10MB
|
||||
stopwaitsecs=10
|
||||
stopasgroup=true
|
||||
killasgroup=true
|
||||
|
||||
@@ -20,7 +20,9 @@ Examples below are representative. For the full option list of any Artisan comma
|
||||
| Entry point | Why it is used | Example |
|
||||
| --- | --- | --- |
|
||||
| `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` |
|
||||
|
||||
## Maintained Standalone Scripts
|
||||
|
||||
+76
-38
@@ -1,18 +1,25 @@
|
||||
# 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
|
||||
|
||||
Run the existing entrypoint:
|
||||
Preferred entrypoints:
|
||||
|
||||
```bash
|
||||
bash sync.sh
|
||||
```bat
|
||||
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:
|
||||
|
||||
@@ -28,7 +35,16 @@ This will:
|
||||
|
||||
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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
```bat
|
||||
deploy.cmd --full-upgrade
|
||||
```
|
||||
|
||||
```bash
|
||||
bash sync.sh --full-upgrade
|
||||
bash deploy.sh --full-upgrade
|
||||
```
|
||||
|
||||
Full-upgrade mode:
|
||||
@@ -49,7 +69,7 @@ Full-upgrade mode:
|
||||
Example with service hooks:
|
||||
|
||||
```bash
|
||||
bash sync.sh --full-upgrade \
|
||||
bash deploy.sh --full-upgrade \
|
||||
--upgrade-pre-hook='sudo systemctl stop reverb' \
|
||||
--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
|
||||
FULL_UPGRADE_PRE_HOOK='sudo systemctl stop reverb' \
|
||||
FULL_UPGRADE_POST_HOOK='sudo systemctl restart reverb meilisearch' \
|
||||
bash sync.sh --full-upgrade
|
||||
bash deploy.sh --full-upgrade
|
||||
```
|
||||
|
||||
## 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 sync.sh --skip-build
|
||||
bash sync.sh --skip-migrate
|
||||
bash sync.sh --no-maintenance
|
||||
bash sync.sh --mode=full-upgrade
|
||||
bash sync.sh --keep-releases=8
|
||||
bash sync.sh --release-id=release-2026-04-25
|
||||
bash deploy.sh --skip-build
|
||||
bash deploy.sh --skip-migrate
|
||||
bash deploy.sh --no-maintenance
|
||||
bash deploy.sh --mode=full-upgrade
|
||||
bash deploy.sh --keep-releases=8
|
||||
bash deploy.sh --release-id=release-2026-04-25
|
||||
bash deploy.sh --build-number=100
|
||||
bash deploy.sh --no-rsync-progress
|
||||
```
|
||||
|
||||
Environment overrides:
|
||||
|
||||
```bash
|
||||
REMOTE_SERVER=user@example.com REMOTE_FOLDER=/var/www/app bash sync.sh
|
||||
REMOTE_RELEASE_ROOT=/var/www/app.releases RELEASE_RETENTION=8 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 deploy.sh
|
||||
```
|
||||
|
||||
You can also override the local build command explicitly:
|
||||
|
||||
```bash
|
||||
LOCAL_BUILD_COMMAND='npm run build' bash sync.sh
|
||||
LOCAL_BUILD_COMMAND='pnpm build' bash sync.sh
|
||||
LOCAL_BUILD_COMMAND='npm run build' bash deploy.sh
|
||||
LOCAL_BUILD_COMMAND='pnpm build' bash deploy.sh
|
||||
```
|
||||
|
||||
Upgrade hooks can also be supplied via environment variables:
|
||||
|
||||
```bash
|
||||
FULL_UPGRADE_PRE_HOOK='sudo systemctl stop reverb' bash sync.sh --full-upgrade
|
||||
FULL_UPGRADE_POST_HOOK='sudo systemctl restart reverb meilisearch' 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 deploy.sh --full-upgrade
|
||||
```
|
||||
|
||||
## 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`.
|
||||
- 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 data lives outside the active app path by default at `REMOTE_FOLDER.releases`, so switching releases happens entirely on the production server.
|
||||
|
||||
## 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
|
||||
- Reldeploy.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.
|
||||
|
||||
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 sync.sh --with-db-from=local \
|
||||
--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
|
||||
|
||||
- Normal deployments should use `bash sync.sh` without `--with-db`.
|
||||
- Use `bash sync.sh --full-upgrade` only when the release also includes Meilisearch-wide refreshes or remote service changes.
|
||||
- Normal deployments should use `deploy.cmd` or `bash deploy.sh` without `--with-db`.
|
||||
- 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 `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.
|
||||
- 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
|
||||
|
||||
|
||||
@@ -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
@@ -13,9 +13,10 @@ import TabActivity from '../../components/profile/tabs/TabActivity'
|
||||
import TabPosts from '../../components/profile/tabs/TabPosts'
|
||||
import TabStories from '../../components/profile/tabs/TabStories'
|
||||
import TabWorlds from '../../components/profile/tabs/TabWorlds'
|
||||
import TabFollowers from '../../components/profile/tabs/TabFollowers'
|
||||
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') {
|
||||
if (typeof window === 'undefined') {
|
||||
@@ -55,6 +56,7 @@ export default function ProfileShow() {
|
||||
socialLinks,
|
||||
followerCount,
|
||||
recentFollowers,
|
||||
followersListing,
|
||||
followContext,
|
||||
followAnalytics,
|
||||
suggestedUsers,
|
||||
@@ -87,6 +89,15 @@ export default function ProfileShow() {
|
||||
|
||||
const handleTabChange = useCallback((tab) => {
|
||||
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)
|
||||
|
||||
try {
|
||||
@@ -274,6 +285,13 @@ export default function ProfileShow() {
|
||||
creatorStories={creatorStories}
|
||||
/>
|
||||
)}
|
||||
{activeTab === 'followers' && (
|
||||
<TabFollowers
|
||||
listing={followersListing}
|
||||
username={user.username || user.name}
|
||||
followerCount={followerCount}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -11,6 +11,7 @@ export const TABS = [
|
||||
{ id: 'stats', label: 'Stats', icon: 'fa-chart-bar' },
|
||||
{ id: 'favourites', label: 'Favourites', icon: 'fa-heart' },
|
||||
{ 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';
|
||||
|
||||
const preloadCache = new Set();
|
||||
const prefetchCache = new Set();
|
||||
|
||||
function scheduleIdleTask(callback, delay = 1200) {
|
||||
if (typeof window === 'undefined') {
|
||||
@@ -32,8 +33,8 @@ function preloadImage(src) {
|
||||
img.src = src;
|
||||
}
|
||||
|
||||
export default function ArtworkNavigator({ artworkId, onNavigate, onOpenViewer, onReady }) {
|
||||
const { getNeighbors } = useNavContext(artworkId);
|
||||
export default function ArtworkNavigator({ artworkId, navigation = null, onNavigate, onOpenViewer, onReady }) {
|
||||
const { getNeighbors } = useNavContext(artworkId, navigation);
|
||||
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)
|
||||
@@ -67,17 +68,42 @@ export default function ArtworkNavigator({ artworkId, onNavigate, onOpenViewer,
|
||||
const ids = [neighbors.prevId, neighbors.nextId].filter(Boolean);
|
||||
if (ids.length === 0) return undefined;
|
||||
|
||||
const previews = new Map([
|
||||
[neighbors.prevId, neighbors.prevPreview],
|
||||
[neighbors.nextId, neighbors.nextPreview],
|
||||
]);
|
||||
|
||||
let cancelled = false;
|
||||
const cancelIdleTask = scheduleIdleTask(() => {
|
||||
ids.forEach((id) => {
|
||||
fetch(`/api/artworks/${id}/page`, { headers: { Accept: 'application/json' } })
|
||||
.then((r) => r.ok ? r.json() : null)
|
||||
if (prefetchCache.has(id)) 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 (cancelled || !data) return;
|
||||
if (!data) {
|
||||
prefetchCache.delete(id);
|
||||
return;
|
||||
}
|
||||
if (cancelled) return;
|
||||
const imgUrl = data.thumbs?.lg?.url || data.thumbs?.md?.url;
|
||||
if (imgUrl) preloadImage(imgUrl);
|
||||
})
|
||||
.catch(() => {});
|
||||
.catch(() => prefetchCache.delete(id));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -85,7 +111,7 @@ export default function ArtworkNavigator({ artworkId, onNavigate, onOpenViewer,
|
||||
cancelled = true;
|
||||
cancelIdleTask();
|
||||
};
|
||||
}, [neighbors.prevId, neighbors.nextId]);
|
||||
}, [neighbors.prevId, neighbors.nextId, neighbors.prevPreview, neighbors.nextPreview]);
|
||||
|
||||
// Stable navigate — reads state via refs, never recreated
|
||||
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' } }))
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -8,6 +8,22 @@ import { useCallback } from 'react';
|
||||
// Module-level cache for API calls
|
||||
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) {
|
||||
const key = String(artworkId);
|
||||
if (fallbackCache.has(key)) return fallbackCache.get(key);
|
||||
@@ -16,28 +32,25 @@ async function fetchFallback(artworkId) {
|
||||
const res = await fetch(`/api/artworks/navigation/${artworkId}`, {
|
||||
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 result = {
|
||||
prevId: data.prev_id ?? null,
|
||||
nextId: data.next_id ?? null,
|
||||
prevUrl: data.prev_url ?? null,
|
||||
nextUrl: data.next_url ?? null,
|
||||
};
|
||||
const result = normalizeNavigation(data) || { prevId: null, nextId: null, prevUrl: null, nextUrl: null, prevPreview: null, nextPreview: null };
|
||||
fallbackCache.set(key, result);
|
||||
return result;
|
||||
} 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.
|
||||
*/
|
||||
const getNeighbors = useCallback(async () => {
|
||||
const embedded = normalizeNavigation(embeddedNavigation);
|
||||
if (embedded) return embedded;
|
||||
return fetchFallback(currentArtworkId);
|
||||
}, [currentArtworkId]);
|
||||
}, [currentArtworkId, embeddedNavigation]);
|
||||
|
||||
return { getNeighbors };
|
||||
}
|
||||
|
||||
@@ -556,7 +556,7 @@
|
||||
<span class="ml-1 px-1.5 py-0.5 rounded text-xs bg-white/5 text-[--sb-muted]">
|
||||
{{ number_format($followerCount) }}
|
||||
</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">
|
||||
All
|
||||
</a>
|
||||
|
||||
@@ -129,6 +129,12 @@ Route::get('/profile.php', function () {
|
||||
// ── 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('/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');
|
||||
|
||||
// Keep legacy `/user` as a permanent redirect to the canonical dashboard path.
|
||||
|
||||
+1
-1
@@ -493,7 +493,7 @@ Route::get('/@{username}/collections/{slug}', [ProfileCollectionController::clas
|
||||
|
||||
Route::get('/@{username}/{tab}', [ProfileController::class, 'showTabByUsername'])
|
||||
->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');
|
||||
|
||||
Route::get('/@{username}', [ProfileController::class, 'showByUsername'])
|
||||
|
||||
@@ -3,10 +3,18 @@
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Read-only analyzer for /var/log/nginx/skinbase-performance.log
|
||||
* Read-only analyzer for nginx skinbase-performance JSON logs.
|
||||
*
|
||||
* Usage:
|
||||
* php scripts/analyze-http-performance.php --file=/var/log/nginx/skinbase-performance.log --since=24h --top=20 --host=skinbase.org
|
||||
* Single file (backward compatible):
|
||||
* 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;
|
||||
@@ -14,9 +22,11 @@ use App\Support\Http\HttpUriNormalizer;
|
||||
|
||||
require __DIR__.'/../vendor/autoload.php';
|
||||
|
||||
$files = [];
|
||||
$globs = [];
|
||||
$options = [
|
||||
'file' => '/var/log/nginx/skinbase-performance.log',
|
||||
'since' => '24h',
|
||||
'until' => null,
|
||||
'min_requests' => 1,
|
||||
'top' => 20,
|
||||
'status' => null,
|
||||
@@ -31,9 +41,18 @@ foreach (array_slice($argv, 1) as $arg) {
|
||||
exit(2);
|
||||
}
|
||||
[$key, $value] = array_pad(explode('=', substr($arg, 2), 2), 2, '1');
|
||||
if ($key === 'file') {
|
||||
$files[] = $value;
|
||||
continue;
|
||||
}
|
||||
if ($key === 'glob') {
|
||||
$globs[] = $value;
|
||||
continue;
|
||||
}
|
||||
$map = [
|
||||
'file' => 'file',
|
||||
'since' => 'since',
|
||||
'until' => 'until',
|
||||
'hours' => 'hours',
|
||||
'min-requests' => 'min_requests',
|
||||
'top' => 'top',
|
||||
'status' => 'status',
|
||||
@@ -48,21 +67,52 @@ foreach (array_slice($argv, 1) as $arg) {
|
||||
$options[$map[$key]] = $value;
|
||||
}
|
||||
|
||||
$file = (string) $options['file'];
|
||||
if (! is_readable($file)) {
|
||||
fwrite(STDERR, "Cannot read {$file}\n");
|
||||
exit(1);
|
||||
if (isset($options['hours']) && is_numeric($options['hours'])) {
|
||||
$options['since'] = ((int) $options['hours']).'h';
|
||||
}
|
||||
|
||||
$analyzer = new HttpPerformanceLogAnalyzer(new HttpUriNormalizer());
|
||||
$result = $analyzer->analyze($file, [
|
||||
|
||||
$discovered = $files;
|
||||
foreach ($globs as $pattern) {
|
||||
$discovered = array_merge($discovered, $analyzer->expandGlob($pattern));
|
||||
}
|
||||
|
||||
if ($discovered === [] && $globs === []) {
|
||||
$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;
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
#!/bin/bash
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Production deploy implementation entry used by deploy.sh / deploy.cmd.
|
||||
script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
||||
|
||||
exec "$script_dir/sync-safe-updated.sh" "$@"
|
||||
|
||||
@@ -316,7 +316,20 @@ fi
|
||||
|
||||
log_step "Switching current release to ${target_release}"
|
||||
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"
|
||||
|
||||
|
||||
+901
-92
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,7 @@
|
||||
#!/bin/bash
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Legacy alias. Prefer: deploy.cmd (Windows) or bash deploy.sh (WSL/Linux).
|
||||
script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
||||
|
||||
exec "$script_dir/scripts/deploy-production.sh" "$@"
|
||||
exec "$script_dir/deploy.sh" "$@"
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -57,7 +57,7 @@ it('downloads an existing artwork file', function () {
|
||||
$response = $this->get("/download/artwork/{$artwork->id}");
|
||||
|
||||
$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 () {
|
||||
@@ -74,7 +74,7 @@ it('forces the download filename using file_name and extension', function () {
|
||||
$response = $this->get("/download/artwork/{$artwork->id}");
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertDownload('My Original Name.jpg');
|
||||
$response->assertDownload('My Original Name (skinbase.org).jpg');
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
@@ -59,3 +59,75 @@ it('redirects legacy full-size photo urls to the original CDN asset', function (
|
||||
->assertStatus(301)
|
||||
->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
@@ -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
|
||||
@@ -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":""}
|
||||
@@ -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.
@@ -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
@@ -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
|
||||
Reference in New Issue
Block a user