Add tests for featured thumbnail generation; apply Pint formatting and related edits

This commit is contained in:
2026-05-06 18:55:40 +02:00
parent 7a8bc8e22a
commit 82f2b1f660
65 changed files with 11325 additions and 49545 deletions

View File

@@ -4,11 +4,14 @@ declare(strict_types=1);
namespace App\Services\Academy;
use App\Models\AcademyAiComparisonResult;
use App\Models\AcademyChallenge;
use App\Models\AcademyLesson;
use App\Models\AcademyLessonBlock;
use App\Models\AcademyPromptPack;
use App\Models\AcademyPromptTemplate;
use App\Models\User;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
final class AcademyAccessService
@@ -66,6 +69,7 @@ final class AcademyAccessService
'access_level' => (string) $lesson->access_level,
'lesson_type' => (string) $lesson->lesson_type,
'cover_image' => $lesson->cover_image,
'cover_image_url' => $this->resolveLessonCoverImageUrl((string) ($lesson->cover_image ?? '')),
'video_url' => $authorized ? $lesson->video_url : null,
'reading_minutes' => (int) $lesson->reading_minutes,
'featured' => (bool) $lesson->featured,
@@ -76,6 +80,9 @@ final class AcademyAccessService
'name' => (string) $lesson->category->name,
'slug' => (string) $lesson->category->slug,
] : null,
'blocks' => ($authorized && $includeFull)
? $lesson->activeBlocks->map(fn (AcademyLessonBlock $block): ?array => $this->lessonBlockPayload($block))->filter()->values()->all()
: [],
'locked' => ! $authorized,
'can_access' => $authorized,
];
@@ -100,7 +107,7 @@ final class AcademyAccessService
'aspect_ratio' => $prompt->aspect_ratio,
'tags' => array_values((array) ($prompt->tags ?? [])),
'tool_notes' => $authorized ? (array) ($prompt->tool_notes ?? []) : [],
'preview_image' => $prompt->preview_image,
'preview_image' => $this->resolvePreviewImageUrl((string) ($prompt->preview_image ?? '')),
'featured' => (bool) $prompt->featured,
'prompt_of_week' => (bool) $prompt->prompt_of_week,
'published_at' => $prompt->published_at?->toISOString(),
@@ -204,6 +211,115 @@ final class AcademyAccessService
$previewLength = max(1, $length - 1);
}
return rtrim(mb_substr($plain, 0, $previewLength)) . '...';
return rtrim(mb_substr($plain, 0, $previewLength)).'...';
}
}
private function resolvePreviewImageUrl(string $previewImage): ?string
{
$previewImage = trim($previewImage);
if ($previewImage === '') {
return null;
}
if (str_starts_with($previewImage, 'http://') || str_starts_with($previewImage, 'https://') || str_starts_with($previewImage, '/')) {
return $previewImage;
}
return Storage::disk((string) config('uploads.object_storage.disk', 's3'))->url($previewImage);
}
private function resolveLessonCoverImageUrl(string $coverImage): ?string
{
$coverImage = trim($coverImage);
if ($coverImage === '') {
return null;
}
if (str_starts_with($coverImage, 'http://') || str_starts_with($coverImage, 'https://') || str_starts_with($coverImage, '/')) {
return $coverImage;
}
return Storage::disk((string) config('uploads.object_storage.disk', 's3'))->url($coverImage);
}
private function resolveLessonMediaUrl(string $path): ?string
{
$path = trim($path);
if ($path === '') {
return null;
}
if (str_starts_with($path, 'http://') || str_starts_with($path, 'https://') || str_starts_with($path, '/')) {
return $path;
}
return Storage::disk((string) config('uploads.object_storage.disk', 's3'))->url($path);
}
/**
* @return array<string, mixed>|null
*/
private function lessonBlockPayload(AcademyLessonBlock $block): ?array
{
if ($block->type !== 'ai_comparison') {
return null;
}
$payload = is_array($block->payload) ? $block->payload : [];
$criteria = collect($payload['criteria'] ?? [])
->map(static fn ($criterion): string => trim((string) $criterion))
->filter(static fn (string $criterion): bool => $criterion !== '')
->values()
->all();
$results = $block->activeComparisonResults
->map(fn (AcademyAiComparisonResult $result): array => [
'id' => (int) $result->id,
'provider' => (string) ($result->provider ?? ''),
'model_name' => (string) ($result->model_name ?? ''),
'image_path' => (string) $result->image_path,
'image_url' => $this->resolveLessonMediaUrl((string) $result->image_path),
'thumb_path' => (string) ($result->thumb_path ?? ''),
'thumb_url' => $this->resolveLessonMediaUrl((string) ($result->thumb_path ?? '')),
'settings' => (string) ($result->settings ?? ''),
'strengths' => (string) ($result->strengths ?? ''),
'weaknesses' => (string) ($result->weaknesses ?? ''),
'best_for' => (string) ($result->best_for ?? ''),
'score' => $result->score,
'sort_order' => (int) $result->sort_order,
'active' => (bool) $result->active,
])
->values()
->all();
$hasPromptData = filled($payload['prompt'] ?? null)
|| filled($payload['negative_prompt'] ?? null)
|| filled($payload['intro'] ?? null)
|| filled($payload['title'] ?? null)
|| filled($payload['aspect_ratio'] ?? null)
|| ! empty($criteria);
if (! $hasPromptData && $results === []) {
return null;
}
return [
'id' => (int) $block->id,
'type' => (string) $block->type,
'title' => (string) ($block->title ?? ($payload['title'] ?? '')),
'payload' => [
'title' => (string) ($payload['title'] ?? ''),
'intro' => (string) ($payload['intro'] ?? ''),
'prompt' => (string) ($payload['prompt'] ?? ''),
'negative_prompt' => (string) ($payload['negative_prompt'] ?? ''),
'aspect_ratio' => (string) ($payload['aspect_ratio'] ?? ''),
'criteria' => $criteria,
],
'sort_order' => (int) $block->sort_order,
'active' => (bool) $block->active,
'comparison_results' => $results,
];
}
}

View File

@@ -27,6 +27,8 @@ class ArtworkService
{
protected int $cacheTtl = 3600; // seconds
private ?bool $featureTypeColumnExists = null;
public function __construct(
private readonly ContentTypeSlugResolver $contentTypeResolver,
private readonly ArtworkMaturityService $maturity,
@@ -340,7 +342,7 @@ class ArtworkService
*/
private function featuredBaseQuery(?int $type): Builder
{
return Artwork::query()
$query = Artwork::query()
->select('artworks.*')
->join('artwork_features as af', 'af.artwork_id', '=', 'artworks.id')
->leftJoin('artwork_medal_stats as aas', 'aas.artwork_id', '=', 'artworks.id')
@@ -349,10 +351,13 @@ class ArtworkService
->where(function ($query): void {
$query->whereNull('af.expires_at')
->orWhere('af.expires_at', '>', now());
})
->when($type !== null, function ($q) use ($type) {
$q->where('af.type', $type);
});
if ($type !== null && $this->featuredTypeColumnExists()) {
$query->where('af.type', $type);
}
return $query;
}
private function applyFeaturedEligibilityFilters(Builder $query): void
@@ -384,6 +389,15 @@ class ArtworkService
return $this->applyFeaturedOrdering($query);
}
private function featuredTypeColumnExists(): bool
{
if ($this->featureTypeColumnExists === null) {
$this->featureTypeColumnExists = Schema::hasColumn('artwork_features', 'type');
}
return $this->featureTypeColumnExists;
}
private function featuredHeroSelectionQuery(?int $type): Builder
{
$query = $this->featuredBaseQuery($type);

View File

@@ -12,6 +12,10 @@ use cPad\Plugins\News\Models\NewsArticle;
final class NewsArticleCommentService
{
public function __construct(private readonly NewsService $news)
{
}
public function create(NewsArticle $article, User $actor, string $body, ?NewsArticleComment $parent = null): NewsArticleComment
{
if (! $article->commentsAreEnabled()) {
@@ -42,6 +46,8 @@ final class NewsArticleCommentService
'status' => 'visible',
]);
$this->news->invalidatePublicCache();
return $comment->fresh(['user.profile', 'replies.user.profile']);
}
@@ -60,6 +66,8 @@ final class NewsArticleCommentService
}
$comment->delete();
$this->news->invalidatePublicCache();
}
private function canDelete(NewsArticleComment $comment, NewsArticle $article, User $actor): bool

View File

@@ -11,11 +11,15 @@ use App\Models\GroupChallenge;
use App\Models\GroupEvent;
use App\Models\GroupProject;
use App\Models\GroupRelease;
use App\Models\NewsArticleComment;
use App\Models\User;
use App\Support\News\NewsCoverImage;
use App\Support\AvatarUrl;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use cPad\Plugins\News\Models\NewsArticle;
@@ -25,6 +29,8 @@ use cPad\Plugins\News\Models\NewsTag;
final class NewsService
{
private const PUBLIC_CACHE_VERSION_KEY = 'news.public.cache.version';
public const RELATION_GROUP = 'group';
public const RELATION_ARTWORK = 'artwork';
public const RELATION_COLLECTION = 'collection';
@@ -45,6 +51,8 @@ final class NewsService
self::RELATION_USER => 'Profile',
];
private ?bool $artworkStatsViewsColumnExists = null;
public function articleTypeOptions(): array
{
return \collect(NewsArticle::TYPE_LABELS)
@@ -98,15 +106,23 @@ final class NewsService
public function sidebarData(): array
{
return [
'categories' => NewsCategory::active()->withCount('publishedArticles')->ordered()->get(),
'trending' => NewsArticle::published()
->with('category')
->orderByDesc('views')
->limit(config('news.trending_limit', 5))
->get(['id', 'title', 'slug', 'views', 'published_at', 'category_id', 'type']),
'tags' => NewsTag::whereHas('articles', fn ($query) => $query->published())->orderBy('name')->get(),
];
return Cache::remember($this->publicCacheKey('sidebar'), $this->publicCacheTtl(), function (): array {
return [
'categories' => NewsCategory::active()->withCount('publishedArticles')->ordered()->get(),
'trending' => NewsArticle::published()
->with('category')
->orderByDesc('views')
->limit(config('news.trending_limit', 5))
->get(['id', 'title', 'slug', 'views', 'published_at', 'category_id', 'type']),
'tags' => NewsTag::query()
->whereHas('articles', fn ($query) => $query->published())
->withCount(['articles as published_articles_count' => fn ($query) => $query->published()])
->orderByDesc('published_articles_count')
->orderBy('name')
->limit((int) config('news.sidebar_tags_limit', 18))
->get(),
];
});
}
public function studioListing(array $filters = []): array
@@ -169,6 +185,9 @@ final class NewsService
'content' => (string) ($article->content ?? ''),
'cover_image' => (string) ($article->cover_image ?? ''),
'cover_url' => $article->cover_url,
'cover_mobile_url' => $article->cover_mobile_url,
'cover_desktop_url' => $article->cover_desktop_url,
'cover_srcset' => $article->cover_srcset,
'type' => (string) ($article->type ?? NewsArticle::TYPE_ANNOUNCEMENT),
'editorial_status' => (string) ($article->editorial_status ?? NewsArticle::EDITORIAL_STATUS_DRAFT),
'published_at' => \optional($article->published_at)?->toIso8601String(),
@@ -214,6 +233,8 @@ final class NewsService
public function deleteArticle(NewsArticle $article): void
{
$article->delete();
$this->invalidatePublicCache();
}
public function publish(NewsArticle $article): NewsArticle
@@ -224,6 +245,8 @@ final class NewsService
'published_at' => $article->published_at ?? \now(),
])->save();
$this->invalidatePublicCache();
return $article->fresh(['author', 'category', 'tags', 'relatedEntities']);
}
@@ -234,6 +257,8 @@ final class NewsService
'status' => 'draft',
])->save();
$this->invalidatePublicCache();
return $article->fresh(['author', 'category', 'tags', 'relatedEntities']);
}
@@ -241,6 +266,8 @@ final class NewsService
{
$article->forceFill(['is_featured' => ! $article->is_featured])->save();
$this->invalidatePublicCache();
return $article->fresh(['author', 'category', 'tags', 'relatedEntities']);
}
@@ -248,6 +275,8 @@ final class NewsService
{
$article->forceFill(['is_pinned' => ! (bool) $article->is_pinned])->save();
$this->invalidatePublicCache();
return $article->fresh(['author', 'category', 'tags', 'relatedEntities']);
}
@@ -280,6 +309,61 @@ final class NewsService
->all();
}
public function publicArticleShowData(NewsArticle $article, ?User $viewer = null): array
{
if ($viewer !== null) {
return $this->buildPublicArticleShowData($article, $viewer);
}
return Cache::remember(
$this->publicCacheKey('article.show.' . $article->id),
$this->publicCacheTtl(),
fn (): array => $this->buildPublicArticleShowData($article, null),
);
}
private function buildPublicArticleShowData(NewsArticle $article, ?User $viewer = null): array
{
$related = NewsArticle::with('author', 'category')
->published()
->when($article->category_id, fn ($query) => $query->where('category_id', $article->category_id))
->where('id', '!=', $article->id)
->editorialOrder()
->limit(config('news.related_limit', 4))
->get();
$comments = collect();
$commentsCount = 0;
if ($article->commentsAreEnabled()) {
$comments = NewsArticleComment::query()
->where('article_id', $article->id)
->whereNull('parent_id')
->where('status', 'visible')
->with(['user.profile'])
->orderBy('created_at')
->orderBy('id')
->get();
$commentsCount = (int) NewsArticleComment::query()
->where('article_id', $article->id)
->where('status', 'visible')
->count();
}
return [
'related' => $related,
'relatedEntities' => $this->resolveRelatedEntities($article, $viewer),
'comments' => $comments,
'commentsCount' => $commentsCount,
];
}
public function invalidatePublicCache(): void
{
Cache::forever(self::PUBLIC_CACHE_VERSION_KEY, $this->publicCacheVersion() + 1);
}
public function syncRelations(NewsArticle $article, array $relations): void
{
$normalized = \collect($relations)
@@ -311,6 +395,23 @@ final class NewsService
'sort_order' => $index,
]);
}
$this->invalidatePublicCache();
}
private function publicCacheKey(string $suffix): string
{
return 'news.public.v' . $this->publicCacheVersion() . '.' . $suffix;
}
private function publicCacheTtl(): int
{
return max(60, (int) config('news.public_cache_ttl', 120));
}
private function publicCacheVersion(): int
{
return (int) Cache::get(self::PUBLIC_CACHE_VERSION_KEY, 1);
}
private function persistArticle(NewsArticle $article, User $editor, array $data): NewsArticle
@@ -362,6 +463,8 @@ final class NewsService
$article->tags()->sync($this->resolveArticleTagIds($data));
$this->syncRelations($article, $data['relations'] ?? []);
$this->invalidatePublicCache();
return $article->fresh(['author.profile', 'category', 'tags', 'relatedEntities']);
}
@@ -376,6 +479,7 @@ final class NewsService
'editorial_status' => (string) ($article->editorial_status ?? NewsArticle::EDITORIAL_STATUS_DRAFT),
'published_at' => \optional($article->published_at)?->toIso8601String(),
'cover_url' => $article->cover_url,
'cover_srcset' => $article->cover_srcset,
'author_name' => (string) ($article->author?->name ?? 'Skinbase'),
'category_name' => (string) ($article->category?->name ?? ''),
'is_featured' => (bool) $article->is_featured,
@@ -485,13 +589,13 @@ final class NewsService
private function deleteManagedCoverImage(string $path): void
{
$trimmed = ltrim(trim($path), '/');
$paths = NewsCoverImage::managedPaths($path);
if ($trimmed === '' || ! Str::startsWith($trimmed, 'news/covers/')) {
if ($paths === []) {
return;
}
Storage::disk((string) config('uploads.object_storage.disk', 's3'))->delete($trimmed);
Storage::disk((string) config('uploads.object_storage.disk', 's3'))->delete($paths);
}
private function searchGroups(string $query, ?User $viewer): array
@@ -517,8 +621,9 @@ final class NewsService
private function searchArtworks(string $query): array
{
return Artwork::query()
$queryBuilder = Artwork::query()
->with(['user.profile'])
->select('artworks.*')
->where('artwork_status', 'published')
->where('visibility', Artwork::VISIBILITY_PUBLIC)
->when($query !== '', function (Builder $builder) use ($query): void {
@@ -528,8 +633,17 @@ final class NewsService
->orWhere('description', 'like', '%' . $query . '%');
});
})
->orderByDesc('views')
->limit(8)
->limit(8);
if ($this->artworkStatsViewsAvailable()) {
$queryBuilder->leftJoin('artwork_stats as stats', 'stats.artwork_id', '=', 'artworks.id')
->orderByRaw('COALESCE(stats.views, 0) DESC');
} else {
$queryBuilder->orderByDesc('published_at');
}
return $queryBuilder
->orderByDesc('artworks.id')
->get()
->map(fn (Artwork $artwork): ?array => $this->resolveArtworkPreview((int) $artwork->id, ''))
->filter()
@@ -537,6 +651,16 @@ final class NewsService
->all();
}
private function artworkStatsViewsAvailable(): bool
{
if ($this->artworkStatsViewsColumnExists === null) {
$this->artworkStatsViewsColumnExists = Schema::hasTable('artwork_stats')
&& Schema::hasColumn('artwork_stats', 'views');
}
return $this->artworkStatsViewsColumnExists;
}
private function searchCollections(string $query, ?User $viewer): array
{
return Collection::query()

View File

@@ -7,6 +7,10 @@ use Illuminate\Support\Facades\Log;
class TurnstileCaptchaProvider implements CaptchaProviderInterface
{
private const DEFAULT_VERIFY_URL = 'https://challenges.cloudflare.com/turnstile/v0/siteverify';
private const DEFAULT_SCRIPT_URL = 'https://challenges.cloudflare.com/turnstile/v0/api.js';
public function name(): string
{
return 'turnstile';
@@ -14,7 +18,7 @@ class TurnstileCaptchaProvider implements CaptchaProviderInterface
public function isEnabled(): bool
{
return (bool) config('registration.enable_turnstile', true)
return (bool) config('services.turnstile.enabled', false)
&& $this->siteKey() !== ''
&& (string) config('services.turnstile.secret_key', '') !== '';
}
@@ -31,7 +35,7 @@ class TurnstileCaptchaProvider implements CaptchaProviderInterface
public function scriptUrl(): string
{
return (string) config('services.turnstile.script_url', 'https://challenges.cloudflare.com/turnstile/v0/api.js');
return (string) config('services.turnstile.script_url', self::DEFAULT_SCRIPT_URL);
}
public function verify(string $token, ?string $ip = null): bool
@@ -47,23 +51,39 @@ class TurnstileCaptchaProvider implements CaptchaProviderInterface
try {
$response = Http::asForm()
->timeout((int) config('services.turnstile.timeout', 5))
->post((string) config('services.turnstile.verify_url', 'https://challenges.cloudflare.com/turnstile/v0/siteverify'), [
->post((string) config('services.turnstile.verify_url', self::DEFAULT_VERIFY_URL), [
'secret' => (string) config('services.turnstile.secret_key', ''),
'response' => $token,
'remoteip' => $ip,
]);
if ($response->failed()) {
Log::info('turnstile verification rejected registration attempt', [
'ip' => $ip,
'hostname' => data_get($response->json(), 'hostname'),
'error_codes' => data_get($response->json(), 'error-codes', []),
]);
return false;
}
return (bool) data_get($response->json(), 'success', false);
$success = (bool) data_get($response->json(), 'success', false);
if (! $success) {
Log::info('turnstile verification rejected registration attempt', [
'ip' => $ip,
'hostname' => data_get($response->json(), 'hostname'),
'error_codes' => data_get($response->json(), 'error-codes', []),
]);
}
return $success;
} catch (\Throwable $exception) {
Log::warning('turnstile verification request failed', [
'message' => $exception->getMessage(),
'ip' => $ip,
]);
return false;
return (bool) config('services.turnstile.fail_open', false);
}
}
}

View File

@@ -2,21 +2,32 @@
namespace App\Services\Security;
use App\Services\Security\Captcha\TurnstileCaptchaProvider;
class TurnstileVerifier
{
public function __construct(
private readonly CaptchaVerifier $captchaVerifier,
private readonly TurnstileCaptchaProvider $turnstileProvider,
) {
}
public function isEnabled(): bool
{
return $this->captchaVerifier->provider() === 'turnstile'
&& $this->captchaVerifier->isEnabled();
return $this->turnstileProvider->isEnabled();
}
public function siteKey(): string
{
return $this->turnstileProvider->siteKey();
}
public function scriptUrl(): string
{
return $this->turnstileProvider->scriptUrl();
}
public function verify(string $token, ?string $ip = null): bool
{
return $this->captchaVerifier->verify($token, $ip);
return $this->turnstileProvider->verify($token, $ip);
}
}

View File

@@ -183,14 +183,32 @@ final class UploadDerivativesService
* @return array<string, array{path: string, size: int, mime: string}>
*/
public function generatePublicDerivatives(string $sourcePath, string $hash): array
{
return $this->generateSelectedPublicDerivatives(
$sourcePath,
$hash,
array_keys((array) config('uploads.derivatives', [])),
);
}
/**
* @param list<string> $variants
* @return array<string, array{path: string, size: int, mime: string}>
*/
public function generateSelectedPublicDerivatives(string $sourcePath, string $hash, array $variants): array
{
$this->assertImageAvailable();
$quality = (int) config('uploads.quality', 85);
$variants = (array) config('uploads.derivatives', []);
$configuredVariants = (array) config('uploads.derivatives', []);
$written = [];
foreach ($variants as $variant => $options) {
$variant = (string) $variant;
foreach ($variants as $variant) {
$variant = strtolower(trim((string) $variant));
if ($variant === '' || ! array_key_exists($variant, $configuredVariants)) {
continue;
}
$options = (array) $configuredVariants[$variant];
if ($variant === 'sq') {
$written[$variant] = $this->generateSquareDerivative($sourcePath, $hash);