Add tests for featured thumbnail generation; apply Pint formatting and related edits
This commit is contained in:
@@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user