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