Compare commits
3 Commits
980a15f66e
...
2119741ba7
| Author | SHA1 | Date | |
|---|---|---|---|
| 2119741ba7 | |||
| 2728644477 | |||
| b3fc889452 |
@@ -0,0 +1,72 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
final class AggregateTagInteractionAnalyticsCommand extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'analytics:aggregate-tag-interactions {--date= : Date (Y-m-d), defaults to yesterday}';
|
||||||
|
|
||||||
|
protected $description = 'Aggregate tag interaction analytics into daily metrics by surface, tag, source tag, and query';
|
||||||
|
|
||||||
|
public function handle(): int
|
||||||
|
{
|
||||||
|
$date = $this->option('date')
|
||||||
|
? (string) $this->option('date')
|
||||||
|
: now()->subDay()->toDateString();
|
||||||
|
|
||||||
|
$normalizedTag = "COALESCE(tag_slug, '')";
|
||||||
|
$normalizedSourceTag = "COALESCE(source_tag_slug, '')";
|
||||||
|
$normalizedQuery = "LOWER(TRIM(COALESCE(query, '')))";
|
||||||
|
|
||||||
|
$rows = DB::table('tag_interaction_events')
|
||||||
|
->selectRaw('surface')
|
||||||
|
->selectRaw("{$normalizedTag} AS tag_slug")
|
||||||
|
->selectRaw("{$normalizedSourceTag} AS source_tag_slug")
|
||||||
|
->selectRaw("{$normalizedQuery} AS query")
|
||||||
|
->selectRaw('COUNT(*) AS clicks')
|
||||||
|
->selectRaw('COUNT(DISTINCT user_id) AS unique_users')
|
||||||
|
->selectRaw("COUNT(DISTINCT CASE WHEN session_key IS NOT NULL AND session_key <> '' THEN session_key END) AS unique_sessions")
|
||||||
|
->selectRaw('AVG(position) AS avg_position')
|
||||||
|
->whereDate('event_date', $date)
|
||||||
|
->where('event_type', 'click')
|
||||||
|
->groupBy('surface', DB::raw($normalizedTag), DB::raw($normalizedSourceTag), DB::raw($normalizedQuery))
|
||||||
|
->get();
|
||||||
|
|
||||||
|
DB::transaction(function () use ($date, $rows): void {
|
||||||
|
DB::table('tag_interaction_daily_metrics')
|
||||||
|
->where('metric_date', $date)
|
||||||
|
->delete();
|
||||||
|
|
||||||
|
$payload = $rows->map(static function ($row) use ($date): array {
|
||||||
|
return [
|
||||||
|
'metric_date' => $date,
|
||||||
|
'surface' => (string) $row->surface,
|
||||||
|
'tag_slug' => trim((string) ($row->tag_slug ?? '')),
|
||||||
|
'source_tag_slug' => trim((string) ($row->source_tag_slug ?? '')),
|
||||||
|
'query' => trim((string) ($row->query ?? '')),
|
||||||
|
'clicks' => (int) ($row->clicks ?? 0),
|
||||||
|
'unique_users' => (int) ($row->unique_users ?? 0),
|
||||||
|
'unique_sessions' => (int) ($row->unique_sessions ?? 0),
|
||||||
|
'avg_position' => round((float) ($row->avg_position ?? 0), 2),
|
||||||
|
'created_at' => now(),
|
||||||
|
'updated_at' => now(),
|
||||||
|
];
|
||||||
|
})->all();
|
||||||
|
|
||||||
|
foreach (array_chunk($payload, 500) as $chunk) {
|
||||||
|
if ($chunk !== []) {
|
||||||
|
DB::table('tag_interaction_daily_metrics')->insert($chunk);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
$this->info("Aggregated tag interaction analytics for {$date}.");
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
}
|
||||||
167
app/Console/Commands/SeedTagInteractionDemoCommand.php
Normal file
167
app/Console/Commands/SeedTagInteractionDemoCommand.php
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use App\Models\Tag;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
use Illuminate\Support\Carbon;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
final class SeedTagInteractionDemoCommand extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'analytics:seed-tag-interaction-demo
|
||||||
|
{--days=14 : Number of days to generate demo events for}
|
||||||
|
{--per-day=90 : Approximate number of demo events to write per day}
|
||||||
|
{--refresh : Remove existing seeded demo events first}
|
||||||
|
{--force : Allow running outside local/testing environments}';
|
||||||
|
|
||||||
|
protected $description = 'Generate demo tag interaction events for local analytics dashboards and ranking validation';
|
||||||
|
|
||||||
|
public function handle(): int
|
||||||
|
{
|
||||||
|
if (! app()->environment(['local', 'testing']) && ! $this->option('force')) {
|
||||||
|
$this->error('This command is restricted to local/testing unless --force is provided.');
|
||||||
|
|
||||||
|
return self::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
$days = max(1, min(60, (int) $this->option('days')));
|
||||||
|
$perDay = max(10, min(500, (int) $this->option('per-day')));
|
||||||
|
|
||||||
|
$tags = Tag::query()
|
||||||
|
->where('is_active', true)
|
||||||
|
->orderByDesc('usage_count')
|
||||||
|
->limit(20)
|
||||||
|
->get(['id', 'name', 'slug', 'usage_count']);
|
||||||
|
|
||||||
|
if ($tags->count() < 2) {
|
||||||
|
$this->error('At least two active tags are required to generate demo interaction data.');
|
||||||
|
|
||||||
|
return self::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
$transitions = $this->buildTransitionMap($tags);
|
||||||
|
|
||||||
|
if ($this->option('refresh')) {
|
||||||
|
DB::table('tag_interaction_events')
|
||||||
|
->where('meta->seeded_demo', true)
|
||||||
|
->delete();
|
||||||
|
}
|
||||||
|
|
||||||
|
$now = now();
|
||||||
|
|
||||||
|
for ($offset = $days - 1; $offset >= 0; $offset--) {
|
||||||
|
$date = Carbon::today()->subDays($offset);
|
||||||
|
$rows = [];
|
||||||
|
|
||||||
|
for ($index = 0; $index < $perDay; $index++) {
|
||||||
|
$surface = $this->pickSurface();
|
||||||
|
$sourceTag = $tags->random();
|
||||||
|
$targetTag = $this->pickTargetTag($surface, $sourceTag->slug, $transitions, $tags);
|
||||||
|
$query = in_array($surface, ['search_suggestion', 'rescue_suggestion', 'recent_search'], true)
|
||||||
|
? $this->queryForTag($targetTag)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
$rows[] = [
|
||||||
|
'event_date' => $date->toDateString(),
|
||||||
|
'event_type' => 'click',
|
||||||
|
'surface' => $surface,
|
||||||
|
'user_id' => null,
|
||||||
|
'session_key' => hash('sha256', 'demo-' . $date->toDateString() . '-' . $index . '-' . $surface),
|
||||||
|
'tag_slug' => $targetTag->slug,
|
||||||
|
'source_tag_slug' => in_array($surface, ['related_chip', 'related_cluster', 'top_companion'], true)
|
||||||
|
? $sourceTag->slug
|
||||||
|
: null,
|
||||||
|
'query' => $query,
|
||||||
|
'position' => random_int(1, 4),
|
||||||
|
'meta' => json_encode([
|
||||||
|
'seeded_demo' => true,
|
||||||
|
'seeded_at' => $now->toISOString(),
|
||||||
|
], JSON_THROW_ON_ERROR),
|
||||||
|
'occurred_at' => $date->copy()->setTime(random_int(8, 23), random_int(0, 59), random_int(0, 59)),
|
||||||
|
'created_at' => $now,
|
||||||
|
'updated_at' => $now,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (array_chunk($rows, 250) as $chunk) {
|
||||||
|
DB::table('tag_interaction_events')->insert($chunk);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->call('analytics:aggregate-tag-interactions', ['--date' => $date->toDateString()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->info("Seeded demo tag interaction events for the last {$days} days.");
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildTransitionMap(Collection $tags): array
|
||||||
|
{
|
||||||
|
$pairs = DB::table('artwork_tag as source_pivot')
|
||||||
|
->join('tags as source_tag', 'source_tag.id', '=', 'source_pivot.tag_id')
|
||||||
|
->join('artwork_tag as target_pivot', 'target_pivot.artwork_id', '=', 'source_pivot.artwork_id')
|
||||||
|
->join('tags as target_tag', 'target_tag.id', '=', 'target_pivot.tag_id')
|
||||||
|
->whereIn('source_tag.id', $tags->pluck('id')->all())
|
||||||
|
->whereIn('target_tag.id', $tags->pluck('id')->all())
|
||||||
|
->whereColumn('source_tag.id', '!=', 'target_tag.id')
|
||||||
|
->groupBy('source_tag.slug', 'target_tag.slug')
|
||||||
|
->orderByRaw('COUNT(*) DESC')
|
||||||
|
->get([
|
||||||
|
'source_tag.slug as source_slug',
|
||||||
|
'target_tag.slug as target_slug',
|
||||||
|
DB::raw('COUNT(*) as pair_count'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$map = [];
|
||||||
|
foreach ($pairs as $pair) {
|
||||||
|
$map[$pair->source_slug][] = $pair->target_slug;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $map;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function pickSurface(): string
|
||||||
|
{
|
||||||
|
$roll = random_int(1, 100);
|
||||||
|
|
||||||
|
return match (true) {
|
||||||
|
$roll <= 32 => 'search_suggestion',
|
||||||
|
$roll <= 46 => 'rescue_suggestion',
|
||||||
|
$roll <= 58 => 'recent_search',
|
||||||
|
$roll <= 80 => 'related_chip',
|
||||||
|
$roll <= 94 => 'related_cluster',
|
||||||
|
default => 'top_companion',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private function pickTargetTag(string $surface, string $sourceSlug, array $transitions, Collection $tags): object
|
||||||
|
{
|
||||||
|
if (in_array($surface, ['related_chip', 'related_cluster', 'top_companion'], true)) {
|
||||||
|
$candidateSlugs = $transitions[$sourceSlug] ?? [];
|
||||||
|
if ($candidateSlugs !== []) {
|
||||||
|
$slug = $candidateSlugs[array_rand($candidateSlugs)];
|
||||||
|
return $tags->firstWhere('slug', $slug) ?? $tags->where('slug', '!=', $sourceSlug)->random();
|
||||||
|
}
|
||||||
|
|
||||||
|
return $tags->where('slug', '!=', $sourceSlug)->random();
|
||||||
|
}
|
||||||
|
|
||||||
|
return $tags->random();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function queryForTag(object $tag): string
|
||||||
|
{
|
||||||
|
$name = trim((string) ($tag->name ?? $tag->slug));
|
||||||
|
$options = array_values(array_filter([
|
||||||
|
strtolower($name),
|
||||||
|
strtolower((string) ($tag->slug ?? '')),
|
||||||
|
strtolower(substr($name, 0, max(3, min(strlen($name), 7)))),
|
||||||
|
]));
|
||||||
|
|
||||||
|
return $options[array_rand($options)];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,6 +9,8 @@ use App\Console\Commands\MigrateFeaturedWorks;
|
|||||||
use App\Console\Commands\BackfillArtworkEmbeddingsCommand;
|
use App\Console\Commands\BackfillArtworkEmbeddingsCommand;
|
||||||
use App\Console\Commands\AggregateSimilarArtworkAnalyticsCommand;
|
use App\Console\Commands\AggregateSimilarArtworkAnalyticsCommand;
|
||||||
use App\Console\Commands\AggregateFeedAnalyticsCommand;
|
use App\Console\Commands\AggregateFeedAnalyticsCommand;
|
||||||
|
use App\Console\Commands\AggregateTagInteractionAnalyticsCommand;
|
||||||
|
use App\Console\Commands\SeedTagInteractionDemoCommand;
|
||||||
use App\Console\Commands\EvaluateFeedWeightsCommand;
|
use App\Console\Commands\EvaluateFeedWeightsCommand;
|
||||||
use App\Console\Commands\AiTagArtworksCommand;
|
use App\Console\Commands\AiTagArtworksCommand;
|
||||||
use App\Console\Commands\CompareFeedAbCommand;
|
use App\Console\Commands\CompareFeedAbCommand;
|
||||||
@@ -41,6 +43,8 @@ class Kernel extends ConsoleKernel
|
|||||||
BackfillArtworkEmbeddingsCommand::class,
|
BackfillArtworkEmbeddingsCommand::class,
|
||||||
AggregateSimilarArtworkAnalyticsCommand::class,
|
AggregateSimilarArtworkAnalyticsCommand::class,
|
||||||
AggregateFeedAnalyticsCommand::class,
|
AggregateFeedAnalyticsCommand::class,
|
||||||
|
AggregateTagInteractionAnalyticsCommand::class,
|
||||||
|
SeedTagInteractionDemoCommand::class,
|
||||||
EvaluateFeedWeightsCommand::class,
|
EvaluateFeedWeightsCommand::class,
|
||||||
CompareFeedAbCommand::class,
|
CompareFeedAbCommand::class,
|
||||||
AiTagArtworksCommand::class,
|
AiTagArtworksCommand::class,
|
||||||
@@ -66,6 +70,7 @@ class Kernel extends ConsoleKernel
|
|||||||
->runInBackground();
|
->runInBackground();
|
||||||
$schedule->command('analytics:aggregate-similar-artworks')->dailyAt('03:10');
|
$schedule->command('analytics:aggregate-similar-artworks')->dailyAt('03:10');
|
||||||
$schedule->command('analytics:aggregate-feed')->dailyAt('03:20');
|
$schedule->command('analytics:aggregate-feed')->dailyAt('03:20');
|
||||||
|
$schedule->command('analytics:aggregate-tag-interactions')->dailyAt('03:30');
|
||||||
// Recalculate trending scores every 30 minutes (staggered to reduce peak load)
|
// Recalculate trending scores every 30 minutes (staggered to reduce peak load)
|
||||||
$schedule->command('skinbase:recalculate-trending --period=24h')->everyThirtyMinutes();
|
$schedule->command('skinbase:recalculate-trending --period=24h')->everyThirtyMinutes();
|
||||||
$schedule->command('skinbase:recalculate-trending --period=7d --skip-index')->everyThirtyMinutes()->runInBackground();
|
$schedule->command('skinbase:recalculate-trending --period=7d --skip-index')->everyThirtyMinutes()->runInBackground();
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Admin;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Services\Analytics\TagInteractionReportService;
|
||||||
|
use Illuminate\Contracts\View\View;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
final class TagInteractionReportController extends Controller
|
||||||
|
{
|
||||||
|
public function __construct(private readonly TagInteractionReportService $reportService) {}
|
||||||
|
|
||||||
|
public function index(Request $request): View
|
||||||
|
{
|
||||||
|
$validated = $request->validate([
|
||||||
|
'from' => ['nullable', 'date_format:Y-m-d'],
|
||||||
|
'to' => ['nullable', 'date_format:Y-m-d'],
|
||||||
|
'limit' => ['nullable', 'integer', 'min:1', 'max:100'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$from = (string) ($validated['from'] ?? now()->subDays(13)->toDateString());
|
||||||
|
$to = (string) ($validated['to'] ?? now()->toDateString());
|
||||||
|
$limit = (int) ($validated['limit'] ?? 15);
|
||||||
|
|
||||||
|
abort_if($from > $to, 422, 'Invalid date range.');
|
||||||
|
|
||||||
|
$report = $this->reportService->buildReport($from, $to, $limit);
|
||||||
|
|
||||||
|
return view('admin.reports.tags', [
|
||||||
|
'filters' => [
|
||||||
|
'from' => $from,
|
||||||
|
'to' => $to,
|
||||||
|
'limit' => $limit,
|
||||||
|
],
|
||||||
|
'overview' => $report['overview'],
|
||||||
|
'dailyClicks' => $report['daily_clicks'],
|
||||||
|
'bySurface' => $report['by_surface'],
|
||||||
|
'topTags' => $report['top_tags'],
|
||||||
|
'topQueries' => $report['top_queries'],
|
||||||
|
'topTransitions' => $report['top_transitions'],
|
||||||
|
'latestAggregatedDate' => $report['latest_aggregated_date'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api\Admin;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Services\Analytics\TagInteractionReportService;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
|
final class TagInteractionReportController extends Controller
|
||||||
|
{
|
||||||
|
public function __construct(private readonly TagInteractionReportService $reportService) {}
|
||||||
|
|
||||||
|
public function index(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$validated = $request->validate([
|
||||||
|
'from' => ['nullable', 'date_format:Y-m-d'],
|
||||||
|
'to' => ['nullable', 'date_format:Y-m-d'],
|
||||||
|
'limit' => ['nullable', 'integer', 'min:1', 'max:100'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$from = (string) ($validated['from'] ?? now()->subDays(13)->toDateString());
|
||||||
|
$to = (string) ($validated['to'] ?? now()->toDateString());
|
||||||
|
$limit = (int) ($validated['limit'] ?? 15);
|
||||||
|
|
||||||
|
if ($from > $to) {
|
||||||
|
return response()->json([
|
||||||
|
'message' => 'Invalid date range: from must be before or equal to to.',
|
||||||
|
], Response::HTTP_UNPROCESSABLE_ENTITY);
|
||||||
|
}
|
||||||
|
|
||||||
|
$report = $this->reportService->buildReport($from, $to, $limit);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'meta' => [
|
||||||
|
'from' => $from,
|
||||||
|
'to' => $to,
|
||||||
|
'limit' => $limit,
|
||||||
|
'generated_at' => now()->toISOString(),
|
||||||
|
'latest_aggregated_date' => $report['latest_aggregated_date'],
|
||||||
|
],
|
||||||
|
'overview' => $report['overview'],
|
||||||
|
'daily_clicks' => $report['daily_clicks'],
|
||||||
|
'by_surface' => $report['by_surface'],
|
||||||
|
'top_tags' => $report['top_tags'],
|
||||||
|
'top_queries' => $report['top_queries'],
|
||||||
|
'top_transitions' => $report['top_transitions'],
|
||||||
|
], Response::HTTP_OK);
|
||||||
|
}
|
||||||
|
}
|
||||||
45
app/Http/Controllers/Api/CommunityActivityController.php
Normal file
45
app/Http/Controllers/Api/CommunityActivityController.php
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Services\CommunityActivityService;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
final class CommunityActivityController extends Controller
|
||||||
|
{
|
||||||
|
public function __construct(private readonly CommunityActivityService $activityService)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public function index(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$filter = $this->resolveFilter($request);
|
||||||
|
|
||||||
|
if ($this->activityService->requiresAuthentication($filter) && ! $request->user()) {
|
||||||
|
return response()->json(['error' => 'Unauthenticated'], 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
$feed = $this->activityService->getFeed(
|
||||||
|
viewer: $request->user(),
|
||||||
|
filter: $filter,
|
||||||
|
page: (int) $request->query('page', 1),
|
||||||
|
perPage: (int) $request->query('per_page', CommunityActivityService::DEFAULT_PER_PAGE),
|
||||||
|
actorUserId: $request->filled('user_id') ? (int) $request->query('user_id') : null,
|
||||||
|
);
|
||||||
|
|
||||||
|
return response()->json($feed);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function resolveFilter(Request $request): string
|
||||||
|
{
|
||||||
|
if ($request->boolean('following') && ! $request->filled('filter')) {
|
||||||
|
return 'following';
|
||||||
|
}
|
||||||
|
|
||||||
|
return (string) $request->query('filter', 'all');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ namespace App\Http\Controllers\Api;
|
|||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use App\Services\Recommendation\UserPreferenceBuilder;
|
use App\Services\Recommendation\UserPreferenceBuilder;
|
||||||
|
use App\Services\Tags\TagDiscoveryService;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\Cache;
|
use Illuminate\Support\Facades\Cache;
|
||||||
@@ -30,7 +31,10 @@ final class SuggestedTagsController extends Controller
|
|||||||
{
|
{
|
||||||
private const LIMIT = 20;
|
private const LIMIT = 20;
|
||||||
|
|
||||||
public function __construct(private readonly UserPreferenceBuilder $prefBuilder) {}
|
public function __construct(
|
||||||
|
private readonly UserPreferenceBuilder $prefBuilder,
|
||||||
|
private readonly TagDiscoveryService $tagDiscoveryService,
|
||||||
|
) {}
|
||||||
|
|
||||||
public function __invoke(Request $request): JsonResponse
|
public function __invoke(Request $request): JsonResponse
|
||||||
{
|
{
|
||||||
@@ -126,27 +130,18 @@ final class SuggestedTagsController extends Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Aggregate tag usage over the last 7 days as a proxy for trend score.
|
|
||||||
* Uses artwork_tag + artworks.published_at to avoid a heavy events table dependency.
|
|
||||||
*
|
|
||||||
* @return \Illuminate\Support\Collection<int, object{slug:string, trend_score:float}>
|
* @return \Illuminate\Support\Collection<int, object{slug:string, trend_score:float}>
|
||||||
*/
|
*/
|
||||||
private function trendingTags(int $limit): \Illuminate\Support\Collection
|
private function trendingTags(int $limit): \Illuminate\Support\Collection
|
||||||
{
|
{
|
||||||
$since = now()->subDays(7);
|
return $this->tagDiscoveryService
|
||||||
|
->popularTags($limit, 7)
|
||||||
return DB::table('artwork_tag as at')
|
->map(static fn ($tag) => (object) [
|
||||||
->join('tags as t', 't.id', '=', 'at.tag_id')
|
'slug' => (string) $tag->slug,
|
||||||
->join('artworks as a', 'a.id', '=', 'at.artwork_id')
|
'trend_score' => max(
|
||||||
->where('a.published_at', '>=', $since)
|
(float) ($tag->recent_clicks ?? 0),
|
||||||
->where('a.is_public', true)
|
(float) ($tag->usage_count ?? 0) / 1000
|
||||||
->where('a.is_approved', true)
|
),
|
||||||
->whereNull('a.deleted_at')
|
]);
|
||||||
->where('t.is_active', true)
|
|
||||||
->selectRaw('t.slug, COUNT(*) / 1.0 as trend_score')
|
|
||||||
->groupBy('t.id', 't.slug')
|
|
||||||
->orderByDesc('trend_score')
|
|
||||||
->limit($limit)
|
|
||||||
->get();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,33 +7,24 @@ namespace App\Http\Controllers\Api;
|
|||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use App\Http\Requests\Tags\PopularTagsRequest;
|
use App\Http\Requests\Tags\PopularTagsRequest;
|
||||||
use App\Http\Requests\Tags\TagSearchRequest;
|
use App\Http\Requests\Tags\TagSearchRequest;
|
||||||
use App\Models\Tag;
|
use App\Services\Tags\TagDiscoveryService;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Support\Facades\Cache;
|
use Illuminate\Support\Facades\Cache;
|
||||||
|
|
||||||
final class TagController extends Controller
|
final class TagController extends Controller
|
||||||
{
|
{
|
||||||
|
public function __construct(private readonly TagDiscoveryService $tagDiscoveryService) {}
|
||||||
|
|
||||||
public function search(TagSearchRequest $request): JsonResponse
|
public function search(TagSearchRequest $request): JsonResponse
|
||||||
{
|
{
|
||||||
$q = trim((string) ($request->validated()['q'] ?? ''));
|
$q = trim((string) ($request->validated()['q'] ?? ''));
|
||||||
|
|
||||||
// Short results cached for 2 min; empty-query (popular suggestions) for 5 min.
|
// Short results cached for 2 min; empty-query (popular suggestions) for 5 min.
|
||||||
$ttl = $q === '' ? 300 : 120;
|
$ttl = $q === '' ? 300 : 120;
|
||||||
$cacheKey = 'tags.search.' . ($q === '' ? '__empty__' : md5($q));
|
$cacheKey = 'tags.search.v2.' . ($q === '' ? '__empty__' : md5($q));
|
||||||
|
|
||||||
$data = Cache::remember($cacheKey, $ttl, function () use ($q): mixed {
|
$data = Cache::remember($cacheKey, $ttl, function () use ($q): mixed {
|
||||||
$query = Tag::query()->where('is_active', true);
|
return $this->tagDiscoveryService->searchSuggestions($q, 20);
|
||||||
if ($q !== '') {
|
|
||||||
$query->where(function ($sub) use ($q): void {
|
|
||||||
$sub->where('name', 'like', $q . '%')
|
|
||||||
->orWhere('slug', 'like', $q . '%');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return $query
|
|
||||||
->orderByDesc('usage_count')
|
|
||||||
->limit(20)
|
|
||||||
->get(['id', 'name', 'slug', 'usage_count']);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return response()->json(['data' => $data]);
|
return response()->json(['data' => $data]);
|
||||||
@@ -42,14 +33,10 @@ final class TagController extends Controller
|
|||||||
public function popular(PopularTagsRequest $request): JsonResponse
|
public function popular(PopularTagsRequest $request): JsonResponse
|
||||||
{
|
{
|
||||||
$limit = (int) ($request->validated()['limit'] ?? 20);
|
$limit = (int) ($request->validated()['limit'] ?? 20);
|
||||||
$cacheKey = 'tags.popular.' . $limit;
|
$cacheKey = 'tags.popular.v2.' . $limit;
|
||||||
|
|
||||||
$data = Cache::remember($cacheKey, 300, function () use ($limit): mixed {
|
$data = Cache::remember($cacheKey, 300, function () use ($limit): mixed {
|
||||||
return Tag::query()
|
return $this->tagDiscoveryService->popularTags($limit);
|
||||||
->where('is_active', true)
|
|
||||||
->orderByDesc('usage_count')
|
|
||||||
->limit($limit)
|
|
||||||
->get(['id', 'name', 'slug', 'usage_count']);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return response()->json(['data' => $data]);
|
return response()->json(['data' => $data]);
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use Carbon\CarbonImmutable;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
|
final class TagInteractionAnalyticsController extends Controller
|
||||||
|
{
|
||||||
|
public function store(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$payload = $request->validate([
|
||||||
|
'event_type' => ['required', 'string', 'in:click'],
|
||||||
|
'surface' => ['required', 'string', 'in:search_suggestion,recent_search,rescue_suggestion,related_chip,related_cluster,top_companion'],
|
||||||
|
'tag_slug' => ['nullable', 'string', 'max:120'],
|
||||||
|
'source_tag_slug' => ['nullable', 'string', 'max:120'],
|
||||||
|
'query' => ['nullable', 'string', 'max:120'],
|
||||||
|
'position' => ['nullable', 'integer', 'min:1', 'max:50'],
|
||||||
|
'occurred_at' => ['nullable', 'date'],
|
||||||
|
'meta' => ['nullable', 'array'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (($payload['surface'] ?? null) !== 'recent_search' && empty($payload['tag_slug'])) {
|
||||||
|
return response()->json([
|
||||||
|
'message' => 'The selected analytics surface requires a tag slug.',
|
||||||
|
'errors' => ['tag_slug' => ['The tag slug field is required for this surface.']],
|
||||||
|
], Response::HTTP_UNPROCESSABLE_ENTITY);
|
||||||
|
}
|
||||||
|
|
||||||
|
$occurredAt = isset($payload['occurred_at'])
|
||||||
|
? CarbonImmutable::parse((string) $payload['occurred_at'])
|
||||||
|
: CarbonImmutable::now();
|
||||||
|
$sessionId = $request->hasSession() ? (string) $request->session()->getId() : '';
|
||||||
|
$sessionKey = $sessionId !== '' ? hash('sha256', $sessionId) : null;
|
||||||
|
|
||||||
|
DB::table('tag_interaction_events')->insert([
|
||||||
|
'event_date' => $occurredAt->toDateString(),
|
||||||
|
'event_type' => (string) $payload['event_type'],
|
||||||
|
'surface' => (string) $payload['surface'],
|
||||||
|
'user_id' => $request->user()?->id,
|
||||||
|
'session_key' => $sessionKey,
|
||||||
|
'tag_slug' => isset($payload['tag_slug']) ? (string) $payload['tag_slug'] : null,
|
||||||
|
'source_tag_slug' => isset($payload['source_tag_slug']) ? (string) $payload['source_tag_slug'] : null,
|
||||||
|
'query' => isset($payload['query']) ? trim((string) $payload['query']) : null,
|
||||||
|
'position' => isset($payload['position']) ? (int) $payload['position'] : null,
|
||||||
|
'meta' => $payload['meta'] ?? null,
|
||||||
|
'occurred_at' => $occurredAt,
|
||||||
|
'created_at' => now(),
|
||||||
|
'updated_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return response()->json(['success' => true], Response::HTTP_OK);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ namespace App\Http\Controllers\Auth;
|
|||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use App\Http\Requests\Auth\LoginRequest;
|
use App\Http\Requests\Auth\LoginRequest;
|
||||||
|
use App\Services\Security\CaptchaVerifier;
|
||||||
use Illuminate\Http\RedirectResponse;
|
use Illuminate\Http\RedirectResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\Auth;
|
use Illuminate\Support\Facades\Auth;
|
||||||
@@ -14,9 +15,17 @@ class AuthenticatedSessionController extends Controller
|
|||||||
/**
|
/**
|
||||||
* Display the login view.
|
* Display the login view.
|
||||||
*/
|
*/
|
||||||
|
public function __construct(
|
||||||
|
private readonly CaptchaVerifier $captchaVerifier,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
public function create(): View
|
public function create(): View
|
||||||
{
|
{
|
||||||
return view('auth.login');
|
return view('auth.login', [
|
||||||
|
'requiresCaptcha' => session('bot_captcha_required', false),
|
||||||
|
'captcha' => $this->captchaVerifier->frontendConfig(),
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ use App\Models\EmailSendEvent;
|
|||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Services\Auth\DisposableEmailService;
|
use App\Services\Auth\DisposableEmailService;
|
||||||
use App\Services\Auth\RegistrationVerificationTokenService;
|
use App\Services\Auth\RegistrationVerificationTokenService;
|
||||||
use App\Services\Security\TurnstileVerifier;
|
use App\Services\Security\CaptchaVerifier;
|
||||||
use Illuminate\Http\RedirectResponse;
|
use Illuminate\Http\RedirectResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\Hash;
|
use Illuminate\Support\Facades\Hash;
|
||||||
@@ -19,7 +19,7 @@ use Illuminate\View\View;
|
|||||||
class RegisteredUserController extends Controller
|
class RegisteredUserController extends Controller
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly TurnstileVerifier $turnstileVerifier,
|
private readonly CaptchaVerifier $captchaVerifier,
|
||||||
private readonly DisposableEmailService $disposableEmailService,
|
private readonly DisposableEmailService $disposableEmailService,
|
||||||
private readonly RegistrationVerificationTokenService $verificationTokenService,
|
private readonly RegistrationVerificationTokenService $verificationTokenService,
|
||||||
)
|
)
|
||||||
@@ -33,8 +33,8 @@ class RegisteredUserController extends Controller
|
|||||||
{
|
{
|
||||||
return view('auth.register', [
|
return view('auth.register', [
|
||||||
'prefillEmail' => (string) $request->query('email', ''),
|
'prefillEmail' => (string) $request->query('email', ''),
|
||||||
'requiresTurnstile' => $this->shouldRequireTurnstile($request->ip()),
|
'requiresCaptcha' => $this->shouldRequireCaptcha($request->ip()),
|
||||||
'turnstileSiteKey' => (string) config('services.turnstile.site_key', ''),
|
'captcha' => $this->captchaVerifier->frontendConfig(),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -56,20 +56,22 @@ class RegisteredUserController extends Controller
|
|||||||
*/
|
*/
|
||||||
public function store(Request $request): RedirectResponse
|
public function store(Request $request): RedirectResponse
|
||||||
{
|
{
|
||||||
$validated = $request->validate([
|
$rules = [
|
||||||
'email' => ['required', 'string', 'lowercase', 'email', 'max:255'],
|
'email' => ['required', 'string', 'lowercase', 'email', 'max:255'],
|
||||||
'website' => ['nullable', 'max:0'],
|
'website' => ['nullable', 'max:0'],
|
||||||
'cf-turnstile-response' => ['nullable', 'string'],
|
];
|
||||||
]);
|
$rules[$this->captchaVerifier->inputName()] = ['nullable', 'string'];
|
||||||
|
|
||||||
|
$validated = $request->validate($rules);
|
||||||
|
|
||||||
$email = strtolower(trim((string) $validated['email']));
|
$email = strtolower(trim((string) $validated['email']));
|
||||||
$ip = $request->ip();
|
$ip = $request->ip();
|
||||||
|
|
||||||
$this->trackRegisterAttempt($ip);
|
$this->trackRegisterAttempt($ip);
|
||||||
|
|
||||||
if ($this->shouldRequireTurnstile($ip)) {
|
if ($this->shouldRequireCaptcha($ip)) {
|
||||||
$verified = $this->turnstileVerifier->verify(
|
$verified = $this->captchaVerifier->verify(
|
||||||
(string) $request->input('cf-turnstile-response', ''),
|
(string) $request->input($this->captchaVerifier->inputName(), ''),
|
||||||
$ip
|
$ip
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -199,9 +201,9 @@ class RegisteredUserController extends Controller
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
private function shouldRequireTurnstile(?string $ip): bool
|
private function shouldRequireCaptcha(?string $ip): bool
|
||||||
{
|
{
|
||||||
if (! $this->turnstileVerifier->isEnabled()) {
|
if (! $this->captchaVerifier->isEnabled()) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ use App\Services\ArtworkSearchIndexer;
|
|||||||
use App\Services\ArtworkVersioningService;
|
use App\Services\ArtworkVersioningService;
|
||||||
use App\Services\Studio\StudioArtworkQueryService;
|
use App\Services\Studio\StudioArtworkQueryService;
|
||||||
use App\Services\Studio\StudioBulkActionService;
|
use App\Services\Studio\StudioBulkActionService;
|
||||||
|
use App\Services\Tags\TagDiscoveryService;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\Log;
|
use Illuminate\Support\Facades\Log;
|
||||||
@@ -27,6 +28,7 @@ final class StudioArtworksApiController extends Controller
|
|||||||
private readonly StudioBulkActionService $bulkService,
|
private readonly StudioBulkActionService $bulkService,
|
||||||
private readonly ArtworkVersioningService $versioningService,
|
private readonly ArtworkVersioningService $versioningService,
|
||||||
private readonly ArtworkSearchIndexer $searchIndexer,
|
private readonly ArtworkSearchIndexer $searchIndexer,
|
||||||
|
private readonly TagDiscoveryService $tagDiscoveryService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -264,18 +266,13 @@ final class StudioArtworksApiController extends Controller
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/studio/tags/search?q=...
|
* GET /api/studio/tags/search?q=...
|
||||||
* Search active tags by name for the bulk tag picker.
|
* Search active tags for studio pickers, with empty-query fallback to popular tags.
|
||||||
*/
|
*/
|
||||||
public function searchTags(Request $request): JsonResponse
|
public function searchTags(Request $request): JsonResponse
|
||||||
{
|
{
|
||||||
$query = trim((string) $request->input('q'));
|
$query = trim((string) $request->input('q'));
|
||||||
|
|
||||||
$tags = \App\Models\Tag::query()
|
$tags = $this->tagDiscoveryService->searchSuggestions($query, 30);
|
||||||
->where('is_active', true)
|
|
||||||
->when($query !== '', fn ($q) => $q->where('name', 'LIKE', "%{$query}%"))
|
|
||||||
->orderByDesc('usage_count')
|
|
||||||
->limit(30)
|
|
||||||
->get(['id', 'name', 'slug', 'usage_count']);
|
|
||||||
|
|
||||||
return response()->json($tags);
|
return response()->json($tags);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ use App\Models\Artwork;
|
|||||||
use App\Models\ProfileComment;
|
use App\Models\ProfileComment;
|
||||||
use App\Models\Story;
|
use App\Models\Story;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
|
use App\Services\Security\CaptchaVerifier;
|
||||||
use App\Services\AvatarService;
|
use App\Services\AvatarService;
|
||||||
use App\Services\ArtworkService;
|
use App\Services\ArtworkService;
|
||||||
use App\Services\FollowService;
|
use App\Services\FollowService;
|
||||||
@@ -47,6 +48,7 @@ class ProfileController extends Controller
|
|||||||
private readonly UsernameApprovalService $usernameApprovalService,
|
private readonly UsernameApprovalService $usernameApprovalService,
|
||||||
private readonly FollowService $followService,
|
private readonly FollowService $followService,
|
||||||
private readonly UserStatsService $userStats,
|
private readonly UserStatsService $userStats,
|
||||||
|
private readonly CaptchaVerifier $captchaVerifier,
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
@@ -240,7 +242,9 @@ class ProfileController extends Controller
|
|||||||
'flash' => [
|
'flash' => [
|
||||||
'status' => session('status'),
|
'status' => session('status'),
|
||||||
'error' => session('error'),
|
'error' => session('error'),
|
||||||
|
'botCaptchaRequired' => session('bot_captcha_required', false),
|
||||||
],
|
],
|
||||||
|
'captcha' => $this->captchaVerifier->frontendConfig(),
|
||||||
])->rootView('settings');
|
])->rootView('settings');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,120 +5,50 @@ declare(strict_types=1);
|
|||||||
namespace App\Http\Controllers\Web;
|
namespace App\Http\Controllers\Web;
|
||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use App\Models\ActivityEvent;
|
use App\Services\CommunityActivityService;
|
||||||
use App\Models\Artwork;
|
|
||||||
use App\Models\User;
|
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\DB;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Community activity feed.
|
|
||||||
*
|
|
||||||
* GET /community/activity?type=global|following
|
|
||||||
*/
|
|
||||||
final class CommunityActivityController extends Controller
|
final class CommunityActivityController extends Controller
|
||||||
{
|
{
|
||||||
private const PER_PAGE = 30;
|
public function __construct(private readonly CommunityActivityService $activityService)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
public function index(Request $request)
|
public function index(Request $request)
|
||||||
{
|
{
|
||||||
$user = $request->user();
|
$filter = $this->resolveFilter($request);
|
||||||
$type = $request->query('type', 'global'); // global | following
|
if ($this->activityService->requiresAuthentication($filter) && ! $request->user()) {
|
||||||
$perPage = self::PER_PAGE;
|
$filter = 'all';
|
||||||
|
|
||||||
$query = ActivityEvent::query()
|
|
||||||
->orderByDesc('created_at')
|
|
||||||
->with(['actor:id,name,username']);
|
|
||||||
|
|
||||||
if ($type === 'following' && $user) {
|
|
||||||
// Show only events from followed users
|
|
||||||
$followingIds = DB::table('user_followers')
|
|
||||||
->where('follower_id', $user->id)
|
|
||||||
->pluck('user_id')
|
|
||||||
->all();
|
|
||||||
|
|
||||||
if (empty($followingIds)) {
|
|
||||||
$query->whereRaw('0 = 1'); // empty result set
|
|
||||||
} else {
|
|
||||||
$query->whereIn('actor_id', $followingIds);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$events = $query->paginate($perPage)->withQueryString();
|
$feed = $this->activityService->getFeed(
|
||||||
$enriched = $this->enrich($events->getCollection());
|
viewer: $request->user(),
|
||||||
|
filter: $filter,
|
||||||
|
page: 1,
|
||||||
|
perPage: CommunityActivityService::DEFAULT_PER_PAGE,
|
||||||
|
actorUserId: $request->filled('user_id') ? (int) $request->query('user_id') : null,
|
||||||
|
);
|
||||||
|
|
||||||
return view('web.community.activity', [
|
return view('web.comments.latest', [
|
||||||
'events' => $events,
|
|
||||||
'enriched' => $enriched,
|
|
||||||
'active_tab' => $type,
|
|
||||||
'page_title' => 'Community Activity',
|
'page_title' => 'Community Activity',
|
||||||
|
'props' => [
|
||||||
|
'initialActivities' => $feed['data'],
|
||||||
|
'initialMeta' => $feed['meta'],
|
||||||
|
'initialFilter' => $feed['filter'],
|
||||||
|
'initialUserId' => $request->filled('user_id') ? (int) $request->query('user_id') : null,
|
||||||
|
'isAuthenticated' => (bool) $request->user(),
|
||||||
|
],
|
||||||
|
'initialFilter' => $feed['filter'],
|
||||||
|
'initialUserId' => $request->filled('user_id') ? (int) $request->query('user_id') : null,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
private function resolveFilter(Request $request): string
|
||||||
* Attach target object data to each event for display.
|
|
||||||
*/
|
|
||||||
private function enrich(\Illuminate\Support\Collection $events): \Illuminate\Support\Collection
|
|
||||||
{
|
{
|
||||||
// Collect artwork IDs and user IDs to eager-load
|
if ($request->boolean('following') && ! $request->filled('filter')) {
|
||||||
$artworkIds = $events
|
return 'following';
|
||||||
->where('target_type', ActivityEvent::TARGET_ARTWORK)
|
}
|
||||||
->pluck('target_id')
|
|
||||||
->unique()
|
|
||||||
->values()
|
|
||||||
->all();
|
|
||||||
|
|
||||||
$userIds = $events
|
return (string) $request->query('filter', 'all');
|
||||||
->where('target_type', ActivityEvent::TARGET_USER)
|
|
||||||
->pluck('target_id')
|
|
||||||
->unique()
|
|
||||||
->values()
|
|
||||||
->all();
|
|
||||||
|
|
||||||
$artworks = Artwork::whereIn('id', $artworkIds)
|
|
||||||
->with('user:id,name,username')
|
|
||||||
->get(['id', 'title', 'slug', 'user_id', 'hash', 'thumb_ext'])
|
|
||||||
->keyBy('id');
|
|
||||||
|
|
||||||
$users = User::whereIn('id', $userIds)
|
|
||||||
->with('profile:user_id,avatar_hash')
|
|
||||||
->get(['id', 'name', 'username'])
|
|
||||||
->keyBy('id');
|
|
||||||
|
|
||||||
return $events->map(function (ActivityEvent $event) use ($artworks, $users): array {
|
|
||||||
$target = null;
|
|
||||||
|
|
||||||
if ($event->target_type === ActivityEvent::TARGET_ARTWORK) {
|
|
||||||
$artwork = $artworks->get($event->target_id);
|
|
||||||
$target = $artwork ? [
|
|
||||||
'id' => $artwork->id,
|
|
||||||
'title' => $artwork->title,
|
|
||||||
'url' => '/art/' . $artwork->id . '/' . $artwork->slug,
|
|
||||||
'thumb' => $artwork->thumbUrl('sm'),
|
|
||||||
] : null;
|
|
||||||
} elseif ($event->target_type === ActivityEvent::TARGET_USER) {
|
|
||||||
$u = $users->get($event->target_id);
|
|
||||||
$target = $u ? [
|
|
||||||
'id' => $u->id,
|
|
||||||
'name' => $u->name,
|
|
||||||
'username' => $u->username,
|
|
||||||
'url' => '/@' . $u->username,
|
|
||||||
] : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return [
|
|
||||||
'id' => $event->id,
|
|
||||||
'type' => $event->type,
|
|
||||||
'target_type' => $event->target_type,
|
|
||||||
'actor' => [
|
|
||||||
'id' => $event->actor?->id,
|
|
||||||
'name' => $event->actor?->name,
|
|
||||||
'username' => $event->actor?->username,
|
|
||||||
'url' => '/@' . $event->actor?->username,
|
|
||||||
],
|
|
||||||
'target' => $target,
|
|
||||||
'created_at' => $event->created_at?->toIso8601String(),
|
|
||||||
];
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
50
app/Http/Middleware/ForumAIModerationMiddleware.php
Normal file
50
app/Http/Middleware/ForumAIModerationMiddleware.php
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Middleware;
|
||||||
|
|
||||||
|
use Closure;
|
||||||
|
use cPad\Plugins\Forum\Services\AI\AIContentModerator;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
|
class ForumAIModerationMiddleware
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly AIContentModerator $aiContentModerator,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public function handle(Request $request, Closure $next, string $action = 'generic'): Response
|
||||||
|
{
|
||||||
|
$assessment = [
|
||||||
|
'action' => $action,
|
||||||
|
'ai_spam_score' => 0,
|
||||||
|
'ai_toxicity_score' => 0,
|
||||||
|
'flags' => [],
|
||||||
|
'reasons' => [],
|
||||||
|
'provider' => 'none',
|
||||||
|
'available' => false,
|
||||||
|
'raw' => null,
|
||||||
|
'language' => null,
|
||||||
|
];
|
||||||
|
|
||||||
|
$title = trim((string) $request->input('title', ''));
|
||||||
|
$content = trim((string) $request->input('content', ''));
|
||||||
|
$combinedContent = trim($title !== '' ? $title . "\n" . $content : $content);
|
||||||
|
|
||||||
|
if (
|
||||||
|
$combinedContent !== ''
|
||||||
|
&& (bool) config('skinbase_ai_moderation.enabled', true)
|
||||||
|
&& (bool) config('skinbase_ai_moderation.preflight.run_ai_sync', true)
|
||||||
|
) {
|
||||||
|
$spamAssessment = $request->attributes->get('forum_spam_assessment');
|
||||||
|
$assessment = ['action' => $action] + $this->aiContentModerator->analyze($combinedContent, [
|
||||||
|
'links' => is_array($spamAssessment) ? (array) ($spamAssessment['links'] ?? []) : [],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$request->attributes->set('forum_ai_assessment', $assessment);
|
||||||
|
|
||||||
|
return $next($request);
|
||||||
|
}
|
||||||
|
}
|
||||||
96
app/Http/Middleware/ForumBotProtectionMiddleware.php
Normal file
96
app/Http/Middleware/ForumBotProtectionMiddleware.php
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Middleware;
|
||||||
|
|
||||||
|
use App\Services\Security\CaptchaVerifier;
|
||||||
|
use cPad\Plugins\Forum\Services\Security\BotProtectionService;
|
||||||
|
use Closure;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Validation\ValidationException;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
|
class ForumBotProtectionMiddleware
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly BotProtectionService $botProtectionService,
|
||||||
|
private readonly CaptchaVerifier $captchaVerifier,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public function handle(Request $request, Closure $next, string $action = 'generic'): Response|RedirectResponse|JsonResponse
|
||||||
|
{
|
||||||
|
$assessment = $this->botProtectionService->assess($request, $action);
|
||||||
|
$request->attributes->set('forum_bot_assessment', $assessment);
|
||||||
|
|
||||||
|
if ($this->requiresCaptcha($assessment, $action)) {
|
||||||
|
$captcha = $this->captchaVerifier->frontendConfig();
|
||||||
|
$tokenInput = (string) ($captcha['inputName'] ?? $this->captchaVerifier->inputName());
|
||||||
|
$token = (string) (
|
||||||
|
$request->input($tokenInput)
|
||||||
|
?: $request->header('X-Captcha-Token', '')
|
||||||
|
?: $request->header('X-Turnstile-Token', '')
|
||||||
|
);
|
||||||
|
|
||||||
|
if (! $this->captchaVerifier->verify($token, $request->ip())) {
|
||||||
|
$message = (string) config('forum_bot_protection.captcha.message', 'Complete the captcha challenge to continue.');
|
||||||
|
|
||||||
|
if ($request->expectsJson()) {
|
||||||
|
return response()->json([
|
||||||
|
'message' => $message,
|
||||||
|
'errors' => [
|
||||||
|
'captcha' => [$message],
|
||||||
|
],
|
||||||
|
'requires_captcha' => true,
|
||||||
|
'captcha' => $captcha,
|
||||||
|
'captcha_provider' => (string) ($captcha['provider'] ?? $this->captchaVerifier->provider()),
|
||||||
|
'captcha_site_key' => (string) ($captcha['siteKey'] ?? ''),
|
||||||
|
'captcha_input' => (string) ($captcha['inputName'] ?? $tokenInput),
|
||||||
|
'captcha_script_url' => (string) ($captcha['scriptUrl'] ?? ''),
|
||||||
|
], 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
return redirect()->back()
|
||||||
|
->withInput($request->except(['password', 'current_password', 'new_password', 'new_password_confirmation', $tokenInput]))
|
||||||
|
->withErrors(['captcha' => $message])
|
||||||
|
->with('bot_captcha_required', true)
|
||||||
|
->with('bot_turnstile_required', true);
|
||||||
|
}
|
||||||
|
|
||||||
|
$request->attributes->set('forum_bot_captcha_verified', true);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((bool) ($assessment['blocked'] ?? false)) {
|
||||||
|
$message = 'Suspicious activity detected.';
|
||||||
|
|
||||||
|
if ($request->expectsJson()) {
|
||||||
|
return response()->json([
|
||||||
|
'message' => $message,
|
||||||
|
'errors' => [
|
||||||
|
'bot' => [$message],
|
||||||
|
],
|
||||||
|
], 429);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
'bot' => [$message],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $next($request);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function requiresCaptcha(array $assessment, string $action): bool
|
||||||
|
{
|
||||||
|
if (! $this->captchaVerifier->isEnabled()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((int) ($assessment['risk_score'] ?? 0) < (int) config('forum_bot_protection.thresholds.captcha', 60)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return in_array($action, (array) config('forum_bot_protection.captcha.actions', []), true);
|
||||||
|
}
|
||||||
|
}
|
||||||
70
app/Http/Middleware/ForumRateLimitMiddleware.php
Normal file
70
app/Http/Middleware/ForumRateLimitMiddleware.php
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Middleware;
|
||||||
|
|
||||||
|
use Closure;
|
||||||
|
use cPad\Plugins\Forum\Services\Security\BotProtectionService;
|
||||||
|
use Illuminate\Http\Exceptions\ThrottleRequestsException;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Routing\Middleware\ThrottleRequests;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
|
class ForumRateLimitMiddleware
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly ThrottleRequests $throttleRequests,
|
||||||
|
private readonly BotProtectionService $botProtectionService,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public function handle(Request $request, Closure $next): Response
|
||||||
|
{
|
||||||
|
$routeName = (string) optional($request->route())->getName();
|
||||||
|
$limiterName = match ($routeName) {
|
||||||
|
'forum.topic.store' => 'forum-thread-create',
|
||||||
|
default => 'forum-post-write',
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
return $this->throttleRequests->handle($request, $next, $limiterName);
|
||||||
|
} catch (ThrottleRequestsException $exception) {
|
||||||
|
$maxAttempts = (int) ($exception->getHeaders()['X-RateLimit-Limit'] ?? 0);
|
||||||
|
|
||||||
|
$this->botProtectionService->recordRateLimitViolation(
|
||||||
|
$request,
|
||||||
|
$this->resolveActionName($routeName),
|
||||||
|
[
|
||||||
|
'limiter' => $limiterName,
|
||||||
|
'bucket' => $this->resolveBucket($limiterName, $maxAttempts),
|
||||||
|
'max_attempts' => $maxAttempts,
|
||||||
|
'retry_after' => (int) ($exception->getHeaders()['Retry-After'] ?? 0),
|
||||||
|
'reason' => sprintf('Forum write rate limit exceeded on %s.', $routeName !== '' ? $routeName : 'unknown route'),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
throw $exception;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function resolveActionName(string $routeName): string
|
||||||
|
{
|
||||||
|
return match ($routeName) {
|
||||||
|
'forum.topic.store' => 'forum_topic_create',
|
||||||
|
'forum.post.update' => 'forum_post_update',
|
||||||
|
default => 'forum_reply_create',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private function resolveBucket(string $limiterName, int $maxAttempts): string
|
||||||
|
{
|
||||||
|
return $maxAttempts <= $this->minuteLimitThreshold($limiterName) ? 'minute' : 'hour';
|
||||||
|
}
|
||||||
|
|
||||||
|
private function minuteLimitThreshold(string $limiterName): int
|
||||||
|
{
|
||||||
|
return match ($limiterName) {
|
||||||
|
'forum-thread-create', 'forum-post-write' => 3,
|
||||||
|
default => PHP_INT_MAX,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
90
app/Http/Middleware/ForumSecurityFirewallMiddleware.php
Normal file
90
app/Http/Middleware/ForumSecurityFirewallMiddleware.php
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Middleware;
|
||||||
|
|
||||||
|
use App\Services\Security\CaptchaVerifier;
|
||||||
|
use Closure;
|
||||||
|
use cPad\Plugins\Forum\Services\Security\ForumSecurityFirewallService;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Validation\ValidationException;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
|
class ForumSecurityFirewallMiddleware
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly ForumSecurityFirewallService $firewallService,
|
||||||
|
private readonly CaptchaVerifier $captchaVerifier,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public function handle(Request $request, Closure $next, string $action = 'generic'): Response|RedirectResponse|JsonResponse
|
||||||
|
{
|
||||||
|
$assessment = $this->firewallService->assess($request, $action);
|
||||||
|
$request->attributes->set('forum_firewall_assessment', $assessment);
|
||||||
|
|
||||||
|
if ($this->requiresCaptcha($assessment, $action)) {
|
||||||
|
$captcha = $this->captchaVerifier->frontendConfig();
|
||||||
|
$tokenInput = (string) ($captcha['inputName'] ?? $this->captchaVerifier->inputName());
|
||||||
|
$token = (string) (
|
||||||
|
$request->input($tokenInput)
|
||||||
|
?: $request->header('X-Captcha-Token', '')
|
||||||
|
?: $request->header('X-Turnstile-Token', '')
|
||||||
|
);
|
||||||
|
|
||||||
|
if (! $this->captchaVerifier->verify($token, $request->ip())) {
|
||||||
|
$message = 'Additional verification is required before continuing.';
|
||||||
|
|
||||||
|
if ($request->expectsJson()) {
|
||||||
|
return response()->json([
|
||||||
|
'message' => $message,
|
||||||
|
'errors' => [
|
||||||
|
'captcha' => [$message],
|
||||||
|
],
|
||||||
|
'requires_captcha' => true,
|
||||||
|
'captcha' => $captcha,
|
||||||
|
], 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
return redirect()->back()
|
||||||
|
->withInput($request->except(['password', 'current_password', 'new_password', 'new_password_confirmation', $tokenInput]))
|
||||||
|
->withErrors(['captcha' => $message]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$request->attributes->set('forum_firewall_captcha_verified', true);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((bool) ($assessment['blocked'] ?? false)) {
|
||||||
|
$message = 'Security firewall blocked this request.';
|
||||||
|
|
||||||
|
if ($request->expectsJson()) {
|
||||||
|
return response()->json([
|
||||||
|
'message' => $message,
|
||||||
|
'errors' => [
|
||||||
|
'security' => [$message],
|
||||||
|
],
|
||||||
|
], 429);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
'security' => [$message],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $next($request);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function requiresCaptcha(array $assessment, string $action): bool
|
||||||
|
{
|
||||||
|
if (! $this->captchaVerifier->isEnabled()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! (bool) ($assessment['requires_captcha'] ?? false)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return in_array($action, (array) config('forum_bot_protection.captcha.actions', []), true);
|
||||||
|
}
|
||||||
|
}
|
||||||
66
app/Http/Middleware/ForumSpamDetectionMiddleware.php
Normal file
66
app/Http/Middleware/ForumSpamDetectionMiddleware.php
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Middleware;
|
||||||
|
|
||||||
|
use Closure;
|
||||||
|
use cPad\Plugins\Forum\Services\ForumSpamDetector;
|
||||||
|
use cPad\Plugins\Forum\Services\LinkAnalyzer;
|
||||||
|
use cPad\Plugins\Forum\Services\Security\ContentPatternAnalyzer;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
|
class ForumSpamDetectionMiddleware
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly ForumSpamDetector $spamDetector,
|
||||||
|
private readonly LinkAnalyzer $linkAnalyzer,
|
||||||
|
private readonly ContentPatternAnalyzer $contentPatternAnalyzer,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public function handle(Request $request, Closure $next, string $action = 'generic'): Response
|
||||||
|
{
|
||||||
|
$title = trim((string) $request->input('title', ''));
|
||||||
|
$content = trim((string) $request->input('content', ''));
|
||||||
|
$combinedContent = trim($title !== '' ? $title . "\n" . $content : $content);
|
||||||
|
|
||||||
|
if ($combinedContent === '') {
|
||||||
|
$request->attributes->set('forum_spam_assessment', [
|
||||||
|
'action' => $action,
|
||||||
|
'spam_score' => 0,
|
||||||
|
'spam_reasons' => [],
|
||||||
|
'link_score' => 0,
|
||||||
|
'link_reasons' => [],
|
||||||
|
'links' => [],
|
||||||
|
'domains' => [],
|
||||||
|
'pattern_score' => 0,
|
||||||
|
'pattern_reasons' => [],
|
||||||
|
'matched_categories' => [],
|
||||||
|
]);
|
||||||
|
|
||||||
|
return $next($request);
|
||||||
|
}
|
||||||
|
|
||||||
|
$spam = $this->spamDetector->analyze($combinedContent);
|
||||||
|
$link = $this->linkAnalyzer->analyze($combinedContent);
|
||||||
|
$patterns = $this->contentPatternAnalyzer->analyze($combinedContent);
|
||||||
|
|
||||||
|
$request->attributes->set('forum_spam_assessment', [
|
||||||
|
'action' => $action,
|
||||||
|
'spam_score' => max((int) ($spam['score'] ?? 0), (int) ($patterns['score'] ?? 0)),
|
||||||
|
'spam_reasons' => array_values(array_unique(array_merge(
|
||||||
|
(array) ($spam['reasons'] ?? []),
|
||||||
|
(array) ($patterns['reasons'] ?? []),
|
||||||
|
))),
|
||||||
|
'link_score' => (int) ($link['score'] ?? 0),
|
||||||
|
'link_reasons' => (array) ($link['reasons'] ?? []),
|
||||||
|
'links' => (array) ($link['links'] ?? []),
|
||||||
|
'domains' => (array) ($link['domains'] ?? []),
|
||||||
|
'pattern_score' => (int) ($patterns['score'] ?? 0),
|
||||||
|
'pattern_reasons' => (array) ($patterns['reasons'] ?? []),
|
||||||
|
'matched_categories' => (array) ($patterns['matched_categories'] ?? []),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return $next($request);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,15 +18,28 @@ class ForumPost extends Model
|
|||||||
'id',
|
'id',
|
||||||
'thread_id',
|
'thread_id',
|
||||||
'topic_id',
|
'topic_id',
|
||||||
|
'source_ip_hash',
|
||||||
'user_id',
|
'user_id',
|
||||||
'content',
|
'content',
|
||||||
|
'content_hash',
|
||||||
'is_edited',
|
'is_edited',
|
||||||
'edited_at',
|
'edited_at',
|
||||||
'spam_score',
|
'spam_score',
|
||||||
'quality_score',
|
'quality_score',
|
||||||
|
'ai_spam_score',
|
||||||
|
'ai_toxicity_score',
|
||||||
|
'behavior_score',
|
||||||
|
'link_score',
|
||||||
|
'learning_score',
|
||||||
|
'risk_score',
|
||||||
|
'trust_modifier',
|
||||||
'flagged',
|
'flagged',
|
||||||
'flagged_reason',
|
'flagged_reason',
|
||||||
'moderation_checked',
|
'moderation_checked',
|
||||||
|
'moderation_status',
|
||||||
|
'moderation_labels',
|
||||||
|
'moderation_meta',
|
||||||
|
'last_ai_scan_at',
|
||||||
];
|
];
|
||||||
|
|
||||||
public $incrementing = true;
|
public $incrementing = true;
|
||||||
@@ -36,8 +49,18 @@ class ForumPost extends Model
|
|||||||
'edited_at' => 'datetime',
|
'edited_at' => 'datetime',
|
||||||
'spam_score' => 'integer',
|
'spam_score' => 'integer',
|
||||||
'quality_score' => 'integer',
|
'quality_score' => 'integer',
|
||||||
|
'ai_spam_score' => 'integer',
|
||||||
|
'ai_toxicity_score' => 'integer',
|
||||||
|
'behavior_score' => 'integer',
|
||||||
|
'link_score' => 'integer',
|
||||||
|
'learning_score' => 'integer',
|
||||||
|
'risk_score' => 'integer',
|
||||||
|
'trust_modifier' => 'integer',
|
||||||
'flagged' => 'boolean',
|
'flagged' => 'boolean',
|
||||||
'moderation_checked' => 'boolean',
|
'moderation_checked' => 'boolean',
|
||||||
|
'moderation_labels' => 'array',
|
||||||
|
'moderation_meta' => 'array',
|
||||||
|
'last_ai_scan_at' => 'datetime',
|
||||||
];
|
];
|
||||||
|
|
||||||
public function thread(): BelongsTo
|
public function thread(): BelongsTo
|
||||||
|
|||||||
@@ -44,6 +44,12 @@ class User extends Authenticatable
|
|||||||
'cover_ext',
|
'cover_ext',
|
||||||
'cover_position',
|
'cover_position',
|
||||||
'trust_score',
|
'trust_score',
|
||||||
|
'bot_risk_score',
|
||||||
|
'bot_flags',
|
||||||
|
'last_bot_activity_at',
|
||||||
|
'spam_reports',
|
||||||
|
'approved_posts',
|
||||||
|
'flagged_posts',
|
||||||
'password',
|
'password',
|
||||||
'role',
|
'role',
|
||||||
'allow_messages_from',
|
'allow_messages_from',
|
||||||
@@ -76,6 +82,12 @@ class User extends Authenticatable
|
|||||||
'deleted_at' => 'datetime',
|
'deleted_at' => 'datetime',
|
||||||
'cover_position' => 'integer',
|
'cover_position' => 'integer',
|
||||||
'trust_score' => 'integer',
|
'trust_score' => 'integer',
|
||||||
|
'bot_risk_score' => 'integer',
|
||||||
|
'bot_flags' => 'array',
|
||||||
|
'last_bot_activity_at' => 'datetime',
|
||||||
|
'spam_reports' => 'integer',
|
||||||
|
'approved_posts' => 'integer',
|
||||||
|
'flagged_posts' => 'integer',
|
||||||
'password' => 'hashed',
|
'password' => 'hashed',
|
||||||
'allow_messages_from' => 'string',
|
'allow_messages_from' => 'string',
|
||||||
];
|
];
|
||||||
|
|||||||
51
app/Models/UserMention.php
Normal file
51
app/Models/UserMention.php
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
|
class UserMention extends Model
|
||||||
|
{
|
||||||
|
protected $table = 'user_mentions';
|
||||||
|
|
||||||
|
public $timestamps = false;
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'user_id',
|
||||||
|
'mentioned_user_id',
|
||||||
|
'artwork_id',
|
||||||
|
'comment_id',
|
||||||
|
'created_at',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'user_id' => 'integer',
|
||||||
|
'mentioned_user_id' => 'integer',
|
||||||
|
'artwork_id' => 'integer',
|
||||||
|
'comment_id' => 'integer',
|
||||||
|
'created_at' => 'datetime',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function actor(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(User::class, 'user_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function mentionedUser(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(User::class, 'mentioned_user_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function artwork(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Artwork::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function comment(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(ArtworkComment::class, 'comment_id');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ namespace App\Observers;
|
|||||||
|
|
||||||
use App\Models\ArtworkComment;
|
use App\Models\ArtworkComment;
|
||||||
use App\Services\UserStatsService;
|
use App\Services\UserStatsService;
|
||||||
|
use App\Services\UserMentionSyncService;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -16,6 +17,7 @@ class ArtworkCommentObserver
|
|||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly UserStatsService $userStats,
|
private readonly UserStatsService $userStats,
|
||||||
|
private readonly UserMentionSyncService $mentionSync,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function created(ArtworkComment $comment): void
|
public function created(ArtworkComment $comment): void
|
||||||
@@ -28,6 +30,14 @@ class ArtworkCommentObserver
|
|||||||
// The commenter is "active"
|
// The commenter is "active"
|
||||||
$this->userStats->ensureRow($comment->user_id);
|
$this->userStats->ensureRow($comment->user_id);
|
||||||
$this->userStats->setLastActiveAt($comment->user_id);
|
$this->userStats->setLastActiveAt($comment->user_id);
|
||||||
|
$this->mentionSync->syncForComment($comment);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function updated(ArtworkComment $comment): void
|
||||||
|
{
|
||||||
|
if ($comment->wasChanged(['content', 'raw_content', 'rendered_content', 'parent_id'])) {
|
||||||
|
$this->mentionSync->syncForComment($comment);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Soft delete. */
|
/** Soft delete. */
|
||||||
@@ -37,6 +47,8 @@ class ArtworkCommentObserver
|
|||||||
if ($creatorId) {
|
if ($creatorId) {
|
||||||
$this->userStats->decrementCommentsReceived($creatorId);
|
$this->userStats->decrementCommentsReceived($creatorId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$this->mentionSync->deleteForComment((int) $comment->id);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Hard delete after soft delete — already decremented; nothing to do. */
|
/** Hard delete after soft delete — already decremented; nothing to do. */
|
||||||
@@ -50,6 +62,13 @@ class ArtworkCommentObserver
|
|||||||
$this->userStats->decrementCommentsReceived($creatorId);
|
$this->userStats->decrementCommentsReceived($creatorId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$this->mentionSync->deleteForComment((int) $comment->id);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function restored(ArtworkComment $comment): void
|
||||||
|
{
|
||||||
|
$this->mentionSync->syncForComment($comment);
|
||||||
}
|
}
|
||||||
|
|
||||||
private function creatorId(int $artworkId): ?int
|
private function creatorId(int $artworkId): ?int
|
||||||
|
|||||||
191
app/Services/Analytics/TagInteractionReportService.php
Normal file
191
app/Services/Analytics/TagInteractionReportService.php
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Services\Analytics;
|
||||||
|
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
final class TagInteractionReportService
|
||||||
|
{
|
||||||
|
public function buildReport(string $from, string $to, int $limit = 20): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'overview' => $this->overview($from, $to),
|
||||||
|
'daily_clicks' => $this->dailyClicks($from, $to),
|
||||||
|
'by_surface' => $this->bySurface($from, $to),
|
||||||
|
'top_tags' => $this->topTags($from, $to, $limit),
|
||||||
|
'top_queries' => $this->topQueries($from, $to, $limit),
|
||||||
|
'top_transitions' => $this->topTransitions($from, $to, $limit),
|
||||||
|
'latest_aggregated_date' => $this->latestAggregatedDate(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function overview(string $from, string $to): array
|
||||||
|
{
|
||||||
|
$row = DB::table('tag_interaction_events')
|
||||||
|
->selectRaw('COUNT(*) AS total_clicks')
|
||||||
|
->selectRaw('COUNT(DISTINCT user_id) AS unique_users')
|
||||||
|
->selectRaw("COUNT(DISTINCT CASE WHEN session_key IS NOT NULL AND session_key <> '' THEN session_key END) AS unique_sessions")
|
||||||
|
->selectRaw("COUNT(DISTINCT CASE WHEN tag_slug IS NOT NULL AND tag_slug <> '' THEN tag_slug END) AS distinct_tags")
|
||||||
|
->selectRaw('MAX(occurred_at) AS latest_event_at')
|
||||||
|
->whereBetween('event_date', [$from, $to])
|
||||||
|
->where('event_type', 'click')
|
||||||
|
->first();
|
||||||
|
|
||||||
|
return [
|
||||||
|
'total_clicks' => (int) ($row->total_clicks ?? 0),
|
||||||
|
'unique_users' => (int) ($row->unique_users ?? 0),
|
||||||
|
'unique_sessions' => (int) ($row->unique_sessions ?? 0),
|
||||||
|
'distinct_tags' => (int) ($row->distinct_tags ?? 0),
|
||||||
|
'latest_event_at' => $row->latest_event_at,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function dailyClicks(string $from, string $to): array
|
||||||
|
{
|
||||||
|
if (Schema::hasTable('tag_interaction_daily_metrics')) {
|
||||||
|
return DB::table('tag_interaction_daily_metrics')
|
||||||
|
->selectRaw('metric_date')
|
||||||
|
->selectRaw('SUM(clicks) AS clicks')
|
||||||
|
->whereBetween('metric_date', [$from, $to])
|
||||||
|
->groupBy('metric_date')
|
||||||
|
->orderBy('metric_date')
|
||||||
|
->get()
|
||||||
|
->map(static fn ($row): array => [
|
||||||
|
'date' => (string) $row->metric_date,
|
||||||
|
'clicks' => (int) ($row->clicks ?? 0),
|
||||||
|
])
|
||||||
|
->all();
|
||||||
|
}
|
||||||
|
|
||||||
|
return DB::table('tag_interaction_events')
|
||||||
|
->selectRaw('event_date')
|
||||||
|
->selectRaw('COUNT(*) AS clicks')
|
||||||
|
->whereBetween('event_date', [$from, $to])
|
||||||
|
->where('event_type', 'click')
|
||||||
|
->groupBy('event_date')
|
||||||
|
->orderBy('event_date')
|
||||||
|
->get()
|
||||||
|
->map(static fn ($row): array => [
|
||||||
|
'date' => (string) $row->event_date,
|
||||||
|
'clicks' => (int) ($row->clicks ?? 0),
|
||||||
|
])
|
||||||
|
->all();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function bySurface(string $from, string $to): array
|
||||||
|
{
|
||||||
|
return DB::table('tag_interaction_events')
|
||||||
|
->selectRaw('surface')
|
||||||
|
->selectRaw('COUNT(*) AS clicks')
|
||||||
|
->selectRaw('COUNT(DISTINCT user_id) AS unique_users')
|
||||||
|
->selectRaw("COUNT(DISTINCT CASE WHEN session_key IS NOT NULL AND session_key <> '' THEN session_key END) AS unique_sessions")
|
||||||
|
->selectRaw('AVG(position) AS avg_position')
|
||||||
|
->whereBetween('event_date', [$from, $to])
|
||||||
|
->where('event_type', 'click')
|
||||||
|
->groupBy('surface')
|
||||||
|
->orderByDesc('clicks')
|
||||||
|
->get()
|
||||||
|
->map(static fn ($row): array => [
|
||||||
|
'surface' => (string) $row->surface,
|
||||||
|
'clicks' => (int) ($row->clicks ?? 0),
|
||||||
|
'unique_users' => (int) ($row->unique_users ?? 0),
|
||||||
|
'unique_sessions' => (int) ($row->unique_sessions ?? 0),
|
||||||
|
'avg_position' => round((float) ($row->avg_position ?? 0), 2),
|
||||||
|
])
|
||||||
|
->all();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function topTags(string $from, string $to, int $limit): array
|
||||||
|
{
|
||||||
|
return DB::table('tag_interaction_events')
|
||||||
|
->selectRaw('tag_slug')
|
||||||
|
->selectRaw('COUNT(*) AS clicks')
|
||||||
|
->selectRaw('COUNT(DISTINCT user_id) AS unique_users')
|
||||||
|
->selectRaw("COUNT(DISTINCT CASE WHEN session_key IS NOT NULL AND session_key <> '' THEN session_key END) AS unique_sessions")
|
||||||
|
->selectRaw("SUM(CASE WHEN surface IN ('related_chip', 'related_cluster', 'top_companion') THEN 1 ELSE 0 END) AS recommendation_clicks")
|
||||||
|
->selectRaw("SUM(CASE WHEN surface IN ('search_suggestion', 'rescue_suggestion') THEN 1 ELSE 0 END) AS search_clicks")
|
||||||
|
->whereBetween('event_date', [$from, $to])
|
||||||
|
->where('event_type', 'click')
|
||||||
|
->whereNotNull('tag_slug')
|
||||||
|
->where('tag_slug', '<>', '')
|
||||||
|
->groupBy('tag_slug')
|
||||||
|
->orderByDesc('clicks')
|
||||||
|
->limit($limit)
|
||||||
|
->get()
|
||||||
|
->map(static fn ($row): array => [
|
||||||
|
'tag_slug' => (string) $row->tag_slug,
|
||||||
|
'clicks' => (int) ($row->clicks ?? 0),
|
||||||
|
'unique_users' => (int) ($row->unique_users ?? 0),
|
||||||
|
'unique_sessions' => (int) ($row->unique_sessions ?? 0),
|
||||||
|
'recommendation_clicks' => (int) ($row->recommendation_clicks ?? 0),
|
||||||
|
'search_clicks' => (int) ($row->search_clicks ?? 0),
|
||||||
|
])
|
||||||
|
->all();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function topQueries(string $from, string $to, int $limit): array
|
||||||
|
{
|
||||||
|
return DB::table('tag_interaction_events')
|
||||||
|
->selectRaw("LOWER(TRIM(query)) AS query")
|
||||||
|
->selectRaw('COUNT(*) AS clicks')
|
||||||
|
->selectRaw("COUNT(DISTINCT CASE WHEN session_key IS NOT NULL AND session_key <> '' THEN session_key END) AS unique_sessions")
|
||||||
|
->selectRaw("COUNT(DISTINCT CASE WHEN tag_slug IS NOT NULL AND tag_slug <> '' THEN tag_slug END) AS resolved_tags")
|
||||||
|
->whereBetween('event_date', [$from, $to])
|
||||||
|
->where('event_type', 'click')
|
||||||
|
->whereNotNull('query')
|
||||||
|
->whereRaw("TRIM(query) <> ''")
|
||||||
|
->groupBy(DB::raw("LOWER(TRIM(query))"))
|
||||||
|
->orderByDesc('clicks')
|
||||||
|
->limit($limit)
|
||||||
|
->get()
|
||||||
|
->map(static fn ($row): array => [
|
||||||
|
'query' => (string) $row->query,
|
||||||
|
'clicks' => (int) ($row->clicks ?? 0),
|
||||||
|
'unique_sessions' => (int) ($row->unique_sessions ?? 0),
|
||||||
|
'resolved_tags' => (int) ($row->resolved_tags ?? 0),
|
||||||
|
])
|
||||||
|
->all();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function topTransitions(string $from, string $to, int $limit): array
|
||||||
|
{
|
||||||
|
return DB::table('tag_interaction_events')
|
||||||
|
->selectRaw('source_tag_slug')
|
||||||
|
->selectRaw('tag_slug')
|
||||||
|
->selectRaw('COUNT(*) AS clicks')
|
||||||
|
->selectRaw("COUNT(DISTINCT CASE WHEN session_key IS NOT NULL AND session_key <> '' THEN session_key END) AS unique_sessions")
|
||||||
|
->selectRaw('AVG(position) AS avg_position')
|
||||||
|
->whereBetween('event_date', [$from, $to])
|
||||||
|
->where('event_type', 'click')
|
||||||
|
->whereNotNull('source_tag_slug')
|
||||||
|
->whereNotNull('tag_slug')
|
||||||
|
->where('source_tag_slug', '<>', '')
|
||||||
|
->where('tag_slug', '<>', '')
|
||||||
|
->groupBy('source_tag_slug', 'tag_slug')
|
||||||
|
->orderByDesc('clicks')
|
||||||
|
->limit($limit)
|
||||||
|
->get()
|
||||||
|
->map(static fn ($row): array => [
|
||||||
|
'source_tag_slug' => (string) $row->source_tag_slug,
|
||||||
|
'tag_slug' => (string) $row->tag_slug,
|
||||||
|
'clicks' => (int) ($row->clicks ?? 0),
|
||||||
|
'unique_sessions' => (int) ($row->unique_sessions ?? 0),
|
||||||
|
'avg_position' => round((float) ($row->avg_position ?? 0), 2),
|
||||||
|
])
|
||||||
|
->all();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function latestAggregatedDate(): ?string
|
||||||
|
{
|
||||||
|
if (!Schema::hasTable('tag_interaction_daily_metrics')) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$date = DB::table('tag_interaction_daily_metrics')->max('metric_date');
|
||||||
|
|
||||||
|
return $date ? (string) $date : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
474
app/Services/CommunityActivityService.php
Normal file
474
app/Services/CommunityActivityService.php
Normal file
@@ -0,0 +1,474 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Services;
|
||||||
|
|
||||||
|
use App\Enums\ReactionType;
|
||||||
|
use App\Models\Artwork;
|
||||||
|
use App\Models\ArtworkComment;
|
||||||
|
use App\Models\CommentReaction;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\UserMention;
|
||||||
|
use App\Services\ThumbnailPresenter;
|
||||||
|
use App\Support\AvatarUrl;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
use Illuminate\Support\Facades\Cache;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
final class CommunityActivityService
|
||||||
|
{
|
||||||
|
public const DEFAULT_PER_PAGE = 20;
|
||||||
|
|
||||||
|
private const FILTER_ALL = 'all';
|
||||||
|
private const FILTER_COMMENTS = 'comments';
|
||||||
|
private const FILTER_REPLIES = 'replies';
|
||||||
|
private const FILTER_FOLLOWING = 'following';
|
||||||
|
private const FILTER_MY = 'my';
|
||||||
|
|
||||||
|
public function getFeed(?User $viewer, string $filter = self::FILTER_ALL, int $page = 1, int $perPage = self::DEFAULT_PER_PAGE, ?int $actorUserId = null): array
|
||||||
|
{
|
||||||
|
$normalizedFilter = $this->normalizeFilter($filter);
|
||||||
|
$resolvedPage = max(1, $page);
|
||||||
|
$resolvedPerPage = max(1, min(50, $perPage));
|
||||||
|
|
||||||
|
$cacheKey = sprintf(
|
||||||
|
'community_activity:%s:%d:%d:%d:%d',
|
||||||
|
$normalizedFilter,
|
||||||
|
(int) ($viewer?->id ?? 0),
|
||||||
|
(int) ($actorUserId ?? 0),
|
||||||
|
$resolvedPage,
|
||||||
|
$resolvedPerPage,
|
||||||
|
);
|
||||||
|
|
||||||
|
return Cache::remember($cacheKey, now()->addSeconds(30), function () use ($viewer, $normalizedFilter, $resolvedPage, $resolvedPerPage, $actorUserId): array {
|
||||||
|
return $this->buildFeed($viewer, $normalizedFilter, $resolvedPage, $resolvedPerPage, $actorUserId);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function requiresAuthentication(string $filter): bool
|
||||||
|
{
|
||||||
|
return in_array($this->normalizeFilter($filter), [self::FILTER_FOLLOWING, self::FILTER_MY], true);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function normalizeFilter(string $filter): string
|
||||||
|
{
|
||||||
|
return match (strtolower(trim($filter))) {
|
||||||
|
self::FILTER_COMMENTS => self::FILTER_COMMENTS,
|
||||||
|
self::FILTER_REPLIES => self::FILTER_REPLIES,
|
||||||
|
self::FILTER_FOLLOWING => self::FILTER_FOLLOWING,
|
||||||
|
self::FILTER_MY => self::FILTER_MY,
|
||||||
|
default => self::FILTER_ALL,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildFeed(?User $viewer, string $filter, int $page, int $perPage, ?int $actorUserId): array
|
||||||
|
{
|
||||||
|
$sourceLimit = max(80, $page * $perPage * 6);
|
||||||
|
$followingIds = $filter === self::FILTER_FOLLOWING && $viewer
|
||||||
|
? $viewer->following()->pluck('users.id')->map(fn ($id) => (int) $id)->all()
|
||||||
|
: [];
|
||||||
|
|
||||||
|
$commentModels = $this->fetchCommentModels($sourceLimit, repliesOnly: false);
|
||||||
|
$replyModels = $this->fetchCommentModels($sourceLimit, repliesOnly: true);
|
||||||
|
$reactionModels = $this->fetchReactionModels($sourceLimit);
|
||||||
|
|
||||||
|
$commentActivities = $commentModels->map(fn (ArtworkComment $comment) => $this->mapCommentActivity($comment, 'comment'));
|
||||||
|
$replyActivities = $replyModels->map(fn (ArtworkComment $comment) => $this->mapCommentActivity($comment, 'reply'));
|
||||||
|
$reactionActivities = $reactionModels->map(fn (CommentReaction $reaction) => $this->mapReactionActivity($reaction));
|
||||||
|
$mentionActivities = $this->fetchMentionActivities($sourceLimit);
|
||||||
|
|
||||||
|
$merged = $commentActivities
|
||||||
|
->concat($replyActivities)
|
||||||
|
->concat($reactionActivities)
|
||||||
|
->concat($mentionActivities)
|
||||||
|
->filter(function (array $activity) use ($filter, $viewer, $followingIds, $actorUserId): bool {
|
||||||
|
$actorId = (int) ($activity['user']['id'] ?? 0);
|
||||||
|
$mentionedUserId = (int) ($activity['mentioned_user']['id'] ?? 0);
|
||||||
|
|
||||||
|
if ($actorUserId !== null && $actorId !== (int) $actorUserId) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return match ($filter) {
|
||||||
|
self::FILTER_COMMENTS => $activity['type'] === 'comment',
|
||||||
|
self::FILTER_REPLIES => $activity['type'] === 'reply',
|
||||||
|
self::FILTER_FOLLOWING => in_array($actorId, $followingIds, true),
|
||||||
|
self::FILTER_MY => $viewer !== null
|
||||||
|
&& ($actorId === (int) $viewer->id || ($activity['type'] === 'mention' && $mentionedUserId === (int) $viewer->id)),
|
||||||
|
default => true,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
->sortByDesc(fn (array $activity) => $activity['sort_timestamp'] ?? $activity['created_at'])
|
||||||
|
->values();
|
||||||
|
|
||||||
|
$total = $merged->count();
|
||||||
|
$offset = ($page - 1) * $perPage;
|
||||||
|
$pageItems = $merged->slice($offset, $perPage)->values();
|
||||||
|
$reactionTotals = $this->loadCommentReactionTotals(
|
||||||
|
$pageItems->pluck('comment.id')->filter()->map(fn ($id) => (int) $id)->unique()->all(),
|
||||||
|
$viewer?->id,
|
||||||
|
);
|
||||||
|
|
||||||
|
$data = $pageItems->map(function (array $activity) use ($reactionTotals): array {
|
||||||
|
$commentId = (int) ($activity['comment']['id'] ?? 0);
|
||||||
|
if ($commentId > 0) {
|
||||||
|
$activity['comment']['reactions'] = $reactionTotals[$commentId] ?? $this->defaultReactionTotals();
|
||||||
|
}
|
||||||
|
|
||||||
|
unset($activity['sort_timestamp']);
|
||||||
|
|
||||||
|
return $activity;
|
||||||
|
})->all();
|
||||||
|
|
||||||
|
return [
|
||||||
|
'data' => $data,
|
||||||
|
'meta' => [
|
||||||
|
'current_page' => $page,
|
||||||
|
'last_page' => (int) max(1, ceil($total / $perPage)),
|
||||||
|
'per_page' => $perPage,
|
||||||
|
'total' => $total,
|
||||||
|
'has_more' => $offset + $perPage < $total,
|
||||||
|
],
|
||||||
|
'filter' => $filter,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function fetchCommentModels(int $limit, bool $repliesOnly): Collection
|
||||||
|
{
|
||||||
|
return ArtworkComment::query()
|
||||||
|
->select([
|
||||||
|
'id',
|
||||||
|
'artwork_id',
|
||||||
|
'user_id',
|
||||||
|
'parent_id',
|
||||||
|
'content',
|
||||||
|
'raw_content',
|
||||||
|
'rendered_content',
|
||||||
|
'created_at',
|
||||||
|
'is_approved',
|
||||||
|
])
|
||||||
|
->with([
|
||||||
|
'user' => function ($query) {
|
||||||
|
$query
|
||||||
|
->select('id', 'name', 'username', 'role', 'is_active', 'created_at')
|
||||||
|
->with('profile:user_id,avatar_hash')
|
||||||
|
->withCount('artworks');
|
||||||
|
},
|
||||||
|
'artwork' => function ($query) {
|
||||||
|
$query->select('id', 'user_id', 'title', 'slug', 'hash', 'thumb_ext', 'published_at', 'deleted_at', 'is_public', 'is_approved');
|
||||||
|
},
|
||||||
|
])
|
||||||
|
->where('is_approved', true)
|
||||||
|
->whereNull('deleted_at')
|
||||||
|
->when($repliesOnly, fn ($query) => $query->whereNotNull('parent_id'), fn ($query) => $query->whereNull('parent_id'))
|
||||||
|
->whereHas('user', function ($query) {
|
||||||
|
$query->where('is_active', true)->whereNull('deleted_at');
|
||||||
|
})
|
||||||
|
->whereHas('artwork', function ($query) {
|
||||||
|
$query->public()->published()->whereNull('deleted_at');
|
||||||
|
})
|
||||||
|
->latest('created_at')
|
||||||
|
->limit($limit)
|
||||||
|
->get();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function fetchReactionModels(int $limit): Collection
|
||||||
|
{
|
||||||
|
return CommentReaction::query()
|
||||||
|
->select(['id', 'comment_id', 'user_id', 'reaction', 'created_at'])
|
||||||
|
->with([
|
||||||
|
'user' => function ($query) {
|
||||||
|
$query
|
||||||
|
->select('id', 'name', 'username', 'role', 'is_active', 'created_at')
|
||||||
|
->with('profile:user_id,avatar_hash')
|
||||||
|
->withCount('artworks');
|
||||||
|
},
|
||||||
|
'comment' => function ($query) {
|
||||||
|
$query
|
||||||
|
->select('id', 'artwork_id', 'user_id', 'parent_id', 'content', 'raw_content', 'rendered_content', 'created_at', 'is_approved')
|
||||||
|
->with([
|
||||||
|
'user' => function ($userQuery) {
|
||||||
|
$userQuery
|
||||||
|
->select('id', 'name', 'username', 'role', 'is_active', 'created_at')
|
||||||
|
->with('profile:user_id,avatar_hash')
|
||||||
|
->withCount('artworks');
|
||||||
|
},
|
||||||
|
'artwork' => function ($artworkQuery) {
|
||||||
|
$artworkQuery->select('id', 'user_id', 'title', 'slug', 'hash', 'thumb_ext', 'published_at', 'deleted_at', 'is_public', 'is_approved');
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
},
|
||||||
|
])
|
||||||
|
->whereHas('user', function ($query) {
|
||||||
|
$query->where('is_active', true)->whereNull('deleted_at');
|
||||||
|
})
|
||||||
|
->whereHas('comment', function ($query) {
|
||||||
|
$query
|
||||||
|
->where('is_approved', true)
|
||||||
|
->whereNull('deleted_at')
|
||||||
|
->whereHas('user', function ($userQuery) {
|
||||||
|
$userQuery->where('is_active', true)->whereNull('deleted_at');
|
||||||
|
})
|
||||||
|
->whereHas('artwork', function ($artworkQuery) {
|
||||||
|
$artworkQuery->public()->published()->whereNull('deleted_at');
|
||||||
|
});
|
||||||
|
})
|
||||||
|
->latest('created_at')
|
||||||
|
->limit($limit)
|
||||||
|
->get();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function mapCommentActivity(ArtworkComment $comment, string $type): array
|
||||||
|
{
|
||||||
|
$artwork = $comment->artwork;
|
||||||
|
$iso = $comment->created_at?->toIso8601String();
|
||||||
|
|
||||||
|
return [
|
||||||
|
'id' => $type . ':' . $comment->id,
|
||||||
|
'type' => $type,
|
||||||
|
'user' => $this->buildUserPayload($comment->user),
|
||||||
|
'comment' => $this->buildCommentPayload($comment),
|
||||||
|
'artwork' => $this->buildArtworkPayload($artwork),
|
||||||
|
'created_at' => $iso,
|
||||||
|
'time_ago' => $comment->created_at?->diffForHumans(),
|
||||||
|
'sort_timestamp' => $iso,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function mapReactionActivity(CommentReaction $reaction): array
|
||||||
|
{
|
||||||
|
$comment = $reaction->comment;
|
||||||
|
$artwork = $comment?->artwork;
|
||||||
|
$reactionType = ReactionType::tryFrom((string) $reaction->reaction);
|
||||||
|
$iso = $reaction->created_at?->toIso8601String();
|
||||||
|
|
||||||
|
return [
|
||||||
|
'id' => 'reaction:' . $reaction->id,
|
||||||
|
'type' => 'reaction',
|
||||||
|
'user' => $this->buildUserPayload($reaction->user),
|
||||||
|
'comment' => $comment ? $this->buildCommentPayload($comment) : null,
|
||||||
|
'artwork' => $this->buildArtworkPayload($artwork),
|
||||||
|
'reaction' => [
|
||||||
|
'slug' => $reactionType?->value ?? (string) $reaction->reaction,
|
||||||
|
'emoji' => $reactionType?->emoji() ?? '👍',
|
||||||
|
'label' => $reactionType?->label() ?? Str::headline((string) $reaction->reaction),
|
||||||
|
],
|
||||||
|
'created_at' => $iso,
|
||||||
|
'time_ago' => $reaction->created_at?->diffForHumans(),
|
||||||
|
'sort_timestamp' => $iso,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function fetchMentionActivities(int $limit): Collection
|
||||||
|
{
|
||||||
|
if (! Schema::hasTable('user_mentions')) {
|
||||||
|
return collect();
|
||||||
|
}
|
||||||
|
|
||||||
|
return UserMention::query()
|
||||||
|
->select(['id', 'user_id', 'mentioned_user_id', 'artwork_id', 'comment_id', 'created_at'])
|
||||||
|
->with([
|
||||||
|
'actor' => function ($query) {
|
||||||
|
$query
|
||||||
|
->select('id', 'name', 'username', 'role', 'is_active', 'created_at')
|
||||||
|
->with('profile:user_id,avatar_hash')
|
||||||
|
->withCount('artworks');
|
||||||
|
},
|
||||||
|
'mentionedUser' => function ($query) {
|
||||||
|
$query
|
||||||
|
->select('id', 'name', 'username', 'role', 'is_active', 'created_at')
|
||||||
|
->with('profile:user_id,avatar_hash')
|
||||||
|
->withCount('artworks');
|
||||||
|
},
|
||||||
|
'comment' => function ($query) {
|
||||||
|
$query
|
||||||
|
->select('id', 'artwork_id', 'user_id', 'parent_id', 'content', 'raw_content', 'rendered_content', 'created_at', 'is_approved')
|
||||||
|
->with([
|
||||||
|
'user' => function ($userQuery) {
|
||||||
|
$userQuery
|
||||||
|
->select('id', 'name', 'username', 'role', 'is_active', 'created_at')
|
||||||
|
->with('profile:user_id,avatar_hash')
|
||||||
|
->withCount('artworks');
|
||||||
|
},
|
||||||
|
'artwork' => function ($artworkQuery) {
|
||||||
|
$artworkQuery->select('id', 'user_id', 'title', 'slug', 'hash', 'thumb_ext', 'published_at', 'deleted_at', 'is_public', 'is_approved');
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
},
|
||||||
|
])
|
||||||
|
->whereHas('actor', fn ($query) => $query->where('is_active', true)->whereNull('deleted_at'))
|
||||||
|
->whereHas('mentionedUser', fn ($query) => $query->where('is_active', true)->whereNull('deleted_at'))
|
||||||
|
->whereHas('comment', function ($query) {
|
||||||
|
$query
|
||||||
|
->where('is_approved', true)
|
||||||
|
->whereNull('deleted_at')
|
||||||
|
->whereHas('artwork', fn ($artworkQuery) => $artworkQuery->public()->published()->whereNull('deleted_at'));
|
||||||
|
})
|
||||||
|
->latest('created_at')
|
||||||
|
->limit($limit)
|
||||||
|
->get()
|
||||||
|
->map(function (UserMention $mention): array {
|
||||||
|
$iso = $mention->created_at?->toIso8601String();
|
||||||
|
|
||||||
|
return [
|
||||||
|
'id' => 'mention:' . $mention->id,
|
||||||
|
'type' => 'mention',
|
||||||
|
'user' => $this->buildUserPayload($mention->actor),
|
||||||
|
'mentioned_user' => $this->buildUserPayload($mention->mentionedUser),
|
||||||
|
'comment' => $mention->comment ? $this->buildCommentPayload($mention->comment) : null,
|
||||||
|
'artwork' => $this->buildArtworkPayload($mention->comment?->artwork),
|
||||||
|
'created_at' => $iso,
|
||||||
|
'time_ago' => $mention->created_at?->diffForHumans(),
|
||||||
|
'sort_timestamp' => $iso,
|
||||||
|
];
|
||||||
|
})
|
||||||
|
->values();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildUserPayload(?User $user): ?array
|
||||||
|
{
|
||||||
|
if (! $user) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$username = (string) ($user->username ?? '');
|
||||||
|
|
||||||
|
return [
|
||||||
|
'id' => (int) $user->id,
|
||||||
|
'name' => html_entity_decode((string) ($user->name ?? $username ?: 'User'), ENT_QUOTES | ENT_HTML5, 'UTF-8'),
|
||||||
|
'username' => $username,
|
||||||
|
'profile_url' => $username !== '' ? '/@' . $username : null,
|
||||||
|
'avatar_url' => $user->profile?->avatar_url ?: AvatarUrl::forUser((int) $user->id, $user->profile?->avatar_hash, 64),
|
||||||
|
'badge' => $this->resolveBadge($user),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function resolveBadge(User $user): ?array
|
||||||
|
{
|
||||||
|
if ($user->isAdmin()) {
|
||||||
|
return ['label' => 'Admin', 'tone' => 'rose'];
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($user->isModerator()) {
|
||||||
|
return ['label' => 'Moderator', 'tone' => 'amber'];
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((int) ($user->artworks_count ?? 0) > 0) {
|
||||||
|
return ['label' => 'Creator', 'tone' => 'sky'];
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildArtworkPayload(?Artwork $artwork): ?array
|
||||||
|
{
|
||||||
|
if (! $artwork) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$slug = Str::slug((string) ($artwork->slug ?: $artwork->title));
|
||||||
|
if ($slug === '') {
|
||||||
|
$slug = (string) $artwork->id;
|
||||||
|
}
|
||||||
|
|
||||||
|
$thumb = ThumbnailPresenter::present($artwork, 'md');
|
||||||
|
|
||||||
|
return [
|
||||||
|
'id' => (int) $artwork->id,
|
||||||
|
'title' => html_entity_decode((string) ($artwork->title ?? 'Artwork'), ENT_QUOTES | ENT_HTML5, 'UTF-8'),
|
||||||
|
'url' => route('art.show', ['id' => (int) $artwork->id, 'slug' => $slug]),
|
||||||
|
'thumb' => $thumb['url'] ?? null,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildCommentPayload(ArtworkComment $comment): array
|
||||||
|
{
|
||||||
|
$artwork = $this->buildArtworkPayload($comment->artwork);
|
||||||
|
$commentUrl = $artwork ? $artwork['url'] . '#comment-' . $comment->id : null;
|
||||||
|
|
||||||
|
return [
|
||||||
|
'id' => (int) $comment->id,
|
||||||
|
'parent_id' => $comment->parent_id ? (int) $comment->parent_id : null,
|
||||||
|
'body' => $this->excerptComment($comment),
|
||||||
|
'body_html' => $comment->getDisplayHtml(),
|
||||||
|
'url' => $commentUrl,
|
||||||
|
'author' => $this->buildUserPayload($comment->user),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function excerptComment(ArtworkComment $comment): string
|
||||||
|
{
|
||||||
|
$raw = $comment->raw_content ?? $comment->content ?? '';
|
||||||
|
$plain = trim(preg_replace('/\s+/', ' ', strip_tags(html_entity_decode((string) $raw, ENT_QUOTES | ENT_HTML5, 'UTF-8'))) ?? '');
|
||||||
|
|
||||||
|
return Str::limit($plain, 180, '…');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function loadCommentReactionTotals(array $commentIds, ?int $viewerId): array
|
||||||
|
{
|
||||||
|
if ($commentIds === []) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$rows = DB::table('comment_reactions')
|
||||||
|
->whereIn('comment_id', $commentIds)
|
||||||
|
->selectRaw('comment_id, reaction, COUNT(*) as total')
|
||||||
|
->groupBy('comment_id', 'reaction')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
$viewerReactions = [];
|
||||||
|
if ($viewerId) {
|
||||||
|
$viewerReactions = DB::table('comment_reactions')
|
||||||
|
->whereIn('comment_id', $commentIds)
|
||||||
|
->where('user_id', $viewerId)
|
||||||
|
->get(['comment_id', 'reaction'])
|
||||||
|
->groupBy('comment_id')
|
||||||
|
->map(fn (Collection $items) => $items->pluck('reaction')->all())
|
||||||
|
->all();
|
||||||
|
}
|
||||||
|
|
||||||
|
$totalsByComment = [];
|
||||||
|
foreach ($commentIds as $commentId) {
|
||||||
|
$totalsByComment[(int) $commentId] = $this->defaultReactionTotals();
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
$commentId = (int) $row->comment_id;
|
||||||
|
$slug = (string) $row->reaction;
|
||||||
|
if (! isset($totalsByComment[$commentId][$slug])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$totalsByComment[$commentId][$slug]['count'] = (int) $row->total;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($viewerReactions as $commentId => $slugs) {
|
||||||
|
foreach ($slugs as $slug) {
|
||||||
|
if (isset($totalsByComment[(int) $commentId][(string) $slug])) {
|
||||||
|
$totalsByComment[(int) $commentId][(string) $slug]['mine'] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $totalsByComment;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function defaultReactionTotals(): array
|
||||||
|
{
|
||||||
|
$totals = [];
|
||||||
|
|
||||||
|
foreach (ReactionType::cases() as $type) {
|
||||||
|
$totals[$type->value] = [
|
||||||
|
'emoji' => $type->emoji(),
|
||||||
|
'label' => $type->label(),
|
||||||
|
'count' => 0,
|
||||||
|
'mine' => false,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $totals;
|
||||||
|
}
|
||||||
|
}
|
||||||
18
app/Services/Security/Captcha/CaptchaProviderInterface.php
Normal file
18
app/Services/Security/Captcha/CaptchaProviderInterface.php
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Security\Captcha;
|
||||||
|
|
||||||
|
interface CaptchaProviderInterface
|
||||||
|
{
|
||||||
|
public function name(): string;
|
||||||
|
|
||||||
|
public function isEnabled(): bool;
|
||||||
|
|
||||||
|
public function siteKey(): string;
|
||||||
|
|
||||||
|
public function inputName(): string;
|
||||||
|
|
||||||
|
public function scriptUrl(): string;
|
||||||
|
|
||||||
|
public function verify(string $token, ?string $ip = null): bool;
|
||||||
|
}
|
||||||
69
app/Services/Security/Captcha/HcaptchaCaptchaProvider.php
Normal file
69
app/Services/Security/Captcha/HcaptchaCaptchaProvider.php
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Security\Captcha;
|
||||||
|
|
||||||
|
use Illuminate\Support\Facades\Http;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
|
||||||
|
class HcaptchaCaptchaProvider implements CaptchaProviderInterface
|
||||||
|
{
|
||||||
|
public function name(): string
|
||||||
|
{
|
||||||
|
return 'hcaptcha';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isEnabled(): bool
|
||||||
|
{
|
||||||
|
return (bool) config('services.hcaptcha.enabled', false)
|
||||||
|
&& $this->siteKey() !== ''
|
||||||
|
&& (string) config('services.hcaptcha.secret', '') !== '';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function siteKey(): string
|
||||||
|
{
|
||||||
|
return (string) config('services.hcaptcha.site_key', '');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function inputName(): string
|
||||||
|
{
|
||||||
|
return 'h-captcha-response';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function scriptUrl(): string
|
||||||
|
{
|
||||||
|
return (string) config('services.hcaptcha.script_url', 'https://js.hcaptcha.com/1/api.js');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function verify(string $token, ?string $ip = null): bool
|
||||||
|
{
|
||||||
|
if (! $this->isEnabled()) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (trim($token) === '') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$response = Http::asForm()
|
||||||
|
->timeout((int) config('services.hcaptcha.timeout', 5))
|
||||||
|
->post((string) config('services.hcaptcha.verify_url', 'https://hcaptcha.com/siteverify'), [
|
||||||
|
'secret' => (string) config('services.hcaptcha.secret', ''),
|
||||||
|
'response' => $token,
|
||||||
|
'remoteip' => $ip,
|
||||||
|
]);
|
||||||
|
|
||||||
|
if ($response->failed()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (bool) data_get($response->json(), 'success', false);
|
||||||
|
} catch (\Throwable $exception) {
|
||||||
|
Log::warning('hcaptcha verification request failed', [
|
||||||
|
'message' => $exception->getMessage(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
69
app/Services/Security/Captcha/RecaptchaCaptchaProvider.php
Normal file
69
app/Services/Security/Captcha/RecaptchaCaptchaProvider.php
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Security\Captcha;
|
||||||
|
|
||||||
|
use Illuminate\Support\Facades\Http;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
|
||||||
|
class RecaptchaCaptchaProvider implements CaptchaProviderInterface
|
||||||
|
{
|
||||||
|
public function name(): string
|
||||||
|
{
|
||||||
|
return 'recaptcha';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isEnabled(): bool
|
||||||
|
{
|
||||||
|
return (bool) config('services.recaptcha.enabled', false)
|
||||||
|
&& $this->siteKey() !== ''
|
||||||
|
&& (string) config('services.recaptcha.secret', '') !== '';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function siteKey(): string
|
||||||
|
{
|
||||||
|
return (string) config('services.recaptcha.site_key', '');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function inputName(): string
|
||||||
|
{
|
||||||
|
return 'g-recaptcha-response';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function scriptUrl(): string
|
||||||
|
{
|
||||||
|
return (string) config('services.recaptcha.script_url', 'https://www.google.com/recaptcha/api.js');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function verify(string $token, ?string $ip = null): bool
|
||||||
|
{
|
||||||
|
if (! $this->isEnabled()) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (trim($token) === '') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$response = Http::asForm()
|
||||||
|
->timeout((int) config('services.recaptcha.timeout', 5))
|
||||||
|
->post((string) config('services.recaptcha.verify_url', 'https://www.google.com/recaptcha/api/siteverify'), [
|
||||||
|
'secret' => (string) config('services.recaptcha.secret', ''),
|
||||||
|
'response' => $token,
|
||||||
|
'remoteip' => $ip,
|
||||||
|
]);
|
||||||
|
|
||||||
|
if ($response->failed()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (bool) data_get($response->json(), 'success', false);
|
||||||
|
} catch (\Throwable $exception) {
|
||||||
|
Log::warning('recaptcha verification request failed', [
|
||||||
|
'message' => $exception->getMessage(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
69
app/Services/Security/Captcha/TurnstileCaptchaProvider.php
Normal file
69
app/Services/Security/Captcha/TurnstileCaptchaProvider.php
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Security\Captcha;
|
||||||
|
|
||||||
|
use Illuminate\Support\Facades\Http;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
|
||||||
|
class TurnstileCaptchaProvider implements CaptchaProviderInterface
|
||||||
|
{
|
||||||
|
public function name(): string
|
||||||
|
{
|
||||||
|
return 'turnstile';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isEnabled(): bool
|
||||||
|
{
|
||||||
|
return (bool) config('registration.enable_turnstile', true)
|
||||||
|
&& $this->siteKey() !== ''
|
||||||
|
&& (string) config('services.turnstile.secret_key', '') !== '';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function siteKey(): string
|
||||||
|
{
|
||||||
|
return (string) config('services.turnstile.site_key', '');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function inputName(): string
|
||||||
|
{
|
||||||
|
return 'cf-turnstile-response';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function scriptUrl(): string
|
||||||
|
{
|
||||||
|
return (string) config('services.turnstile.script_url', 'https://challenges.cloudflare.com/turnstile/v0/api.js');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function verify(string $token, ?string $ip = null): bool
|
||||||
|
{
|
||||||
|
if (! $this->isEnabled()) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (trim($token) === '') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
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'), [
|
||||||
|
'secret' => (string) config('services.turnstile.secret_key', ''),
|
||||||
|
'response' => $token,
|
||||||
|
'remoteip' => $ip,
|
||||||
|
]);
|
||||||
|
|
||||||
|
if ($response->failed()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (bool) data_get($response->json(), 'success', false);
|
||||||
|
} catch (\Throwable $exception) {
|
||||||
|
Log::warning('turnstile verification request failed', [
|
||||||
|
'message' => $exception->getMessage(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
71
app/Services/Security/CaptchaVerifier.php
Normal file
71
app/Services/Security/CaptchaVerifier.php
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Security;
|
||||||
|
|
||||||
|
use App\Services\Security\Captcha\CaptchaProviderInterface;
|
||||||
|
use App\Services\Security\Captcha\HcaptchaCaptchaProvider;
|
||||||
|
use App\Services\Security\Captcha\RecaptchaCaptchaProvider;
|
||||||
|
use App\Services\Security\Captcha\TurnstileCaptchaProvider;
|
||||||
|
|
||||||
|
class CaptchaVerifier
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly TurnstileCaptchaProvider $turnstileProvider,
|
||||||
|
private readonly RecaptchaCaptchaProvider $recaptchaProvider,
|
||||||
|
private readonly HcaptchaCaptchaProvider $hcaptchaProvider,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public function provider(): string
|
||||||
|
{
|
||||||
|
$configured = strtolower(trim((string) config('forum_bot_protection.captcha.provider', 'turnstile')));
|
||||||
|
|
||||||
|
return match ($configured) {
|
||||||
|
'recaptcha' => 'recaptcha',
|
||||||
|
'hcaptcha' => 'hcaptcha',
|
||||||
|
default => 'turnstile',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isEnabled(): bool
|
||||||
|
{
|
||||||
|
return $this->resolveProvider()->isEnabled();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function inputName(): string
|
||||||
|
{
|
||||||
|
$configured = trim((string) config('forum_bot_protection.captcha.input', ''));
|
||||||
|
|
||||||
|
if ($configured !== '') {
|
||||||
|
return $configured;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->resolveProvider()->inputName();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function verify(string $token, ?string $ip = null): bool
|
||||||
|
{
|
||||||
|
return $this->resolveProvider()->verify($token, $ip);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function frontendConfig(): array
|
||||||
|
{
|
||||||
|
$provider = $this->resolveProvider();
|
||||||
|
|
||||||
|
return [
|
||||||
|
'provider' => $provider->name(),
|
||||||
|
'siteKey' => $provider->isEnabled() ? $provider->siteKey() : '',
|
||||||
|
'inputName' => $this->inputName(),
|
||||||
|
'scriptUrl' => $provider->isEnabled() ? $provider->scriptUrl() : '',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function resolveProvider(): CaptchaProviderInterface
|
||||||
|
{
|
||||||
|
return match ($this->provider()) {
|
||||||
|
'recaptcha' => $this->recaptchaProvider,
|
||||||
|
'hcaptcha' => $this->hcaptchaProvider,
|
||||||
|
default => $this->turnstileProvider,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,50 +2,21 @@
|
|||||||
|
|
||||||
namespace App\Services\Security;
|
namespace App\Services\Security;
|
||||||
|
|
||||||
use Illuminate\Support\Facades\Http;
|
|
||||||
use Illuminate\Support\Facades\Log;
|
|
||||||
|
|
||||||
class TurnstileVerifier
|
class TurnstileVerifier
|
||||||
{
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly CaptchaVerifier $captchaVerifier,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
public function isEnabled(): bool
|
public function isEnabled(): bool
|
||||||
{
|
{
|
||||||
return (bool) config('registration.enable_turnstile', true)
|
return $this->captchaVerifier->provider() === 'turnstile'
|
||||||
&& (string) config('services.turnstile.site_key', '') !== ''
|
&& $this->captchaVerifier->isEnabled();
|
||||||
&& (string) config('services.turnstile.secret_key', '') !== '';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function verify(string $token, ?string $ip = null): bool
|
public function verify(string $token, ?string $ip = null): bool
|
||||||
{
|
{
|
||||||
if (! $this->isEnabled()) {
|
return $this->captchaVerifier->verify($token, $ip);
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (trim($token) === '') {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
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'), [
|
|
||||||
'secret' => (string) config('services.turnstile.secret_key', ''),
|
|
||||||
'response' => $token,
|
|
||||||
'remoteip' => $ip,
|
|
||||||
]);
|
|
||||||
|
|
||||||
if ($response->failed()) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
$payload = $response->json();
|
|
||||||
|
|
||||||
return (bool) data_get($payload, 'success', false);
|
|
||||||
} catch (\Throwable $exception) {
|
|
||||||
Log::warning('turnstile verification request failed', [
|
|
||||||
'message' => $exception->getMessage(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
215
app/Services/Tags/TagDiscoveryService.php
Normal file
215
app/Services/Tags/TagDiscoveryService.php
Normal file
@@ -0,0 +1,215 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Services\Tags;
|
||||||
|
|
||||||
|
use App\Models\Tag;
|
||||||
|
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
final class TagDiscoveryService
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @return array<int, string>
|
||||||
|
*/
|
||||||
|
private function discoverySelectColumns(): array
|
||||||
|
{
|
||||||
|
return ['tags.id', 'tags.name', 'tags.slug', 'tags.usage_count'];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function featuredTags(int $limit = 6, int $windowDays = 14): Collection
|
||||||
|
{
|
||||||
|
return $this->activeTagsQuery($windowDays)
|
||||||
|
->withCount('artworks')
|
||||||
|
->orderByDesc('recent_clicks')
|
||||||
|
->orderByDesc('usage_count')
|
||||||
|
->orderByDesc('artworks_count')
|
||||||
|
->orderBy('name')
|
||||||
|
->limit($limit)
|
||||||
|
->get();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function risingTags(Collection $featuredTags, int $limit = 12, int $windowDays = 14): Collection
|
||||||
|
{
|
||||||
|
$featuredSlugs = $featuredTags->pluck('slug')->filter()->values();
|
||||||
|
|
||||||
|
$risingTags = $this->activeTagsQuery($windowDays)
|
||||||
|
->withCount('artworks')
|
||||||
|
->when($featuredSlugs->isNotEmpty(), function ($builder) use ($featuredSlugs): void {
|
||||||
|
$builder->whereNotIn('tags.slug', $featuredSlugs->all());
|
||||||
|
})
|
||||||
|
->when($this->hasDailyMetrics(), function ($builder): void {
|
||||||
|
$builder->whereRaw('COALESCE(tag_momentum.recent_clicks, 0) > 0');
|
||||||
|
})
|
||||||
|
->orderByDesc('recent_clicks')
|
||||||
|
->orderByDesc('artworks_count')
|
||||||
|
->orderBy('name')
|
||||||
|
->limit($limit)
|
||||||
|
->get();
|
||||||
|
|
||||||
|
if ($risingTags->count() >= $limit) {
|
||||||
|
return $risingTags;
|
||||||
|
}
|
||||||
|
|
||||||
|
$excludeSlugs = $risingTags
|
||||||
|
->pluck('slug')
|
||||||
|
->merge($featuredSlugs)
|
||||||
|
->filter()
|
||||||
|
->values();
|
||||||
|
|
||||||
|
$fallback = $this->activeTagsQuery($windowDays)
|
||||||
|
->withCount('artworks')
|
||||||
|
->when($excludeSlugs->isNotEmpty(), function ($builder) use ($excludeSlugs): void {
|
||||||
|
$builder->whereNotIn('tags.slug', $excludeSlugs->all());
|
||||||
|
})
|
||||||
|
->orderByDesc('usage_count')
|
||||||
|
->orderByDesc('artworks_count')
|
||||||
|
->orderBy('name')
|
||||||
|
->limit($limit - $risingTags->count())
|
||||||
|
->get();
|
||||||
|
|
||||||
|
return $risingTags->concat($fallback)->values();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function paginatedTags(string $query = '', int $perPage = 48, int $windowDays = 14): LengthAwarePaginator
|
||||||
|
{
|
||||||
|
$tagsQuery = $this->activeTagsQuery($windowDays)
|
||||||
|
->withCount('artworks');
|
||||||
|
|
||||||
|
if ($query !== '') {
|
||||||
|
$tagsQuery->where(function ($builder) use ($query): void {
|
||||||
|
$builder
|
||||||
|
->where('tags.name', 'like', '%' . $query . '%')
|
||||||
|
->orWhere('tags.slug', 'like', '%' . $query . '%');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return $tagsQuery
|
||||||
|
->orderByDesc('recent_clicks')
|
||||||
|
->orderByDesc('usage_count')
|
||||||
|
->orderByDesc('artworks_count')
|
||||||
|
->orderBy('name')
|
||||||
|
->paginate($perPage)
|
||||||
|
->withQueryString();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function stats(int $matchingTotal, int $windowDays = 14): array
|
||||||
|
{
|
||||||
|
$activeTags = $this->activeTagsQuery($windowDays);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'active' => (clone $activeTags)->count(),
|
||||||
|
'usage' => (clone $activeTags)->sum('usage_count'),
|
||||||
|
'matching' => $matchingTotal,
|
||||||
|
'recent_clicks' => $this->hasDailyMetrics()
|
||||||
|
? (int) DB::table('tag_interaction_daily_metrics')
|
||||||
|
->whereBetween('metric_date', [$this->windowStartDate($windowDays), now()->toDateString()])
|
||||||
|
->sum('clicks')
|
||||||
|
: 0,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function popularTags(int $limit = 20, int $windowDays = 14): Collection
|
||||||
|
{
|
||||||
|
return $this->activeTagsQuery($windowDays)
|
||||||
|
->orderByDesc('recent_clicks')
|
||||||
|
->orderByDesc('usage_count')
|
||||||
|
->orderBy('name')
|
||||||
|
->limit($limit)
|
||||||
|
->get();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function searchSuggestions(string $query, int $limit = 20, int $windowDays = 14): Collection
|
||||||
|
{
|
||||||
|
$normalizedQuery = trim($query);
|
||||||
|
|
||||||
|
return $this->activeTagsQuery($windowDays)
|
||||||
|
->when($normalizedQuery !== '', function ($builder) use ($normalizedQuery): void {
|
||||||
|
$builder->where(function ($subQuery) use ($normalizedQuery): void {
|
||||||
|
$subQuery->where('tags.name', 'like', $normalizedQuery . '%')
|
||||||
|
->orWhere('tags.slug', 'like', $normalizedQuery . '%');
|
||||||
|
});
|
||||||
|
})
|
||||||
|
->orderByDesc('recent_clicks')
|
||||||
|
->orderByDesc('usage_count')
|
||||||
|
->orderBy('name')
|
||||||
|
->limit($limit)
|
||||||
|
->get();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function relatedTags(Tag $tag, int $limit = 8, int $windowDays = 14): Collection
|
||||||
|
{
|
||||||
|
return DB::table('artwork_tag as current_tag')
|
||||||
|
->join('artwork_tag as related_tag', 'related_tag.artwork_id', '=', 'current_tag.artwork_id')
|
||||||
|
->join('tags', 'tags.id', '=', 'related_tag.tag_id')
|
||||||
|
->select([
|
||||||
|
'tags.name',
|
||||||
|
'tags.slug',
|
||||||
|
'tags.usage_count',
|
||||||
|
])
|
||||||
|
->selectRaw('COUNT(DISTINCT current_tag.artwork_id) as shared_artworks_count')
|
||||||
|
->when($this->hasDailyMetrics(), function ($builder) use ($tag, $windowDays): void {
|
||||||
|
$transitionMomentum = DB::table('tag_interaction_daily_metrics')
|
||||||
|
->selectRaw('tag_slug')
|
||||||
|
->selectRaw('SUM(clicks) AS transition_clicks')
|
||||||
|
->whereBetween('metric_date', [$this->windowStartDate($windowDays), now()->toDateString()])
|
||||||
|
->where('surface', '!=', 'recent_search')
|
||||||
|
->where('source_tag_slug', $tag->slug)
|
||||||
|
->where('tag_slug', '<>', '')
|
||||||
|
->groupBy('tag_slug');
|
||||||
|
|
||||||
|
$builder
|
||||||
|
->leftJoinSub($transitionMomentum, 'tag_transition_momentum', function ($join): void {
|
||||||
|
$join->on('tag_transition_momentum.tag_slug', '=', 'tags.slug');
|
||||||
|
})
|
||||||
|
->selectRaw('COALESCE(tag_transition_momentum.transition_clicks, 0) as transition_clicks')
|
||||||
|
->groupBy('tag_transition_momentum.transition_clicks')
|
||||||
|
->orderByDesc('transition_clicks');
|
||||||
|
})
|
||||||
|
->where('current_tag.tag_id', '=', $tag->getKey())
|
||||||
|
->where('related_tag.tag_id', '!=', $tag->getKey())
|
||||||
|
->where('tags.is_active', true)
|
||||||
|
->groupBy('tags.id', 'tags.name', 'tags.slug', 'tags.usage_count')
|
||||||
|
->orderByRaw('COUNT(DISTINCT current_tag.artwork_id) DESC')
|
||||||
|
->orderByDesc('tags.usage_count')
|
||||||
|
->limit($limit)
|
||||||
|
->get();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function activeTagsQuery(int $windowDays)
|
||||||
|
{
|
||||||
|
$query = Tag::query()
|
||||||
|
->select($this->discoverySelectColumns())
|
||||||
|
->where('tags.is_active', true);
|
||||||
|
|
||||||
|
if (! $this->hasDailyMetrics()) {
|
||||||
|
return $query->selectRaw('0 AS recent_clicks');
|
||||||
|
}
|
||||||
|
|
||||||
|
$tagMomentum = DB::table('tag_interaction_daily_metrics')
|
||||||
|
->selectRaw('tag_slug')
|
||||||
|
->selectRaw('SUM(clicks) AS recent_clicks')
|
||||||
|
->whereBetween('metric_date', [$this->windowStartDate($windowDays), now()->toDateString()])
|
||||||
|
->where('tag_slug', '<>', '')
|
||||||
|
->groupBy('tag_slug');
|
||||||
|
|
||||||
|
return $query
|
||||||
|
->leftJoinSub($tagMomentum, 'tag_momentum', function ($join): void {
|
||||||
|
$join->on('tag_momentum.tag_slug', '=', 'tags.slug');
|
||||||
|
})
|
||||||
|
->selectRaw('COALESCE(tag_momentum.recent_clicks, 0) AS recent_clicks');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function hasDailyMetrics(): bool
|
||||||
|
{
|
||||||
|
return Schema::hasTable('tag_interaction_daily_metrics');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function windowStartDate(int $windowDays): string
|
||||||
|
{
|
||||||
|
return now()->subDays(max(0, $windowDays - 1))->toDateString();
|
||||||
|
}
|
||||||
|
}
|
||||||
78
app/Services/UserMentionSyncService.php
Normal file
78
app/Services/UserMentionSyncService.php
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Services;
|
||||||
|
|
||||||
|
use App\Models\ArtworkComment;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
final class UserMentionSyncService
|
||||||
|
{
|
||||||
|
public function syncForComment(ArtworkComment $comment): void
|
||||||
|
{
|
||||||
|
if (! Schema::hasTable('user_mentions')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$usernames = $this->extractMentions((string) ($comment->raw_content ?? $comment->content ?? ''));
|
||||||
|
$mentionedIds = $usernames === []
|
||||||
|
? []
|
||||||
|
: User::query()
|
||||||
|
->whereIn(DB::raw('LOWER(username)'), $usernames)
|
||||||
|
->where('is_active', true)
|
||||||
|
->whereNull('deleted_at')
|
||||||
|
->pluck('id')
|
||||||
|
->map(fn ($id) => (int) $id)
|
||||||
|
->all();
|
||||||
|
|
||||||
|
DB::transaction(function () use ($comment, $mentionedIds): void {
|
||||||
|
DB::table('user_mentions')
|
||||||
|
->where('comment_id', (int) $comment->id)
|
||||||
|
->delete();
|
||||||
|
|
||||||
|
if ($mentionedIds === []) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$rows = collect($mentionedIds)
|
||||||
|
->reject(fn (int $id) => $id === (int) $comment->user_id)
|
||||||
|
->unique()
|
||||||
|
->map(fn (int $mentionedUserId) => [
|
||||||
|
'user_id' => (int) $comment->user_id,
|
||||||
|
'mentioned_user_id' => $mentionedUserId,
|
||||||
|
'artwork_id' => (int) $comment->artwork_id,
|
||||||
|
'comment_id' => (int) $comment->id,
|
||||||
|
'created_at' => $comment->created_at ?? now(),
|
||||||
|
])
|
||||||
|
->values()
|
||||||
|
->all();
|
||||||
|
|
||||||
|
if ($rows !== []) {
|
||||||
|
DB::table('user_mentions')->insert($rows);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function deleteForComment(int $commentId): void
|
||||||
|
{
|
||||||
|
if (! Schema::hasTable('user_mentions')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
DB::table('user_mentions')->where('comment_id', $commentId)->delete();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function extractMentions(string $content): array
|
||||||
|
{
|
||||||
|
preg_match_all('/(^|[^A-Za-z0-9_])@([A-Za-z0-9_-]{3,20})/', $content, $matches);
|
||||||
|
|
||||||
|
return collect($matches[2] ?? [])
|
||||||
|
->map(fn ($username) => strtolower((string) $username))
|
||||||
|
->unique()
|
||||||
|
->values()
|
||||||
|
->all();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,6 +12,11 @@ return Application::configure(basePath: dirname(__DIR__))
|
|||||||
health: '/up',
|
health: '/up',
|
||||||
)
|
)
|
||||||
->withMiddleware(function (Middleware $middleware): void {
|
->withMiddleware(function (Middleware $middleware): void {
|
||||||
|
$middleware->validateCsrfTokens(except: [
|
||||||
|
'chat_post',
|
||||||
|
'chat_post/*',
|
||||||
|
]);
|
||||||
|
|
||||||
$middleware->web(append: [
|
$middleware->web(append: [
|
||||||
\App\Http\Middleware\HandleInertiaRequests::class,
|
\App\Http\Middleware\HandleInertiaRequests::class,
|
||||||
// Runs on every web request; no-ops for guests, redirects authenticated
|
// Runs on every web request; no-ops for guests, redirects authenticated
|
||||||
@@ -23,6 +28,11 @@ return Application::configure(basePath: dirname(__DIR__))
|
|||||||
'admin.moderation' => \App\Http\Middleware\EnsureAdminOrModerator::class,
|
'admin.moderation' => \App\Http\Middleware\EnsureAdminOrModerator::class,
|
||||||
'creator.access' => \App\Http\Middleware\EnsureCreatorAccess::class,
|
'creator.access' => \App\Http\Middleware\EnsureCreatorAccess::class,
|
||||||
'ensure.onboarding.complete'=> \App\Http\Middleware\EnsureOnboardingComplete::class,
|
'ensure.onboarding.complete'=> \App\Http\Middleware\EnsureOnboardingComplete::class,
|
||||||
|
'forum.ai.moderation' => \App\Http\Middleware\ForumAIModerationMiddleware::class,
|
||||||
|
'forum.bot.protection' => \App\Http\Middleware\ForumBotProtectionMiddleware::class,
|
||||||
|
'forum.spam.detection' => \App\Http\Middleware\ForumSpamDetectionMiddleware::class,
|
||||||
|
'forum.security.firewall' => \App\Http\Middleware\ForumSecurityFirewallMiddleware::class,
|
||||||
|
'forum.rate_limit' => \App\Http\Middleware\ForumRateLimitMiddleware::class,
|
||||||
'onboarding' => \App\Http\Middleware\EnsureOnboardingComplete::class,
|
'onboarding' => \App\Http\Middleware\EnsureOnboardingComplete::class,
|
||||||
'normalize.username' => \App\Http\Middleware\NormalizeUsername::class,
|
'normalize.username' => \App\Http\Middleware\NormalizeUsername::class,
|
||||||
]);
|
]);
|
||||||
|
|||||||
147
config/forum_bot_protection.php
Normal file
147
config/forum_bot_protection.php
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
return [
|
||||||
|
'enabled' => env('FORUM_BOT_PROTECTION_ENABLED', true),
|
||||||
|
|
||||||
|
'thresholds' => [
|
||||||
|
'allow' => 20,
|
||||||
|
'log' => 20,
|
||||||
|
'captcha' => 40,
|
||||||
|
'moderate' => 60,
|
||||||
|
'block' => 80,
|
||||||
|
],
|
||||||
|
|
||||||
|
'honeypots' => [
|
||||||
|
'fields' => ['homepage_url', 'company_name'],
|
||||||
|
'penalty' => 60,
|
||||||
|
],
|
||||||
|
|
||||||
|
'captcha' => [
|
||||||
|
'provider' => env('FORUM_BOT_CAPTCHA_PROVIDER', 'turnstile'),
|
||||||
|
'actions' => [
|
||||||
|
'register',
|
||||||
|
'login',
|
||||||
|
'forum_topic_create',
|
||||||
|
'forum_reply_create',
|
||||||
|
'forum_post_update',
|
||||||
|
'profile_update',
|
||||||
|
'api_write',
|
||||||
|
],
|
||||||
|
'input' => env('FORUM_BOT_CAPTCHA_INPUT', ''),
|
||||||
|
'message' => 'Complete the captcha challenge to continue.',
|
||||||
|
],
|
||||||
|
|
||||||
|
'behavior' => [
|
||||||
|
'new_account_days' => 7,
|
||||||
|
'rapid_post_window_minutes' => 1,
|
||||||
|
'rapid_post_threshold' => 5,
|
||||||
|
'rapid_thread_threshold' => 2,
|
||||||
|
'recent_action_window_seconds' => 45,
|
||||||
|
'recent_action_threshold' => 6,
|
||||||
|
'login_attempt_window_minutes' => 10,
|
||||||
|
'login_attempt_threshold' => 8,
|
||||||
|
'profile_update_threshold' => 6,
|
||||||
|
'profile_update_window_minutes' => 60,
|
||||||
|
'api_request_window_minutes' => 1,
|
||||||
|
'api_request_threshold' => 100,
|
||||||
|
'repeated_content_penalty' => 50,
|
||||||
|
'new_account_links_penalty' => 30,
|
||||||
|
'rapid_post_penalty' => 40,
|
||||||
|
'recent_action_penalty' => 40,
|
||||||
|
'login_burst_penalty' => 35,
|
||||||
|
'profile_burst_penalty' => 20,
|
||||||
|
'api_burst_penalty' => 60,
|
||||||
|
],
|
||||||
|
|
||||||
|
'account_farm' => [
|
||||||
|
'window_minutes' => 10,
|
||||||
|
'register_attempt_threshold' => 10,
|
||||||
|
'same_ip_users_threshold' => 5,
|
||||||
|
'same_fingerprint_users_threshold' => 3,
|
||||||
|
'same_pattern_users_threshold' => 3,
|
||||||
|
'register_attempt_penalty' => 50,
|
||||||
|
'same_ip_penalty' => 35,
|
||||||
|
'same_fingerprint_penalty' => 40,
|
||||||
|
'same_pattern_penalty' => 45,
|
||||||
|
],
|
||||||
|
|
||||||
|
'ip' => [
|
||||||
|
'cache_ttl_minutes' => 15,
|
||||||
|
'recent_high_risk_window_hours' => 24,
|
||||||
|
'recent_high_risk_threshold' => 3,
|
||||||
|
'recent_high_risk_penalty' => 20,
|
||||||
|
'known_proxy_penalty' => 20,
|
||||||
|
'datacenter_penalty' => 25,
|
||||||
|
'tor_penalty' => 40,
|
||||||
|
'blacklist_penalty' => 100,
|
||||||
|
'known_proxies' => [],
|
||||||
|
'datacenter_ranges' => [],
|
||||||
|
'provider_ranges' => [
|
||||||
|
'aws' => [],
|
||||||
|
'azure' => [],
|
||||||
|
'gcp' => [],
|
||||||
|
'digitalocean' => [],
|
||||||
|
'hetzner' => [],
|
||||||
|
'ovh' => [],
|
||||||
|
],
|
||||||
|
'tor_exit_nodes' => [],
|
||||||
|
],
|
||||||
|
|
||||||
|
'rate_limits' => [
|
||||||
|
'penalties' => [
|
||||||
|
'default' => 35,
|
||||||
|
'minute' => 35,
|
||||||
|
'hour' => 45,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
|
||||||
|
'geo_behavior' => [
|
||||||
|
'enabled' => true,
|
||||||
|
'login_actions' => ['login'],
|
||||||
|
'country_headers' => [
|
||||||
|
'CF-IPCountry',
|
||||||
|
'CloudFront-Viewer-Country',
|
||||||
|
'X-Country-Code',
|
||||||
|
'X-App-Country-Code',
|
||||||
|
],
|
||||||
|
'recent_login_window_minutes' => 60,
|
||||||
|
'country_change_penalty' => 50,
|
||||||
|
],
|
||||||
|
|
||||||
|
'patterns' => [
|
||||||
|
'seo' => [
|
||||||
|
'best seo service',
|
||||||
|
'cheap backlinks',
|
||||||
|
'guaranteed traffic',
|
||||||
|
'rank your website',
|
||||||
|
],
|
||||||
|
'casino' => [
|
||||||
|
'online casino',
|
||||||
|
'jackpot bonus',
|
||||||
|
'slot machine',
|
||||||
|
'betting tips',
|
||||||
|
],
|
||||||
|
'crypto' => [
|
||||||
|
'crypto signal',
|
||||||
|
'double your bitcoin',
|
||||||
|
'guaranteed profit',
|
||||||
|
'token presale',
|
||||||
|
],
|
||||||
|
'affiliate' => [
|
||||||
|
'affiliate link',
|
||||||
|
'promo code',
|
||||||
|
'limited offer',
|
||||||
|
'work from home',
|
||||||
|
],
|
||||||
|
'repeated_phrase_penalty' => 40,
|
||||||
|
'category_penalty' => 30,
|
||||||
|
],
|
||||||
|
|
||||||
|
'scan' => [
|
||||||
|
'lookback_minutes' => 5,
|
||||||
|
'auto_blacklist_attempts' => 10,
|
||||||
|
'auto_blacklist_risk' => 80,
|
||||||
|
'auto_blacklist_reason' => 'Automatically blacklisted by bot activity monitor.',
|
||||||
|
'queue' => env('FORUM_BOT_SCAN_QUEUE', 'forum-moderation'),
|
||||||
|
],
|
||||||
|
];
|
||||||
65
config/forum_security.php
Normal file
65
config/forum_security.php
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
return [
|
||||||
|
'enabled' => env('FORUM_SECURITY_ENABLED', true),
|
||||||
|
|
||||||
|
'thresholds' => [
|
||||||
|
'safe' => 20,
|
||||||
|
'log' => 20,
|
||||||
|
'captcha' => 40,
|
||||||
|
'moderate' => 60,
|
||||||
|
'block' => 80,
|
||||||
|
'firewall_block' => 70,
|
||||||
|
],
|
||||||
|
|
||||||
|
'queues' => [
|
||||||
|
'moderation' => env('FORUM_SECURITY_MODERATION_QUEUE', 'forum-moderation'),
|
||||||
|
'firewall' => env('FORUM_SECURITY_FIREWALL_QUEUE', 'forum-security'),
|
||||||
|
],
|
||||||
|
|
||||||
|
'firewall' => [
|
||||||
|
'enabled' => true,
|
||||||
|
'request_pattern' => [
|
||||||
|
'window_seconds' => 60,
|
||||||
|
'burst_threshold' => 15,
|
||||||
|
'burst_penalty' => 25,
|
||||||
|
'missing_user_agent_penalty' => 10,
|
||||||
|
'suspicious_path_penalty' => 20,
|
||||||
|
'repeat_route_penalty' => 20,
|
||||||
|
],
|
||||||
|
'spam_wave' => [
|
||||||
|
'window_minutes' => 15,
|
||||||
|
'same_hash_threshold' => 3,
|
||||||
|
'same_hash_penalty' => 30,
|
||||||
|
'same_ip_flagged_threshold' => 4,
|
||||||
|
'same_ip_flagged_penalty' => 25,
|
||||||
|
'same_signature_threshold' => 3,
|
||||||
|
'same_signature_penalty' => 20,
|
||||||
|
],
|
||||||
|
'thread_attack' => [
|
||||||
|
'window_minutes' => 10,
|
||||||
|
'topic_threshold' => 4,
|
||||||
|
'reply_threshold' => 8,
|
||||||
|
'topic_penalty' => 25,
|
||||||
|
'reply_penalty' => 20,
|
||||||
|
],
|
||||||
|
'login_attack' => [
|
||||||
|
'window_minutes' => 15,
|
||||||
|
'login_threshold' => 10,
|
||||||
|
'register_threshold' => 6,
|
||||||
|
'login_penalty' => 30,
|
||||||
|
'register_penalty' => 35,
|
||||||
|
],
|
||||||
|
'scan' => [
|
||||||
|
'lookback_minutes' => 15,
|
||||||
|
'auto_blacklist_attempts' => 4,
|
||||||
|
'auto_blacklist_risk' => 70,
|
||||||
|
'auto_blacklist_reason' => 'Automatically blacklisted by forum firewall activity monitor.',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
|
||||||
|
'logging' => [
|
||||||
|
'store_request_payload' => false,
|
||||||
|
'reason_limit' => 8,
|
||||||
|
],
|
||||||
|
];
|
||||||
@@ -43,13 +43,24 @@ return [
|
|||||||
'enabled' => env('RECAPTCHA_ENABLED', false),
|
'enabled' => env('RECAPTCHA_ENABLED', false),
|
||||||
'site_key' => env('RECAPTCHA_SITE_KEY'),
|
'site_key' => env('RECAPTCHA_SITE_KEY'),
|
||||||
'secret' => env('RECAPTCHA_SECRET_KEY'),
|
'secret' => env('RECAPTCHA_SECRET_KEY'),
|
||||||
|
'script_url' => env('RECAPTCHA_SCRIPT_URL', 'https://www.google.com/recaptcha/api.js'),
|
||||||
'verify_url' => env('RECAPTCHA_VERIFY_URL', 'https://www.google.com/recaptcha/api/siteverify'),
|
'verify_url' => env('RECAPTCHA_VERIFY_URL', 'https://www.google.com/recaptcha/api/siteverify'),
|
||||||
'timeout' => (int) env('RECAPTCHA_TIMEOUT', 5),
|
'timeout' => (int) env('RECAPTCHA_TIMEOUT', 5),
|
||||||
],
|
],
|
||||||
|
|
||||||
|
'hcaptcha' => [
|
||||||
|
'enabled' => env('HCAPTCHA_ENABLED', false),
|
||||||
|
'site_key' => env('HCAPTCHA_SITE_KEY'),
|
||||||
|
'secret' => env('HCAPTCHA_SECRET_KEY'),
|
||||||
|
'script_url' => env('HCAPTCHA_SCRIPT_URL', 'https://js.hcaptcha.com/1/api.js'),
|
||||||
|
'verify_url' => env('HCAPTCHA_VERIFY_URL', 'https://hcaptcha.com/siteverify'),
|
||||||
|
'timeout' => (int) env('HCAPTCHA_TIMEOUT', 5),
|
||||||
|
],
|
||||||
|
|
||||||
'turnstile' => [
|
'turnstile' => [
|
||||||
'site_key' => env('TURNSTILE_SITE_KEY'),
|
'site_key' => env('TURNSTILE_SITE_KEY'),
|
||||||
'secret_key' => env('TURNSTILE_SECRET_KEY'),
|
'secret_key' => env('TURNSTILE_SECRET_KEY'),
|
||||||
|
'script_url' => env('TURNSTILE_SCRIPT_URL', 'https://challenges.cloudflare.com/turnstile/v0/api.js'),
|
||||||
'verify_url' => env('TURNSTILE_VERIFY_URL', 'https://challenges.cloudflare.com/turnstile/v0/siteverify'),
|
'verify_url' => env('TURNSTILE_VERIFY_URL', 'https://challenges.cloudflare.com/turnstile/v0/siteverify'),
|
||||||
'timeout' => (int) env('TURNSTILE_TIMEOUT', 5),
|
'timeout' => (int) env('TURNSTILE_TIMEOUT', 5),
|
||||||
],
|
],
|
||||||
|
|||||||
132
config/skinbase_ai_moderation.php
Normal file
132
config/skinbase_ai_moderation.php
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
return [
|
||||||
|
'enabled' => env('SKINBASE_AI_MODERATION_ENABLED', true),
|
||||||
|
|
||||||
|
'provider' => env('SKINBASE_AI_MODERATION_PROVIDER', 'openai'),
|
||||||
|
|
||||||
|
'queue' => [
|
||||||
|
'name' => env('SKINBASE_AI_MODERATION_QUEUE', 'forum-moderation'),
|
||||||
|
],
|
||||||
|
|
||||||
|
'preflight' => [
|
||||||
|
'run_ai_sync' => (bool) env('SKINBASE_AI_PREFLIGHT_SYNC', true),
|
||||||
|
],
|
||||||
|
|
||||||
|
'thresholds' => [
|
||||||
|
'safe' => 20,
|
||||||
|
'low_quality' => 40,
|
||||||
|
'suspicious' => 60,
|
||||||
|
'block' => 80,
|
||||||
|
],
|
||||||
|
|
||||||
|
'behavior' => [
|
||||||
|
'new_account_days' => 7,
|
||||||
|
'rapid_post_window_minutes' => 2,
|
||||||
|
'rapid_post_threshold' => 5,
|
||||||
|
'same_ip_window_days' => 7,
|
||||||
|
'same_ip_accounts_threshold' => 2,
|
||||||
|
'repeat_content_penalty' => 40,
|
||||||
|
'new_account_with_links_penalty' => 30,
|
||||||
|
'rapid_post_penalty' => 20,
|
||||||
|
'same_ip_penalty' => 25,
|
||||||
|
'high_link_frequency_penalty' => 10,
|
||||||
|
'flagged_history_penalty' => 15,
|
||||||
|
],
|
||||||
|
|
||||||
|
'links' => [
|
||||||
|
'too_many_links_penalty' => 15,
|
||||||
|
'suspicious_domain_penalty' => 40,
|
||||||
|
'shortener_penalty' => 10,
|
||||||
|
'suspicious_tld_penalty' => 15,
|
||||||
|
'too_many_links_threshold' => 3,
|
||||||
|
'shorteners' => [
|
||||||
|
'bit.ly',
|
||||||
|
'tinyurl.com',
|
||||||
|
'goo.gl',
|
||||||
|
't.co',
|
||||||
|
'ow.ly',
|
||||||
|
'cutt.ly',
|
||||||
|
'rebrand.ly',
|
||||||
|
],
|
||||||
|
'suspicious_tlds' => [
|
||||||
|
'xyz',
|
||||||
|
'top',
|
||||||
|
'click',
|
||||||
|
'loan',
|
||||||
|
'work',
|
||||||
|
'gq',
|
||||||
|
'ml',
|
||||||
|
'tk',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
|
||||||
|
'trust' => [
|
||||||
|
'high' => 80,
|
||||||
|
'medium' => 50,
|
||||||
|
'high_modifier' => -15,
|
||||||
|
'medium_modifier' => -8,
|
||||||
|
'low_modifier' => 0,
|
||||||
|
'flagged_ratio_penalty' => 10,
|
||||||
|
],
|
||||||
|
|
||||||
|
'learning' => [
|
||||||
|
'spam_penalty' => 40,
|
||||||
|
'safe_modifier' => -12,
|
||||||
|
'max_spam_penalty' => 60,
|
||||||
|
'max_safe_modifier' => -20,
|
||||||
|
],
|
||||||
|
|
||||||
|
'scan' => [
|
||||||
|
'limit' => 200,
|
||||||
|
'stale_after_minutes' => 10,
|
||||||
|
],
|
||||||
|
|
||||||
|
'privacy' => [
|
||||||
|
'redact_emails' => true,
|
||||||
|
'redact_ip_addresses' => true,
|
||||||
|
'redact_mentions' => false,
|
||||||
|
],
|
||||||
|
|
||||||
|
'heuristics' => [
|
||||||
|
'promotional_phrases' => [
|
||||||
|
'buy now',
|
||||||
|
'limited offer',
|
||||||
|
'cheap seo',
|
||||||
|
'guaranteed traffic',
|
||||||
|
'visit my profile',
|
||||||
|
'work from home',
|
||||||
|
'crypto signal',
|
||||||
|
'telegram me',
|
||||||
|
'whatsapp me',
|
||||||
|
'dm for service',
|
||||||
|
],
|
||||||
|
'toxic_phrases' => [
|
||||||
|
'kill yourself',
|
||||||
|
'you idiot',
|
||||||
|
'piece of trash',
|
||||||
|
'hate you',
|
||||||
|
'worthless',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
|
||||||
|
'providers' => [
|
||||||
|
'openai' => [
|
||||||
|
'api_key' => env('OPENAI_API_KEY'),
|
||||||
|
'base_url' => env('OPENAI_BASE_URL', 'https://api.openai.com/v1'),
|
||||||
|
'model' => env('SKINBASE_AI_OPENAI_MODEL', 'gpt-4.1-mini'),
|
||||||
|
'timeout' => (int) env('SKINBASE_AI_OPENAI_TIMEOUT', 5),
|
||||||
|
],
|
||||||
|
'perspective_api' => [
|
||||||
|
'api_key' => env('PERSPECTIVE_API_KEY'),
|
||||||
|
'base_url' => env('PERSPECTIVE_API_BASE_URL', 'https://commentanalyzer.googleapis.com/v1alpha1/comments:analyze'),
|
||||||
|
'timeout' => (int) env('SKINBASE_AI_PERSPECTIVE_TIMEOUT', 5),
|
||||||
|
],
|
||||||
|
'local_llm' => [
|
||||||
|
'endpoint' => env('SKINBASE_AI_LOCAL_LLM_ENDPOINT'),
|
||||||
|
'model' => env('SKINBASE_AI_LOCAL_LLM_MODEL', 'moderation'),
|
||||||
|
'timeout' => (int) env('SKINBASE_AI_LOCAL_LLM_TIMEOUT', 5),
|
||||||
|
'token' => env('SKINBASE_AI_LOCAL_LLM_TOKEN'),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
];
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('tag_interaction_events', function (Blueprint $table): void {
|
||||||
|
$table->id();
|
||||||
|
$table->date('event_date')->index();
|
||||||
|
$table->string('event_type', 24)->index();
|
||||||
|
$table->string('surface', 32)->index();
|
||||||
|
$table->foreignId('user_id')->nullable()->constrained('users')->nullOnDelete();
|
||||||
|
$table->string('session_key', 64)->nullable()->index();
|
||||||
|
$table->string('tag_slug', 120)->nullable()->index();
|
||||||
|
$table->string('source_tag_slug', 120)->nullable()->index();
|
||||||
|
$table->string('query', 120)->nullable();
|
||||||
|
$table->unsignedSmallInteger('position')->nullable();
|
||||||
|
$table->json('meta')->nullable();
|
||||||
|
$table->timestamp('occurred_at')->nullable()->index();
|
||||||
|
$table->timestamps();
|
||||||
|
|
||||||
|
$table->index(['event_date', 'surface', 'event_type'], 'tag_interaction_events_daily_idx');
|
||||||
|
$table->index(['tag_slug', 'surface', 'event_date'], 'tag_interaction_events_tag_surface_idx');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('tag_interaction_events');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('user_mentions', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->unsignedBigInteger('user_id');
|
||||||
|
$table->unsignedBigInteger('mentioned_user_id');
|
||||||
|
$table->unsignedBigInteger('artwork_id')->nullable();
|
||||||
|
$table->unsignedBigInteger('comment_id');
|
||||||
|
$table->timestamp('created_at')->useCurrent();
|
||||||
|
|
||||||
|
$table->unique(['comment_id', 'mentioned_user_id'], 'user_mentions_comment_user_unique');
|
||||||
|
$table->index(['mentioned_user_id', 'created_at'], 'user_mentions_mentioned_created_idx');
|
||||||
|
$table->index(['user_id', 'created_at'], 'user_mentions_actor_created_idx');
|
||||||
|
|
||||||
|
$table->foreign('user_id')->references('id')->on('users')->onDelete('cascade');
|
||||||
|
$table->foreign('mentioned_user_id')->references('id')->on('users')->onDelete('cascade');
|
||||||
|
$table->foreign('artwork_id')->references('id')->on('artworks')->onDelete('cascade');
|
||||||
|
$table->foreign('comment_id')->references('id')->on('artwork_comments')->onDelete('cascade');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('user_mentions');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('tag_interaction_daily_metrics', function (Blueprint $table): void {
|
||||||
|
$table->id();
|
||||||
|
$table->date('metric_date');
|
||||||
|
$table->string('surface', 32);
|
||||||
|
$table->string('tag_slug', 120)->default('');
|
||||||
|
$table->string('source_tag_slug', 120)->default('');
|
||||||
|
$table->string('query', 120)->default('');
|
||||||
|
$table->unsignedInteger('clicks')->default(0);
|
||||||
|
$table->unsignedInteger('unique_users')->default(0);
|
||||||
|
$table->unsignedInteger('unique_sessions')->default(0);
|
||||||
|
$table->decimal('avg_position', 8, 2)->default(0);
|
||||||
|
$table->timestamps();
|
||||||
|
|
||||||
|
$table->unique(
|
||||||
|
['metric_date', 'surface', 'tag_slug', 'source_tag_slug', 'query'],
|
||||||
|
'tag_interaction_daily_metrics_unique_idx'
|
||||||
|
);
|
||||||
|
$table->index(['metric_date', 'surface'], 'tag_interaction_daily_metrics_surface_idx');
|
||||||
|
$table->index(['metric_date', 'tag_slug'], 'tag_interaction_daily_metrics_tag_idx');
|
||||||
|
$table->index(['metric_date', 'query'], 'tag_interaction_daily_metrics_query_idx');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('tag_interaction_daily_metrics');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
[program:skinbase-queue]
|
[program:skinbase-queue]
|
||||||
command=/usr/bin/php /var/www/skinbase/artisan queue:work --sleep=3 --tries=3 --timeout=90 --queue=default
|
command=/usr/bin/php /var/www/skinbase/artisan queue:work --sleep=3 --tries=3 --timeout=90 --queue=forum-security,forum-moderation,default
|
||||||
process_name=%(program_name)s_%(process_num)02d
|
process_name=%(program_name)s_%(process_num)02d
|
||||||
numprocs=1
|
numprocs=1
|
||||||
autostart=true
|
autostart=true
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ Group=www-data
|
|||||||
Restart=always
|
Restart=always
|
||||||
RestartSec=3
|
RestartSec=3
|
||||||
WorkingDirectory=/var/www/skinbase
|
WorkingDirectory=/var/www/skinbase
|
||||||
ExecStart=/usr/bin/php /var/www/skinbase/artisan queue:work --sleep=3 --tries=3 --timeout=90 --queue=default
|
ExecStart=/usr/bin/php /var/www/skinbase/artisan queue:work --sleep=3 --tries=3 --timeout=90 --queue=forum-security,forum-moderation,default
|
||||||
StandardOutput=syslog
|
StandardOutput=syslog
|
||||||
StandardError=syslog
|
StandardError=syslog
|
||||||
SyslogIdentifier=skinbase-queue
|
SyslogIdentifier=skinbase-queue
|
||||||
|
|||||||
208
docs/forum-bot-protection.md
Normal file
208
docs/forum-bot-protection.md
Normal file
@@ -0,0 +1,208 @@
|
|||||||
|
# Forum Bot Protection
|
||||||
|
|
||||||
|
This document describes the production anti-bot stack protecting forum, auth, profile, and selected API write actions.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
Primary implementation lives in:
|
||||||
|
|
||||||
|
- `config/forum_bot_protection.php`
|
||||||
|
- `packages/klevze/Plugins/Forum/Services/Security`
|
||||||
|
- `app/Http/Middleware/ForumBotProtectionMiddleware.php`
|
||||||
|
- `packages/klevze/Plugins/Forum/Console/ForumBotScanCommand.php`
|
||||||
|
- `packages/klevze/Plugins/Forum/Jobs/BotActivityMonitor.php`
|
||||||
|
|
||||||
|
Protected actions currently include:
|
||||||
|
|
||||||
|
- registration
|
||||||
|
- login
|
||||||
|
- forum topic create
|
||||||
|
- forum reply create
|
||||||
|
- forum post update
|
||||||
|
- profile update
|
||||||
|
- selected API write routes
|
||||||
|
|
||||||
|
## Detection Layers
|
||||||
|
|
||||||
|
Risk scoring combines multiple signals:
|
||||||
|
|
||||||
|
- honeypot hits
|
||||||
|
- browser and device fingerprints
|
||||||
|
- repeated content and spam phrase analysis
|
||||||
|
- account age and action burst behavior
|
||||||
|
- proxy, Tor, and blacklist checks
|
||||||
|
- provider and datacenter CIDR range checks
|
||||||
|
- account farm heuristics across IP and fingerprint reuse
|
||||||
|
|
||||||
|
The score is interpreted through `config/forum_bot_protection.php`:
|
||||||
|
|
||||||
|
- `allow`
|
||||||
|
- `log`
|
||||||
|
- `captcha`
|
||||||
|
- `moderate`
|
||||||
|
- `block`
|
||||||
|
|
||||||
|
## Persistence
|
||||||
|
|
||||||
|
Bot activity is stored in:
|
||||||
|
|
||||||
|
- `forum_bot_logs`
|
||||||
|
- `forum_bot_ip_blacklist`
|
||||||
|
- `forum_bot_device_fingerprints`
|
||||||
|
- `forum_bot_behavior_profiles`
|
||||||
|
|
||||||
|
User records also carry:
|
||||||
|
|
||||||
|
- `bot_risk_score`
|
||||||
|
- `bot_flags`
|
||||||
|
- `last_bot_activity_at`
|
||||||
|
|
||||||
|
## Captcha Escalation
|
||||||
|
|
||||||
|
When a request risk score reaches the configured captcha threshold, middleware requires a provider-backed challenge before allowing the action.
|
||||||
|
|
||||||
|
Provider selection:
|
||||||
|
|
||||||
|
- `FORUM_BOT_CAPTCHA_PROVIDER=turnstile`
|
||||||
|
- `FORUM_BOT_CAPTCHA_PROVIDER=recaptcha`
|
||||||
|
- `FORUM_BOT_CAPTCHA_PROVIDER=hcaptcha`
|
||||||
|
|
||||||
|
Optional request input override:
|
||||||
|
|
||||||
|
- `FORUM_BOT_CAPTCHA_INPUT`
|
||||||
|
|
||||||
|
Supported provider environment keys:
|
||||||
|
|
||||||
|
### Turnstile
|
||||||
|
|
||||||
|
- `TURNSTILE_SITE_KEY`
|
||||||
|
- `TURNSTILE_SECRET_KEY`
|
||||||
|
- `TURNSTILE_SCRIPT_URL`
|
||||||
|
- `TURNSTILE_VERIFY_URL`
|
||||||
|
|
||||||
|
### reCAPTCHA
|
||||||
|
|
||||||
|
- `RECAPTCHA_ENABLED`
|
||||||
|
- `RECAPTCHA_SITE_KEY`
|
||||||
|
- `RECAPTCHA_SECRET_KEY`
|
||||||
|
- `RECAPTCHA_SCRIPT_URL`
|
||||||
|
- `RECAPTCHA_VERIFY_URL`
|
||||||
|
|
||||||
|
### hCaptcha
|
||||||
|
|
||||||
|
- `HCAPTCHA_ENABLED`
|
||||||
|
- `HCAPTCHA_SITE_KEY`
|
||||||
|
- `HCAPTCHA_SECRET_KEY`
|
||||||
|
- `HCAPTCHA_SCRIPT_URL`
|
||||||
|
- `HCAPTCHA_VERIFY_URL`
|
||||||
|
|
||||||
|
If the selected provider is missing required keys, captcha escalation is effectively disabled and high-risk requests will continue through the non-captcha anti-bot path.
|
||||||
|
|
||||||
|
## Origin Header Setup
|
||||||
|
|
||||||
|
Geo-behavior scoring only activates when the origin receives a trusted two-letter country header. The current analyzer checks these headers in order:
|
||||||
|
|
||||||
|
- `CF-IPCountry`
|
||||||
|
- `CloudFront-Viewer-Country`
|
||||||
|
- `X-Country-Code`
|
||||||
|
- `X-App-Country-Code`
|
||||||
|
|
||||||
|
Recommended production setup:
|
||||||
|
|
||||||
|
### Cloudflare
|
||||||
|
|
||||||
|
- If you only need country detection: Cloudflare Dashboard → `Network` → turn `IP Geolocation` on.
|
||||||
|
- If you want the broader location header set: Cloudflare Dashboard → `Rules` → `Managed Transforms` → enable `Add visitor location headers`.
|
||||||
|
- The origin header used by this app is `CF-IPCountry`.
|
||||||
|
|
||||||
|
### Amazon CloudFront
|
||||||
|
|
||||||
|
- Edit the distribution behavior used for the app origin.
|
||||||
|
- Attach an origin request policy that includes geolocation headers, or create a custom origin request policy that forwards `CloudFront-Viewer-Country`.
|
||||||
|
- If you cache on that behavior and want cache variation by forwarded headers, ensure the paired cache policy is compatible with the origin request policy you choose.
|
||||||
|
|
||||||
|
### Reverse Proxy / Load Balancer
|
||||||
|
|
||||||
|
- Pass the CDN country header through unchanged to PHP-FPM / Laravel.
|
||||||
|
- For Nginx, avoid clearing the header and explicitly preserve it if you normalize upstream headers, for example: `proxy_set_header CF-IPCountry $http_cf_ipcountry;` or `proxy_set_header CloudFront-Viewer-Country $http_cloudfront_viewer_country;`.
|
||||||
|
- If you terminate the CDN header at the proxy and want a normalized application header instead, map it to `X-Country-Code` and keep the value as a two-letter ISO country code.
|
||||||
|
|
||||||
|
Validation:
|
||||||
|
|
||||||
|
- Send a request through the real edge and confirm the header is visible in Laravel request headers.
|
||||||
|
- Check that a login event stored in `forum_bot_logs.metadata.country_code` contains the expected country code.
|
||||||
|
|
||||||
|
## IP Range Configuration
|
||||||
|
|
||||||
|
IP reputation supports three types of network lists in `config/forum_bot_protection.php`:
|
||||||
|
|
||||||
|
- `known_proxies`: exact IPs or CIDRs for proxy and VPN ranges
|
||||||
|
- `datacenter_ranges`: generic datacenter or hosting CIDRs
|
||||||
|
- `provider_ranges`: provider-specific buckets such as `aws`, `azure`, `gcp`, `digitalocean`, `hetzner`, and `ovh`
|
||||||
|
|
||||||
|
All three lists accept either exact IP strings or CIDR notation.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
```php
|
||||||
|
'ip' => [
|
||||||
|
'known_proxies' => ['198.51.100.0/24'],
|
||||||
|
'datacenter_ranges' => ['203.0.113.0/24'],
|
||||||
|
'provider_ranges' => [
|
||||||
|
'aws' => ['54.240.0.0/12'],
|
||||||
|
'hetzner' => ['88.198.0.0/16'],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
```
|
||||||
|
|
||||||
|
Operational guidance:
|
||||||
|
|
||||||
|
- keep provider ranges in the named `provider_ranges` buckets so the control panel can show per-provider coverage counts
|
||||||
|
- populate ranges only from provider-owned feeds or other trusted sources you maintain internally
|
||||||
|
- after changing CIDR lists, clear cache if you need immediate effect on hot IPs
|
||||||
|
|
||||||
|
## Queue and Scheduling
|
||||||
|
|
||||||
|
Recent activity scanning runs through:
|
||||||
|
|
||||||
|
- command: `php artisan forum:bot-scan`
|
||||||
|
- queued job: `BotActivityMonitor`
|
||||||
|
- schedule: every 5 minutes in `routes/console.php`
|
||||||
|
|
||||||
|
Default command behavior dispatches the monitor job onto the configured queue. Use `--sync` for inline execution.
|
||||||
|
|
||||||
|
## Admin Operations
|
||||||
|
|
||||||
|
Control panel screen:
|
||||||
|
|
||||||
|
- route: `admin.forum.security.bot-protection.main`
|
||||||
|
|
||||||
|
Available actions:
|
||||||
|
|
||||||
|
- review recent bot events
|
||||||
|
- inspect suspicious users
|
||||||
|
- inspect high-risk fingerprints
|
||||||
|
- inspect recent rate-limit violations and their limiter metadata
|
||||||
|
- manually blacklist IPs
|
||||||
|
- approve or ban flagged users
|
||||||
|
- confirm current captcha provider, threshold, and required env keys
|
||||||
|
- confirm configured proxy, datacenter, tor, and provider CIDR coverage counts
|
||||||
|
- filter analytics by time window and action
|
||||||
|
- export recent bot events as CSV
|
||||||
|
- export top bot reasons as JSON
|
||||||
|
|
||||||
|
## Validation Checklist
|
||||||
|
|
||||||
|
Useful commands:
|
||||||
|
|
||||||
|
- `php artisan forum:bot-scan --help`
|
||||||
|
- `php artisan forum:bot-scan --sync --minutes=5`
|
||||||
|
- `php artisan route:list --name=admin.forum.security.bot-protection.main`
|
||||||
|
- `npm run build`
|
||||||
|
|
||||||
|
Quick runtime checks:
|
||||||
|
|
||||||
|
- confirm new bot events land in `forum_bot_logs`
|
||||||
|
- confirm fingerprints land in `forum_bot_device_fingerprints`
|
||||||
|
- confirm the jobs table contains `BotActivityMonitor` after `forum:bot-scan`
|
||||||
|
- confirm the control panel shows the expected captcha provider and action list
|
||||||
220
resources/js/Pages/Community/CommunityActivityPage.jsx
Normal file
220
resources/js/Pages/Community/CommunityActivityPage.jsx
Normal file
@@ -0,0 +1,220 @@
|
|||||||
|
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
|
import { createRoot } from 'react-dom/client'
|
||||||
|
import ActivityFeed from '../../components/community/ActivityFeed'
|
||||||
|
|
||||||
|
const FILTER_TABS = [
|
||||||
|
{ key: 'all', label: 'All Activity' },
|
||||||
|
{ key: 'comments', label: 'Comments' },
|
||||||
|
{ key: 'replies', label: 'Replies' },
|
||||||
|
{ key: 'following', label: 'Following', authRequired: true },
|
||||||
|
{ key: 'my', label: 'My Activity', authRequired: true },
|
||||||
|
]
|
||||||
|
|
||||||
|
function FilterPills({ activeFilter, isAuthenticated, onChange }) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
{FILTER_TABS.map((tab) => {
|
||||||
|
const disabled = tab.authRequired && !isAuthenticated
|
||||||
|
const active = activeFilter === tab.key
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={tab.key}
|
||||||
|
type="button"
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={() => !disabled && onChange(tab.key)}
|
||||||
|
className={[
|
||||||
|
'rounded-full border px-4 py-2 text-sm font-medium transition-all',
|
||||||
|
active
|
||||||
|
? 'border-sky-400/30 bg-sky-500/14 text-sky-200 shadow-[0_0_0_1px_rgba(56,189,248,0.08)]'
|
||||||
|
: 'border-white/[0.06] bg-white/[0.03] text-white/55 hover:border-white/15 hover:bg-white/[0.05] hover:text-white/85',
|
||||||
|
disabled ? 'cursor-not-allowed opacity-35' : '',
|
||||||
|
].join(' ')}
|
||||||
|
title={disabled ? 'Log in to use this filter' : undefined}
|
||||||
|
>
|
||||||
|
{tab.label}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateUrl(filter, userId) {
|
||||||
|
const url = new URL(window.location.href)
|
||||||
|
|
||||||
|
if (filter && filter !== 'all') url.searchParams.set('filter', filter)
|
||||||
|
else url.searchParams.delete('filter')
|
||||||
|
|
||||||
|
if (userId) url.searchParams.set('user_id', String(userId))
|
||||||
|
else url.searchParams.delete('user_id')
|
||||||
|
|
||||||
|
window.history.replaceState({}, '', url.toString())
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateHeaderSummary(filter, userId) {
|
||||||
|
const filterLabels = {
|
||||||
|
all: 'All Activity',
|
||||||
|
comments: 'Comments',
|
||||||
|
replies: 'Replies',
|
||||||
|
following: 'Following',
|
||||||
|
my: 'My Activity',
|
||||||
|
}
|
||||||
|
|
||||||
|
const filterNode = document.getElementById('community-activity-filter-summary')
|
||||||
|
const scopeNode = document.getElementById('community-activity-scope-summary')
|
||||||
|
|
||||||
|
if (filterNode) {
|
||||||
|
filterNode.innerHTML = `<i class="fa-solid fa-filter"></i> ${filterLabels[filter] || filterLabels.all}`
|
||||||
|
}
|
||||||
|
|
||||||
|
if (scopeNode) {
|
||||||
|
if (userId) {
|
||||||
|
scopeNode.className = 'inline-flex items-center gap-1.5 rounded-full border border-white/[0.08] bg-white/[0.04] px-3 py-1 text-white/65'
|
||||||
|
scopeNode.innerHTML = `<i class="fa-solid fa-user"></i> User #${userId}`
|
||||||
|
} else {
|
||||||
|
scopeNode.className = 'hidden'
|
||||||
|
scopeNode.innerHTML = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function CommunityActivityPage({
|
||||||
|
initialActivities = [],
|
||||||
|
initialMeta = {},
|
||||||
|
initialFilter = 'all',
|
||||||
|
initialUserId = null,
|
||||||
|
isAuthenticated = false,
|
||||||
|
}) {
|
||||||
|
const [activeFilter, setActiveFilter] = useState(initialFilter)
|
||||||
|
const [activities, setActivities] = useState(initialActivities)
|
||||||
|
const [meta, setMeta] = useState(initialMeta)
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [loadingMore, setLoadingMore] = useState(false)
|
||||||
|
const [error, setError] = useState(null)
|
||||||
|
const sentinelRef = useRef(null)
|
||||||
|
const requestIdRef = useRef(0)
|
||||||
|
|
||||||
|
const hasMore = Boolean(meta?.has_more)
|
||||||
|
const nextPage = Number(meta?.current_page || 1) + 1
|
||||||
|
|
||||||
|
const fetchFeed = useCallback(async ({ filter, page, append }) => {
|
||||||
|
const requestId = ++requestIdRef.current
|
||||||
|
setError(null)
|
||||||
|
if (append) setLoadingMore(true)
|
||||||
|
else setLoading(true)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams({ filter, page: String(page) })
|
||||||
|
if (initialUserId) params.set('user_id', String(initialUserId))
|
||||||
|
|
||||||
|
const response = await fetch(`/api/community/activity?${params.toString()}`, {
|
||||||
|
headers: { Accept: 'application/json', 'X-Requested-With': 'XMLHttpRequest' },
|
||||||
|
credentials: 'same-origin',
|
||||||
|
})
|
||||||
|
|
||||||
|
if (requestId !== requestIdRef.current) return
|
||||||
|
|
||||||
|
if (response.status === 401) {
|
||||||
|
setError('Please log in to view this activity filter.')
|
||||||
|
if (!append) {
|
||||||
|
setActivities([])
|
||||||
|
setMeta({ current_page: 1, last_page: 1, has_more: false, total: 0 })
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to load community activity.')
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = await response.json()
|
||||||
|
setActivities((prev) => append ? [...prev, ...(payload.data || [])] : (payload.data || []))
|
||||||
|
setMeta(payload.meta || {})
|
||||||
|
} catch {
|
||||||
|
if (requestId === requestIdRef.current) {
|
||||||
|
setError('Failed to load community activity. Please try again.')
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (requestId === requestIdRef.current) {
|
||||||
|
setLoading(false)
|
||||||
|
setLoadingMore(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [initialUserId])
|
||||||
|
|
||||||
|
const handleFilterChange = useCallback((nextFilter) => {
|
||||||
|
if (nextFilter === activeFilter) return
|
||||||
|
setActiveFilter(nextFilter)
|
||||||
|
updateUrl(nextFilter, initialUserId)
|
||||||
|
fetchFeed({ filter: nextFilter, page: 1, append: false })
|
||||||
|
}, [activeFilter, fetchFeed, initialUserId])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
updateHeaderSummary(activeFilter, initialUserId)
|
||||||
|
}, [activeFilter, initialUserId])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const sentinel = sentinelRef.current
|
||||||
|
if (!sentinel || loading || loadingMore || !hasMore) return undefined
|
||||||
|
|
||||||
|
const observer = new IntersectionObserver((entries) => {
|
||||||
|
const [entry] = entries
|
||||||
|
if (entry?.isIntersecting) {
|
||||||
|
fetchFeed({ filter: activeFilter, page: nextPage, append: true })
|
||||||
|
}
|
||||||
|
}, { rootMargin: '220px 0px' })
|
||||||
|
|
||||||
|
observer.observe(sentinel)
|
||||||
|
return () => observer.disconnect()
|
||||||
|
}, [activeFilter, fetchFeed, hasMore, loading, loadingMore, nextPage])
|
||||||
|
|
||||||
|
const resultsLabel = useMemo(() => {
|
||||||
|
const total = Number(meta?.total || activities.length || 0)
|
||||||
|
if (!total) return 'No recent activity'
|
||||||
|
return `${total.toLocaleString()} events`
|
||||||
|
}, [activities.length, meta?.total])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-6xl px-6 pt-8 pb-20 md:px-10">
|
||||||
|
<div className="mb-6 flex flex-col gap-4 rounded-[28px] border border-white/[0.06] bg-[linear-gradient(180deg,rgba(10,16,26,0.92),rgba(6,10,18,0.88))] p-5 shadow-[0_20px_60px_rgba(0,0,0,0.25)]">
|
||||||
|
<div className="flex flex-col gap-3 lg:flex-row lg:items-end lg:justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.22em] text-white/35">Live community pulse</p>
|
||||||
|
<p className="mt-2 max-w-2xl text-sm leading-6 text-white/55">
|
||||||
|
Comments, replies, reactions, and mentions from across Skinbase in one scrolling Nova feed.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-sm font-medium text-white/45">{resultsLabel}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<FilterPills activeFilter={activeFilter} isAuthenticated={isAuthenticated} onChange={handleFilterChange} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ActivityFeed
|
||||||
|
activities={activities}
|
||||||
|
isLoggedIn={isAuthenticated}
|
||||||
|
loading={loading}
|
||||||
|
loadingMore={loadingMore}
|
||||||
|
error={error}
|
||||||
|
sentinelRef={sentinelRef}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const mountEl = document.getElementById('community-activity-root')
|
||||||
|
|
||||||
|
if (mountEl) {
|
||||||
|
let props = {}
|
||||||
|
try {
|
||||||
|
const propsEl = document.getElementById('community-activity-props')
|
||||||
|
props = propsEl ? JSON.parse(propsEl.textContent || '{}') : {}
|
||||||
|
} catch {
|
||||||
|
props = {}
|
||||||
|
}
|
||||||
|
|
||||||
|
createRoot(mountEl).render(<CommunityActivityPage {...props} />)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default CommunityActivityPage
|
||||||
@@ -8,6 +8,8 @@ import Toggle from '../../components/ui/Toggle'
|
|||||||
import Select from '../../components/ui/Select'
|
import Select from '../../components/ui/Select'
|
||||||
import Modal from '../../components/ui/Modal'
|
import Modal from '../../components/ui/Modal'
|
||||||
import { RadioGroup } from '../../components/ui/Radio'
|
import { RadioGroup } from '../../components/ui/Radio'
|
||||||
|
import { buildBotFingerprint } from '../../lib/security/botFingerprint'
|
||||||
|
import TurnstileField from '../../components/security/TurnstileField'
|
||||||
|
|
||||||
const SETTINGS_SECTIONS = [
|
const SETTINGS_SECTIONS = [
|
||||||
{ key: 'profile', label: 'Profile', icon: 'fa-solid fa-user-astronaut', description: 'Public identity and avatar.' },
|
{ key: 'profile', label: 'Profile', icon: 'fa-solid fa-user-astronaut', description: 'Public identity and avatar.' },
|
||||||
@@ -57,6 +59,16 @@ function getCsrfToken() {
|
|||||||
return document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || ''
|
return document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || ''
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function botHeaders(extra = {}, captcha = {}) {
|
||||||
|
const fingerprint = await buildBotFingerprint()
|
||||||
|
|
||||||
|
return {
|
||||||
|
...extra,
|
||||||
|
'X-Bot-Fingerprint': fingerprint,
|
||||||
|
...(captcha?.token ? { 'X-Captcha-Token': captcha.token } : {}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function toIsoDate(day, month, year) {
|
function toIsoDate(day, month, year) {
|
||||||
if (!day || !month || !year) return ''
|
if (!day || !month || !year) return ''
|
||||||
return `${String(year).padStart(4, '0')}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`
|
return `${String(year).padStart(4, '0')}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`
|
||||||
@@ -122,6 +134,8 @@ export default function ProfileEdit() {
|
|||||||
usernameCooldownDays = 30,
|
usernameCooldownDays = 30,
|
||||||
usernameCooldownRemainingDays = 0,
|
usernameCooldownRemainingDays = 0,
|
||||||
usernameCooldownActive = false,
|
usernameCooldownActive = false,
|
||||||
|
captcha: initialCaptcha = {},
|
||||||
|
flash = {},
|
||||||
} = props
|
} = props
|
||||||
|
|
||||||
const fallbackDate = toIsoDate(
|
const fallbackDate = toIsoDate(
|
||||||
@@ -194,6 +208,17 @@ export default function ProfileEdit() {
|
|||||||
notifications: {},
|
notifications: {},
|
||||||
security: {},
|
security: {},
|
||||||
})
|
})
|
||||||
|
const [captchaState, setCaptchaState] = useState({
|
||||||
|
required: !!flash?.botCaptchaRequired,
|
||||||
|
section: '',
|
||||||
|
token: '',
|
||||||
|
message: '',
|
||||||
|
nonce: 0,
|
||||||
|
provider: initialCaptcha?.provider || '',
|
||||||
|
siteKey: initialCaptcha?.siteKey || '',
|
||||||
|
inputName: initialCaptcha?.inputName || 'cf-turnstile-response',
|
||||||
|
scriptUrl: initialCaptcha?.scriptUrl || '',
|
||||||
|
})
|
||||||
|
|
||||||
const [avatarUrl, setAvatarUrl] = useState(initialAvatarUrl || '')
|
const [avatarUrl, setAvatarUrl] = useState(initialAvatarUrl || '')
|
||||||
const [avatarFile, setAvatarFile] = useState(null)
|
const [avatarFile, setAvatarFile] = useState(null)
|
||||||
@@ -346,6 +371,92 @@ export default function ProfileEdit() {
|
|||||||
setErrorsBySection((prev) => ({ ...prev, [section]: {} }))
|
setErrorsBySection((prev) => ({ ...prev, [section]: {} }))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const resetCaptchaState = () => {
|
||||||
|
setCaptchaState((prev) => ({
|
||||||
|
...prev,
|
||||||
|
required: false,
|
||||||
|
section: '',
|
||||||
|
token: '',
|
||||||
|
message: '',
|
||||||
|
nonce: prev.nonce + 1,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
const captureCaptchaRequirement = (section, payload = {}) => {
|
||||||
|
const requiresCaptcha = !!(payload?.requires_captcha || payload?.requiresCaptcha)
|
||||||
|
|
||||||
|
if (!requiresCaptcha) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextCaptcha = payload?.captcha || {}
|
||||||
|
const message = payload?.errors?.captcha?.[0] || payload?.message || 'Complete the captcha challenge to continue.'
|
||||||
|
|
||||||
|
setCaptchaState((prev) => ({
|
||||||
|
required: true,
|
||||||
|
section,
|
||||||
|
token: '',
|
||||||
|
message,
|
||||||
|
nonce: prev.nonce + 1,
|
||||||
|
provider: nextCaptcha.provider || payload?.captcha_provider || prev.provider || initialCaptcha?.provider || 'turnstile',
|
||||||
|
siteKey: nextCaptcha.siteKey || payload?.captcha_site_key || prev.siteKey || initialCaptcha?.siteKey || '',
|
||||||
|
inputName: nextCaptcha.inputName || payload?.captcha_input || prev.inputName || initialCaptcha?.inputName || 'cf-turnstile-response',
|
||||||
|
scriptUrl: nextCaptcha.scriptUrl || payload?.captcha_script_url || prev.scriptUrl || initialCaptcha?.scriptUrl || '',
|
||||||
|
}))
|
||||||
|
|
||||||
|
updateSectionErrors(section, {
|
||||||
|
_general: [message],
|
||||||
|
captcha: [message],
|
||||||
|
})
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
const applyCaptchaPayload = (payload = {}) => {
|
||||||
|
if (!captchaState.required || !captchaState.inputName) {
|
||||||
|
return payload
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...payload,
|
||||||
|
[captchaState.inputName]: captchaState.token || '',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const applyCaptchaFormData = (formData) => {
|
||||||
|
if (captchaState.required && captchaState.inputName) {
|
||||||
|
formData.set(captchaState.inputName, captchaState.token || '')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const renderCaptchaChallenge = (section, placement = 'section') => {
|
||||||
|
if (!captchaState.required || !captchaState.siteKey || activeSection !== section) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (section === 'account' && showEmailChangeModal && placement !== 'modal') {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (section === 'account' && !showEmailChangeModal && placement === 'modal') {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl border border-amber-400/20 bg-amber-500/10 p-4">
|
||||||
|
<p className="mb-3 text-sm text-amber-100">{captchaState.message || 'Complete the captcha challenge to continue.'}</p>
|
||||||
|
<TurnstileField
|
||||||
|
key={`${section}-${placement}-${captchaState.nonce}`}
|
||||||
|
provider={captchaState.provider}
|
||||||
|
siteKey={captchaState.siteKey}
|
||||||
|
scriptUrl={captchaState.scriptUrl}
|
||||||
|
onToken={(token) => setCaptchaState((prev) => ({ ...prev, token }))}
|
||||||
|
className="rounded-lg border border-white/10 bg-black/20 p-3"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const switchSection = (nextSection) => {
|
const switchSection = (nextSection) => {
|
||||||
if (activeSection === nextSection) return
|
if (activeSection === nextSection) return
|
||||||
if (dirtyMap[activeSection]) {
|
if (dirtyMap[activeSection]) {
|
||||||
@@ -397,19 +508,23 @@ export default function ProfileEdit() {
|
|||||||
if (avatarFile) {
|
if (avatarFile) {
|
||||||
formData.append('avatar', avatarFile)
|
formData.append('avatar', avatarFile)
|
||||||
}
|
}
|
||||||
|
applyCaptchaFormData(formData)
|
||||||
|
|
||||||
const response = await fetch('/settings/profile/update', {
|
const response = await fetch('/settings/profile/update', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
credentials: 'same-origin',
|
credentials: 'same-origin',
|
||||||
headers: {
|
headers: await botHeaders({
|
||||||
Accept: 'application/json',
|
Accept: 'application/json',
|
||||||
'X-CSRF-TOKEN': getCsrfToken(),
|
'X-CSRF-TOKEN': getCsrfToken(),
|
||||||
},
|
}, captchaState),
|
||||||
body: formData,
|
body: formData,
|
||||||
})
|
})
|
||||||
|
|
||||||
const payload = await response.json().catch(() => ({}))
|
const payload = await response.json().catch(() => ({}))
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
|
if (captureCaptchaRequirement('profile', payload)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
updateSectionErrors('profile', payload.errors || { _general: [payload.message || 'Unable to save profile section.'] })
|
updateSectionErrors('profile', payload.errors || { _general: [payload.message || 'Unable to save profile section.'] })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -421,6 +536,7 @@ export default function ProfileEdit() {
|
|||||||
setAvatarFile(null)
|
setAvatarFile(null)
|
||||||
setAvatarPosition('center')
|
setAvatarPosition('center')
|
||||||
setRemoveAvatar(false)
|
setRemoveAvatar(false)
|
||||||
|
resetCaptchaState()
|
||||||
setSavedMessage({ section: 'profile', text: payload.message || 'Profile updated successfully.' })
|
setSavedMessage({ section: 'profile', text: payload.message || 'Profile updated successfully.' })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
updateSectionErrors('profile', { _general: ['Request failed. Please try again.'] })
|
updateSectionErrors('profile', { _general: ['Request failed. Please try again.'] })
|
||||||
@@ -446,21 +562,25 @@ export default function ProfileEdit() {
|
|||||||
const response = await fetch('/settings/account/username', {
|
const response = await fetch('/settings/account/username', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
credentials: 'same-origin',
|
credentials: 'same-origin',
|
||||||
headers: {
|
headers: await botHeaders({
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
Accept: 'application/json',
|
Accept: 'application/json',
|
||||||
'X-CSRF-TOKEN': getCsrfToken(),
|
'X-CSRF-TOKEN': getCsrfToken(),
|
||||||
},
|
}, captchaState),
|
||||||
body: JSON.stringify({ username: accountForm.username }),
|
body: JSON.stringify(applyCaptchaPayload({ username: accountForm.username, homepage_url: '' })),
|
||||||
})
|
})
|
||||||
|
|
||||||
const payload = await response.json().catch(() => ({}))
|
const payload = await response.json().catch(() => ({}))
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
|
if (captureCaptchaRequirement('account', payload)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
updateSectionErrors('account', payload.errors || { _general: [payload.message || 'Unable to save account section.'] })
|
updateSectionErrors('account', payload.errors || { _general: [payload.message || 'Unable to save account section.'] })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
initialRef.current.accountForm = { ...accountForm }
|
initialRef.current.accountForm = { ...accountForm }
|
||||||
|
resetCaptchaState()
|
||||||
setSavedMessage({ section: 'account', text: payload.message || 'Account updated successfully.' })
|
setSavedMessage({ section: 'account', text: payload.message || 'Account updated successfully.' })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
updateSectionErrors('account', { _general: ['Request failed. Please try again.'] })
|
updateSectionErrors('account', { _general: ['Request failed. Please try again.'] })
|
||||||
@@ -478,21 +598,26 @@ export default function ProfileEdit() {
|
|||||||
const response = await fetch('/settings/email/request', {
|
const response = await fetch('/settings/email/request', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
credentials: 'same-origin',
|
credentials: 'same-origin',
|
||||||
headers: {
|
headers: await botHeaders({
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
Accept: 'application/json',
|
Accept: 'application/json',
|
||||||
'X-CSRF-TOKEN': getCsrfToken(),
|
'X-CSRF-TOKEN': getCsrfToken(),
|
||||||
},
|
}, captchaState),
|
||||||
body: JSON.stringify({ new_email: emailChangeForm.new_email }),
|
body: JSON.stringify(applyCaptchaPayload({ new_email: emailChangeForm.new_email, homepage_url: '' })),
|
||||||
})
|
})
|
||||||
|
|
||||||
const payload = await response.json().catch(() => ({}))
|
const payload = await response.json().catch(() => ({}))
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
|
if (captureCaptchaRequirement('account', payload)) {
|
||||||
|
setEmailChangeError(payload?.errors?.captcha?.[0] || payload?.message || 'Complete the captcha challenge to continue.')
|
||||||
|
return
|
||||||
|
}
|
||||||
setEmailChangeError(payload?.errors?.new_email?.[0] || payload?.message || 'Unable to request email change.')
|
setEmailChangeError(payload?.errors?.new_email?.[0] || payload?.message || 'Unable to request email change.')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
setEmailChangeStep('verify')
|
setEmailChangeStep('verify')
|
||||||
|
resetCaptchaState()
|
||||||
setEmailChangeInfo(payload.message || 'Verification code sent to your new email address.')
|
setEmailChangeInfo(payload.message || 'Verification code sent to your new email address.')
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setEmailChangeError('Request failed. Please try again.')
|
setEmailChangeError('Request failed. Please try again.')
|
||||||
@@ -510,16 +635,20 @@ export default function ProfileEdit() {
|
|||||||
const response = await fetch('/settings/email/verify', {
|
const response = await fetch('/settings/email/verify', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
credentials: 'same-origin',
|
credentials: 'same-origin',
|
||||||
headers: {
|
headers: await botHeaders({
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
Accept: 'application/json',
|
Accept: 'application/json',
|
||||||
'X-CSRF-TOKEN': getCsrfToken(),
|
'X-CSRF-TOKEN': getCsrfToken(),
|
||||||
},
|
}, captchaState),
|
||||||
body: JSON.stringify({ code: emailChangeForm.code }),
|
body: JSON.stringify(applyCaptchaPayload({ code: emailChangeForm.code, homepage_url: '' })),
|
||||||
})
|
})
|
||||||
|
|
||||||
const payload = await response.json().catch(() => ({}))
|
const payload = await response.json().catch(() => ({}))
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
|
if (captureCaptchaRequirement('account', payload)) {
|
||||||
|
setEmailChangeError(payload?.errors?.captcha?.[0] || payload?.message || 'Complete the captcha challenge to continue.')
|
||||||
|
return
|
||||||
|
}
|
||||||
setEmailChangeError(payload?.errors?.code?.[0] || payload?.message || 'Verification failed.')
|
setEmailChangeError(payload?.errors?.code?.[0] || payload?.message || 'Verification failed.')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -530,6 +659,7 @@ export default function ProfileEdit() {
|
|||||||
setShowEmailChangeModal(false)
|
setShowEmailChangeModal(false)
|
||||||
setEmailChangeStep('request')
|
setEmailChangeStep('request')
|
||||||
setEmailChangeForm({ new_email: '', code: '' })
|
setEmailChangeForm({ new_email: '', code: '' })
|
||||||
|
resetCaptchaState()
|
||||||
setSavedMessage({ section: 'account', text: payload.message || 'Email updated successfully.' })
|
setSavedMessage({ section: 'account', text: payload.message || 'Email updated successfully.' })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setEmailChangeError('Request failed. Please try again.')
|
setEmailChangeError('Request failed. Please try again.')
|
||||||
@@ -547,25 +677,30 @@ export default function ProfileEdit() {
|
|||||||
const response = await fetch('/settings/personal/update', {
|
const response = await fetch('/settings/personal/update', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
credentials: 'same-origin',
|
credentials: 'same-origin',
|
||||||
headers: {
|
headers: await botHeaders({
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
Accept: 'application/json',
|
Accept: 'application/json',
|
||||||
'X-CSRF-TOKEN': getCsrfToken(),
|
'X-CSRF-TOKEN': getCsrfToken(),
|
||||||
},
|
}, captchaState),
|
||||||
body: JSON.stringify({
|
body: JSON.stringify(applyCaptchaPayload({
|
||||||
birthday: toIsoDate(personalForm.day, personalForm.month, personalForm.year) || null,
|
birthday: toIsoDate(personalForm.day, personalForm.month, personalForm.year) || null,
|
||||||
gender: personalForm.gender || null,
|
gender: personalForm.gender || null,
|
||||||
country: personalForm.country || null,
|
country: personalForm.country || null,
|
||||||
}),
|
homepage_url: '',
|
||||||
|
})),
|
||||||
})
|
})
|
||||||
|
|
||||||
const payload = await response.json().catch(() => ({}))
|
const payload = await response.json().catch(() => ({}))
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
|
if (captureCaptchaRequirement('personal', payload)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
updateSectionErrors('personal', payload.errors || { _general: [payload.message || 'Unable to save personal details.'] })
|
updateSectionErrors('personal', payload.errors || { _general: [payload.message || 'Unable to save personal details.'] })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
initialRef.current.personalForm = { ...personalForm }
|
initialRef.current.personalForm = { ...personalForm }
|
||||||
|
resetCaptchaState()
|
||||||
setSavedMessage({ section: 'personal', text: payload.message || 'Personal details saved successfully.' })
|
setSavedMessage({ section: 'personal', text: payload.message || 'Personal details saved successfully.' })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
updateSectionErrors('personal', { _general: ['Request failed. Please try again.'] })
|
updateSectionErrors('personal', { _general: ['Request failed. Please try again.'] })
|
||||||
@@ -583,21 +718,25 @@ export default function ProfileEdit() {
|
|||||||
const response = await fetch('/settings/notifications/update', {
|
const response = await fetch('/settings/notifications/update', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
credentials: 'same-origin',
|
credentials: 'same-origin',
|
||||||
headers: {
|
headers: await botHeaders({
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
Accept: 'application/json',
|
Accept: 'application/json',
|
||||||
'X-CSRF-TOKEN': getCsrfToken(),
|
'X-CSRF-TOKEN': getCsrfToken(),
|
||||||
},
|
}, captchaState),
|
||||||
body: JSON.stringify(notificationForm),
|
body: JSON.stringify(applyCaptchaPayload({ ...notificationForm, homepage_url: '' })),
|
||||||
})
|
})
|
||||||
|
|
||||||
const payload = await response.json().catch(() => ({}))
|
const payload = await response.json().catch(() => ({}))
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
|
if (captureCaptchaRequirement('notifications', payload)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
updateSectionErrors('notifications', payload.errors || { _general: [payload.message || 'Unable to save notifications.'] })
|
updateSectionErrors('notifications', payload.errors || { _general: [payload.message || 'Unable to save notifications.'] })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
initialRef.current.notificationForm = { ...notificationForm }
|
initialRef.current.notificationForm = { ...notificationForm }
|
||||||
|
resetCaptchaState()
|
||||||
setSavedMessage({ section: 'notifications', text: payload.message || 'Notification settings saved successfully.' })
|
setSavedMessage({ section: 'notifications', text: payload.message || 'Notification settings saved successfully.' })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
updateSectionErrors('notifications', { _general: ['Request failed. Please try again.'] })
|
updateSectionErrors('notifications', { _general: ['Request failed. Please try again.'] })
|
||||||
@@ -615,16 +754,19 @@ export default function ProfileEdit() {
|
|||||||
const response = await fetch('/settings/security/password', {
|
const response = await fetch('/settings/security/password', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
credentials: 'same-origin',
|
credentials: 'same-origin',
|
||||||
headers: {
|
headers: await botHeaders({
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
Accept: 'application/json',
|
Accept: 'application/json',
|
||||||
'X-CSRF-TOKEN': getCsrfToken(),
|
'X-CSRF-TOKEN': getCsrfToken(),
|
||||||
},
|
}, captchaState),
|
||||||
body: JSON.stringify(securityForm),
|
body: JSON.stringify(applyCaptchaPayload({ ...securityForm, homepage_url: '' })),
|
||||||
})
|
})
|
||||||
|
|
||||||
const payload = await response.json().catch(() => ({}))
|
const payload = await response.json().catch(() => ({}))
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
|
if (captureCaptchaRequirement('security', payload)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
updateSectionErrors('security', payload.errors || { _general: [payload.message || 'Unable to update password.'] })
|
updateSectionErrors('security', payload.errors || { _general: [payload.message || 'Unable to update password.'] })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -634,6 +776,7 @@ export default function ProfileEdit() {
|
|||||||
new_password: '',
|
new_password: '',
|
||||||
new_password_confirmation: '',
|
new_password_confirmation: '',
|
||||||
})
|
})
|
||||||
|
resetCaptchaState()
|
||||||
setSavedMessage({ section: 'security', text: payload.message || 'Password updated successfully.' })
|
setSavedMessage({ section: 'security', text: payload.message || 'Password updated successfully.' })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
updateSectionErrors('security', { _general: ['Request failed. Please try again.'] })
|
updateSectionErrors('security', { _general: ['Request failed. Please try again.'] })
|
||||||
@@ -857,6 +1000,8 @@ export default function ProfileEdit() {
|
|||||||
rows={3}
|
rows={3}
|
||||||
error={errorsBySection.profile.description?.[0]}
|
error={errorsBySection.profile.description?.[0]}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{renderCaptchaChallenge('profile')}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</SectionCard>
|
</SectionCard>
|
||||||
@@ -933,6 +1078,8 @@ export default function ProfileEdit() {
|
|||||||
<p className="mt-4 rounded-lg border border-white/10 bg-white/[0.02] px-3 py-2 text-xs text-slate-300">
|
<p className="mt-4 rounded-lg border border-white/10 bg-white/[0.02] px-3 py-2 text-xs text-slate-300">
|
||||||
You can change your username once every {usernameCooldownDays} days.
|
You can change your username once every {usernameCooldownDays} days.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
{renderCaptchaChallenge('account')}
|
||||||
</SectionCard>
|
</SectionCard>
|
||||||
</form>
|
</form>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -1034,6 +1181,8 @@ export default function ProfileEdit() {
|
|||||||
error={errorsBySection.personal.country?.[0]}
|
error={errorsBySection.personal.country?.[0]}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{renderCaptchaChallenge('personal')}
|
||||||
</div>
|
</div>
|
||||||
</SectionCard>
|
</SectionCard>
|
||||||
</form>
|
</form>
|
||||||
@@ -1085,6 +1234,8 @@ export default function ProfileEdit() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
|
{renderCaptchaChallenge('notifications')}
|
||||||
</div>
|
</div>
|
||||||
</SectionCard>
|
</SectionCard>
|
||||||
</form>
|
</form>
|
||||||
@@ -1152,6 +1303,8 @@ export default function ProfileEdit() {
|
|||||||
<div className="rounded-lg border border-white/5 bg-white/[0.02] p-3 text-xs text-slate-400">
|
<div className="rounded-lg border border-white/5 bg-white/[0.02] p-3 text-xs text-slate-400">
|
||||||
Future security controls: Two-factor authentication, active sessions, and login history.
|
Future security controls: Two-factor authentication, active sessions, and login history.
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{renderCaptchaChallenge('security')}
|
||||||
</div>
|
</div>
|
||||||
</SectionCard>
|
</SectionCard>
|
||||||
</form>
|
</form>
|
||||||
@@ -1221,6 +1374,8 @@ export default function ProfileEdit() {
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
{renderCaptchaChallenge('account', 'modal')}
|
||||||
|
|
||||||
{emailChangeStep === 'request' ? (
|
{emailChangeStep === 'request' ? (
|
||||||
<TextInput
|
<TextInput
|
||||||
label="Enter new email address"
|
label="Enter new email address"
|
||||||
|
|||||||
@@ -154,6 +154,8 @@ export default function BulkTagModal({ open, mode = 'add', onClose, onConfirm })
|
|||||||
{!loading &&
|
{!loading &&
|
||||||
results.map((tag) => {
|
results.map((tag) => {
|
||||||
const isSelected = selected.some((t) => t.id === tag.id)
|
const isSelected = selected.some((t) => t.id === tag.id)
|
||||||
|
const recentClicks = Number(tag.recent_clicks || 0)
|
||||||
|
const usageCount = Number(tag.usage_count || 0)
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={tag.id}
|
key={tag.id}
|
||||||
@@ -174,7 +176,9 @@ export default function BulkTagModal({ open, mode = 'add', onClose, onConfirm })
|
|||||||
/>
|
/>
|
||||||
{tag.name}
|
{tag.name}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xs text-slate-500">{tag.usage_count?.toLocaleString() ?? 0} uses</span>
|
<span className="text-xs text-slate-500">
|
||||||
|
{recentClicks > 0 ? `${recentClicks.toLocaleString()} recent clicks` : `${usageCount.toLocaleString()} uses`}
|
||||||
|
</span>
|
||||||
</button>
|
</button>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|||||||
63
resources/js/components/Studio/BulkTagModal.test.jsx
Normal file
63
resources/js/components/Studio/BulkTagModal.test.jsx
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import { render, screen, waitFor } from '@testing-library/react'
|
||||||
|
import userEvent from '@testing-library/user-event'
|
||||||
|
import BulkTagModal from './BulkTagModal'
|
||||||
|
|
||||||
|
describe('BulkTagModal', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
document.head.innerHTML = '<meta name="csrf-token" content="test-token">'
|
||||||
|
|
||||||
|
global.fetch = vi.fn(async (url) => {
|
||||||
|
const requestUrl = String(url)
|
||||||
|
|
||||||
|
if (requestUrl.includes('?q=high')) {
|
||||||
|
return {
|
||||||
|
json: async () => ([
|
||||||
|
{ id: 2, name: 'High Contrast', slug: 'high-contrast', usage_count: 120, recent_clicks: 18 },
|
||||||
|
{ id: 3, name: 'High Detail', slug: 'high-detail', usage_count: 90, recent_clicks: 0 },
|
||||||
|
]),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
json: async () => ([
|
||||||
|
{ id: 1, name: 'Popular Pick', slug: 'popular-pick', usage_count: 300, recent_clicks: 9 },
|
||||||
|
]),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks()
|
||||||
|
document.head.innerHTML = ''
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows recent click momentum for initial results', async () => {
|
||||||
|
render(<BulkTagModal open mode="add" onClose={() => {}} onConfirm={() => {}} />)
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Popular Pick')).not.toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(screen.getByText('9 recent clicks')).not.toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns selected tag ids and shows recent click momentum in search results', async () => {
|
||||||
|
const onConfirm = vi.fn()
|
||||||
|
|
||||||
|
render(<BulkTagModal open mode="add" onClose={() => {}} onConfirm={onConfirm} />)
|
||||||
|
|
||||||
|
const input = screen.getByPlaceholderText('Search tags…')
|
||||||
|
await userEvent.type(input, 'high')
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('18 recent clicks')).not.toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: /High Contrast/i }))
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: /Add 1 tag/i }))
|
||||||
|
|
||||||
|
expect(onConfirm).toHaveBeenCalledWith([2])
|
||||||
|
})
|
||||||
|
})
|
||||||
25
resources/js/components/community/ActivityArtworkPreview.jsx
Normal file
25
resources/js/components/community/ActivityArtworkPreview.jsx
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import React from 'react'
|
||||||
|
|
||||||
|
export default function ActivityArtworkPreview({ artwork }) {
|
||||||
|
if (!artwork?.url || !artwork?.thumb) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
href={artwork.url}
|
||||||
|
className="group block w-full shrink-0 overflow-hidden rounded-2xl border border-white/[0.08] bg-white/[0.03] sm:w-[120px]"
|
||||||
|
>
|
||||||
|
<div className="aspect-[6/5] overflow-hidden bg-black/20">
|
||||||
|
<img
|
||||||
|
src={artwork.thumb}
|
||||||
|
alt={artwork.title || 'Artwork'}
|
||||||
|
className="h-full w-full object-cover transition duration-300 group-hover:scale-[1.04]"
|
||||||
|
loading="lazy"
|
||||||
|
decoding="async"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="border-t border-white/[0.06] px-3 py-2">
|
||||||
|
<p className="truncate text-[11px] font-medium text-white/65">{artwork.title || 'Artwork'}</p>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
)
|
||||||
|
}
|
||||||
44
resources/js/components/community/ActivityAvatar.jsx
Normal file
44
resources/js/components/community/ActivityAvatar.jsx
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import React from 'react'
|
||||||
|
|
||||||
|
const FALLBACK_AVATAR = 'https://files.skinbase.org/default/avatar_default.webp'
|
||||||
|
|
||||||
|
const BADGE_TONES = {
|
||||||
|
rose: 'border-rose-400/25 bg-rose-500/10 text-rose-200',
|
||||||
|
amber: 'border-amber-400/25 bg-amber-500/10 text-amber-200',
|
||||||
|
sky: 'border-sky-400/25 bg-sky-500/10 text-sky-200',
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ActivityAvatar({ user }) {
|
||||||
|
if (!user) return null
|
||||||
|
|
||||||
|
const badgeClassName = BADGE_TONES[user.badge?.tone] || BADGE_TONES.sky
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<a href={user.profile_url || '#'} className="shrink-0">
|
||||||
|
<img
|
||||||
|
src={user.avatar_url || FALLBACK_AVATAR}
|
||||||
|
alt={user.name || user.username || 'User'}
|
||||||
|
className="h-11 w-11 rounded-2xl object-cover ring-1 ring-white/10"
|
||||||
|
loading="lazy"
|
||||||
|
decoding="async"
|
||||||
|
onError={(event) => {
|
||||||
|
event.currentTarget.src = FALLBACK_AVATAR
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<div className="min-w-0">
|
||||||
|
<a href={user.profile_url || '#'} className="truncate text-sm font-semibold text-white hover:text-sky-200 transition-colors">
|
||||||
|
{user.name || user.username || 'User'}
|
||||||
|
</a>
|
||||||
|
{user.username && <p className="truncate text-xs text-white/35">@{user.username}</p>}
|
||||||
|
{user.badge && (
|
||||||
|
<span className={`mt-1 inline-flex items-center rounded-full border px-2 py-0.5 text-[10px] font-semibold uppercase tracking-[0.16em] ${badgeClassName}`}>
|
||||||
|
{user.badge.label}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
88
resources/js/components/community/ActivityCard.jsx
Normal file
88
resources/js/components/community/ActivityCard.jsx
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import ActivityAvatar from './ActivityAvatar'
|
||||||
|
import ActivityArtworkPreview from './ActivityArtworkPreview'
|
||||||
|
import ActivityReactions from './ActivityReactions'
|
||||||
|
|
||||||
|
function ActivityHeadline({ activity }) {
|
||||||
|
const artworkLink = activity?.artwork?.url
|
||||||
|
const artworkTitle = activity?.artwork?.title || 'an artwork'
|
||||||
|
const mentionedUser = activity?.mentioned_user
|
||||||
|
const reaction = activity?.reaction
|
||||||
|
const commentAuthor = activity?.comment?.author
|
||||||
|
|
||||||
|
switch (activity?.type) {
|
||||||
|
case 'comment':
|
||||||
|
return (
|
||||||
|
<p className="text-sm leading-6 text-white/70">
|
||||||
|
<span className="font-medium text-white">commented on </span>
|
||||||
|
{artworkLink ? <a href={artworkLink} className="text-sky-300 hover:text-sky-200">{artworkTitle}</a> : <span className="text-white">{artworkTitle}</span>}
|
||||||
|
</p>
|
||||||
|
)
|
||||||
|
case 'reply':
|
||||||
|
return (
|
||||||
|
<p className="text-sm leading-6 text-white/70">
|
||||||
|
<span className="font-medium text-white">replied on </span>
|
||||||
|
{artworkLink ? <a href={artworkLink} className="text-sky-300 hover:text-sky-200">{artworkTitle}</a> : <span className="text-white">{artworkTitle}</span>}
|
||||||
|
</p>
|
||||||
|
)
|
||||||
|
case 'reaction':
|
||||||
|
return (
|
||||||
|
<p className="text-sm leading-6 text-white/70">
|
||||||
|
<span className="font-medium text-white">reacted {reaction?.emoji || '👍'} {reaction?.label || 'Like'} </span>
|
||||||
|
<span>to </span>
|
||||||
|
{commentAuthor?.profile_url ? <a href={commentAuthor.profile_url} className="text-sky-300 hover:text-sky-200">{commentAuthor.name || commentAuthor.username || 'a creator'}</a> : <span className="text-white">a creator</span>}
|
||||||
|
<span> on </span>
|
||||||
|
{artworkLink ? <a href={artworkLink} className="text-sky-300 hover:text-sky-200">{artworkTitle}</a> : <span className="text-white">{artworkTitle}</span>}
|
||||||
|
</p>
|
||||||
|
)
|
||||||
|
case 'mention':
|
||||||
|
return (
|
||||||
|
<p className="text-sm leading-6 text-white/70">
|
||||||
|
<span className="font-medium text-white">mentioned </span>
|
||||||
|
{mentionedUser?.profile_url ? <a href={mentionedUser.profile_url} className="text-sky-300 hover:text-sky-200">@{mentionedUser.username || mentionedUser.name}</a> : <span className="text-white">someone</span>}
|
||||||
|
<span> on </span>
|
||||||
|
{artworkLink ? <a href={artworkLink} className="text-sky-300 hover:text-sky-200">{artworkTitle}</a> : <span className="text-white">{artworkTitle}</span>}
|
||||||
|
</p>
|
||||||
|
)
|
||||||
|
default:
|
||||||
|
return <p className="text-sm leading-6 text-white/70">Shared new activity.</p>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ActivityCard({ activity, isLoggedIn = false }) {
|
||||||
|
return (
|
||||||
|
<article className="rounded-[28px] border border-white/[0.06] bg-[linear-gradient(180deg,rgba(11,16,26,0.96),rgba(7,11,19,0.92))] p-4 shadow-[0_18px_45px_rgba(0,0,0,0.28)] backdrop-blur-xl sm:p-5">
|
||||||
|
<div className="flex flex-col gap-4 sm:flex-row sm:items-start">
|
||||||
|
<div className="sm:w-[220px] sm:shrink-0">
|
||||||
|
<ActivityAvatar user={activity.user} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<ActivityHeadline activity={activity} />
|
||||||
|
<span className="text-[11px] uppercase tracking-[0.18em] text-white/25">{activity.time_ago || ''}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{activity.comment?.body ? (
|
||||||
|
<div className="mt-3 rounded-2xl border border-white/[0.06] bg-white/[0.025] px-4 py-3">
|
||||||
|
<p className="whitespace-pre-line break-words text-sm leading-6 text-white/80">{activity.comment.body}</p>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{activity.type === 'mention' && activity.mentioned_user ? (
|
||||||
|
<div className="mt-3 inline-flex items-center gap-2 rounded-full border border-sky-400/20 bg-sky-500/10 px-3 py-1 text-xs text-sky-200">
|
||||||
|
<i className="fa-solid fa-at" />
|
||||||
|
Mentioned @{activity.mentioned_user.username || activity.mentioned_user.name}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<ActivityReactions activity={activity} isLoggedIn={isLoggedIn} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="sm:ml-auto">
|
||||||
|
<ActivityArtworkPreview artwork={activity.artwork} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
)
|
||||||
|
}
|
||||||
80
resources/js/components/community/ActivityFeed.jsx
Normal file
80
resources/js/components/community/ActivityFeed.jsx
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import ActivityCard from './ActivityCard'
|
||||||
|
|
||||||
|
function ActivitySkeleton() {
|
||||||
|
return (
|
||||||
|
<div className="space-y-4 animate-pulse">
|
||||||
|
{Array.from({ length: 5 }).map((_, index) => (
|
||||||
|
<div key={index} className="rounded-[28px] border border-white/[0.06] bg-white/[0.025] p-5">
|
||||||
|
<div className="flex flex-col gap-4 sm:flex-row">
|
||||||
|
<div className="flex items-start gap-3 sm:w-[220px]">
|
||||||
|
<div className="h-11 w-11 rounded-2xl bg-white/[0.08]" />
|
||||||
|
<div className="flex-1 space-y-2">
|
||||||
|
<div className="h-3 w-24 rounded bg-white/[0.08]" />
|
||||||
|
<div className="h-2.5 w-16 rounded bg-white/[0.06]" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 space-y-3">
|
||||||
|
<div className="h-3 w-4/5 rounded bg-white/[0.08]" />
|
||||||
|
<div className="rounded-2xl border border-white/[0.04] bg-white/[0.02] px-4 py-3">
|
||||||
|
<div className="h-3 w-full rounded bg-white/[0.06]" />
|
||||||
|
<div className="mt-2 h-3 w-3/4 rounded bg-white/[0.05]" />
|
||||||
|
</div>
|
||||||
|
<div className="h-8 w-48 rounded-full bg-white/[0.05]" />
|
||||||
|
</div>
|
||||||
|
<div className="h-[132px] w-full rounded-2xl bg-white/[0.05] sm:w-[120px]" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function EmptyState({ isFiltered }) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-[28px] border border-white/[0.06] bg-white/[0.025] px-6 py-16 text-center">
|
||||||
|
<div className="mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-2xl border border-white/[0.06] bg-white/[0.03] text-white/35">
|
||||||
|
<i className="fa-solid fa-wave-square text-xl" />
|
||||||
|
</div>
|
||||||
|
<h3 className="text-lg font-semibold text-white/80">No activity yet</h3>
|
||||||
|
<p className="mx-auto mt-2 max-w-md text-sm leading-6 text-white/45">
|
||||||
|
{isFiltered ? 'This filter has no recent activity right now.' : 'When creators and members interact around artworks, their activity will appear here.'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ActivityFeed({
|
||||||
|
activities = [],
|
||||||
|
isLoggedIn = false,
|
||||||
|
loading = false,
|
||||||
|
loadingMore = false,
|
||||||
|
error = null,
|
||||||
|
sentinelRef,
|
||||||
|
}) {
|
||||||
|
if (loading && activities.length === 0) {
|
||||||
|
return <ActivitySkeleton />
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!loading && activities.length === 0) {
|
||||||
|
return <EmptyState isFiltered={Boolean(error) === false} />
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{error ? (
|
||||||
|
<div className="rounded-2xl border border-rose-500/20 bg-rose-500/10 px-4 py-3 text-sm text-rose-200">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{activities.map((activity) => (
|
||||||
|
<ActivityCard key={activity.id} activity={activity} isLoggedIn={isLoggedIn} />
|
||||||
|
))}
|
||||||
|
|
||||||
|
{loadingMore ? <ActivitySkeleton /> : null}
|
||||||
|
|
||||||
|
<div ref={sentinelRef} className="h-6" aria-hidden="true" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
39
resources/js/components/community/ActivityReactions.jsx
Normal file
39
resources/js/components/community/ActivityReactions.jsx
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import ReactionBar from '../comments/ReactionBar'
|
||||||
|
|
||||||
|
export default function ActivityReactions({ activity, isLoggedIn = false }) {
|
||||||
|
const commentId = activity?.comment?.id || null
|
||||||
|
const commentUrl = activity?.comment?.url || activity?.artwork?.url || '#'
|
||||||
|
const artworkUrl = activity?.artwork?.url || null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap items-center gap-3 pt-2">
|
||||||
|
{commentId ? (
|
||||||
|
<ReactionBar
|
||||||
|
entityType="comment"
|
||||||
|
entityId={commentId}
|
||||||
|
initialTotals={activity?.comment?.reactions || {}}
|
||||||
|
isLoggedIn={isLoggedIn}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<a
|
||||||
|
href={commentUrl}
|
||||||
|
className="inline-flex items-center gap-1.5 rounded-full border border-white/[0.08] bg-white/[0.03] px-3 py-1.5 text-xs font-medium text-white/55 transition hover:border-sky-400/30 hover:bg-sky-500/10 hover:text-sky-200"
|
||||||
|
>
|
||||||
|
<i className="fa-regular fa-comment-dots" />
|
||||||
|
Reply
|
||||||
|
</a>
|
||||||
|
|
||||||
|
{artworkUrl ? (
|
||||||
|
<a
|
||||||
|
href={artworkUrl}
|
||||||
|
className="inline-flex items-center gap-1.5 rounded-full border border-white/[0.08] bg-white/[0.03] px-3 py-1.5 text-xs font-medium text-white/55 transition hover:border-white/15 hover:bg-white/[0.07] hover:text-white"
|
||||||
|
>
|
||||||
|
<i className="fa-regular fa-image" />
|
||||||
|
View artwork
|
||||||
|
</a>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
165
resources/js/components/security/TurnstileField.jsx
Normal file
165
resources/js/components/security/TurnstileField.jsx
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
import React, { useEffect, useRef } from 'react'
|
||||||
|
|
||||||
|
const providerAdapters = {
|
||||||
|
turnstile: {
|
||||||
|
globalName: 'turnstile',
|
||||||
|
render(api, container, { siteKey, theme, onToken }) {
|
||||||
|
return api.render(container, {
|
||||||
|
sitekey: siteKey,
|
||||||
|
theme,
|
||||||
|
callback: (token) => onToken?.(token || ''),
|
||||||
|
'expired-callback': () => onToken?.(''),
|
||||||
|
'error-callback': () => onToken?.(''),
|
||||||
|
})
|
||||||
|
},
|
||||||
|
cleanup(api, widgetId, container, onToken) {
|
||||||
|
if (widgetId !== null && api?.remove) {
|
||||||
|
api.remove(widgetId)
|
||||||
|
}
|
||||||
|
if (container) {
|
||||||
|
container.innerHTML = ''
|
||||||
|
}
|
||||||
|
onToken?.('')
|
||||||
|
},
|
||||||
|
},
|
||||||
|
recaptcha: {
|
||||||
|
globalName: 'grecaptcha',
|
||||||
|
render(api, container, { siteKey, theme, onToken }) {
|
||||||
|
return api.render(container, {
|
||||||
|
sitekey: siteKey,
|
||||||
|
theme,
|
||||||
|
callback: (token) => onToken?.(token || ''),
|
||||||
|
'expired-callback': () => onToken?.(''),
|
||||||
|
'error-callback': () => onToken?.(''),
|
||||||
|
})
|
||||||
|
},
|
||||||
|
cleanup(api, widgetId, container, onToken) {
|
||||||
|
if (widgetId !== null && api?.reset) {
|
||||||
|
api.reset(widgetId)
|
||||||
|
}
|
||||||
|
if (container) {
|
||||||
|
container.innerHTML = ''
|
||||||
|
}
|
||||||
|
onToken?.('')
|
||||||
|
},
|
||||||
|
},
|
||||||
|
hcaptcha: {
|
||||||
|
globalName: 'hcaptcha',
|
||||||
|
render(api, container, { siteKey, theme, onToken }) {
|
||||||
|
return api.render(container, {
|
||||||
|
sitekey: siteKey,
|
||||||
|
theme,
|
||||||
|
callback: (token) => onToken?.(token || ''),
|
||||||
|
'expired-callback': () => onToken?.(''),
|
||||||
|
'error-callback': () => onToken?.(''),
|
||||||
|
})
|
||||||
|
},
|
||||||
|
cleanup(api, widgetId, container, onToken) {
|
||||||
|
if (widgetId !== null && api?.remove) {
|
||||||
|
api.remove(widgetId)
|
||||||
|
}
|
||||||
|
if (container) {
|
||||||
|
container.innerHTML = ''
|
||||||
|
}
|
||||||
|
onToken?.('')
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadCaptchaScript(src) {
|
||||||
|
if (!src) {
|
||||||
|
return Promise.resolve()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!window.__skinbaseCaptchaScripts) {
|
||||||
|
window.__skinbaseCaptchaScripts = {}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!window.__skinbaseCaptchaScripts[src]) {
|
||||||
|
window.__skinbaseCaptchaScripts[src] = new Promise((resolve, reject) => {
|
||||||
|
const existing = document.querySelector(`script[src="${src}"]`)
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
if (existing.dataset.loaded === 'true') {
|
||||||
|
resolve()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
existing.addEventListener('load', () => resolve(), { once: true })
|
||||||
|
existing.addEventListener('error', () => reject(new Error(`Failed to load captcha script: ${src}`)), { once: true })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const script = document.createElement('script')
|
||||||
|
script.src = src
|
||||||
|
script.async = true
|
||||||
|
script.defer = true
|
||||||
|
script.addEventListener('load', () => {
|
||||||
|
script.dataset.loaded = 'true'
|
||||||
|
resolve()
|
||||||
|
}, { once: true })
|
||||||
|
script.addEventListener('error', () => reject(new Error(`Failed to load captcha script: ${src}`)), { once: true })
|
||||||
|
document.head.appendChild(script)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return window.__skinbaseCaptchaScripts[src]
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TurnstileField({ provider = 'turnstile', siteKey, scriptUrl = '', onToken, theme = 'dark', className = '' }) {
|
||||||
|
const containerRef = useRef(null)
|
||||||
|
const widgetIdRef = useRef(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const adapter = providerAdapters[provider] || providerAdapters.turnstile
|
||||||
|
|
||||||
|
if (!siteKey || !containerRef.current) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
let cancelled = false
|
||||||
|
let intervalId = null
|
||||||
|
|
||||||
|
const mountWidget = () => {
|
||||||
|
const api = window[adapter.globalName]
|
||||||
|
|
||||||
|
if (cancelled || !api?.render || widgetIdRef.current !== null) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
widgetIdRef.current = adapter.render(api, containerRef.current, {
|
||||||
|
siteKey,
|
||||||
|
theme,
|
||||||
|
onToken,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
loadCaptchaScript(scriptUrl).catch(() => onToken?.('')).finally(() => {
|
||||||
|
const api = window[adapter.globalName]
|
||||||
|
if (typeof api?.ready === 'function') {
|
||||||
|
api.ready(mountWidget)
|
||||||
|
} else {
|
||||||
|
mountWidget()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (widgetIdRef.current === null) {
|
||||||
|
intervalId = window.setInterval(mountWidget, 250)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
if (intervalId) {
|
||||||
|
window.clearInterval(intervalId)
|
||||||
|
}
|
||||||
|
adapter.cleanup(window[adapter.globalName], widgetIdRef.current, containerRef.current, onToken)
|
||||||
|
widgetIdRef.current = null
|
||||||
|
}
|
||||||
|
}, [className, onToken, provider, scriptUrl, siteKey, theme])
|
||||||
|
|
||||||
|
if (!siteKey) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return <div ref={containerRef} className={className} />
|
||||||
|
}
|
||||||
@@ -52,7 +52,8 @@ function toSuggestionItems(raw) {
|
|||||||
key: item?.id ?? tag,
|
key: item?.id ?? tag,
|
||||||
label: item?.name || item?.tag || item?.slug || tag,
|
label: item?.name || item?.tag || item?.slug || tag,
|
||||||
tag,
|
tag,
|
||||||
usageCount: typeof item?.usage_count === 'number' ? item.usage_count : null,
|
usageCount: Number.isFinite(Number(item?.usage_count)) ? Number(item.usage_count) : null,
|
||||||
|
recentClicks: Number.isFinite(Number(item?.recent_clicks)) ? Number(item.recent_clicks) : 0,
|
||||||
isAi: Boolean(item?.is_ai || item?.source === 'ai'),
|
isAi: Boolean(item?.is_ai || item?.source === 'ai'),
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -173,6 +174,9 @@ function SuggestionDropdown({
|
|||||||
|
|
||||||
{!loading && !error && suggestions.map((item, index) => {
|
{!loading && !error && suggestions.map((item, index) => {
|
||||||
const active = highlightedIndex === index
|
const active = highlightedIndex === index
|
||||||
|
const detailLabel = item.recentClicks > 0
|
||||||
|
? `${item.recentClicks.toLocaleString()} recent`
|
||||||
|
: (typeof item.usageCount === 'number' ? `${item.usageCount.toLocaleString()} uses` : null)
|
||||||
return (
|
return (
|
||||||
<li
|
<li
|
||||||
key={item.key}
|
key={item.key}
|
||||||
@@ -192,8 +196,8 @@ function SuggestionDropdown({
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{typeof item.usageCount === 'number' && (
|
{detailLabel && (
|
||||||
<span className="shrink-0 text-[11px] text-white/50">{item.usageCount}</span>
|
<span className="shrink-0 text-[11px] text-white/50">{detailLabel}</span>
|
||||||
)}
|
)}
|
||||||
</li>
|
</li>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -26,8 +26,8 @@ describe('TagInput', () => {
|
|||||||
return {
|
return {
|
||||||
data: {
|
data: {
|
||||||
data: [
|
data: [
|
||||||
{ id: 1, name: 'cityscape', slug: 'cityscape', usage_count: 10 },
|
{ id: 1, name: 'cityscape', slug: 'cityscape', usage_count: 10, recent_clicks: 2 },
|
||||||
{ id: 2, name: 'city', slug: 'city', usage_count: 30 },
|
{ id: 2, name: 'city', slug: 'city', usage_count: 30, recent_clicks: 14 },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -74,6 +74,17 @@ describe('TagInput', () => {
|
|||||||
expect(screen.getByRole('button', { name: /Remove tag/i })).not.toBeNull()
|
expect(screen.getByRole('button', { name: /Remove tag/i })).not.toBeNull()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('shows recent click momentum when suggestions provide it', async () => {
|
||||||
|
render(<Harness />)
|
||||||
|
|
||||||
|
const input = screen.getByLabelText('Tag input')
|
||||||
|
await userEvent.type(input, 'city')
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('14 recent')).not.toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
it('supports comma-separated paste', async () => {
|
it('supports comma-separated paste', async () => {
|
||||||
render(<Harness />)
|
render(<Harness />)
|
||||||
|
|
||||||
|
|||||||
@@ -37,15 +37,18 @@ function toListItem(item) {
|
|||||||
if (!item) return null
|
if (!item) return null
|
||||||
if (typeof item === 'string') {
|
if (typeof item === 'string') {
|
||||||
const slug = normalizeSlug(item)
|
const slug = normalizeSlug(item)
|
||||||
return slug ? { key: slug, slug, name: slug, usageCount: null, isAi: false } : null
|
return slug ? { key: slug, slug, name: slug, usageCount: null, recentClicks: 0, isAi: false } : null
|
||||||
}
|
}
|
||||||
const slug = normalizeSlug(item.slug || item.tag || item.name || '')
|
const slug = normalizeSlug(item.slug || item.tag || item.name || '')
|
||||||
if (!slug) return null
|
if (!slug) return null
|
||||||
|
const usageCount = Number(item.usage_count)
|
||||||
|
const recentClicks = Number(item.recent_clicks)
|
||||||
return {
|
return {
|
||||||
key: String(item.id ?? slug),
|
key: String(item.id ?? slug),
|
||||||
slug,
|
slug,
|
||||||
name: item.name || item.tag || item.slug || slug,
|
name: item.name || item.tag || item.slug || slug,
|
||||||
usageCount: typeof item.usage_count === 'number' ? item.usage_count : null,
|
usageCount: Number.isFinite(usageCount) ? usageCount : null,
|
||||||
|
recentClicks: Number.isFinite(recentClicks) ? recentClicks : 0,
|
||||||
isAi: Boolean(item.is_ai || item.source === 'ai'),
|
isAi: Boolean(item.is_ai || item.source === 'ai'),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -123,6 +126,10 @@ function AddNewRow({ label, onAdd, disabled }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function ListRow({ item, isSelected, onToggle, disabled }) {
|
function ListRow({ item, isSelected, onToggle, disabled }) {
|
||||||
|
const detailLabel = item.recentClicks > 0
|
||||||
|
? `${item.recentClicks.toLocaleString()} recent clicks`
|
||||||
|
: (typeof item.usageCount === 'number' ? `${item.usageCount.toLocaleString()} uses` : null)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -154,9 +161,9 @@ function ListRow({ item, isSelected, onToggle, disabled }) {
|
|||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
{typeof item.usageCount === 'number' && (
|
{detailLabel && (
|
||||||
<span className="ml-3 shrink-0 text-[11px] text-white/40">
|
<span className="ml-3 shrink-0 text-[11px] text-white/40">
|
||||||
{item.usageCount.toLocaleString()} uses
|
{detailLabel}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
76
resources/js/components/tags/TagPicker.test.jsx
Normal file
76
resources/js/components/tags/TagPicker.test.jsx
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'
|
||||||
|
import { render, screen, waitFor } from '@testing-library/react'
|
||||||
|
import userEvent from '@testing-library/user-event'
|
||||||
|
import TagPicker from './TagPicker'
|
||||||
|
|
||||||
|
function Harness({ initial = [] }) {
|
||||||
|
const [tags, setTags] = React.useState(initial)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TagPicker
|
||||||
|
value={tags}
|
||||||
|
onChange={setTags}
|
||||||
|
suggestedTags={['sunset']}
|
||||||
|
searchEndpoint="/api/tags/search"
|
||||||
|
popularEndpoint="/api/tags/popular"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('TagPicker', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
window.axios = {
|
||||||
|
get: vi.fn(async (url) => {
|
||||||
|
if (url.startsWith('/api/tags/search')) {
|
||||||
|
return {
|
||||||
|
data: {
|
||||||
|
data: [
|
||||||
|
{ id: 2, name: 'High Contrast', slug: 'high-contrast', usage_count: 120, recent_clicks: 18 },
|
||||||
|
{ id: 3, name: 'High Detail', slug: 'high-detail', usage_count: 90, recent_clicks: 0 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
data: {
|
||||||
|
data: [
|
||||||
|
{ id: 1, name: 'Popular Pick', slug: 'popular-pick', usage_count: 300, recent_clicks: 9 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows recent click momentum for popular tags on mount', async () => {
|
||||||
|
render(<Harness />)
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Popular Pick')).not.toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(screen.getByText('9 recent clicks')).not.toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows recent click momentum in search results and lets the user select a tag', async () => {
|
||||||
|
render(<Harness />)
|
||||||
|
|
||||||
|
const input = screen.getByLabelText('Search or add tags')
|
||||||
|
await userEvent.type(input, 'high')
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('18 recent clicks')).not.toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: /High Contrast/i }))
|
||||||
|
|
||||||
|
expect(screen.getByText('High Contrast')).not.toBeNull()
|
||||||
|
expect(screen.getByRole('button', { name: 'Remove tag High Contrast' })).not.toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
65
resources/js/lib/security/botFingerprint.js
Normal file
65
resources/js/lib/security/botFingerprint.js
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
async function sha256Hex(value) {
|
||||||
|
if (!window.crypto?.subtle) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const encoded = new TextEncoder().encode(value)
|
||||||
|
const digest = await window.crypto.subtle.digest('SHA-256', encoded)
|
||||||
|
return Array.from(new Uint8Array(digest))
|
||||||
|
.map((part) => part.toString(16).padStart(2, '0'))
|
||||||
|
.join('')
|
||||||
|
}
|
||||||
|
|
||||||
|
function readWebglVendor() {
|
||||||
|
try {
|
||||||
|
const canvas = document.createElement('canvas')
|
||||||
|
const gl = canvas.getContext('webgl') || canvas.getContext('experimental-webgl')
|
||||||
|
if (!gl) {
|
||||||
|
return 'no-webgl'
|
||||||
|
}
|
||||||
|
|
||||||
|
const extension = gl.getExtension('WEBGL_debug_renderer_info')
|
||||||
|
if (!extension) {
|
||||||
|
return 'webgl-hidden'
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
gl.getParameter(extension.UNMASKED_VENDOR_WEBGL),
|
||||||
|
gl.getParameter(extension.UNMASKED_RENDERER_WEBGL),
|
||||||
|
].join(':')
|
||||||
|
} catch {
|
||||||
|
return 'webgl-error'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function buildBotFingerprint() {
|
||||||
|
const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone || 'unknown'
|
||||||
|
const screenSize = typeof window.screen !== 'undefined'
|
||||||
|
? `${window.screen.width}x${window.screen.height}x${window.devicePixelRatio || 1}`
|
||||||
|
: 'no-screen'
|
||||||
|
|
||||||
|
const payload = [
|
||||||
|
navigator.userAgent || 'unknown-ua',
|
||||||
|
navigator.language || 'unknown-language',
|
||||||
|
navigator.platform || 'unknown-platform',
|
||||||
|
timezone,
|
||||||
|
screenSize,
|
||||||
|
readWebglVendor(),
|
||||||
|
].join('|')
|
||||||
|
|
||||||
|
return sha256Hex(payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function populateBotFingerprint(form) {
|
||||||
|
if (!form) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const fingerprint = await buildBotFingerprint()
|
||||||
|
const field = form.querySelector('input[name="_bot_fingerprint"]')
|
||||||
|
if (field && fingerprint !== '') {
|
||||||
|
field.value = fingerprint
|
||||||
|
}
|
||||||
|
|
||||||
|
return fingerprint
|
||||||
|
}
|
||||||
19
resources/js/lib/tagAnalytics.js
Normal file
19
resources/js/lib/tagAnalytics.js
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
export function sendTagInteractionEvent(payload) {
|
||||||
|
const endpoint = '/api/analytics/tags'
|
||||||
|
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || ''
|
||||||
|
const body = JSON.stringify(payload)
|
||||||
|
|
||||||
|
fetch(endpoint, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Accept': 'application/json',
|
||||||
|
'X-Requested-With': 'XMLHttpRequest',
|
||||||
|
...(csrfToken ? { 'X-CSRF-TOKEN': csrfToken } : {}),
|
||||||
|
},
|
||||||
|
body,
|
||||||
|
keepalive: true,
|
||||||
|
credentials: 'same-origin',
|
||||||
|
}).catch(() => {
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@ if (!window.Alpine) {
|
|||||||
|
|
||||||
// Gallery navigation context: stores artwork list for prev/next on artwork page
|
// Gallery navigation context: stores artwork list for prev/next on artwork page
|
||||||
import './lib/nav-context.js';
|
import './lib/nav-context.js';
|
||||||
|
import { sendTagInteractionEvent } from './lib/tagAnalytics';
|
||||||
|
|
||||||
function mountStoryEditor() {
|
function mountStoryEditor() {
|
||||||
var storyEditorRoot = document.getElementById('story-editor-react-root');
|
var storyEditorRoot = document.getElementById('story-editor-react-root');
|
||||||
@@ -61,6 +62,475 @@ function mountStoryEditor() {
|
|||||||
|
|
||||||
mountStoryEditor();
|
mountStoryEditor();
|
||||||
|
|
||||||
|
function initTagsSearchAssist() {
|
||||||
|
var roots = document.querySelectorAll('[data-tags-search-root]');
|
||||||
|
if (!roots.length) return;
|
||||||
|
|
||||||
|
var recentSearchesKey = 'skinbase.tags.recent-searches';
|
||||||
|
|
||||||
|
function findClosest(el, selector) {
|
||||||
|
while (el && el.nodeType === 1) {
|
||||||
|
if (el.matches(selector)) return el;
|
||||||
|
el = el.parentElement;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(value) {
|
||||||
|
return String(value || '')
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''');
|
||||||
|
}
|
||||||
|
|
||||||
|
roots.forEach(function (root, rootIndex) {
|
||||||
|
var form = findClosest(root, '[data-tags-search-form]');
|
||||||
|
var input = root.querySelector('[data-tags-search-input]');
|
||||||
|
var panel = root.querySelector('[data-tags-search-panel]');
|
||||||
|
var title = root.querySelector('[data-tags-search-title]');
|
||||||
|
var results = root.querySelector('[data-tags-search-results]');
|
||||||
|
var endpoint = root.getAttribute('data-search-endpoint') || '/api/tags/search';
|
||||||
|
var popularEndpoint = root.getAttribute('data-popular-endpoint') || '/api/tags/popular';
|
||||||
|
var optionIdPrefix = 'tags-search-option-' + rootIndex + '-';
|
||||||
|
var debounceTimer = null;
|
||||||
|
var abortController = null;
|
||||||
|
var latestQuery = '';
|
||||||
|
var activeIndex = -1;
|
||||||
|
|
||||||
|
if (!input || !panel || !results) return;
|
||||||
|
|
||||||
|
function readRecentSearches() {
|
||||||
|
try {
|
||||||
|
var parsed = JSON.parse(window.localStorage.getItem(recentSearchesKey) || '[]');
|
||||||
|
return Array.isArray(parsed) ? parsed.filter(Boolean).slice(0, 5) : [];
|
||||||
|
} catch (_error) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeRecentSearch(query) {
|
||||||
|
var normalized = (query || '').trim();
|
||||||
|
if (!normalized) return;
|
||||||
|
|
||||||
|
var next = readRecentSearches().filter(function (item) {
|
||||||
|
return item.toLowerCase() !== normalized.toLowerCase();
|
||||||
|
});
|
||||||
|
|
||||||
|
next.unshift(normalized);
|
||||||
|
|
||||||
|
try {
|
||||||
|
window.localStorage.setItem(recentSearchesKey, JSON.stringify(next.slice(0, 5)));
|
||||||
|
} catch (_error) {
|
||||||
|
// Ignore storage failures silently.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearRecentSearches() {
|
||||||
|
try {
|
||||||
|
window.localStorage.removeItem(recentSearchesKey);
|
||||||
|
} catch (_error) {
|
||||||
|
// Ignore storage failures silently.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeRecentSearch(query) {
|
||||||
|
var normalized = (query || '').trim().toLowerCase();
|
||||||
|
if (!normalized) return;
|
||||||
|
|
||||||
|
var next = readRecentSearches().filter(function (item) {
|
||||||
|
return item.toLowerCase() !== normalized;
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
window.localStorage.setItem(recentSearchesKey, JSON.stringify(next));
|
||||||
|
} catch (_error) {
|
||||||
|
// Ignore storage failures silently.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getItems() {
|
||||||
|
return Array.prototype.slice.call(results.querySelectorAll('[data-tags-search-item]'));
|
||||||
|
}
|
||||||
|
|
||||||
|
function setActiveItem(nextIndex) {
|
||||||
|
var items = getItems();
|
||||||
|
activeIndex = nextIndex;
|
||||||
|
|
||||||
|
items.forEach(function (item, index) {
|
||||||
|
var active = index === nextIndex;
|
||||||
|
item.classList.toggle('bg-white/[0.06]', active);
|
||||||
|
item.classList.toggle('text-white', active);
|
||||||
|
item.setAttribute('aria-selected', active ? 'true' : 'false');
|
||||||
|
if (!item.id) {
|
||||||
|
item.id = optionIdPrefix + index;
|
||||||
|
}
|
||||||
|
if (active) {
|
||||||
|
item.scrollIntoView({ block: 'nearest' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (nextIndex >= 0 && items[nextIndex]) {
|
||||||
|
input.setAttribute('aria-activedescendant', items[nextIndex].id);
|
||||||
|
} else {
|
||||||
|
input.removeAttribute('aria-activedescendant');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function focusItem(nextIndex) {
|
||||||
|
var items = getItems();
|
||||||
|
if (!items.length) return;
|
||||||
|
|
||||||
|
var boundedIndex = Math.max(0, Math.min(nextIndex, items.length - 1));
|
||||||
|
setActiveItem(boundedIndex);
|
||||||
|
items[boundedIndex].focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function setExpanded(expanded) {
|
||||||
|
input.setAttribute('aria-expanded', expanded ? 'true' : 'false');
|
||||||
|
panel.classList.toggle('hidden', !expanded);
|
||||||
|
if (!expanded) {
|
||||||
|
activeIndex = -1;
|
||||||
|
setActiveItem(-1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearResults() {
|
||||||
|
results.innerHTML = '';
|
||||||
|
activeIndex = -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hidePanel() {
|
||||||
|
setExpanded(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderLoadingState(query) {
|
||||||
|
title.textContent = query ? 'Searching tags' : 'Loading suggestions';
|
||||||
|
results.setAttribute('aria-busy', 'true');
|
||||||
|
results.innerHTML = '<div class="space-y-2 px-2 py-2">'
|
||||||
|
+ '<div class="flex items-center justify-between gap-3 rounded-xl px-3 py-3">'
|
||||||
|
+ '<div class="flex items-center gap-3"><span class="h-8 w-8 animate-pulse rounded-full bg-white/[0.08]"></span><span class="space-y-1"><span class="block h-3 w-24 animate-pulse rounded bg-white/[0.08]"></span><span class="block h-2.5 w-16 animate-pulse rounded bg-white/[0.05]"></span></span></div>'
|
||||||
|
+ '<span class="h-3 w-10 animate-pulse rounded bg-white/[0.06]"></span>'
|
||||||
|
+ '</div>'
|
||||||
|
+ '<div class="flex items-center justify-between gap-3 rounded-xl px-3 py-3">'
|
||||||
|
+ '<div class="flex items-center gap-3"><span class="h-8 w-8 animate-pulse rounded-full bg-white/[0.08]"></span><span class="space-y-1"><span class="block h-3 w-28 animate-pulse rounded bg-white/[0.08]"></span><span class="block h-2.5 w-20 animate-pulse rounded bg-white/[0.05]"></span></span></div>'
|
||||||
|
+ '<span class="h-3 w-12 animate-pulse rounded bg-white/[0.06]"></span>'
|
||||||
|
+ '</div>'
|
||||||
|
+ '<div class="flex items-center justify-between gap-3 rounded-xl px-3 py-3">'
|
||||||
|
+ '<div class="flex items-center gap-3"><span class="h-8 w-8 animate-pulse rounded-full bg-white/[0.08]"></span><span class="space-y-1"><span class="block h-3 w-20 animate-pulse rounded bg-white/[0.08]"></span><span class="block h-2.5 w-14 animate-pulse rounded bg-white/[0.05]"></span></span></div>'
|
||||||
|
+ '<span class="h-3 w-8 animate-pulse rounded bg-white/[0.06]"></span>'
|
||||||
|
+ '</div>'
|
||||||
|
+ '</div>';
|
||||||
|
setExpanded(true);
|
||||||
|
setActiveItem(-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function fetchJson(url, signal) {
|
||||||
|
return fetch(url, {
|
||||||
|
headers: { 'X-Requested-With': 'XMLHttpRequest', 'Accept': 'application/json' },
|
||||||
|
signal: signal,
|
||||||
|
}).then(function (response) {
|
||||||
|
if (!response.ok) throw new Error('Failed to load tag suggestions');
|
||||||
|
return response.json();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderRecentSearches(items) {
|
||||||
|
if (!items.length) return '';
|
||||||
|
|
||||||
|
return '<div class="px-2 pb-2">'
|
||||||
|
+ '<div class="flex items-center justify-between gap-3 px-2 pb-2 pt-1">'
|
||||||
|
+ '<div class="text-[11px] font-semibold uppercase tracking-[0.2em] text-white/30">Recent searches</div>'
|
||||||
|
+ '<button type="button" data-tags-search-clear-recent class="rounded-full border border-white/10 bg-white/[0.04] px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.18em] text-white/48 transition hover:bg-white/[0.08] hover:text-white">Clear</button>'
|
||||||
|
+ '</div>'
|
||||||
|
+ '<div class="flex flex-wrap gap-2 px-2 pb-2">'
|
||||||
|
+ items.map(function (item) {
|
||||||
|
var encoded = encodeURIComponent(item);
|
||||||
|
var escaped = escapeHtml(item);
|
||||||
|
return '<span class="inline-flex items-center gap-1.5 rounded-full border border-white/10 bg-white/[0.05] pr-1 text-xs font-medium text-white/68 transition hover:bg-white/[0.08] hover:text-white">'
|
||||||
|
+ '<a href="/tags?q=' + encoded + '" data-tags-search-item data-tags-search-surface="recent_search" data-tags-search-query="' + escaped + '" role="option" aria-selected="false" class="rounded-full px-3 py-1.5">' + escaped + '</a>'
|
||||||
|
+ '<button type="button" data-tags-search-remove-recent data-tags-search-query="' + escaped + '" class="inline-flex h-6 w-6 items-center justify-center rounded-full text-white/38 transition hover:bg-black/20 hover:text-white" aria-label="Remove recent search ' + escaped + '">'
|
||||||
|
+ '×'
|
||||||
|
+ '</button>'
|
||||||
|
+ '</span>';
|
||||||
|
}).join('')
|
||||||
|
+ '</div>'
|
||||||
|
+ '</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderRescueSuggestions(items, query) {
|
||||||
|
if (!items.length) return '';
|
||||||
|
|
||||||
|
return '<div class="border-t border-white/8 px-2 pt-3">'
|
||||||
|
+ '<div class="px-2 pb-2 text-[11px] font-semibold uppercase tracking-[0.2em] text-white/30">Try these instead</div>'
|
||||||
|
+ '<div class="grid gap-1.5 px-1 pb-2">'
|
||||||
|
+ items.map(function (item, index) {
|
||||||
|
var name = escapeHtml(item.name || '');
|
||||||
|
var slug = escapeHtml(item.slug || '');
|
||||||
|
return '<a href="/tag/' + slug + '" data-tags-search-item data-tags-search-surface="rescue_suggestion" data-tags-search-tag="' + slug + '" data-tags-search-query="' + escapeHtml(query || '') + '" data-tags-search-position="' + (index + 1) + '" role="option" aria-selected="false" class="flex items-center justify-between gap-3 rounded-xl px-3 py-2.5 text-sm text-white/72 transition hover:bg-white/[0.06] hover:text-white">'
|
||||||
|
+ '<span class="min-w-0"><span class="block truncate font-medium text-white">' + name + '</span><span class="block truncate text-xs text-white/34">#' + slug + '</span></span>'
|
||||||
|
+ '<span class="shrink-0 text-xs text-white/30">Popular</span>'
|
||||||
|
+ '</a>';
|
||||||
|
}).join('')
|
||||||
|
+ '</div>'
|
||||||
|
+ '<div class="px-2 pb-2"><a href="/tags" class="inline-flex items-center gap-2 text-xs font-semibold uppercase tracking-[0.18em] text-sky-200 transition hover:text-white">Browse full tags index</a></div>'
|
||||||
|
+ '</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderNoMatchState(query, rescueItems) {
|
||||||
|
title.textContent = 'No matching tags';
|
||||||
|
results.setAttribute('aria-busy', 'false');
|
||||||
|
results.innerHTML = '<div class="rounded-xl px-3 py-4 text-sm text-white/42">No direct matches for <span class="text-white">' + escapeHtml(query) + '</span>. Try a broader keyword or jump into one of these popular tags.</div>'
|
||||||
|
+ renderRescueSuggestions(rescueItems || [], query);
|
||||||
|
setExpanded(true);
|
||||||
|
setActiveItem(-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderItems(items, query) {
|
||||||
|
clearResults();
|
||||||
|
results.setAttribute('aria-busy', 'false');
|
||||||
|
|
||||||
|
var recentSearches = !query ? readRecentSearches() : [];
|
||||||
|
|
||||||
|
if (!items.length && recentSearches.length) {
|
||||||
|
title.textContent = 'Recent searches';
|
||||||
|
results.innerHTML = renderRecentSearches(recentSearches);
|
||||||
|
setExpanded(true);
|
||||||
|
setActiveItem(-1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!items.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
title.textContent = query ? 'Matching tags' : (recentSearches.length ? 'Recent and popular' : 'Popular tags');
|
||||||
|
|
||||||
|
if (recentSearches.length) {
|
||||||
|
results.insertAdjacentHTML('beforeend', renderRecentSearches(recentSearches));
|
||||||
|
}
|
||||||
|
|
||||||
|
items.forEach(function (item, index) {
|
||||||
|
var link = document.createElement('a');
|
||||||
|
var itemName = escapeHtml(item.name || '');
|
||||||
|
var itemSlug = escapeHtml(item.slug || '');
|
||||||
|
var recentClicks = Number(item.recent_clicks || 0);
|
||||||
|
var metricLabel = recentClicks > 0
|
||||||
|
? recentClicks.toLocaleString() + ' recent'
|
||||||
|
: Number(item.usage_count || 0).toLocaleString() + ' uses';
|
||||||
|
link.href = '/tag/' + item.slug;
|
||||||
|
link.className = 'flex items-center justify-between gap-3 rounded-xl px-3 py-3 text-sm text-white/72 transition hover:bg-white/[0.06] hover:text-white';
|
||||||
|
link.setAttribute('data-tags-search-item', '');
|
||||||
|
link.setAttribute('data-tags-search-surface', 'search_suggestion');
|
||||||
|
link.setAttribute('data-tags-search-tag', item.slug || '');
|
||||||
|
link.setAttribute('data-tags-search-query', item.name || item.slug || '');
|
||||||
|
link.setAttribute('data-tags-search-input', query || '');
|
||||||
|
link.setAttribute('data-tags-search-position', String(index + 1));
|
||||||
|
link.setAttribute('aria-selected', 'false');
|
||||||
|
link.setAttribute('role', 'option');
|
||||||
|
link.tabIndex = -1;
|
||||||
|
link.innerHTML =
|
||||||
|
'<span class="flex min-w-0 items-center gap-3">'
|
||||||
|
+ '<span class="inline-flex h-8 w-8 items-center justify-center rounded-full bg-white/[0.06] text-xs text-sky-200">#</span>'
|
||||||
|
+ '<span class="min-w-0"><span class="block truncate font-medium text-white">' + itemName + '</span><span class="block truncate text-xs text-white/36">#' + itemSlug + '</span></span>'
|
||||||
|
+ '</span>'
|
||||||
|
+ '<span class="shrink-0 text-xs text-white/34">' + metricLabel + '</span>';
|
||||||
|
results.appendChild(link);
|
||||||
|
});
|
||||||
|
|
||||||
|
setExpanded(true);
|
||||||
|
setActiveItem(-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function fetchSuggestions(query) {
|
||||||
|
if (abortController) abortController.abort();
|
||||||
|
abortController = new AbortController();
|
||||||
|
latestQuery = query;
|
||||||
|
renderLoadingState(query);
|
||||||
|
|
||||||
|
var params = new URLSearchParams();
|
||||||
|
if (query) params.set('q', query);
|
||||||
|
|
||||||
|
fetchJson(endpoint + (params.toString() ? ('?' + params.toString()) : ''), abortController.signal)
|
||||||
|
.then(function (payload) {
|
||||||
|
if (input.value.trim() !== latestQuery) return;
|
||||||
|
var items = Array.isArray(payload.data) ? payload.data.slice(0, 8) : [];
|
||||||
|
if (items.length || query === '') {
|
||||||
|
renderItems(items, query);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var popularParams = new URLSearchParams();
|
||||||
|
popularParams.set('limit', '4');
|
||||||
|
|
||||||
|
return fetchJson(popularEndpoint + '?' + popularParams.toString(), abortController.signal)
|
||||||
|
.then(function (popularPayload) {
|
||||||
|
if (input.value.trim() !== latestQuery) return;
|
||||||
|
renderNoMatchState(query, Array.isArray(popularPayload.data) ? popularPayload.data : []);
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch(function (error) {
|
||||||
|
if (error && error.name === 'AbortError') return;
|
||||||
|
hidePanel();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
input.addEventListener('focus', function () {
|
||||||
|
fetchSuggestions(input.value.trim());
|
||||||
|
});
|
||||||
|
|
||||||
|
input.addEventListener('input', function () {
|
||||||
|
var query = input.value.trim();
|
||||||
|
window.clearTimeout(debounceTimer);
|
||||||
|
debounceTimer = window.setTimeout(function () {
|
||||||
|
fetchSuggestions(query);
|
||||||
|
}, 180);
|
||||||
|
});
|
||||||
|
|
||||||
|
input.addEventListener('keydown', function (event) {
|
||||||
|
var items = getItems();
|
||||||
|
|
||||||
|
if ((event.key === 'ArrowDown' || event.key === 'ArrowUp') && items.length) {
|
||||||
|
event.preventDefault();
|
||||||
|
if (event.key === 'ArrowDown') {
|
||||||
|
setActiveItem(activeIndex < 0 ? 0 : Math.min(activeIndex + 1, items.length - 1));
|
||||||
|
} else {
|
||||||
|
setActiveItem(activeIndex < 0 ? items.length - 1 : Math.max(activeIndex - 1, 0));
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.key === 'Enter' && activeIndex >= 0 && items[activeIndex]) {
|
||||||
|
event.preventDefault();
|
||||||
|
writeRecentSearch(items[activeIndex].getAttribute('data-tags-search-query') || input.value);
|
||||||
|
window.location.href = items[activeIndex].href;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.key === 'Escape') {
|
||||||
|
hidePanel();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
results.addEventListener('keydown', function (event) {
|
||||||
|
var items = getItems();
|
||||||
|
var currentIndex = items.indexOf(document.activeElement);
|
||||||
|
|
||||||
|
if (event.key === 'ArrowDown' && items.length) {
|
||||||
|
event.preventDefault();
|
||||||
|
focusItem(currentIndex + 1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.key === 'ArrowUp' && items.length) {
|
||||||
|
event.preventDefault();
|
||||||
|
if (currentIndex <= 0) {
|
||||||
|
setActiveItem(-1);
|
||||||
|
input.focus();
|
||||||
|
} else {
|
||||||
|
focusItem(currentIndex - 1);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.key === 'Escape') {
|
||||||
|
event.preventDefault();
|
||||||
|
hidePanel();
|
||||||
|
input.focus();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
results.addEventListener('mouseover', function (event) {
|
||||||
|
var item = findClosest(event.target, '[data-tags-search-item]');
|
||||||
|
if (!item) return;
|
||||||
|
var items = getItems();
|
||||||
|
setActiveItem(items.indexOf(item));
|
||||||
|
});
|
||||||
|
|
||||||
|
results.addEventListener('click', function (event) {
|
||||||
|
var clearButton = findClosest(event.target, '[data-tags-search-clear-recent]');
|
||||||
|
if (clearButton) {
|
||||||
|
event.preventDefault();
|
||||||
|
clearRecentSearches();
|
||||||
|
fetchSuggestions(input.value.trim());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var removeButton = findClosest(event.target, '[data-tags-search-remove-recent]');
|
||||||
|
if (removeButton) {
|
||||||
|
event.preventDefault();
|
||||||
|
removeRecentSearch(removeButton.getAttribute('data-tags-search-query') || '');
|
||||||
|
fetchSuggestions(input.value.trim());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var item = findClosest(event.target, '[data-tags-search-item]');
|
||||||
|
if (!item) return;
|
||||||
|
var query = item.getAttribute('data-tags-search-query') || item.textContent || '';
|
||||||
|
writeRecentSearch(query);
|
||||||
|
|
||||||
|
var surface = item.getAttribute('data-tags-search-surface') || '';
|
||||||
|
var tagSlug = item.getAttribute('data-tags-search-tag') || '';
|
||||||
|
|
||||||
|
if (surface && (tagSlug || surface === 'recent_search')) {
|
||||||
|
sendTagInteractionEvent({
|
||||||
|
event_type: 'click',
|
||||||
|
surface: surface,
|
||||||
|
tag_slug: tagSlug || null,
|
||||||
|
query: item.getAttribute('data-tags-search-input') || item.getAttribute('data-tags-search-query') || input.value.trim() || null,
|
||||||
|
position: Number(item.getAttribute('data-tags-search-position') || 0) || null,
|
||||||
|
occurred_at: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (form) {
|
||||||
|
form.addEventListener('submit', function () {
|
||||||
|
writeRecentSearch(input.value);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('click', function (event) {
|
||||||
|
if (!root.contains(event.target)) {
|
||||||
|
hidePanel();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
initTagsSearchAssist();
|
||||||
|
|
||||||
|
function initTagAnalyticsLinks() {
|
||||||
|
document.addEventListener('click', function (event) {
|
||||||
|
var el = event.target;
|
||||||
|
|
||||||
|
while (el && el.nodeType === 1) {
|
||||||
|
if (el.matches('[data-tag-analytics-link]')) {
|
||||||
|
var tagSlug = el.getAttribute('data-tag-analytics-tag') || '';
|
||||||
|
var surface = el.getAttribute('data-tag-analytics-surface') || '';
|
||||||
|
|
||||||
|
if (tagSlug && surface) {
|
||||||
|
sendTagInteractionEvent({
|
||||||
|
event_type: 'click',
|
||||||
|
surface: surface,
|
||||||
|
tag_slug: tagSlug,
|
||||||
|
source_tag_slug: el.getAttribute('data-tag-analytics-source-tag') || null,
|
||||||
|
position: Number(el.getAttribute('data-tag-analytics-position') || 0) || null,
|
||||||
|
occurred_at: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
el = el.parentElement;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
initTagAnalyticsLinks();
|
||||||
|
|
||||||
(function () {
|
(function () {
|
||||||
function initBlurPreviewImages() {
|
function initBlurPreviewImages() {
|
||||||
var selector = 'img[data-blur-preview]';
|
var selector = 'img[data-blur-preview]';
|
||||||
|
|||||||
@@ -2,9 +2,23 @@
|
|||||||
|
|
||||||
@section('content')
|
@section('content')
|
||||||
<div class="mx-auto max-w-5xl px-4 sm:px-6 lg:px-8 py-10">
|
<div class="mx-auto max-w-5xl px-4 sm:px-6 lg:px-8 py-10">
|
||||||
<div class="rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 p-6">
|
<div class="space-y-6">
|
||||||
<h1 class="text-xl font-semibold text-gray-900 dark:text-gray-100">Reports Queue</h1>
|
<div class="rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 p-6">
|
||||||
<p class="mt-2 text-sm text-gray-500">Use the API endpoint <code>/api/reports</code> to submit reports and review records in the <code>reports</code> table.</p>
|
<h1 class="text-xl font-semibold text-gray-900 dark:text-gray-100">Reports Hub</h1>
|
||||||
|
<p class="mt-2 text-sm text-gray-500">Internal reporting entry points for moderation and discovery analytics.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid gap-4 md:grid-cols-2">
|
||||||
|
<a href="{{ route('admin.reports.tags') }}" class="rounded-xl border border-sky-200 bg-sky-50 p-6 transition hover:border-sky-300 hover:bg-sky-100/80 dark:border-sky-900/60 dark:bg-sky-950/30 dark:hover:border-sky-700 dark:hover:bg-sky-950/50">
|
||||||
|
<h2 class="text-lg font-semibold text-slate-900 dark:text-sky-100">Tag Interaction Report</h2>
|
||||||
|
<p class="mt-2 text-sm text-slate-600 dark:text-sky-200/70">Inspect top surfaces, tags, search terms, and related-tag transitions from the new tag analytics pipeline.</p>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<div class="rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 p-6">
|
||||||
|
<h2 class="text-lg font-semibold text-gray-900 dark:text-gray-100">Moderation Queue</h2>
|
||||||
|
<p class="mt-2 text-sm text-gray-500">Use the API endpoint <code>/api/reports</code> to submit reports and review records in the <code>reports</code> table.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@endsection
|
@endsection
|
||||||
|
|||||||
216
resources/views/admin/reports/tags.blade.php
Normal file
216
resources/views/admin/reports/tags.blade.php
Normal file
@@ -0,0 +1,216 @@
|
|||||||
|
@extends('layouts.nova.content-layout')
|
||||||
|
|
||||||
|
@section('page-content')
|
||||||
|
<div class="mx-auto max-w-7xl space-y-8">
|
||||||
|
<div class="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-2xl font-bold text-white">Tag Interaction Report</h1>
|
||||||
|
<p class="mt-2 max-w-3xl text-sm text-neutral-400">
|
||||||
|
Internal dashboard for tag discovery clicks. Use it to inspect surface performance, top tags, query demand, and tag-to-tag transitions for recommendation tuning.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-wrap gap-3 text-xs">
|
||||||
|
<a href="{{ route('api.admin.reports.tags', request()->query()) }}" class="inline-flex items-center rounded-lg border border-neutral-700 bg-neutral-900 px-3 py-2 font-medium text-neutral-200 transition hover:border-sky-500 hover:text-white">JSON report</a>
|
||||||
|
<a href="{{ route('admin.reports.queue') }}" class="inline-flex items-center rounded-lg border border-neutral-700 bg-neutral-900 px-3 py-2 font-medium text-neutral-200 transition hover:border-sky-500 hover:text-white">Reports hub</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-xl border border-neutral-800 bg-neutral-900 p-5">
|
||||||
|
<form method="GET" action="{{ route('admin.reports.tags') }}" class="grid gap-4 md:grid-cols-4">
|
||||||
|
<label class="space-y-2 text-sm text-neutral-300">
|
||||||
|
<span class="block text-xs font-semibold uppercase tracking-[0.18em] text-neutral-500">From</span>
|
||||||
|
<input type="date" name="from" value="{{ $filters['from'] }}" class="w-full rounded-lg border border-neutral-700 bg-neutral-950 px-3 py-2 text-white focus:border-sky-500 focus:outline-none">
|
||||||
|
</label>
|
||||||
|
<label class="space-y-2 text-sm text-neutral-300">
|
||||||
|
<span class="block text-xs font-semibold uppercase tracking-[0.18em] text-neutral-500">To</span>
|
||||||
|
<input type="date" name="to" value="{{ $filters['to'] }}" class="w-full rounded-lg border border-neutral-700 bg-neutral-950 px-3 py-2 text-white focus:border-sky-500 focus:outline-none">
|
||||||
|
</label>
|
||||||
|
<label class="space-y-2 text-sm text-neutral-300">
|
||||||
|
<span class="block text-xs font-semibold uppercase tracking-[0.18em] text-neutral-500">Row limit</span>
|
||||||
|
<input type="number" min="1" max="100" name="limit" value="{{ $filters['limit'] }}" class="w-full rounded-lg border border-neutral-700 bg-neutral-950 px-3 py-2 text-white focus:border-sky-500 focus:outline-none">
|
||||||
|
</label>
|
||||||
|
<div class="flex items-end gap-3">
|
||||||
|
<button type="submit" class="inline-flex items-center rounded-lg bg-sky-500 px-4 py-2 text-sm font-semibold text-slate-950 transition hover:bg-sky-400">Refresh</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="mt-4 flex flex-wrap gap-4 text-xs text-neutral-500">
|
||||||
|
<span>Latest aggregated date: <span class="font-medium text-neutral-300">{{ $latestAggregatedDate ?? 'not aggregated yet' }}</span></span>
|
||||||
|
<span>Latest raw event: <span class="font-medium text-neutral-300">{{ $overview['latest_event_at'] ?? 'n/a' }}</span></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if(app()->environment('local'))
|
||||||
|
<div class="mt-4 rounded-xl border border-amber-500/30 bg-amber-500/10 p-4 text-sm text-amber-100">
|
||||||
|
<p class="font-semibold">Local demo data</p>
|
||||||
|
<p class="mt-1 text-amber-100/80">
|
||||||
|
This report can be filled locally with seeded click data. Run
|
||||||
|
<code class="rounded bg-black/30 px-2 py-1 text-xs text-amber-50">php artisan analytics:seed-tag-interaction-demo --days=14 --per-day=80 --refresh</code>
|
||||||
|
and refresh this page to inspect realistic search, recommendation, and transition metrics.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||||
|
<div class="rounded-xl border border-neutral-800 bg-neutral-900 p-5">
|
||||||
|
<p class="text-xs font-semibold uppercase tracking-[0.18em] text-neutral-500">Total clicks</p>
|
||||||
|
<p class="mt-3 text-3xl font-bold text-white">{{ number_format($overview['total_clicks']) }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-xl border border-neutral-800 bg-neutral-900 p-5">
|
||||||
|
<p class="text-xs font-semibold uppercase tracking-[0.18em] text-neutral-500">Unique users</p>
|
||||||
|
<p class="mt-3 text-3xl font-bold text-white">{{ number_format($overview['unique_users']) }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-xl border border-neutral-800 bg-neutral-900 p-5">
|
||||||
|
<p class="text-xs font-semibold uppercase tracking-[0.18em] text-neutral-500">Unique sessions</p>
|
||||||
|
<p class="mt-3 text-3xl font-bold text-white">{{ number_format($overview['unique_sessions']) }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-xl border border-neutral-800 bg-neutral-900 p-5">
|
||||||
|
<p class="text-xs font-semibold uppercase tracking-[0.18em] text-neutral-500">Distinct tags</p>
|
||||||
|
<p class="mt-3 text-3xl font-bold text-white">{{ number_format($overview['distinct_tags']) }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-xl border border-neutral-800 bg-neutral-900 p-5">
|
||||||
|
<div class="flex items-center justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h2 class="text-lg font-semibold text-white">Daily Click Trend</h2>
|
||||||
|
<p class="mt-1 text-sm text-neutral-500">Daily rollups for tuning trending and recommendation decisions.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mt-4 grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
|
||||||
|
@forelse($dailyClicks as $row)
|
||||||
|
<div class="rounded-lg border border-neutral-800 bg-neutral-950/70 p-4">
|
||||||
|
<p class="text-xs uppercase tracking-[0.18em] text-neutral-500">{{ $row['date'] }}</p>
|
||||||
|
<p class="mt-2 text-2xl font-semibold text-white">{{ number_format($row['clicks']) }}</p>
|
||||||
|
<p class="mt-1 text-xs text-neutral-500">clicks</p>
|
||||||
|
</div>
|
||||||
|
@empty
|
||||||
|
<p class="text-sm text-neutral-500">No aggregated rows available for the selected range yet.</p>
|
||||||
|
@endforelse
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid gap-6 xl:grid-cols-2">
|
||||||
|
<section class="rounded-xl border border-neutral-800 bg-neutral-900 p-5">
|
||||||
|
<h2 class="text-lg font-semibold text-white">Top Surfaces</h2>
|
||||||
|
<div class="mt-4 overflow-x-auto">
|
||||||
|
<table class="min-w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr class="border-b border-neutral-800 text-left text-xs uppercase tracking-[0.18em] text-neutral-500">
|
||||||
|
<th class="pb-3 pr-4">Surface</th>
|
||||||
|
<th class="pb-3 pr-4">Clicks</th>
|
||||||
|
<th class="pb-3 pr-4">Users</th>
|
||||||
|
<th class="pb-3 pr-4">Sessions</th>
|
||||||
|
<th class="pb-3">Avg pos.</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@forelse($bySurface as $row)
|
||||||
|
<tr class="border-b border-neutral-800/70 text-neutral-200">
|
||||||
|
<td class="py-3 pr-4 font-medium text-white">{{ $row['surface'] }}</td>
|
||||||
|
<td class="py-3 pr-4">{{ number_format($row['clicks']) }}</td>
|
||||||
|
<td class="py-3 pr-4">{{ number_format($row['unique_users']) }}</td>
|
||||||
|
<td class="py-3 pr-4">{{ number_format($row['unique_sessions']) }}</td>
|
||||||
|
<td class="py-3">{{ $row['avg_position'] > 0 ? number_format($row['avg_position'], 2) : 'n/a' }}</td>
|
||||||
|
</tr>
|
||||||
|
@empty
|
||||||
|
<tr><td colspan="5" class="py-4 text-neutral-500">No surface data in this range.</td></tr>
|
||||||
|
@endforelse
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="rounded-xl border border-neutral-800 bg-neutral-900 p-5">
|
||||||
|
<h2 class="text-lg font-semibold text-white">Top Query Terms</h2>
|
||||||
|
<div class="mt-4 overflow-x-auto">
|
||||||
|
<table class="min-w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr class="border-b border-neutral-800 text-left text-xs uppercase tracking-[0.18em] text-neutral-500">
|
||||||
|
<th class="pb-3 pr-4">Query</th>
|
||||||
|
<th class="pb-3 pr-4">Clicks</th>
|
||||||
|
<th class="pb-3 pr-4">Sessions</th>
|
||||||
|
<th class="pb-3">Resolved tags</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@forelse($topQueries as $row)
|
||||||
|
<tr class="border-b border-neutral-800/70 text-neutral-200">
|
||||||
|
<td class="py-3 pr-4 font-medium text-white">{{ $row['query'] }}</td>
|
||||||
|
<td class="py-3 pr-4">{{ number_format($row['clicks']) }}</td>
|
||||||
|
<td class="py-3 pr-4">{{ number_format($row['unique_sessions']) }}</td>
|
||||||
|
<td class="py-3">{{ number_format($row['resolved_tags']) }}</td>
|
||||||
|
</tr>
|
||||||
|
@empty
|
||||||
|
<tr><td colspan="4" class="py-4 text-neutral-500">No query data in this range.</td></tr>
|
||||||
|
@endforelse
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid gap-6 xl:grid-cols-2">
|
||||||
|
<section class="rounded-xl border border-neutral-800 bg-neutral-900 p-5">
|
||||||
|
<h2 class="text-lg font-semibold text-white">Top Tags</h2>
|
||||||
|
<div class="mt-4 overflow-x-auto">
|
||||||
|
<table class="min-w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr class="border-b border-neutral-800 text-left text-xs uppercase tracking-[0.18em] text-neutral-500">
|
||||||
|
<th class="pb-3 pr-4">Tag</th>
|
||||||
|
<th class="pb-3 pr-4">Clicks</th>
|
||||||
|
<th class="pb-3 pr-4">Recommendation</th>
|
||||||
|
<th class="pb-3 pr-4">Search</th>
|
||||||
|
<th class="pb-3">Sessions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@forelse($topTags as $row)
|
||||||
|
<tr class="border-b border-neutral-800/70 text-neutral-200">
|
||||||
|
<td class="py-3 pr-4 font-medium text-white">#{{ $row['tag_slug'] }}</td>
|
||||||
|
<td class="py-3 pr-4">{{ number_format($row['clicks']) }}</td>
|
||||||
|
<td class="py-3 pr-4">{{ number_format($row['recommendation_clicks']) }}</td>
|
||||||
|
<td class="py-3 pr-4">{{ number_format($row['search_clicks']) }}</td>
|
||||||
|
<td class="py-3">{{ number_format($row['unique_sessions']) }}</td>
|
||||||
|
</tr>
|
||||||
|
@empty
|
||||||
|
<tr><td colspan="5" class="py-4 text-neutral-500">No tag click data in this range.</td></tr>
|
||||||
|
@endforelse
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="rounded-xl border border-neutral-800 bg-neutral-900 p-5">
|
||||||
|
<h2 class="text-lg font-semibold text-white">Top Tag Transitions</h2>
|
||||||
|
<p class="mt-1 text-sm text-neutral-500">Most-clicked source tag to target tag paths from related-tag surfaces.</p>
|
||||||
|
<div class="mt-4 overflow-x-auto">
|
||||||
|
<table class="min-w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr class="border-b border-neutral-800 text-left text-xs uppercase tracking-[0.18em] text-neutral-500">
|
||||||
|
<th class="pb-3 pr-4">Source</th>
|
||||||
|
<th class="pb-3 pr-4">Target</th>
|
||||||
|
<th class="pb-3 pr-4">Clicks</th>
|
||||||
|
<th class="pb-3 pr-4">Sessions</th>
|
||||||
|
<th class="pb-3">Avg pos.</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@forelse($topTransitions as $row)
|
||||||
|
<tr class="border-b border-neutral-800/70 text-neutral-200">
|
||||||
|
<td class="py-3 pr-4 font-medium text-white">#{{ $row['source_tag_slug'] }}</td>
|
||||||
|
<td class="py-3 pr-4 font-medium text-white">#{{ $row['tag_slug'] }}</td>
|
||||||
|
<td class="py-3 pr-4">{{ number_format($row['clicks']) }}</td>
|
||||||
|
<td class="py-3 pr-4">{{ number_format($row['unique_sessions']) }}</td>
|
||||||
|
<td class="py-3">{{ $row['avg_position'] > 0 ? number_format($row['avg_position'], 2) : 'n/a' }}</td>
|
||||||
|
</tr>
|
||||||
|
@empty
|
||||||
|
<tr><td colspan="5" class="py-4 text-neutral-500">No transition data in this range.</td></tr>
|
||||||
|
@endforelse
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endsection
|
||||||
@@ -16,10 +16,23 @@
|
|||||||
</div>
|
</div>
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
|
@if($errors->has('bot'))
|
||||||
|
<div class="rounded-lg bg-red-900/40 border border-red-500/40 px-4 py-3 text-sm text-red-300 mb-4">
|
||||||
|
{{ $errors->first('bot') }}
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
@include('auth.partials.social-login')
|
@include('auth.partials.social-login')
|
||||||
|
|
||||||
<form method="POST" action="{{ route('login') }}" class="space-y-5">
|
<form method="POST" action="{{ route('login') }}" class="space-y-5" data-bot-form>
|
||||||
@csrf
|
@csrf
|
||||||
|
<input type="text" name="homepage_url" value="" tabindex="-1" autocomplete="off" class="hidden" aria-hidden="true">
|
||||||
|
<input type="hidden" name="_bot_fingerprint" value="">
|
||||||
|
|
||||||
|
@php
|
||||||
|
$captchaProvider = $captcha['provider'] ?? 'turnstile';
|
||||||
|
$captchaSiteKey = $captcha['siteKey'] ?? '';
|
||||||
|
@endphp
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm mb-1 text-white/80" for="email">Email</label>
|
<label class="block text-sm mb-1 text-white/80" for="email">Email</label>
|
||||||
@@ -33,6 +46,17 @@
|
|||||||
<x-input-error :messages="$errors->get('password')" class="mt-2" />
|
<x-input-error :messages="$errors->get('password')" class="mt-2" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@if(($requiresCaptcha ?? false) && $captchaSiteKey !== '')
|
||||||
|
@if($captchaProvider === 'recaptcha')
|
||||||
|
<div class="g-recaptcha" data-sitekey="{{ $captchaSiteKey }}" data-theme="dark"></div>
|
||||||
|
@elseif($captchaProvider === 'hcaptcha')
|
||||||
|
<div class="h-captcha" data-sitekey="{{ $captchaSiteKey }}" data-theme="dark"></div>
|
||||||
|
@else
|
||||||
|
<div class="cf-turnstile" data-sitekey="{{ $captchaSiteKey }}" data-theme="dark"></div>
|
||||||
|
@endif
|
||||||
|
<x-input-error :messages="$errors->get('captcha')" class="mt-2" />
|
||||||
|
@endif
|
||||||
|
|
||||||
<div class="flex items-center justify-between text-sm text-white/60">
|
<div class="flex items-center justify-between text-sm text-white/60">
|
||||||
<label class="flex items-center gap-2">
|
<label class="flex items-center gap-2">
|
||||||
<input type="checkbox" name="remember" class="rounded bg-slate-800 border-white/20" />
|
<input type="checkbox" name="remember" class="rounded bg-slate-800 border-white/20" />
|
||||||
@@ -51,4 +75,8 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@if(($requiresCaptcha ?? false) && (($captcha['siteKey'] ?? '') !== '') && (($captcha['scriptUrl'] ?? '') !== ''))
|
||||||
|
<script src="{{ $captcha['scriptUrl'] }}" async defer></script>
|
||||||
|
@endif
|
||||||
|
@include('partials.bot-fingerprint-script')
|
||||||
@endsection
|
@endsection
|
||||||
|
|||||||
@@ -13,9 +13,22 @@
|
|||||||
</div>
|
</div>
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
|
@if($errors->has('bot'))
|
||||||
|
<div class="rounded-lg bg-red-900/40 border border-red-500/40 px-4 py-3 text-sm text-red-300 mb-4">
|
||||||
|
{{ $errors->first('bot') }}
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
@include('auth.partials.social-login', ['dividerLabel' => 'or register with email'])
|
@include('auth.partials.social-login', ['dividerLabel' => 'or register with email'])
|
||||||
<form method="POST" action="{{ route('register') }}" class="space-y-5">
|
<form method="POST" action="{{ route('register') }}" class="space-y-5" data-bot-form>
|
||||||
@csrf
|
@csrf
|
||||||
|
<input type="text" name="homepage_url" value="" tabindex="-1" autocomplete="off" class="hidden" aria-hidden="true">
|
||||||
|
<input type="hidden" name="_bot_fingerprint" value="">
|
||||||
|
|
||||||
|
@php
|
||||||
|
$captchaProvider = $captcha['provider'] ?? 'turnstile';
|
||||||
|
$captchaSiteKey = $captcha['siteKey'] ?? '';
|
||||||
|
@endphp
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm mb-1 text-white/80" for="email">Email</label>
|
<label class="block text-sm mb-1 text-white/80" for="email">Email</label>
|
||||||
@@ -23,8 +36,14 @@
|
|||||||
<x-input-error :messages="$errors->get('email')" class="mt-2" />
|
<x-input-error :messages="$errors->get('email')" class="mt-2" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@if(($requiresTurnstile ?? false) && ($turnstileSiteKey ?? '') !== '')
|
@if((($requiresCaptcha ?? false) || session('bot_captcha_required')) && $captchaSiteKey !== '')
|
||||||
<div class="cf-turnstile" data-sitekey="{{ $turnstileSiteKey }}"></div>
|
@if($captchaProvider === 'recaptcha')
|
||||||
|
<div class="g-recaptcha" data-sitekey="{{ $captchaSiteKey }}" data-theme="dark"></div>
|
||||||
|
@elseif($captchaProvider === 'hcaptcha')
|
||||||
|
<div class="h-captcha" data-sitekey="{{ $captchaSiteKey }}" data-theme="dark"></div>
|
||||||
|
@else
|
||||||
|
<div class="cf-turnstile" data-sitekey="{{ $captchaSiteKey }}" data-theme="dark"></div>
|
||||||
|
@endif
|
||||||
<x-input-error :messages="$errors->get('captcha')" class="mt-2" />
|
<x-input-error :messages="$errors->get('captcha')" class="mt-2" />
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
@@ -35,7 +54,8 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@if(($requiresTurnstile ?? false) && ($turnstileSiteKey ?? '') !== '')
|
@if((($requiresCaptcha ?? false) || session('bot_captcha_required')) && (($captcha['siteKey'] ?? '') !== '') && (($captcha['scriptUrl'] ?? '') !== ''))
|
||||||
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
|
<script src="{{ $captcha['scriptUrl'] }}" async defer></script>
|
||||||
@endif
|
@endif
|
||||||
|
@include('partials.bot-fingerprint-script')
|
||||||
@endsection
|
@endsection
|
||||||
|
|||||||
71
resources/views/components/nova-page-header.blade.php
Normal file
71
resources/views/components/nova-page-header.blade.php
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
@props([
|
||||||
|
'section' => null,
|
||||||
|
'title' => null,
|
||||||
|
'icon' => null,
|
||||||
|
'breadcrumbs' => collect(),
|
||||||
|
'description' => null,
|
||||||
|
'showSection' => true,
|
||||||
|
'showIcon' => true,
|
||||||
|
'showTitle' => true,
|
||||||
|
'showBreadcrumbs' => true,
|
||||||
|
'showDescription' => true,
|
||||||
|
'headerClass' => '',
|
||||||
|
'innerClass' => '',
|
||||||
|
'contentClass' => '',
|
||||||
|
'actionsClass' => '',
|
||||||
|
'sectionClass' => '',
|
||||||
|
'titleClass' => '',
|
||||||
|
'iconClass' => '',
|
||||||
|
'descriptionClass' => '',
|
||||||
|
])
|
||||||
|
|
||||||
|
@php
|
||||||
|
$headerBreadcrumbs = $breadcrumbs instanceof \Illuminate\Support\Collection
|
||||||
|
? $breadcrumbs
|
||||||
|
: collect($breadcrumbs ?? []);
|
||||||
|
|
||||||
|
$hasActions = isset($actions) && trim((string) $actions) !== '';
|
||||||
|
$resolvedHeaderClass = trim('px-6 pt-10 pb-7 md:px-10 border-b border-white/[0.06] bg-gradient-to-b from-sky-500/[0.04] to-transparent ' . $headerClass);
|
||||||
|
$resolvedInnerClass = trim('flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between ' . $innerClass);
|
||||||
|
$resolvedContentClass = trim('max-w-3xl min-w-0 ' . $contentClass);
|
||||||
|
$resolvedActionsClass = trim('flex flex-col items-start gap-3 lg:items-end ' . $actionsClass);
|
||||||
|
$resolvedSectionClass = trim('text-xs font-semibold uppercase tracking-widest text-white/30 mb-1 ' . $sectionClass);
|
||||||
|
$resolvedTitleClass = trim('text-3xl font-bold text-white leading-tight flex items-center gap-3 ' . $titleClass);
|
||||||
|
$resolvedIconClass = trim('text-sky-400 text-2xl ' . $iconClass);
|
||||||
|
$resolvedDescriptionClass = trim('mt-1 text-sm text-white/50 ' . $descriptionClass);
|
||||||
|
@endphp
|
||||||
|
|
||||||
|
<header {{ $attributes->class([$resolvedHeaderClass]) }}>
|
||||||
|
<div class="{{ $resolvedInnerClass }}">
|
||||||
|
<div class="{{ $resolvedContentClass }}">
|
||||||
|
@if($showSection && filled($section))
|
||||||
|
<p class="{{ $resolvedSectionClass }}">{{ $section }}</p>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
@if($showTitle && filled($title))
|
||||||
|
<h1 class="{{ $resolvedTitleClass }}">
|
||||||
|
@if($showIcon && filled($icon))
|
||||||
|
<i class="fa-solid {{ $icon }} {{ $resolvedIconClass }}"></i>
|
||||||
|
@endif
|
||||||
|
<span class="min-w-0">{{ $title }}</span>
|
||||||
|
</h1>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
@if($showBreadcrumbs && $headerBreadcrumbs->isNotEmpty())
|
||||||
|
<div class="mt-3">
|
||||||
|
@include('components.breadcrumbs', ['breadcrumbs' => $headerBreadcrumbs])
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
@if($showDescription && filled($description))
|
||||||
|
<p class="{{ $resolvedDescriptionClass }}">{!! $description !!}</p>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if($hasActions)
|
||||||
|
<div class="{{ $resolvedActionsClass }}">
|
||||||
|
{{ $actions }}
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
@@ -6,6 +6,7 @@
|
|||||||
|
|
||||||
@php
|
@php
|
||||||
$active = $section ?? 'artworks';
|
$active = $section ?? 'artworks';
|
||||||
|
$includeTags = (bool) ($includeTags ?? false);
|
||||||
|
|
||||||
$sections = collect([
|
$sections = collect([
|
||||||
'artworks' => ['label' => 'All Artworks', 'icon' => 'fa-border-all', 'href' => '/browse'],
|
'artworks' => ['label' => 'All Artworks', 'icon' => 'fa-border-all', 'href' => '/browse'],
|
||||||
@@ -14,6 +15,10 @@
|
|||||||
'skins' => ['label' => 'Skins', 'icon' => 'fa-layer-group', 'href' => '/skins'],
|
'skins' => ['label' => 'Skins', 'icon' => 'fa-layer-group', 'href' => '/skins'],
|
||||||
'other' => ['label' => 'Other', 'icon' => 'fa-folder-open', 'href' => '/other'],
|
'other' => ['label' => 'Other', 'icon' => 'fa-folder-open', 'href' => '/other'],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
if ($includeTags) {
|
||||||
|
$sections->put('tags', ['label' => 'Tags', 'icon' => 'fa-tags', 'href' => '/tags']);
|
||||||
|
}
|
||||||
@endphp
|
@endphp
|
||||||
|
|
||||||
<nav class="flex flex-wrap items-center gap-2 text-sm" aria-label="Browse sections">
|
<nav class="flex flex-wrap items-center gap-2 text-sm" aria-label="Browse sections">
|
||||||
|
|||||||
@@ -69,6 +69,8 @@
|
|||||||
$rankApiEndpoint = '/api/rank/global';
|
$rankApiEndpoint = '/api/rank/global';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$tagContext = ($gallery_type ?? null) === 'tag' ? ($tag_context ?? null) : null;
|
||||||
@endphp
|
@endphp
|
||||||
|
|
||||||
@section('content')
|
@section('content')
|
||||||
@@ -76,13 +78,15 @@
|
|||||||
@php Banner::ShowResponsiveAd(); @endphp
|
@php Banner::ShowResponsiveAd(); @endphp
|
||||||
|
|
||||||
@php
|
@php
|
||||||
$browseSection = isset($contentType) && $contentType ? strtolower((string) $contentType->slug) : 'artworks';
|
$browseSection = $gallery_nav_section
|
||||||
|
?? (isset($contentType) && $contentType ? strtolower((string) $contentType->slug) : (($gallery_type ?? null) === 'tag' ? 'tags' : 'artworks'));
|
||||||
$browseIconMap = [
|
$browseIconMap = [
|
||||||
'artworks' => 'fa-border-all',
|
'artworks' => 'fa-border-all',
|
||||||
'photography' => 'fa-camera',
|
'photography' => 'fa-camera',
|
||||||
'wallpapers' => 'fa-desktop',
|
'wallpapers' => 'fa-desktop',
|
||||||
'skins' => 'fa-layer-group',
|
'skins' => 'fa-layer-group',
|
||||||
'other' => 'fa-folder-open',
|
'other' => 'fa-folder-open',
|
||||||
|
'tags' => 'fa-tags',
|
||||||
];
|
];
|
||||||
$browseIcon = $browseIconMap[$browseSection] ?? 'fa-border-all';
|
$browseIcon = $browseIconMap[$browseSection] ?? 'fa-border-all';
|
||||||
@endphp
|
@endphp
|
||||||
@@ -94,39 +98,141 @@
|
|||||||
<main class="w-full">
|
<main class="w-full">
|
||||||
|
|
||||||
{{-- ── Hero header (discover-style) ── --}}
|
{{-- ── Hero header (discover-style) ── --}}
|
||||||
<header class="px-6 pt-10 pb-7 md:px-10 border-b border-white/[0.06] bg-gradient-to-b from-sky-500/[0.04] to-transparent">
|
@php
|
||||||
|
$headerBreadcrumbs = collect();
|
||||||
|
|
||||||
|
if (($gallery_type ?? null) === 'browse') {
|
||||||
|
$headerBreadcrumbs = collect([
|
||||||
|
(object) ['name' => 'Browse', 'url' => '/browse'],
|
||||||
|
]);
|
||||||
|
} elseif (isset($contentType) && $contentType) {
|
||||||
|
$headerBreadcrumbs = collect([
|
||||||
|
(object) ['name' => 'Browse', 'url' => '/browse'],
|
||||||
|
(object) ['name' => $contentType->name, 'url' => '/' . strtolower($contentType->slug)],
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (($gallery_type ?? null) === 'category' && isset($breadcrumbs) && $breadcrumbs->isNotEmpty()) {
|
||||||
|
$headerBreadcrumbs = $breadcrumbs;
|
||||||
|
}
|
||||||
|
} elseif (isset($breadcrumbs) && $breadcrumbs->isNotEmpty()) {
|
||||||
|
$headerBreadcrumbs = $breadcrumbs;
|
||||||
|
}
|
||||||
|
@endphp
|
||||||
|
|
||||||
|
<x-nova-page-header
|
||||||
|
section="{{ ($gallery_type ?? null) === 'tag' ? 'Tags' : 'Browse' }}"
|
||||||
|
:title="$hero_title ?? 'Browse Artworks'"
|
||||||
|
:icon="$browseIcon"
|
||||||
|
:breadcrumbs="$headerBreadcrumbs"
|
||||||
|
:description="$hero_description ?? null"
|
||||||
|
actionsClass="lg:pt-8"
|
||||||
|
>
|
||||||
|
<x-slot name="actions">
|
||||||
|
@include('gallery._browse_nav', ['section' => $browseSection, 'includeTags' => ($gallery_type ?? null) === 'tag'])
|
||||||
|
</x-slot>
|
||||||
|
</x-nova-page-header>
|
||||||
|
|
||||||
|
@if($tagContext)
|
||||||
|
<section class="px-6 pt-8 md:px-10">
|
||||||
@php
|
@php
|
||||||
$headerBreadcrumbs = collect(array_filter([
|
$topCompanionTag = collect($tagContext['related_tags'] ?? [])->first();
|
||||||
isset($contentType) && $contentType ? (object) ['name' => 'Explore', 'url' => '/explore'] : null,
|
|
||||||
isset($contentType) && $contentType ? (object) ['name' => $contentType->name, 'url' => '/explore/' . strtolower($contentType->slug)] : (object) ['name' => 'Explore', 'url' => '/explore'],
|
|
||||||
...(($gallery_type ?? null) === 'category' && isset($breadcrumbs) ? $breadcrumbs->all() : []),
|
|
||||||
]));
|
|
||||||
@endphp
|
@endphp
|
||||||
|
<div class="overflow-hidden rounded-[1.75rem] border border-white/[0.08] bg-[radial-gradient(circle_at_top_left,_rgba(56,189,248,0.16),_transparent_34%),linear-gradient(135deg,rgba(11,17,27,0.96),rgba(10,16,24,0.88))] shadow-[0_20px_70px_rgba(3,7,18,0.24)]">
|
||||||
|
<div class="grid gap-6 p-6 md:p-7 xl:grid-cols-[minmax(0,1.35fr)_minmax(300px,0.85fr)] xl:items-start">
|
||||||
|
<div class="space-y-6">
|
||||||
|
<div class="flex flex-wrap items-center gap-3 text-sm text-white/56">
|
||||||
|
<span class="inline-flex items-center gap-2 rounded-full border border-sky-400/20 bg-sky-400/10 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.24em] text-sky-200">
|
||||||
|
<span class="h-2 w-2 rounded-full bg-sky-300"></span>
|
||||||
|
Tag feed
|
||||||
|
</span>
|
||||||
|
<span class="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.05] px-3 py-1 text-xs font-medium text-white/60">
|
||||||
|
Sorted by {{ $tagContext['current_sort_label'] ?? 'Most viewed' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
|
<div class="space-y-3">
|
||||||
<div class="max-w-3xl">
|
<h2 class="text-2xl font-semibold tracking-tight text-white md:text-3xl">
|
||||||
<p class="text-xs font-semibold uppercase tracking-widest text-white/30 mb-1">Browse</p>
|
#{{ $tagContext['slug'] ?? $tagContext['name'] }}
|
||||||
<h1 class="text-3xl font-bold text-white leading-tight flex items-center gap-3">
|
</h2>
|
||||||
<i class="fa-solid {{ $browseIcon }} text-sky-400 text-2xl"></i>
|
<p class="max-w-3xl text-sm leading-6 text-white/62 md:text-base">
|
||||||
{{ $hero_title ?? 'Browse Artworks' }}
|
A focused feed for artwork tied to this theme. Use the ranking tabs to switch between momentum, recency, and quality without leaving the tag context.
|
||||||
</h1>
|
</p>
|
||||||
@if(!empty($hero_description))
|
@if($topCompanionTag && isset($topCompanionTag->shared_artworks_count))
|
||||||
<p class="mt-1 text-sm text-white/50">{!! $hero_description !!}</p>
|
<a href="{{ route('tags.show', $topCompanionTag->slug) }}" data-tag-analytics-link data-tag-analytics-surface="top_companion" data-tag-analytics-tag="{{ $topCompanionTag->slug }}" data-tag-analytics-source-tag="{{ $tagContext['slug'] ?? '' }}" data-tag-analytics-position="1" class="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.05] px-3.5 py-2 text-sm text-white/68 transition hover:border-sky-400/28 hover:bg-sky-400/[0.08] hover:text-white">
|
||||||
@endif
|
<span class="text-sky-200">Top companion</span>
|
||||||
</div>
|
<span class="font-medium text-white">#{{ $topCompanionTag->name }}</span>
|
||||||
|
<span class="text-white/34">{{ number_format($topCompanionTag->shared_artworks_count) }} shared artworks</span>
|
||||||
|
@if(isset($topCompanionTag->transition_clicks) && (int) $topCompanionTag->transition_clicks > 0)
|
||||||
|
<span class="text-emerald-200">{{ number_format((int) $topCompanionTag->transition_clicks) }} recent clicks</span>
|
||||||
|
@endif
|
||||||
|
</a>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="flex flex-col items-start gap-3 lg:items-end">
|
@if(collect($tagContext['related_tags'] ?? [])->isNotEmpty())
|
||||||
<div class="hidden lg:flex lg:justify-end">
|
<div>
|
||||||
@include('components.breadcrumbs', ['breadcrumbs' => $headerBreadcrumbs])
|
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-white/38">Related tags</p>
|
||||||
|
<div class="mt-3 flex flex-wrap gap-2.5">
|
||||||
|
@foreach($tagContext['related_tags'] as $relatedIndex => $relatedTag)
|
||||||
|
<a href="{{ route('tags.show', $relatedTag->slug) }}" data-tag-analytics-link data-tag-analytics-surface="related_chip" data-tag-analytics-tag="{{ $relatedTag->slug }}" data-tag-analytics-source-tag="{{ $tagContext['slug'] ?? '' }}" data-tag-analytics-position="{{ $relatedIndex + 1 }}" class="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.06] px-3.5 py-2 text-sm text-white/72 transition hover:border-sky-400/28 hover:bg-sky-400/[0.08] hover:text-white">
|
||||||
|
<span>#{{ $relatedTag->name }}</span>
|
||||||
|
<span class="text-xs text-white/36">
|
||||||
|
{{ number_format($relatedTag->shared_artworks_count ?? $relatedTag->usage_count) }}
|
||||||
|
{{ isset($relatedTag->shared_artworks_count) ? 'shared' : 'uses' }}
|
||||||
|
</span>
|
||||||
|
@if(isset($relatedTag->transition_clicks) && (int) $relatedTag->transition_clicks > 0)
|
||||||
|
<span class="text-xs text-emerald-200">{{ number_format((int) $relatedTag->transition_clicks) }} recent</span>
|
||||||
|
@endif
|
||||||
|
</a>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-4 grid gap-3 md:grid-cols-3">
|
||||||
|
@foreach(collect($tagContext['related_tags'])->take(3)->values() as $clusterIndex => $clusterTag)
|
||||||
|
<a href="{{ route('tags.show', $clusterTag->slug) }}" data-tag-analytics-link data-tag-analytics-surface="related_cluster" data-tag-analytics-tag="{{ $clusterTag->slug }}" data-tag-analytics-source-tag="{{ $tagContext['slug'] ?? '' }}" data-tag-analytics-position="{{ $clusterIndex + 1 }}" class="rounded-2xl border border-white/10 bg-white/[0.04] p-4 transition hover:border-sky-400/28 hover:bg-sky-400/[0.08]">
|
||||||
|
<p class="text-[11px] font-semibold uppercase tracking-[0.2em] text-white/34">Related cluster</p>
|
||||||
|
<h3 class="mt-2 text-base font-semibold text-white">#{{ $clusterTag->name }}</h3>
|
||||||
|
<p class="mt-2 text-sm text-white/52">
|
||||||
|
{{ number_format($clusterTag->shared_artworks_count ?? 0) }} shared artworks with this tag feed.
|
||||||
|
</p>
|
||||||
|
@if(isset($clusterTag->transition_clicks) && (int) $clusterTag->transition_clicks > 0)
|
||||||
|
<p class="mt-2 text-xs font-medium uppercase tracking-[0.18em] text-emerald-200">{{ number_format((int) $clusterTag->transition_clicks) }} recent clicks</p>
|
||||||
|
@endif
|
||||||
|
</a>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
</div>
|
</div>
|
||||||
@include('gallery._browse_nav', ['section' => $browseSection])
|
|
||||||
|
<aside class="grid gap-3 sm:grid-cols-3 xl:grid-cols-1">
|
||||||
|
<div class="rounded-2xl border border-white/10 bg-white/[0.05] p-4 backdrop-blur-sm">
|
||||||
|
<p class="text-[11px] font-semibold uppercase tracking-[0.22em] text-white/40">Artworks</p>
|
||||||
|
<p class="mt-3 text-3xl font-semibold text-white">{{ number_format((int) ($tagContext['artworks_total'] ?? 0)) }}</p>
|
||||||
|
<p class="mt-2 text-sm text-white/45">Public approved artworks currently in this feed.</p>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-2xl border border-white/10 bg-white/[0.05] p-4 backdrop-blur-sm">
|
||||||
|
<p class="text-[11px] font-semibold uppercase tracking-[0.22em] text-white/40">Total uses</p>
|
||||||
|
<p class="mt-3 text-3xl font-semibold text-white">{{ number_format((int) ($tagContext['usage_count'] ?? 0)) }}</p>
|
||||||
|
<p class="mt-2 text-sm text-white/45">How often this tag is attached across the catalog.</p>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-2xl border border-white/10 bg-white/[0.05] p-4 backdrop-blur-sm">
|
||||||
|
<p class="text-[11px] font-semibold uppercase tracking-[0.22em] text-white/40">Feed tools</p>
|
||||||
|
<div class="mt-3 flex flex-wrap gap-2">
|
||||||
|
<a href="{{ route('tags.index') }}" class="inline-flex items-center gap-2 rounded-xl border border-white/10 bg-black/20 px-3 py-2 text-sm font-medium text-white/72 transition hover:bg-white/[0.08] hover:text-white">
|
||||||
|
All tags
|
||||||
|
</a>
|
||||||
|
<a href="{{ $tagContext['rss_url'] ?? '#' }}" class="inline-flex items-center gap-2 rounded-xl border border-white/10 bg-black/20 px-3 py-2 text-sm font-medium text-white/72 transition hover:bg-white/[0.08] hover:text-white">
|
||||||
|
RSS
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<p class="mt-3 text-sm text-white/45">Jump back to discovery or subscribe to this tag feed.</p>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</section>
|
||||||
<div class="mt-4 lg:hidden">
|
@endif
|
||||||
@include('components.breadcrumbs', ['breadcrumbs' => $headerBreadcrumbs])
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
{{-- ═══════════════════════════════════════════════════════════════ --}}
|
{{-- ═══════════════════════════════════════════════════════════════ --}}
|
||||||
{{-- RANKING TABS --}}
|
{{-- RANKING TABS --}}
|
||||||
@@ -188,9 +294,11 @@
|
|||||||
@php
|
@php
|
||||||
$filterItems = $subcategories ?? collect();
|
$filterItems = $subcategories ?? collect();
|
||||||
$activeFilterId = isset($category) ? ($category->id ?? null) : null;
|
$activeFilterId = isset($category) ? ($category->id ?? null) : null;
|
||||||
$categoryAllHref = isset($contentType) && $contentType
|
$categoryAllHref = isset($subcategory_parent) && $subcategory_parent && ($subcategory_parent->url ?? null)
|
||||||
? url('/' . $contentType->slug)
|
? url($subcategory_parent->url)
|
||||||
: url('/browse');
|
: (isset($contentType) && $contentType
|
||||||
|
? url('/' . $contentType->slug)
|
||||||
|
: url('/browse'));
|
||||||
$activeSortSlug = $activeTab !== 'trending' ? $activeTab : null;
|
$activeSortSlug = $activeTab !== 'trending' ? $activeTab : null;
|
||||||
@endphp
|
@endphp
|
||||||
|
|
||||||
@@ -382,7 +490,6 @@
|
|||||||
@push('scripts')
|
@push('scripts')
|
||||||
@vite('resources/js/entry-masonry-gallery.jsx')
|
@vite('resources/js/entry-masonry-gallery.jsx')
|
||||||
@vite('resources/js/entry-pill-carousel.jsx')
|
@vite('resources/js/entry-pill-carousel.jsx')
|
||||||
<script src="/js/legacy-gallery-init.js" defer></script>
|
|
||||||
<script>
|
<script>
|
||||||
(function () {
|
(function () {
|
||||||
'use strict';
|
'use strict';
|
||||||
|
|||||||
55
resources/views/partials/bot-fingerprint-script.blade.php
Normal file
55
resources/views/partials/bot-fingerprint-script.blade.php
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
<script>
|
||||||
|
(() => {
|
||||||
|
const forms = document.querySelectorAll('[data-bot-form]');
|
||||||
|
if (!forms.length || !window.crypto?.subtle) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const readWebglVendor = () => {
|
||||||
|
try {
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
const gl = canvas.getContext('webgl') || canvas.getContext('experimental-webgl');
|
||||||
|
if (!gl) {
|
||||||
|
return 'no-webgl';
|
||||||
|
}
|
||||||
|
|
||||||
|
const extension = gl.getExtension('WEBGL_debug_renderer_info');
|
||||||
|
if (!extension) {
|
||||||
|
return 'webgl-hidden';
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
gl.getParameter(extension.UNMASKED_VENDOR_WEBGL),
|
||||||
|
gl.getParameter(extension.UNMASKED_RENDERER_WEBGL),
|
||||||
|
].join(':');
|
||||||
|
} catch {
|
||||||
|
return 'webgl-error';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const fingerprintPayload = [
|
||||||
|
navigator.userAgent || 'unknown-ua',
|
||||||
|
navigator.language || 'unknown-language',
|
||||||
|
navigator.platform || 'unknown-platform',
|
||||||
|
Intl.DateTimeFormat().resolvedOptions().timeZone || 'unknown-timezone',
|
||||||
|
`${window.screen?.width || 0}x${window.screen?.height || 0}x${window.devicePixelRatio || 1}`,
|
||||||
|
readWebglVendor(),
|
||||||
|
].join('|');
|
||||||
|
|
||||||
|
const encodeHex = (buffer) => Array.from(new Uint8Array(buffer))
|
||||||
|
.map((part) => part.toString(16).padStart(2, '0'))
|
||||||
|
.join('');
|
||||||
|
|
||||||
|
window.crypto.subtle.digest('SHA-256', new TextEncoder().encode(fingerprintPayload))
|
||||||
|
.then((buffer) => {
|
||||||
|
const fingerprint = encodeHex(buffer);
|
||||||
|
forms.forEach((form) => {
|
||||||
|
const input = form.querySelector('input[name="_bot_fingerprint"]');
|
||||||
|
if (input) {
|
||||||
|
input.value = fingerprint;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
@@ -1,24 +1,64 @@
|
|||||||
@extends('layouts.nova')
|
@extends('layouts.nova')
|
||||||
|
|
||||||
@section('content')
|
@php
|
||||||
|
$headerBreadcrumbs = collect([
|
||||||
|
(object) ['name' => $page_title ?? 'Community Activity', 'url' => route('community.activity')],
|
||||||
|
]);
|
||||||
|
|
||||||
{{-- Inline props for the React component --}}
|
$initialFilterLabel = match (($initialFilter ?? 'all')) {
|
||||||
<script id="latest-comments-props" type="application/json">
|
'comments' => 'Comments',
|
||||||
|
'replies' => 'Replies',
|
||||||
|
'following' => 'Following',
|
||||||
|
'my' => 'My Activity',
|
||||||
|
default => 'All Activity',
|
||||||
|
};
|
||||||
|
@endphp
|
||||||
|
|
||||||
|
@section('content')
|
||||||
|
<x-nova-page-header
|
||||||
|
section="Community"
|
||||||
|
:title="$page_title ?? 'Community Activity'"
|
||||||
|
icon="fa-wave-square"
|
||||||
|
:breadcrumbs="$headerBreadcrumbs"
|
||||||
|
description="Track comments, replies, reactions, and mentions from across the Skinbase community in one live feed."
|
||||||
|
headerClass="pb-6"
|
||||||
|
>
|
||||||
|
<x-slot name="actions">
|
||||||
|
<div class="flex flex-wrap items-center gap-2 text-xs font-medium">
|
||||||
|
<span id="community-activity-filter-summary" class="inline-flex items-center gap-1.5 rounded-full border border-sky-400/20 bg-sky-500/10 px-3 py-1 text-sky-200">
|
||||||
|
<i class="fa-solid fa-filter"></i>
|
||||||
|
{{ $initialFilterLabel }}
|
||||||
|
</span>
|
||||||
|
@if (!empty($initialUserId))
|
||||||
|
<span id="community-activity-scope-summary" class="inline-flex items-center gap-1.5 rounded-full border border-white/[0.08] bg-white/[0.04] px-3 py-1 text-white/65">
|
||||||
|
<i class="fa-solid fa-user"></i>
|
||||||
|
User #{{ $initialUserId }}
|
||||||
|
</span>
|
||||||
|
@else
|
||||||
|
<span id="community-activity-scope-summary" class="hidden"></span>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</x-slot>
|
||||||
|
</x-nova-page-header>
|
||||||
|
|
||||||
|
<script id="community-activity-props" type="application/json">
|
||||||
{!! json_encode($props, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_AMP) !!}
|
{!! json_encode($props, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_AMP) !!}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div id="latest-comments-root" class="min-h-screen">
|
<div id="community-activity-root" class="min-h-[480px]">
|
||||||
{{-- SSR skeleton replaced on React hydration --}}
|
<div class="mx-auto max-w-6xl px-6 pt-8 pb-20 md:px-10">
|
||||||
<div class="px-4 pt-10 pb-20 sm:px-6 lg:px-8 max-w-5xl mx-auto">
|
<div class="mb-6 rounded-[28px] border border-white/[0.06] bg-white/[0.025] p-5 shadow-[0_18px_45px_rgba(0,0,0,0.22)]">
|
||||||
<div class="mb-8">
|
<div class="h-3 w-40 animate-pulse rounded bg-white/[0.08]"></div>
|
||||||
<p class="text-xs font-semibold uppercase tracking-widest text-white/30 mb-1">Community</p>
|
<div class="mt-3 h-3 w-2/3 animate-pulse rounded bg-white/[0.06]"></div>
|
||||||
<h1 class="text-3xl font-bold text-white leading-tight">{{ $page_title }}</h1>
|
<div class="mt-5 flex gap-2">
|
||||||
<p class="mt-1 text-sm text-white/50">Most recent artwork comments from the community.</p>
|
<div class="h-10 w-28 animate-pulse rounded-full bg-white/[0.06]"></div>
|
||||||
|
<div class="h-10 w-24 animate-pulse rounded-full bg-white/[0.05]"></div>
|
||||||
|
<div class="h-10 w-24 animate-pulse rounded-full bg-white/[0.05]"></div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="h-8 w-8 animate-spin rounded-full border-4 border-nova-500 border-t-transparent mx-auto mt-20"></div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@vite(['resources/js/Pages/Community/LatestCommentsPage.jsx'])
|
@vite(['resources/js/Pages/Community/CommunityActivityPage.jsx'])
|
||||||
|
|
||||||
@endsection
|
@endsection
|
||||||
|
|||||||
@@ -1,34 +1,308 @@
|
|||||||
@extends('layouts.nova.content-layout')
|
@extends('layouts.nova')
|
||||||
|
|
||||||
@php
|
@php
|
||||||
$hero_title = 'Tags';
|
$hero_title = 'Tags';
|
||||||
$hero_description = 'Browse all artwork tags on Skinbase.';
|
$hero_description = 'Browse all artwork tags on Skinbase.';
|
||||||
$breadcrumbs = $breadcrumbs ?? collect([
|
$breadcrumbs = $breadcrumbs ?? collect([
|
||||||
(object) ['name' => 'Explore', 'url' => '/explore'],
|
(object) ['name' => 'Browse', 'url' => '/browse'],
|
||||||
(object) ['name' => 'Tags', 'url' => '/tags'],
|
(object) ['name' => 'Tags', 'url' => '/tags'],
|
||||||
]);
|
]);
|
||||||
@endphp
|
@endphp
|
||||||
|
|
||||||
@section('page-content')
|
@push('head')
|
||||||
@if($tags->isNotEmpty())
|
@isset($page_canonical)
|
||||||
<div class="flex flex-wrap gap-2">
|
<link rel="canonical" href="{{ $page_canonical }}" />
|
||||||
@foreach($tags as $tag)
|
@endisset
|
||||||
<a href="{{ route('tags.show', $tag->slug) }}"
|
<meta name="robots" content="{{ $page_robots ?? 'index,follow' }}">
|
||||||
class="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-white/[0.05] border border-white/[0.07]
|
<meta property="og:type" content="website" />
|
||||||
text-sm text-white/70 hover:bg-white/[0.1] hover:text-white transition-colors">
|
<meta property="og:url" content="{{ $page_canonical ?? url()->current() }}" />
|
||||||
<i class="fa-solid fa-hashtag text-xs text-sky-400/70"></i>
|
<meta property="og:title" content="{{ $page_title ?? 'Skinbase' }}" />
|
||||||
{{ $tag->name }}
|
<meta property="og:description" content="{{ $page_meta_description ?? $hero_description }}" />
|
||||||
<span class="text-xs text-white/30 ml-1">{{ number_format($tag->artworks_count) }}</span>
|
<meta property="og:site_name" content="Skinbase" />
|
||||||
</a>
|
@endpush
|
||||||
@endforeach
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-10 flex justify-center">
|
@section('content')
|
||||||
{{ $tags->withQueryString()->links() }}
|
@php
|
||||||
|
$query = trim((string) ($query ?? ''));
|
||||||
|
$featuredTags = $featuredTags ?? collect();
|
||||||
|
$risingTags = $risingTags ?? collect();
|
||||||
|
$tagStats = $tagStats ?? ['active' => 0, 'usage' => 0, 'matching' => $tags->total(), 'recent_clicks' => 0];
|
||||||
|
$topFeaturedTag = $featuredTags->first();
|
||||||
|
@endphp
|
||||||
|
|
||||||
|
<div class="container-fluid legacy-page">
|
||||||
|
<div class="pt-0">
|
||||||
|
<div class="mx-auto w-full">
|
||||||
|
<div class="relative min-h-[calc(100vh-64px)]">
|
||||||
|
<div aria-hidden="true" class="pointer-events-none absolute inset-x-0 top-0 overflow-hidden">
|
||||||
|
<div class="absolute left-[-6rem] top-10 h-56 w-56 rounded-full bg-sky-500/10 blur-3xl"></div>
|
||||||
|
<div class="absolute right-[-4rem] top-24 h-64 w-64 rounded-full bg-cyan-400/10 blur-3xl"></div>
|
||||||
|
<div class="absolute left-1/3 top-56 h-48 w-48 rounded-full bg-emerald-400/10 blur-3xl"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<main class="w-full">
|
||||||
|
<x-nova-page-header
|
||||||
|
section="Browse"
|
||||||
|
title="Tags"
|
||||||
|
icon="fa-tags"
|
||||||
|
:breadcrumbs="$breadcrumbs"
|
||||||
|
:description="$hero_description"
|
||||||
|
actionsClass="lg:pt-8"
|
||||||
|
>
|
||||||
|
<x-slot name="actions">
|
||||||
|
@include('gallery._browse_nav', ['section' => 'tags', 'includeTags' => true])
|
||||||
|
</x-slot>
|
||||||
|
</x-nova-page-header>
|
||||||
|
|
||||||
|
<section class="px-6 pb-16 pt-8 md:px-10">
|
||||||
|
<div class="grid gap-6 xl:grid-cols-[minmax(0,1.55fr)_minmax(320px,0.9fr)]">
|
||||||
|
<div class="overflow-hidden rounded-[1.75rem] border border-white/[0.08] bg-[radial-gradient(circle_at_top_left,_rgba(56,189,248,0.18),_transparent_38%),linear-gradient(135deg,rgba(13,19,30,0.98),rgba(8,14,24,0.92))] shadow-[0_24px_80px_rgba(3,7,18,0.32)]">
|
||||||
|
<div class="grid gap-8 p-6 md:p-8 xl:grid-cols-[minmax(0,1.2fr)_minmax(260px,0.8fr)] xl:items-end">
|
||||||
|
<div class="space-y-6">
|
||||||
|
<div class="inline-flex items-center gap-2 rounded-full border border-sky-400/20 bg-sky-400/10 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.28em] text-sky-200">
|
||||||
|
<span class="h-2 w-2 rounded-full bg-sky-300"></span>
|
||||||
|
Explore by vibe, medium, and theme
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-3">
|
||||||
|
<h2 class="max-w-2xl text-3xl font-semibold tracking-tight text-white md:text-4xl xl:text-[2.8rem] xl:leading-[1.05]">
|
||||||
|
Find collections faster with a cleaner tag browsing experience.
|
||||||
|
</h2>
|
||||||
|
<p class="max-w-2xl text-sm leading-6 text-white/64 md:text-base">
|
||||||
|
Jump into the most used themes on Skinbase, search by keyword, and move from discovery to relevant artwork feeds without scanning an endless wall of chips.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form method="GET" action="{{ route('tags.index') }}" class="space-y-3" data-tags-search-form>
|
||||||
|
<label for="tags-search" class="text-xs font-semibold uppercase tracking-[0.24em] text-white/40">Search tags</label>
|
||||||
|
<div class="flex flex-col gap-3 sm:flex-row">
|
||||||
|
<div class="relative flex-1" data-tags-search-root data-search-endpoint="/api/tags/search" data-popular-endpoint="/api/tags/popular">
|
||||||
|
<i class="fa-solid fa-magnifying-glass pointer-events-none absolute left-4 top-1/2 -translate-y-1/2 text-sm text-white/35"></i>
|
||||||
|
<input
|
||||||
|
id="tags-search"
|
||||||
|
type="search"
|
||||||
|
name="q"
|
||||||
|
value="{{ $query }}"
|
||||||
|
placeholder="Search aesthetics, games, styles..."
|
||||||
|
autocomplete="off"
|
||||||
|
spellcheck="false"
|
||||||
|
aria-autocomplete="list"
|
||||||
|
aria-expanded="false"
|
||||||
|
aria-controls="tags-search-suggestions"
|
||||||
|
data-tags-search-input
|
||||||
|
class="h-12 w-full rounded-2xl border border-white/10 bg-black/25 pl-11 pr-4 text-sm text-white placeholder:text-white/30 focus:border-sky-400/45 focus:outline-none focus:ring-2 focus:ring-sky-400/20"
|
||||||
|
>
|
||||||
|
<div id="tags-search-suggestions" data-tags-search-panel class="absolute left-0 right-0 top-[calc(100%+0.75rem)] z-20 hidden overflow-hidden rounded-2xl border border-white/10 bg-[linear-gradient(180deg,rgba(13,19,30,0.98),rgba(8,14,24,0.98))] shadow-[0_18px_50px_rgba(3,7,18,0.36)]">
|
||||||
|
<div class="border-b border-white/8 px-4 py-3 text-[11px] font-semibold uppercase tracking-[0.24em] text-white/38" data-tags-search-title>
|
||||||
|
Suggested tags
|
||||||
|
</div>
|
||||||
|
<div class="max-h-72 overflow-y-auto p-2" data-tags-search-results role="listbox"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-3 sm:shrink-0">
|
||||||
|
<button type="submit" class="inline-flex h-12 items-center justify-center rounded-2xl bg-sky-500 px-5 text-sm font-semibold text-slate-950 transition hover:bg-sky-400">
|
||||||
|
Search
|
||||||
|
</button>
|
||||||
|
@if($query !== '')
|
||||||
|
<a href="{{ route('tags.index') }}" class="inline-flex h-12 items-center justify-center rounded-2xl border border-white/10 bg-white/[0.04] px-5 text-sm font-semibold text-white/72 transition hover:bg-white/[0.08] hover:text-white">
|
||||||
|
Reset
|
||||||
|
</a>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
@if($risingTags->isNotEmpty())
|
||||||
|
<div class="space-y-3">
|
||||||
|
<div class="flex items-center justify-between gap-3">
|
||||||
|
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-white/40">Popular right now</p>
|
||||||
|
<p class="text-xs text-white/35">{{ $risingTags->count() }} quick jumps tuned by recent clicks</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-wrap gap-2.5">
|
||||||
|
@foreach($risingTags as $tag)
|
||||||
|
<a href="{{ route('tags.show', $tag->slug) }}" class="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.06] px-3.5 py-2 text-sm text-white/72 transition hover:border-sky-400/30 hover:bg-sky-400/10 hover:text-white">
|
||||||
|
<span class="inline-flex h-6 w-6 items-center justify-center rounded-full bg-white/[0.08] text-[11px] text-sky-200">#</span>
|
||||||
|
<span>{{ $tag->name }}</span>
|
||||||
|
</a>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid gap-3 sm:grid-cols-3 xl:grid-cols-1">
|
||||||
|
<div class="rounded-2xl border border-white/10 bg-white/[0.05] p-4 backdrop-blur-sm">
|
||||||
|
<p class="text-[11px] font-semibold uppercase tracking-[0.24em] text-white/40">Active tags</p>
|
||||||
|
<p class="mt-3 text-3xl font-semibold text-white">{{ number_format($tagStats['active']) }}</p>
|
||||||
|
<p class="mt-2 text-sm text-white/45">Browsable tags with active artwork associations.</p>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-2xl border border-white/10 bg-white/[0.05] p-4 backdrop-blur-sm">
|
||||||
|
<p class="text-[11px] font-semibold uppercase tracking-[0.24em] text-white/40">Total usage</p>
|
||||||
|
<p class="mt-3 text-3xl font-semibold text-white">{{ number_format($tagStats['usage']) }}</p>
|
||||||
|
<p class="mt-2 text-sm text-white/45">Tag assignments used across the catalog.</p>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-2xl border border-white/10 bg-white/[0.05] p-4 backdrop-blur-sm">
|
||||||
|
<p class="text-[11px] font-semibold uppercase tracking-[0.24em] text-white/40">Matching now</p>
|
||||||
|
<p class="mt-3 text-3xl font-semibold text-white">{{ number_format($tagStats['matching']) }}</p>
|
||||||
|
<p class="mt-2 text-sm text-white/45">
|
||||||
|
{{ $query !== '' ? 'Results for your current search term.' : 'The current catalog available to browse.' }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-2xl border border-white/10 bg-white/[0.05] p-4 backdrop-blur-sm">
|
||||||
|
<p class="text-[11px] font-semibold uppercase tracking-[0.24em] text-white/40">Recent clicks</p>
|
||||||
|
<p class="mt-3 text-3xl font-semibold text-white">{{ number_format((int) ($tagStats['recent_clicks'] ?? 0)) }}</p>
|
||||||
|
<p class="mt-2 text-sm text-white/45">Last 14 days of tag discovery clicks used to tune highlights.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<aside class="rounded-[1.75rem] border border-white/[0.08] bg-white/[0.04] p-6 shadow-[0_16px_60px_rgba(3,7,18,0.22)] backdrop-blur-sm">
|
||||||
|
<div class="flex items-start justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-white/40">Featured tag</p>
|
||||||
|
<h3 class="mt-2 text-2xl font-semibold text-white">{{ $topFeaturedTag?->name ?? 'No featured tag yet' }}</h3>
|
||||||
|
</div>
|
||||||
|
<div class="inline-flex h-12 w-12 items-center justify-center rounded-2xl bg-sky-400/12 text-sky-200">
|
||||||
|
<i class="fa-solid fa-wand-magic-sparkles text-lg"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if($topFeaturedTag)
|
||||||
|
<p class="mt-4 text-sm leading-6 text-white/58">
|
||||||
|
One of the strongest tags in current discovery behavior, with {{ number_format($topFeaturedTag->artworks_count) }} artworks currently attached.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<a href="{{ route('tags.show', $topFeaturedTag->slug) }}" class="mt-6 inline-flex items-center gap-2 rounded-2xl bg-white px-4 py-2.5 text-sm font-semibold text-slate-950 transition hover:bg-sky-100">
|
||||||
|
Open #{{ $topFeaturedTag->slug }}
|
||||||
|
<i class="fa-solid fa-arrow-right text-xs"></i>
|
||||||
|
</a>
|
||||||
|
@else
|
||||||
|
<p class="mt-4 text-sm leading-6 text-white/58">Tag highlights will appear here as soon as the catalog has enough data.</p>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
<div class="mt-8 border-t border-white/10 pt-6">
|
||||||
|
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-white/40">Browse tips</p>
|
||||||
|
<div class="mt-4 space-y-3 text-sm text-white/56">
|
||||||
|
<div class="rounded-2xl border border-white/8 bg-black/20 px-4 py-3">
|
||||||
|
Use broad tags like <span class="text-white">anime</span> or <span class="text-white">minimal</span> to start wide, then narrow down inside each feed.
|
||||||
|
</div>
|
||||||
|
<div class="rounded-2xl border border-white/8 bg-black/20 px-4 py-3">
|
||||||
|
Search matches both display names and slugs, so shorthand and full names work.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if($featuredTags->isNotEmpty())
|
||||||
|
<div class="mt-8 rounded-[1.75rem] border border-white/[0.08] bg-white/[0.03] p-6 md:p-7">
|
||||||
|
<div class="flex flex-col gap-2 md:flex-row md:items-end md:justify-between">
|
||||||
|
<div>
|
||||||
|
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-white/40">Editor's picks</p>
|
||||||
|
<h2 class="mt-2 text-2xl font-semibold text-white">High-signal tags worth exploring first</h2>
|
||||||
|
</div>
|
||||||
|
<p class="text-sm text-white/48">Ranked by recent discovery clicks first, then usage and artwork volume.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-6 grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||||
|
@foreach($featuredTags as $index => $tag)
|
||||||
|
<a href="{{ route('tags.show', $tag->slug) }}" class="group rounded-[1.35rem] border border-white/10 bg-[linear-gradient(180deg,rgba(255,255,255,0.06),rgba(255,255,255,0.02))] p-5 transition duration-200 hover:-translate-y-0.5 hover:border-sky-400/30 hover:bg-sky-400/[0.08]">
|
||||||
|
<div class="flex items-start justify-between gap-3">
|
||||||
|
<span class="inline-flex h-9 min-w-9 items-center justify-center rounded-xl bg-sky-400/12 px-3 text-sm font-semibold text-sky-200">
|
||||||
|
{{ str_pad((string) ($index + 1), 2, '0', STR_PAD_LEFT) }}
|
||||||
|
</span>
|
||||||
|
<span class="rounded-full border border-white/10 bg-white/[0.05] px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.2em] text-white/42">
|
||||||
|
{{ number_format((int) ($tag->recent_clicks ?? 0)) }} recent clicks
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-6 flex items-center gap-2 text-white">
|
||||||
|
<i class="fa-solid fa-hashtag text-sky-300"></i>
|
||||||
|
<h3 class="text-xl font-semibold tracking-tight">{{ $tag->name }}</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="mt-3 text-sm leading-6 text-white/56">
|
||||||
|
{{ number_format($tag->artworks_count) }} artworks tagged. Open the feed to see the strongest matches first.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="mt-6 inline-flex items-center gap-2 text-sm font-medium text-white/72 transition group-hover:text-white">
|
||||||
|
Browse tag
|
||||||
|
<i class="fa-solid fa-arrow-right text-xs transition group-hover:translate-x-0.5"></i>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
<div class="mt-8 rounded-[1.75rem] border border-white/[0.08] bg-white/[0.02] p-6 md:p-7">
|
||||||
|
<div class="flex flex-col gap-3 md:flex-row md:items-end md:justify-between">
|
||||||
|
<div>
|
||||||
|
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-white/40">All tags</p>
|
||||||
|
<h2 class="mt-2 text-2xl font-semibold text-white">
|
||||||
|
{{ $query !== '' ? 'Search results' : 'Browse the full catalog' }}
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<p class="text-sm text-white/46">
|
||||||
|
Showing {{ number_format($tags->count()) }} of {{ number_format($tags->total()) }} tags.
|
||||||
|
@if($query !== '')
|
||||||
|
<span>Matching "{{ $query }}".</span>
|
||||||
|
@endif
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if($tags->isNotEmpty())
|
||||||
|
<div class="mt-6 grid gap-3 sm:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4">
|
||||||
|
@foreach($tags as $tag)
|
||||||
|
<a href="{{ route('tags.show', $tag->slug) }}" class="group flex min-h-[132px] flex-col justify-between rounded-[1.25rem] border border-white/[0.08] bg-[linear-gradient(180deg,rgba(255,255,255,0.04),rgba(255,255,255,0.02))] p-4 transition duration-200 hover:border-sky-400/28 hover:bg-sky-400/[0.07] hover:shadow-[0_16px_40px_rgba(14,165,233,0.08)]">
|
||||||
|
<div class="flex items-start justify-between gap-3">
|
||||||
|
<div class="inline-flex h-10 w-10 items-center justify-center rounded-2xl bg-white/[0.05] text-sky-300 transition group-hover:bg-sky-400/12">
|
||||||
|
<i class="fa-solid fa-hashtag text-sm"></i>
|
||||||
|
</div>
|
||||||
|
<span class="rounded-full border border-white/10 bg-black/20 px-2.5 py-1 text-[11px] font-semibold uppercase tracking-[0.16em] text-white/38">
|
||||||
|
{{ number_format($tag->artworks_count) }} artworks
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-5">
|
||||||
|
<h3 class="text-lg font-semibold tracking-tight text-white">{{ $tag->name }}</h3>
|
||||||
|
<p class="mt-1 text-sm text-white/44">#{{ $tag->slug }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-5 flex items-center justify-between text-sm text-white/58 transition group-hover:text-white/78">
|
||||||
|
<span>{{ number_format($tag->usage_count) }} total uses</span>
|
||||||
|
<i class="fa-solid fa-arrow-up-right-from-square text-xs"></i>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-10 flex justify-center">
|
||||||
|
{{ $tags->withQueryString()->links() }}
|
||||||
|
</div>
|
||||||
|
@else
|
||||||
|
<div class="mt-6 rounded-[1.4rem] border border-dashed border-white/12 bg-black/20 px-8 py-12 text-center">
|
||||||
|
<div class="mx-auto flex h-16 w-16 items-center justify-center rounded-2xl bg-white/[0.05] text-sky-300">
|
||||||
|
<i class="fa-solid fa-tags text-xl"></i>
|
||||||
|
</div>
|
||||||
|
<h3 class="mt-5 text-xl font-semibold text-white">No tags matched this search</h3>
|
||||||
|
<p class="mx-auto mt-2 max-w-xl text-sm leading-6 text-white/50">
|
||||||
|
Try a broader keyword, remove punctuation, or reset the search to return to the full tag catalog.
|
||||||
|
</p>
|
||||||
|
<div class="mt-6 flex justify-center">
|
||||||
|
<a href="{{ route('tags.index') }}" class="inline-flex items-center gap-2 rounded-2xl border border-white/10 bg-white/[0.04] px-5 py-3 text-sm font-semibold text-white/72 transition hover:bg-white/[0.08] hover:text-white">
|
||||||
|
View all tags
|
||||||
|
<i class="fa-solid fa-arrow-right text-xs"></i>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@else
|
</div>
|
||||||
<div class="rounded-xl border border-white/[0.06] bg-white/[0.02] px-8 py-12 text-center">
|
|
||||||
<p class="text-white/40 text-sm">No tags found.</p>
|
|
||||||
</div>
|
|
||||||
@endif
|
|
||||||
@endsection
|
@endsection
|
||||||
|
|||||||
@@ -11,20 +11,20 @@ Route::middleware(['web', 'auth'])->prefix('dashboard')->name('api.dashboard.')-
|
|||||||
});
|
});
|
||||||
|
|
||||||
Route::middleware(['web', 'auth', 'creator.access'])->prefix('stories')->name('api.stories.')->group(function () {
|
Route::middleware(['web', 'auth', 'creator.access'])->prefix('stories')->name('api.stories.')->group(function () {
|
||||||
Route::post('create', [\App\Http\Controllers\StoryController::class, 'apiCreate'])->name('create');
|
Route::post('create', [\App\Http\Controllers\StoryController::class, 'apiCreate'])->middleware('forum.bot.protection:api_write')->name('create');
|
||||||
Route::put('update', [\App\Http\Controllers\StoryController::class, 'apiUpdate'])->name('update');
|
Route::put('update', [\App\Http\Controllers\StoryController::class, 'apiUpdate'])->middleware('forum.bot.protection:api_write')->name('update');
|
||||||
Route::post('autosave', [\App\Http\Controllers\StoryController::class, 'apiAutosave'])->name('autosave');
|
Route::post('autosave', [\App\Http\Controllers\StoryController::class, 'apiAutosave'])->middleware('forum.bot.protection:api_write')->name('autosave');
|
||||||
});
|
});
|
||||||
|
|
||||||
Route::middleware(['web', 'auth', 'creator.access'])->prefix('story')->name('api.story.')->group(function () {
|
Route::middleware(['web', 'auth', 'creator.access'])->prefix('story')->name('api.story.')->group(function () {
|
||||||
Route::post('upload-image', [\App\Http\Controllers\StoryController::class, 'apiUploadImage'])->name('upload-image');
|
Route::post('upload-image', [\App\Http\Controllers\StoryController::class, 'apiUploadImage'])->middleware('forum.bot.protection:api_write')->name('upload-image');
|
||||||
Route::get('artworks', [\App\Http\Controllers\StoryController::class, 'apiArtworks'])->name('artworks');
|
Route::get('artworks', [\App\Http\Controllers\StoryController::class, 'apiArtworks'])->name('artworks');
|
||||||
});
|
});
|
||||||
|
|
||||||
Route::middleware(['web', 'auth', 'normalize.username'])->prefix('profile/cover')->name('api.profile.cover.')->group(function () {
|
Route::middleware(['web', 'auth', 'normalize.username'])->prefix('profile/cover')->name('api.profile.cover.')->group(function () {
|
||||||
Route::post('upload', [\App\Http\Controllers\User\ProfileCoverController::class, 'upload'])->middleware('throttle:20,1')->name('upload');
|
Route::post('upload', [\App\Http\Controllers\User\ProfileCoverController::class, 'upload'])->middleware(['throttle:20,1', 'forum.bot.protection:profile_update'])->name('upload');
|
||||||
Route::post('position', [\App\Http\Controllers\User\ProfileCoverController::class, 'updatePosition'])->middleware('throttle:30,1')->name('position');
|
Route::post('position', [\App\Http\Controllers\User\ProfileCoverController::class, 'updatePosition'])->middleware(['throttle:30,1', 'forum.bot.protection:profile_update'])->name('position');
|
||||||
Route::delete('/', [\App\Http\Controllers\User\ProfileCoverController::class, 'destroy'])->middleware('throttle:20,1')->name('destroy');
|
Route::delete('/', [\App\Http\Controllers\User\ProfileCoverController::class, 'destroy'])->middleware(['throttle:20,1', 'forum.bot.protection:profile_update'])->name('destroy');
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Per-artwork signal tracking (public) ────────────────────────────────────
|
// ── Per-artwork signal tracking (public) ────────────────────────────────────
|
||||||
@@ -38,14 +38,20 @@ Route::middleware(['web', 'throttle:300,1'])
|
|||||||
|
|
||||||
Route::middleware(['web', 'throttle:5,10'])
|
Route::middleware(['web', 'throttle:5,10'])
|
||||||
->post('art/{id}/view', \App\Http\Controllers\Api\ArtworkViewController::class)
|
->post('art/{id}/view', \App\Http\Controllers\Api\ArtworkViewController::class)
|
||||||
|
->middleware('forum.bot.protection:api_write')
|
||||||
->whereNumber('id')
|
->whereNumber('id')
|
||||||
->name('api.art.view');
|
->name('api.art.view');
|
||||||
|
|
||||||
Route::middleware(['web', 'throttle:10,1'])
|
Route::middleware(['web', 'throttle:10,1'])
|
||||||
->post('art/{id}/download', \App\Http\Controllers\Api\ArtworkDownloadController::class)
|
->post('art/{id}/download', \App\Http\Controllers\Api\ArtworkDownloadController::class)
|
||||||
|
->middleware('forum.bot.protection:api_write')
|
||||||
->whereNumber('id')
|
->whereNumber('id')
|
||||||
->name('api.art.download');
|
->name('api.art.download');
|
||||||
|
|
||||||
|
Route::middleware(['web', 'throttle:reactions-read'])
|
||||||
|
->get('community/activity', [\App\Http\Controllers\Api\CommunityActivityController::class, 'index'])
|
||||||
|
->name('api.community.activity');
|
||||||
|
|
||||||
// ── Ranking lists (public, throttled, Redis-cached) ─────────────────────────
|
// ── Ranking lists (public, throttled, Redis-cached) ─────────────────────────
|
||||||
// GET /api/rank/global?type=trending|new_hot|best
|
// GET /api/rank/global?type=trending|new_hot|best
|
||||||
// GET /api/rank/category/{id}?type=trending|new_hot|best
|
// GET /api/rank/category/{id}?type=trending|new_hot|best
|
||||||
@@ -136,24 +142,25 @@ Route::middleware(['throttle:60,1'])
|
|||||||
|
|
||||||
Route::middleware(['web', 'auth', 'normalize.username'])->prefix('artworks')->name('api.artworks.')->group(function () {
|
Route::middleware(['web', 'auth', 'normalize.username'])->prefix('artworks')->name('api.artworks.')->group(function () {
|
||||||
Route::post('/', [\App\Http\Controllers\Api\ArtworkController::class, 'store'])
|
Route::post('/', [\App\Http\Controllers\Api\ArtworkController::class, 'store'])
|
||||||
|
->middleware('forum.bot.protection:api_write')
|
||||||
->name('store');
|
->name('store');
|
||||||
});
|
});
|
||||||
|
|
||||||
Route::middleware(['web', 'auth', 'normalize.username'])->prefix('uploads')->name('api.uploads.')->group(function () {
|
Route::middleware(['web', 'auth', 'normalize.username'])->prefix('uploads')->name('api.uploads.')->group(function () {
|
||||||
Route::post('init', [\App\Http\Controllers\Api\UploadController::class, 'init'])
|
Route::post('init', [\App\Http\Controllers\Api\UploadController::class, 'init'])
|
||||||
->middleware('throttle:uploads-init')
|
->middleware(['throttle:uploads-init', 'forum.bot.protection:api_write'])
|
||||||
->name('init');
|
->name('init');
|
||||||
|
|
||||||
Route::post('preload', [\App\Http\Controllers\Api\UploadController::class, 'preload'])
|
Route::post('preload', [\App\Http\Controllers\Api\UploadController::class, 'preload'])
|
||||||
->middleware('throttle:uploads-init')
|
->middleware(['throttle:uploads-init', 'forum.bot.protection:api_write'])
|
||||||
->name('preload');
|
->name('preload');
|
||||||
|
|
||||||
Route::post('{id}/autosave', [\App\Http\Controllers\Api\UploadController::class, 'autosave'])
|
Route::post('{id}/autosave', [\App\Http\Controllers\Api\UploadController::class, 'autosave'])
|
||||||
->middleware('throttle:uploads-finish')
|
->middleware(['throttle:uploads-finish', 'forum.bot.protection:api_write'])
|
||||||
->name('autosave');
|
->name('autosave');
|
||||||
|
|
||||||
Route::post('{id}/publish', [\App\Http\Controllers\Api\UploadController::class, 'publish'])
|
Route::post('{id}/publish', [\App\Http\Controllers\Api\UploadController::class, 'publish'])
|
||||||
->middleware('throttle:uploads-finish')
|
->middleware(['throttle:uploads-finish', 'forum.bot.protection:api_write'])
|
||||||
->name('publish');
|
->name('publish');
|
||||||
|
|
||||||
Route::get('{id}/status', [\App\Http\Controllers\Api\UploadController::class, 'processingStatus'])
|
Route::get('{id}/status', [\App\Http\Controllers\Api\UploadController::class, 'processingStatus'])
|
||||||
@@ -161,15 +168,15 @@ Route::middleware(['web', 'auth', 'normalize.username'])->prefix('uploads')->nam
|
|||||||
->name('processing-status');
|
->name('processing-status');
|
||||||
|
|
||||||
Route::post('chunk', [\App\Http\Controllers\Api\UploadController::class, 'chunk'])
|
Route::post('chunk', [\App\Http\Controllers\Api\UploadController::class, 'chunk'])
|
||||||
->middleware('throttle:uploads-init')
|
->middleware(['throttle:uploads-init', 'forum.bot.protection:api_write'])
|
||||||
->name('chunk');
|
->name('chunk');
|
||||||
|
|
||||||
Route::post('finish', [\App\Http\Controllers\Api\UploadController::class, 'finish'])
|
Route::post('finish', [\App\Http\Controllers\Api\UploadController::class, 'finish'])
|
||||||
->middleware('throttle:uploads-finish')
|
->middleware(['throttle:uploads-finish', 'forum.bot.protection:api_write'])
|
||||||
->name('finish');
|
->name('finish');
|
||||||
|
|
||||||
Route::post('cancel', [\App\Http\Controllers\Api\UploadController::class, 'cancel'])
|
Route::post('cancel', [\App\Http\Controllers\Api\UploadController::class, 'cancel'])
|
||||||
->middleware('throttle:uploads-finish')
|
->middleware(['throttle:uploads-finish', 'forum.bot.protection:api_write'])
|
||||||
->name('cancel');
|
->name('cancel');
|
||||||
|
|
||||||
Route::get('status/{id}', [\App\Http\Controllers\Api\UploadController::class, 'status'])
|
Route::get('status/{id}', [\App\Http\Controllers\Api\UploadController::class, 'status'])
|
||||||
@@ -204,6 +211,9 @@ Route::middleware(['web', 'auth', 'admin.moderation'])->prefix('admin/reports')-
|
|||||||
|
|
||||||
Route::get('feed-performance', [\App\Http\Controllers\Api\Admin\FeedPerformanceReportController::class, 'index'])
|
Route::get('feed-performance', [\App\Http\Controllers\Api\Admin\FeedPerformanceReportController::class, 'index'])
|
||||||
->name('feed-performance');
|
->name('feed-performance');
|
||||||
|
|
||||||
|
Route::get('tags', [\App\Http\Controllers\Api\Admin\TagInteractionReportController::class, 'index'])
|
||||||
|
->name('tags');
|
||||||
});
|
});
|
||||||
|
|
||||||
Route::middleware(['web', 'auth', 'admin.moderation'])->prefix('admin/usernames')->name('api.admin.usernames.')->group(function () {
|
Route::middleware(['web', 'auth', 'admin.moderation'])->prefix('admin/usernames')->name('api.admin.usernames.')->group(function () {
|
||||||
@@ -223,13 +233,17 @@ Route::post('analytics/similar-artworks', [\App\Http\Controllers\Api\SimilarArtw
|
|||||||
->middleware('throttle:uploads-status')
|
->middleware('throttle:uploads-status')
|
||||||
->name('api.analytics.similar-artworks.store');
|
->name('api.analytics.similar-artworks.store');
|
||||||
|
|
||||||
|
Route::middleware(['web'])->post('analytics/tags', [\App\Http\Controllers\Api\TagInteractionAnalyticsController::class, 'store'])
|
||||||
|
->middleware(['throttle:uploads-status', 'forum.bot.protection:api_write'])
|
||||||
|
->name('api.analytics.tags.store');
|
||||||
|
|
||||||
Route::middleware(['web', 'auth'])->post('analytics/feed', [\App\Http\Controllers\Api\FeedAnalyticsController::class, 'store'])
|
Route::middleware(['web', 'auth'])->post('analytics/feed', [\App\Http\Controllers\Api\FeedAnalyticsController::class, 'store'])
|
||||||
->middleware('throttle:uploads-status')
|
->middleware(['throttle:uploads-status', 'forum.bot.protection:api_write'])
|
||||||
->name('api.analytics.feed.store');
|
->name('api.analytics.feed.store');
|
||||||
|
|
||||||
Route::middleware(['web', 'auth', 'normalize.username'])->prefix('discovery')->name('api.discovery.')->group(function () {
|
Route::middleware(['web', 'auth', 'normalize.username'])->prefix('discovery')->name('api.discovery.')->group(function () {
|
||||||
Route::post('events', [\App\Http\Controllers\Api\DiscoveryEventController::class, 'store'])
|
Route::post('events', [\App\Http\Controllers\Api\DiscoveryEventController::class, 'store'])
|
||||||
->middleware('throttle:uploads-status')
|
->middleware(['throttle:uploads-status', 'forum.bot.protection:api_write'])
|
||||||
->name('events.store');
|
->name('events.store');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ Route::middleware(['guest', 'normalize.username'])->group(function () {
|
|||||||
->name('register.notice');
|
->name('register.notice');
|
||||||
|
|
||||||
Route::post('register', [RegisteredUserController::class, 'store'])
|
Route::post('register', [RegisteredUserController::class, 'store'])
|
||||||
->middleware(['throttle:register-ip', 'throttle:register-ip-daily']);
|
->middleware(['throttle:register-ip', 'throttle:register-ip-daily', 'forum.security.firewall:register', 'forum.bot.protection:register']);
|
||||||
|
|
||||||
Route::post('register/resend-verification', [RegisteredUserController::class, 'resendVerification'])
|
Route::post('register/resend-verification', [RegisteredUserController::class, 'resendVerification'])
|
||||||
->middleware('throttle:register')
|
->middleware('throttle:register')
|
||||||
@@ -47,7 +47,8 @@ Route::middleware(['guest', 'normalize.username'])->group(function () {
|
|||||||
Route::get('login', [AuthenticatedSessionController::class, 'create'])
|
Route::get('login', [AuthenticatedSessionController::class, 'create'])
|
||||||
->name('login');
|
->name('login');
|
||||||
|
|
||||||
Route::post('login', [AuthenticatedSessionController::class, 'store']);
|
Route::post('login', [AuthenticatedSessionController::class, 'store'])
|
||||||
|
->middleware(['forum.security.firewall:login', 'forum.bot.protection:login']);
|
||||||
|
|
||||||
Route::get('forgot-password', [PasswordResetLinkController::class, 'create'])
|
Route::get('forgot-password', [PasswordResetLinkController::class, 'create'])
|
||||||
->name('password.request');
|
->name('password.request');
|
||||||
|
|||||||
@@ -110,7 +110,26 @@ Schedule::command('nova:recalculate-rankings --sync-rank-scores')
|
|||||||
->withoutOverlapping()
|
->withoutOverlapping()
|
||||||
->runInBackground();
|
->runInBackground();
|
||||||
|
|
||||||
Schedule::command('forum:scan-posts')
|
Schedule::command('forum:ai-scan')
|
||||||
->everyTenMinutes()
|
->everyTenMinutes()
|
||||||
->name('forum-scan-posts')
|
->name('forum-ai-scan')
|
||||||
->withoutOverlapping();
|
->withoutOverlapping()
|
||||||
|
->runInBackground();
|
||||||
|
|
||||||
|
Schedule::command('forum:bot-scan')
|
||||||
|
->everyFiveMinutes()
|
||||||
|
->name('forum-bot-scan')
|
||||||
|
->withoutOverlapping()
|
||||||
|
->runInBackground();
|
||||||
|
|
||||||
|
Schedule::command('forum:scan-posts --limit=250')
|
||||||
|
->everyFifteenMinutes()
|
||||||
|
->name('forum-post-scan')
|
||||||
|
->withoutOverlapping()
|
||||||
|
->runInBackground();
|
||||||
|
|
||||||
|
Schedule::command('forum:firewall-scan')
|
||||||
|
->everyFiveMinutes()
|
||||||
|
->name('forum-firewall-scan')
|
||||||
|
->withoutOverlapping()
|
||||||
|
->runInBackground();
|
||||||
|
|||||||
@@ -273,18 +273,18 @@ Route::middleware(['auth', 'normalize.username', 'ensure.onboarding.complete'])-
|
|||||||
Route::match(['post', 'put'], '/profile/password', [ProfileController::class, 'password'])->name('profile.password');
|
Route::match(['post', 'put'], '/profile/password', [ProfileController::class, 'password'])->name('profile.password');
|
||||||
Route::post('/avatar/upload', [AvatarController::class, 'upload'])->middleware('throttle:20,1')->name('avatar.upload');
|
Route::post('/avatar/upload', [AvatarController::class, 'upload'])->middleware('throttle:20,1')->name('avatar.upload');
|
||||||
|
|
||||||
Route::post('/settings/profile/update', [ProfileController::class, 'updateProfileSection'])->name('settings.profile.update');
|
Route::post('/settings/profile/update', [ProfileController::class, 'updateProfileSection'])->middleware('forum.bot.protection:profile_update')->name('settings.profile.update');
|
||||||
Route::post('/settings/account/username', [ProfileController::class, 'updateUsername'])->name('settings.account.username');
|
Route::post('/settings/account/username', [ProfileController::class, 'updateUsername'])->middleware('forum.bot.protection:profile_update')->name('settings.account.username');
|
||||||
Route::post('/settings/account/update', [ProfileController::class, 'updateAccountSection'])->name('settings.account.update');
|
Route::post('/settings/account/update', [ProfileController::class, 'updateAccountSection'])->middleware('forum.bot.protection:profile_update')->name('settings.account.update');
|
||||||
Route::post('/settings/email/request', [ProfileController::class, 'requestEmailChange'])
|
Route::post('/settings/email/request', [ProfileController::class, 'requestEmailChange'])
|
||||||
->middleware('throttle:email-change-request')
|
->middleware('throttle:email-change-request')
|
||||||
->name('settings.email.request');
|
->name('settings.email.request');
|
||||||
Route::post('/settings/email/verify', [ProfileController::class, 'verifyEmailChange'])
|
Route::post('/settings/email/verify', [ProfileController::class, 'verifyEmailChange'])
|
||||||
->middleware('throttle:10,1')
|
->middleware('throttle:10,1')
|
||||||
->name('settings.email.verify');
|
->name('settings.email.verify');
|
||||||
Route::post('/settings/personal/update', [ProfileController::class, 'updatePersonalSection'])->name('settings.personal.update');
|
Route::post('/settings/personal/update', [ProfileController::class, 'updatePersonalSection'])->middleware('forum.bot.protection:profile_update')->name('settings.personal.update');
|
||||||
Route::post('/settings/notifications/update', [ProfileController::class, 'updateNotificationsSection'])->name('settings.notifications.update');
|
Route::post('/settings/notifications/update', [ProfileController::class, 'updateNotificationsSection'])->middleware('forum.bot.protection:profile_update')->name('settings.notifications.update');
|
||||||
Route::post('/settings/security/password', [ProfileController::class, 'updateSecurityPassword'])->name('settings.security.password');
|
Route::post('/settings/security/password', [ProfileController::class, 'updateSecurityPassword'])->middleware('forum.bot.protection:profile_update')->name('settings.security.password');
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── UPLOAD ────────────────────────────────────────────────────────────────────
|
// ── UPLOAD ────────────────────────────────────────────────────────────────────
|
||||||
@@ -377,6 +377,10 @@ Route::middleware(['auth'])->prefix('admin')->name('admin.')->group(function ()
|
|||||||
->middleware('admin.moderation')
|
->middleware('admin.moderation')
|
||||||
->name('reports.queue');
|
->name('reports.queue');
|
||||||
|
|
||||||
|
Route::get('reports/tags', [\App\Http\Controllers\Admin\TagInteractionReportController::class, 'index'])
|
||||||
|
->middleware('admin.moderation')
|
||||||
|
->name('reports.tags');
|
||||||
|
|
||||||
Route::middleware('admin.moderation')->prefix('early-growth')->name('early-growth.')->group(function () {
|
Route::middleware('admin.moderation')->prefix('early-growth')->name('early-growth.')->group(function () {
|
||||||
Route::get('/', [\App\Http\Controllers\Admin\EarlyGrowthAdminController::class, 'index'])->name('index');
|
Route::get('/', [\App\Http\Controllers\Admin\EarlyGrowthAdminController::class, 'index'])->name('index');
|
||||||
Route::delete('/cache', [\App\Http\Controllers\Admin\EarlyGrowthAdminController::class, 'flushCache'])->name('cache.flush');
|
Route::delete('/cache', [\App\Http\Controllers\Admin\EarlyGrowthAdminController::class, 'flushCache'])->name('cache.flush');
|
||||||
@@ -411,6 +415,9 @@ Route::middleware(['auth', 'ensure.onboarding.complete'])->prefix('messages')->n
|
|||||||
});
|
});
|
||||||
|
|
||||||
// ── COMMUNITY ACTIVITY ────────────────────────────────────────────────────────
|
// ── COMMUNITY ACTIVITY ────────────────────────────────────────────────────────
|
||||||
|
Route::match(['get', 'post'], '/community/chat', [\App\Http\Controllers\Community\ChatController::class, 'index'])
|
||||||
|
->name('community.chat');
|
||||||
|
|
||||||
Route::get('/community/activity', [\App\Http\Controllers\Web\CommunityActivityController::class, 'index'])
|
Route::get('/community/activity', [\App\Http\Controllers\Web\CommunityActivityController::class, 'index'])
|
||||||
->name('community.activity');
|
->name('community.activity');
|
||||||
|
|
||||||
@@ -430,9 +437,11 @@ Route::get('/feed/search', [\App\Http\Controllers\Web\Posts\SearchFeedController
|
|||||||
->name('feed.search');
|
->name('feed.search');
|
||||||
|
|
||||||
// ── CONTENT BROWSER (artwork / category universal router) ─────────────────────
|
// ── CONTENT BROWSER (artwork / category universal router) ─────────────────────
|
||||||
// Bind the artwork route parameter to the Artwork model by slug.
|
// Bind the artwork route parameter by slug when possible, but don't hard-fail.
|
||||||
|
// Some URLs that match this shape are actually nested category paths such as
|
||||||
|
// /skins/audio/blazemedia-pro, which should fall through to category handling.
|
||||||
Route::bind('artwork', function ($value) {
|
Route::bind('artwork', function ($value) {
|
||||||
return Artwork::where('slug', $value)->firstOrFail();
|
return Artwork::where('slug', $value)->first();
|
||||||
});
|
});
|
||||||
|
|
||||||
Route::get('/{contentTypeSlug}/{categoryPath}/{artwork}', [BrowseGalleryController::class, 'showArtwork'])
|
Route::get('/{contentTypeSlug}/{categoryPath}/{artwork}', [BrowseGalleryController::class, 'showArtwork'])
|
||||||
|
|||||||
95
tests/Unit/AccountFarmDetectorTest.php
Normal file
95
tests/Unit/AccountFarmDetectorTest.php
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use cPad\Plugins\Forum\Services\Security\AccountFarmDetector;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
uses(Tests\TestCase::class);
|
||||||
|
|
||||||
|
it('flags repeated posting patterns across multiple accounts', function () {
|
||||||
|
config()->set('forum_bot_protection.account_farm', [
|
||||||
|
'window_minutes' => 10,
|
||||||
|
'register_attempt_threshold' => 10,
|
||||||
|
'same_ip_users_threshold' => 5,
|
||||||
|
'same_fingerprint_users_threshold' => 3,
|
||||||
|
'same_pattern_users_threshold' => 2,
|
||||||
|
'register_attempt_penalty' => 50,
|
||||||
|
'same_ip_penalty' => 35,
|
||||||
|
'same_fingerprint_penalty' => 40,
|
||||||
|
'same_pattern_penalty' => 45,
|
||||||
|
]);
|
||||||
|
|
||||||
|
Schema::dropIfExists('forum_posts');
|
||||||
|
Schema::dropIfExists('forum_bot_device_fingerprints');
|
||||||
|
Schema::dropIfExists('forum_bot_logs');
|
||||||
|
|
||||||
|
Schema::create('forum_bot_logs', function (Blueprint $table): void {
|
||||||
|
$table->id();
|
||||||
|
$table->unsignedBigInteger('user_id')->nullable();
|
||||||
|
$table->string('ip_address', 45)->nullable();
|
||||||
|
$table->string('action', 80);
|
||||||
|
$table->unsignedTinyInteger('risk_score')->default(0);
|
||||||
|
$table->string('decision', 20)->default('allow');
|
||||||
|
$table->json('metadata')->nullable();
|
||||||
|
$table->timestamp('created_at')->nullable();
|
||||||
|
});
|
||||||
|
|
||||||
|
Schema::create('forum_bot_device_fingerprints', function (Blueprint $table): void {
|
||||||
|
$table->id();
|
||||||
|
$table->unsignedBigInteger('user_id')->nullable();
|
||||||
|
$table->string('fingerprint', 128)->nullable();
|
||||||
|
$table->timestamp('first_seen')->nullable();
|
||||||
|
$table->timestamp('last_seen')->nullable();
|
||||||
|
$table->unsignedTinyInteger('risk_score')->default(0);
|
||||||
|
$table->string('user_agent_hash', 64)->nullable();
|
||||||
|
$table->timestamps();
|
||||||
|
});
|
||||||
|
|
||||||
|
Schema::create('forum_posts', function (Blueprint $table): void {
|
||||||
|
$table->id();
|
||||||
|
$table->unsignedBigInteger('thread_id')->nullable();
|
||||||
|
$table->unsignedBigInteger('topic_id')->nullable();
|
||||||
|
$table->string('source_ip_hash', 64)->nullable();
|
||||||
|
$table->unsignedBigInteger('user_id')->nullable();
|
||||||
|
$table->longText('content')->nullable();
|
||||||
|
$table->string('content_hash', 64)->nullable();
|
||||||
|
$table->boolean('is_edited')->default(false);
|
||||||
|
$table->timestamp('edited_at')->nullable();
|
||||||
|
$table->unsignedInteger('spam_score')->default(0);
|
||||||
|
$table->unsignedInteger('quality_score')->default(0);
|
||||||
|
$table->unsignedInteger('ai_spam_score')->default(0);
|
||||||
|
$table->unsignedInteger('ai_toxicity_score')->default(0);
|
||||||
|
$table->unsignedInteger('behavior_score')->default(0);
|
||||||
|
$table->unsignedInteger('link_score')->default(0);
|
||||||
|
$table->integer('learning_score')->default(0);
|
||||||
|
$table->unsignedInteger('risk_score')->default(0);
|
||||||
|
$table->integer('trust_modifier')->default(0);
|
||||||
|
$table->boolean('flagged')->default(false);
|
||||||
|
$table->string('flagged_reason')->nullable();
|
||||||
|
$table->boolean('moderation_checked')->default(false);
|
||||||
|
$table->string('moderation_status')->nullable();
|
||||||
|
$table->json('moderation_labels')->nullable();
|
||||||
|
$table->json('moderation_meta')->nullable();
|
||||||
|
$table->timestamp('last_ai_scan_at')->nullable();
|
||||||
|
$table->timestamps();
|
||||||
|
$table->softDeletes();
|
||||||
|
});
|
||||||
|
|
||||||
|
$hash = hash('sha256', 'buy cheap backlinks now');
|
||||||
|
|
||||||
|
foreach ([1, 2, 3] as $userId) {
|
||||||
|
DB::table('forum_posts')->insert([
|
||||||
|
'user_id' => $userId,
|
||||||
|
'content' => 'buy cheap backlinks now',
|
||||||
|
'content_hash' => $hash,
|
||||||
|
'created_at' => now()->subMinutes(2),
|
||||||
|
'updated_at' => now()->subMinutes(2),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$result = app(AccountFarmDetector::class)->analyze(1, '203.0.113.10', null, 'forum_reply_create');
|
||||||
|
|
||||||
|
expect($result['score'])->toBe(45)
|
||||||
|
->and($result['reasons'])->toContain('Posting patterns or repeated content overlap across multiple accounts.');
|
||||||
|
});
|
||||||
52
tests/Unit/BotRiskScorerTest.php
Normal file
52
tests/Unit/BotRiskScorerTest.php
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use cPad\Plugins\Forum\Services\Security\BotRiskScorer;
|
||||||
|
|
||||||
|
uses(Tests\TestCase::class);
|
||||||
|
|
||||||
|
it('maps bot risk thresholds to the expected decisions', function () {
|
||||||
|
config()->set('forum_bot_protection.thresholds', [
|
||||||
|
'allow' => 20,
|
||||||
|
'log' => 20,
|
||||||
|
'captcha' => 40,
|
||||||
|
'moderate' => 60,
|
||||||
|
'block' => 80,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$scorer = app(BotRiskScorer::class);
|
||||||
|
|
||||||
|
expect($scorer->score(['behavior' => 10]))->toMatchArray([
|
||||||
|
'risk_score' => 10,
|
||||||
|
'decision' => 'allow',
|
||||||
|
'requires_review' => false,
|
||||||
|
'blocked' => false,
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect($scorer->score(['behavior' => 20]))->toMatchArray([
|
||||||
|
'risk_score' => 20,
|
||||||
|
'decision' => 'log',
|
||||||
|
'requires_review' => false,
|
||||||
|
'blocked' => false,
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect($scorer->score(['behavior' => 40]))->toMatchArray([
|
||||||
|
'risk_score' => 40,
|
||||||
|
'decision' => 'captcha',
|
||||||
|
'requires_review' => false,
|
||||||
|
'blocked' => false,
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect($scorer->score(['behavior' => 60]))->toMatchArray([
|
||||||
|
'risk_score' => 60,
|
||||||
|
'decision' => 'moderate',
|
||||||
|
'requires_review' => true,
|
||||||
|
'blocked' => false,
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect($scorer->score(['behavior' => 80]))->toMatchArray([
|
||||||
|
'risk_score' => 80,
|
||||||
|
'decision' => 'block',
|
||||||
|
'requires_review' => false,
|
||||||
|
'blocked' => true,
|
||||||
|
]);
|
||||||
|
});
|
||||||
166
tests/Unit/ForumRateLimitMiddlewareTest.php
Normal file
166
tests/Unit/ForumRateLimitMiddlewareTest.php
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Http\Middleware\ForumRateLimitMiddleware;
|
||||||
|
use cPad\Plugins\Forum\Services\Security\BotProtectionService;
|
||||||
|
use Illuminate\Http\Exceptions\ThrottleRequestsException;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Routing\Middleware\ThrottleRequests;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
|
uses(Tests\TestCase::class);
|
||||||
|
|
||||||
|
it('reports forum throttle violations to bot protection before rethrowing', function () {
|
||||||
|
$request = Request::create('/forum/topic/example-topic/reply', 'POST');
|
||||||
|
$request->setRouteResolver(static fn (): object => new class {
|
||||||
|
public function getName(): string
|
||||||
|
{
|
||||||
|
return 'forum.topic.reply';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
$throttle = \Mockery::mock(ThrottleRequests::class);
|
||||||
|
$botProtection = \Mockery::mock(BotProtectionService::class);
|
||||||
|
|
||||||
|
$exception = new ThrottleRequestsException('Too Many Attempts.', null, [
|
||||||
|
'Retry-After' => '42',
|
||||||
|
'X-RateLimit-Limit' => '3',
|
||||||
|
'X-RateLimit-Remaining' => '0',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$throttle->shouldReceive('handle')
|
||||||
|
->once()
|
||||||
|
->with($request, \Mockery::type(Closure::class), 'forum-post-write')
|
||||||
|
->andThrow($exception);
|
||||||
|
|
||||||
|
$botProtection->shouldReceive('recordRateLimitViolation')
|
||||||
|
->once()
|
||||||
|
->with(
|
||||||
|
$request,
|
||||||
|
'forum_reply_create',
|
||||||
|
\Mockery::on(static function (array $context): bool {
|
||||||
|
return $context['limiter'] === 'forum-post-write'
|
||||||
|
&& $context['bucket'] === 'minute'
|
||||||
|
&& $context['max_attempts'] === 3
|
||||||
|
&& $context['retry_after'] === 42;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
$middleware = new ForumRateLimitMiddleware($throttle, $botProtection);
|
||||||
|
|
||||||
|
$next = static fn (): Response => response('ok');
|
||||||
|
|
||||||
|
$middleware->handle($request, $next);
|
||||||
|
})->throws(ThrottleRequestsException::class);
|
||||||
|
|
||||||
|
it('classifies forum hourly limiter violations using the actual limit bucket', function () {
|
||||||
|
$request = Request::create('/forum/topic/example-topic/reply', 'POST');
|
||||||
|
$request->setRouteResolver(static fn (): object => new class {
|
||||||
|
public function getName(): string
|
||||||
|
{
|
||||||
|
return 'forum.topic.reply';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
$throttle = \Mockery::mock(ThrottleRequests::class);
|
||||||
|
$botProtection = \Mockery::mock(BotProtectionService::class);
|
||||||
|
|
||||||
|
$exception = new ThrottleRequestsException('Too Many Attempts.', null, [
|
||||||
|
'Retry-After' => '120',
|
||||||
|
'X-RateLimit-Limit' => '10',
|
||||||
|
'X-RateLimit-Remaining' => '0',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$throttle->shouldReceive('handle')
|
||||||
|
->once()
|
||||||
|
->with($request, \Mockery::type(Closure::class), 'forum-post-write')
|
||||||
|
->andThrow($exception);
|
||||||
|
|
||||||
|
$botProtection->shouldReceive('recordRateLimitViolation')
|
||||||
|
->once()
|
||||||
|
->with(
|
||||||
|
$request,
|
||||||
|
'forum_reply_create',
|
||||||
|
\Mockery::on(static function (array $context): bool {
|
||||||
|
return $context['bucket'] === 'hour'
|
||||||
|
&& $context['max_attempts'] === 10
|
||||||
|
&& $context['retry_after'] === 120;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
$middleware = new ForumRateLimitMiddleware($throttle, $botProtection);
|
||||||
|
|
||||||
|
$next = static fn (): Response => response('ok');
|
||||||
|
|
||||||
|
$middleware->handle($request, $next);
|
||||||
|
})->throws(ThrottleRequestsException::class);
|
||||||
|
|
||||||
|
it('classifies thread creation minute and hour limiter buckets correctly', function () {
|
||||||
|
$request = Request::create('/forum/example-board/new', 'POST');
|
||||||
|
$request->setRouteResolver(static fn (): object => new class {
|
||||||
|
public function getName(): string
|
||||||
|
{
|
||||||
|
return 'forum.topic.store';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
$throttle = \Mockery::mock(ThrottleRequests::class);
|
||||||
|
$botProtection = \Mockery::mock(BotProtectionService::class);
|
||||||
|
|
||||||
|
$minuteException = new ThrottleRequestsException('Too Many Attempts.', null, [
|
||||||
|
'Retry-After' => '30',
|
||||||
|
'X-RateLimit-Limit' => '3',
|
||||||
|
'X-RateLimit-Remaining' => '0',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$hourException = new ThrottleRequestsException('Too Many Attempts.', null, [
|
||||||
|
'Retry-After' => '120',
|
||||||
|
'X-RateLimit-Limit' => '10',
|
||||||
|
'X-RateLimit-Remaining' => '0',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$throttle->shouldReceive('handle')
|
||||||
|
->once()
|
||||||
|
->with($request, \Mockery::type(Closure::class), 'forum-thread-create')
|
||||||
|
->andThrow($minuteException);
|
||||||
|
|
||||||
|
$throttle->shouldReceive('handle')
|
||||||
|
->once()
|
||||||
|
->with($request, \Mockery::type(Closure::class), 'forum-thread-create')
|
||||||
|
->andThrow($hourException);
|
||||||
|
|
||||||
|
$botProtection->shouldReceive('recordRateLimitViolation')
|
||||||
|
->once()
|
||||||
|
->with(
|
||||||
|
$request,
|
||||||
|
'forum_topic_create',
|
||||||
|
\Mockery::on(static function (array $context): bool {
|
||||||
|
return $context['limiter'] === 'forum-thread-create'
|
||||||
|
&& $context['bucket'] === 'minute'
|
||||||
|
&& $context['max_attempts'] === 3
|
||||||
|
&& $context['retry_after'] === 30;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
$botProtection->shouldReceive('recordRateLimitViolation')
|
||||||
|
->once()
|
||||||
|
->with(
|
||||||
|
$request,
|
||||||
|
'forum_topic_create',
|
||||||
|
\Mockery::on(static function (array $context): bool {
|
||||||
|
return $context['limiter'] === 'forum-thread-create'
|
||||||
|
&& $context['bucket'] === 'hour'
|
||||||
|
&& $context['max_attempts'] === 10
|
||||||
|
&& $context['retry_after'] === 120;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
$middleware = new ForumRateLimitMiddleware($throttle, $botProtection);
|
||||||
|
$next = static fn (): Response => response('ok');
|
||||||
|
|
||||||
|
try {
|
||||||
|
$middleware->handle($request, $next);
|
||||||
|
} catch (ThrottleRequestsException) {
|
||||||
|
}
|
||||||
|
|
||||||
|
$middleware->handle($request, $next);
|
||||||
|
})->throws(ThrottleRequestsException::class);
|
||||||
482
tests/Unit/ForumRateLimitRouteTest.php
Normal file
482
tests/Unit/ForumRateLimitRouteTest.php
Normal file
@@ -0,0 +1,482 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use cPad\Plugins\Forum\Models\ForumBoard;
|
||||||
|
use cPad\Plugins\Forum\Models\ForumCategory;
|
||||||
|
use cPad\Plugins\Forum\Models\ForumPost;
|
||||||
|
use cPad\Plugins\Forum\Models\ForumTopic;
|
||||||
|
use cPad\Plugins\Forum\Services\ForumModerationService;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Queue;
|
||||||
|
use Illuminate\Support\Facades\RateLimiter;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
uses(Tests\TestCase::class);
|
||||||
|
|
||||||
|
it('enforces forum write rate limits for thread creation and replies', function () {
|
||||||
|
ensureForumRateLimitModelClassesLoaded();
|
||||||
|
|
||||||
|
Queue::fake();
|
||||||
|
|
||||||
|
config()->set('forum_bot_protection.enabled', false);
|
||||||
|
config()->set('forum_bot_protection.behavior.new_account_days', 0);
|
||||||
|
config()->set('skinbase_ai_moderation.enabled', false);
|
||||||
|
|
||||||
|
$moderationService = \Mockery::mock(ForumModerationService::class);
|
||||||
|
$moderationService->shouldReceive('preflight')->andReturnUsing(static function ($user, $content, $sourceIp): array {
|
||||||
|
return [
|
||||||
|
'spam_score' => 0,
|
||||||
|
'quality_score' => 0,
|
||||||
|
'ai_spam_score' => 0,
|
||||||
|
'ai_toxicity_score' => 0,
|
||||||
|
'behavior_score' => 0,
|
||||||
|
'link_score' => 0,
|
||||||
|
'learning_score' => 0,
|
||||||
|
'risk_score' => 0,
|
||||||
|
'trust_modifier' => 0,
|
||||||
|
'decision' => 'allowed',
|
||||||
|
'captcha_required' => false,
|
||||||
|
'blocked' => false,
|
||||||
|
'requires_review' => false,
|
||||||
|
'flagged' => false,
|
||||||
|
'reason' => null,
|
||||||
|
'content_hash' => hash('sha256', (string) $content),
|
||||||
|
'pattern_signature' => null,
|
||||||
|
'source_ip_hash' => $sourceIp ? hash('sha256', $sourceIp) : null,
|
||||||
|
'moderation_labels' => ['preflight', 'allowed'],
|
||||||
|
'provider' => 'none',
|
||||||
|
'provider_available' => false,
|
||||||
|
'language' => null,
|
||||||
|
];
|
||||||
|
});
|
||||||
|
$moderationService->shouldReceive('applyPreflightAssessment')->andReturnUsing(static function (ForumPost $post, array $assessment): void {
|
||||||
|
$post->forceFill([
|
||||||
|
'source_ip_hash' => $assessment['source_ip_hash'] ?? $post->source_ip_hash,
|
||||||
|
'content_hash' => $assessment['content_hash'] ?? $post->content_hash,
|
||||||
|
'spam_score' => (int) ($assessment['spam_score'] ?? 0),
|
||||||
|
'quality_score' => (int) ($assessment['quality_score'] ?? 0),
|
||||||
|
'ai_spam_score' => (int) ($assessment['ai_spam_score'] ?? 0),
|
||||||
|
'ai_toxicity_score' => (int) ($assessment['ai_toxicity_score'] ?? 0),
|
||||||
|
'behavior_score' => (int) ($assessment['behavior_score'] ?? 0),
|
||||||
|
'link_score' => (int) ($assessment['link_score'] ?? 0),
|
||||||
|
'learning_score' => (int) ($assessment['learning_score'] ?? 0),
|
||||||
|
'risk_score' => (int) ($assessment['risk_score'] ?? 0),
|
||||||
|
'trust_modifier' => (int) ($assessment['trust_modifier'] ?? 0),
|
||||||
|
'flagged' => (bool) ($assessment['flagged'] ?? false),
|
||||||
|
'flagged_reason' => $assessment['reason'] ?? null,
|
||||||
|
'moderation_checked' => false,
|
||||||
|
'moderation_status' => 'pending_ai_scan',
|
||||||
|
'moderation_labels' => (array) ($assessment['moderation_labels'] ?? []),
|
||||||
|
'moderation_meta' => [
|
||||||
|
'provider' => $assessment['provider'] ?? 'none',
|
||||||
|
'provider_available' => (bool) ($assessment['provider_available'] ?? false),
|
||||||
|
'language' => $assessment['language'] ?? null,
|
||||||
|
],
|
||||||
|
])->save();
|
||||||
|
});
|
||||||
|
$moderationService->shouldReceive('logRequestSecurity')->andReturnNull();
|
||||||
|
$moderationService->shouldReceive('dispatchAsyncScan')->andReturnNull();
|
||||||
|
|
||||||
|
$this->app->instance(ForumModerationService::class, $moderationService);
|
||||||
|
|
||||||
|
createForumRateLimitTestSchema();
|
||||||
|
|
||||||
|
$user = User::query()->create([
|
||||||
|
'username' => 'ratelimit-user',
|
||||||
|
'username_changed_at' => now()->subDays(120),
|
||||||
|
'last_username_change_at' => now()->subDays(120),
|
||||||
|
'onboarding_step' => 'complete',
|
||||||
|
'name' => 'Rate Limit User',
|
||||||
|
'email' => 'ratelimit@example.com',
|
||||||
|
'email_verified_at' => now(),
|
||||||
|
'password' => 'password',
|
||||||
|
'is_active' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
markForumRateLimitUserAsEstablished($user);
|
||||||
|
|
||||||
|
$board = makeForumBoard('thread-limit');
|
||||||
|
|
||||||
|
clearForumRateLimiters((string) $user->id);
|
||||||
|
|
||||||
|
for ($attempt = 1; $attempt <= 3; $attempt++) {
|
||||||
|
$response = $this->actingAs($user)->post(route('forum.topic.store', ['boardSlug' => $board->slug]), [
|
||||||
|
'title' => 'Rate limit topic ' . $attempt,
|
||||||
|
'content' => 'Thread body ' . $attempt,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response->assertRedirect();
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->actingAs($user)->post(route('forum.topic.store', ['boardSlug' => $board->slug]), [
|
||||||
|
'title' => 'Rate limit topic 4',
|
||||||
|
'content' => 'Thread body 4',
|
||||||
|
])->assertStatus(429);
|
||||||
|
|
||||||
|
expect(ForumTopic::query()->count())->toBe(3)
|
||||||
|
->and(ForumPost::query()->count())->toBe(3);
|
||||||
|
|
||||||
|
clearForumRateLimiters((string) $user->id);
|
||||||
|
|
||||||
|
$replyUser = User::query()->create([
|
||||||
|
'username' => 'reply-limit-user',
|
||||||
|
'username_changed_at' => now()->subDays(120),
|
||||||
|
'last_username_change_at' => now()->subDays(120),
|
||||||
|
'onboarding_step' => 'complete',
|
||||||
|
'name' => 'Reply Limit User',
|
||||||
|
'email' => 'replylimit@example.com',
|
||||||
|
'email_verified_at' => now(),
|
||||||
|
'password' => 'password',
|
||||||
|
'is_active' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
markForumRateLimitUserAsEstablished($replyUser);
|
||||||
|
|
||||||
|
clearForumRateLimiters((string) $replyUser->id);
|
||||||
|
|
||||||
|
$topic = makeForumTopic($replyUser);
|
||||||
|
|
||||||
|
for ($attempt = 1; $attempt <= 3; $attempt++) {
|
||||||
|
$response = $this->actingAs($replyUser)->post(route('forum.topic.reply', ['topic' => $topic->slug]), [
|
||||||
|
'content' => 'Reply burst ' . $attempt,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response->assertRedirect();
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->actingAs($replyUser)->post(route('forum.topic.reply', ['topic' => $topic->slug]), [
|
||||||
|
'content' => 'Reply burst 4',
|
||||||
|
])->assertStatus(429);
|
||||||
|
|
||||||
|
expect(ForumPost::query()->where('topic_id', $topic->id)->count())->toBe(4);
|
||||||
|
|
||||||
|
clearForumRateLimiters((string) $user->id);
|
||||||
|
clearForumRateLimiters((string) $replyUser->id);
|
||||||
|
});
|
||||||
|
|
||||||
|
function createForumRateLimitTestSchema(): void
|
||||||
|
{
|
||||||
|
foreach ([
|
||||||
|
'forum_security_logs',
|
||||||
|
'forum_firewall_logs',
|
||||||
|
'forum_bot_ip_blacklist',
|
||||||
|
'forum_spam_signatures',
|
||||||
|
'forum_spam_learning',
|
||||||
|
'forum_spam_domains',
|
||||||
|
'forum_spam_keywords',
|
||||||
|
'forum_topic_tags',
|
||||||
|
'forum_tags',
|
||||||
|
'forum_posts',
|
||||||
|
'forum_topics',
|
||||||
|
'forum_threads',
|
||||||
|
'forum_boards',
|
||||||
|
'forum_categories',
|
||||||
|
'users',
|
||||||
|
] as $table) {
|
||||||
|
Schema::dropIfExists($table);
|
||||||
|
}
|
||||||
|
|
||||||
|
Schema::create('forum_bot_ip_blacklist', function (Blueprint $table): void {
|
||||||
|
$table->id();
|
||||||
|
$table->string('ip_address', 45)->unique();
|
||||||
|
$table->string('reason', 255)->nullable();
|
||||||
|
$table->unsignedTinyInteger('risk_score')->default(100);
|
||||||
|
$table->timestamp('expires_at')->nullable();
|
||||||
|
$table->timestamp('created_at')->nullable();
|
||||||
|
});
|
||||||
|
|
||||||
|
Schema::create('forum_firewall_logs', function (Blueprint $table): void {
|
||||||
|
$table->id();
|
||||||
|
$table->unsignedBigInteger('user_id')->nullable();
|
||||||
|
$table->string('ip_address', 45)->nullable();
|
||||||
|
$table->string('action', 80);
|
||||||
|
$table->unsignedTinyInteger('risk_score')->default(0);
|
||||||
|
$table->string('decision', 20)->default('allow');
|
||||||
|
$table->string('threat_type', 80)->nullable();
|
||||||
|
$table->string('reason', 255)->nullable();
|
||||||
|
$table->json('metadata')->nullable();
|
||||||
|
$table->timestamps();
|
||||||
|
});
|
||||||
|
|
||||||
|
Schema::create('forum_security_logs', function (Blueprint $table): void {
|
||||||
|
$table->id();
|
||||||
|
$table->unsignedBigInteger('user_id')->nullable();
|
||||||
|
$table->unsignedBigInteger('post_id')->nullable();
|
||||||
|
$table->string('ip_address', 45)->nullable();
|
||||||
|
$table->string('action', 80);
|
||||||
|
$table->unsignedTinyInteger('risk_score')->default(0);
|
||||||
|
$table->string('decision', 20)->default('allow');
|
||||||
|
$table->string('reason', 255)->nullable();
|
||||||
|
$table->json('layer_scores')->nullable();
|
||||||
|
$table->json('metadata')->nullable();
|
||||||
|
$table->timestamps();
|
||||||
|
});
|
||||||
|
|
||||||
|
Schema::create('forum_spam_signatures', function (Blueprint $table): void {
|
||||||
|
$table->id();
|
||||||
|
$table->string('content_hash', 64)->nullable()->index();
|
||||||
|
$table->string('pattern_signature', 191)->nullable()->index();
|
||||||
|
$table->string('source', 32)->nullable();
|
||||||
|
$table->string('reason', 255)->nullable();
|
||||||
|
$table->unsignedInteger('confidence')->default(0);
|
||||||
|
$table->unsignedBigInteger('reviewed_by')->nullable();
|
||||||
|
$table->json('metadata')->nullable();
|
||||||
|
$table->timestamp('created_at')->nullable();
|
||||||
|
});
|
||||||
|
|
||||||
|
Schema::create('users', function (Blueprint $table): void {
|
||||||
|
$table->id();
|
||||||
|
$table->string('username')->nullable();
|
||||||
|
$table->timestamp('username_changed_at')->nullable();
|
||||||
|
$table->timestamp('last_username_change_at')->nullable();
|
||||||
|
$table->string('onboarding_step')->nullable();
|
||||||
|
$table->string('name')->nullable();
|
||||||
|
$table->string('email')->nullable();
|
||||||
|
$table->timestamp('email_verified_at')->nullable();
|
||||||
|
$table->string('password')->nullable();
|
||||||
|
$table->boolean('is_active')->default(true);
|
||||||
|
$table->unsignedInteger('trust_score')->default(0);
|
||||||
|
$table->unsignedInteger('approved_posts')->default(0);
|
||||||
|
$table->unsignedInteger('flagged_posts')->default(0);
|
||||||
|
$table->string('role')->nullable();
|
||||||
|
$table->rememberToken();
|
||||||
|
$table->timestamps();
|
||||||
|
$table->softDeletes();
|
||||||
|
});
|
||||||
|
|
||||||
|
Schema::create('forum_categories', function (Blueprint $table): void {
|
||||||
|
$table->id();
|
||||||
|
$table->string('name')->nullable();
|
||||||
|
$table->string('title')->nullable();
|
||||||
|
$table->string('slug')->unique();
|
||||||
|
$table->text('description')->nullable();
|
||||||
|
$table->unsignedBigInteger('parent_id')->nullable();
|
||||||
|
$table->integer('position')->default(0);
|
||||||
|
$table->boolean('is_active')->default(true);
|
||||||
|
$table->timestamps();
|
||||||
|
});
|
||||||
|
|
||||||
|
Schema::create('forum_boards', function (Blueprint $table): void {
|
||||||
|
$table->id();
|
||||||
|
$table->unsignedBigInteger('category_id');
|
||||||
|
$table->unsignedBigInteger('legacy_category_id')->nullable();
|
||||||
|
$table->string('title');
|
||||||
|
$table->string('slug')->unique();
|
||||||
|
$table->text('description')->nullable();
|
||||||
|
$table->string('icon')->nullable();
|
||||||
|
$table->string('image')->nullable();
|
||||||
|
$table->integer('position')->default(0);
|
||||||
|
$table->boolean('is_active')->default(true);
|
||||||
|
$table->boolean('is_read_only')->default(false);
|
||||||
|
$table->timestamps();
|
||||||
|
});
|
||||||
|
|
||||||
|
Schema::create('forum_threads', function (Blueprint $table): void {
|
||||||
|
$table->id();
|
||||||
|
$table->unsignedBigInteger('category_id');
|
||||||
|
$table->unsignedBigInteger('user_id');
|
||||||
|
$table->string('title');
|
||||||
|
$table->string('slug')->unique();
|
||||||
|
$table->longText('content');
|
||||||
|
$table->unsignedInteger('views')->default(0);
|
||||||
|
$table->boolean('is_locked')->default(false);
|
||||||
|
$table->boolean('is_pinned')->default(false);
|
||||||
|
$table->string('visibility')->default('public');
|
||||||
|
$table->timestamp('last_post_at')->nullable();
|
||||||
|
$table->timestamps();
|
||||||
|
$table->softDeletes();
|
||||||
|
});
|
||||||
|
|
||||||
|
Schema::create('forum_topics', function (Blueprint $table): void {
|
||||||
|
$table->id();
|
||||||
|
$table->unsignedBigInteger('board_id');
|
||||||
|
$table->unsignedBigInteger('user_id');
|
||||||
|
$table->unsignedBigInteger('artwork_id')->nullable();
|
||||||
|
$table->unsignedBigInteger('legacy_thread_id')->nullable();
|
||||||
|
$table->string('title');
|
||||||
|
$table->string('slug')->unique();
|
||||||
|
$table->unsignedInteger('views')->default(0);
|
||||||
|
$table->unsignedInteger('replies_count')->default(0);
|
||||||
|
$table->boolean('is_pinned')->default(false);
|
||||||
|
$table->boolean('is_locked')->default(false);
|
||||||
|
$table->boolean('is_deleted')->default(false);
|
||||||
|
$table->unsignedBigInteger('last_post_id')->nullable();
|
||||||
|
$table->timestamp('last_post_at')->nullable();
|
||||||
|
$table->timestamps();
|
||||||
|
});
|
||||||
|
|
||||||
|
Schema::create('forum_posts', function (Blueprint $table): void {
|
||||||
|
$table->id();
|
||||||
|
$table->unsignedBigInteger('thread_id');
|
||||||
|
$table->unsignedBigInteger('topic_id')->nullable();
|
||||||
|
$table->string('source_ip_hash', 64)->nullable();
|
||||||
|
$table->unsignedBigInteger('user_id');
|
||||||
|
$table->longText('content');
|
||||||
|
$table->string('content_hash', 64)->nullable();
|
||||||
|
$table->boolean('is_edited')->default(false);
|
||||||
|
$table->timestamp('edited_at')->nullable();
|
||||||
|
$table->unsignedInteger('spam_score')->default(0);
|
||||||
|
$table->unsignedInteger('quality_score')->default(0);
|
||||||
|
$table->unsignedInteger('ai_spam_score')->default(0);
|
||||||
|
$table->unsignedInteger('ai_toxicity_score')->default(0);
|
||||||
|
$table->unsignedInteger('behavior_score')->default(0);
|
||||||
|
$table->unsignedInteger('link_score')->default(0);
|
||||||
|
$table->integer('learning_score')->default(0);
|
||||||
|
$table->unsignedInteger('risk_score')->default(0);
|
||||||
|
$table->integer('trust_modifier')->default(0);
|
||||||
|
$table->boolean('flagged')->default(false);
|
||||||
|
$table->string('flagged_reason')->nullable();
|
||||||
|
$table->boolean('moderation_checked')->default(false);
|
||||||
|
$table->string('moderation_status')->nullable();
|
||||||
|
$table->json('moderation_labels')->nullable();
|
||||||
|
$table->json('moderation_meta')->nullable();
|
||||||
|
$table->timestamp('last_ai_scan_at')->nullable();
|
||||||
|
$table->timestamps();
|
||||||
|
$table->softDeletes();
|
||||||
|
});
|
||||||
|
|
||||||
|
Schema::create('forum_tags', function (Blueprint $table): void {
|
||||||
|
$table->id();
|
||||||
|
$table->string('name')->unique();
|
||||||
|
$table->string('slug')->unique();
|
||||||
|
$table->timestamps();
|
||||||
|
});
|
||||||
|
|
||||||
|
Schema::create('forum_topic_tags', function (Blueprint $table): void {
|
||||||
|
$table->id();
|
||||||
|
$table->unsignedBigInteger('topic_id');
|
||||||
|
$table->unsignedBigInteger('tag_id');
|
||||||
|
$table->timestamps();
|
||||||
|
|
||||||
|
$table->unique(['topic_id', 'tag_id']);
|
||||||
|
});
|
||||||
|
|
||||||
|
Schema::create('forum_spam_domains', function (Blueprint $table): void {
|
||||||
|
$table->id();
|
||||||
|
$table->string('domain')->unique();
|
||||||
|
$table->timestamps();
|
||||||
|
});
|
||||||
|
|
||||||
|
Schema::create('forum_spam_keywords', function (Blueprint $table): void {
|
||||||
|
$table->id();
|
||||||
|
$table->string('keyword', 120)->unique();
|
||||||
|
$table->timestamp('created_at')->nullable();
|
||||||
|
});
|
||||||
|
|
||||||
|
Schema::create('forum_spam_learning', function (Blueprint $table): void {
|
||||||
|
$table->id();
|
||||||
|
$table->string('content_hash', 64)->index();
|
||||||
|
$table->string('decision', 32);
|
||||||
|
$table->string('pattern_signature', 191)->nullable();
|
||||||
|
$table->unsignedBigInteger('reviewed_by')->nullable();
|
||||||
|
$table->json('metadata')->nullable();
|
||||||
|
$table->timestamps();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureForumRateLimitModelClassesLoaded(): void
|
||||||
|
{
|
||||||
|
foreach ([
|
||||||
|
'packages/klevze/Plugins/Forum/Models/ForumPost.php',
|
||||||
|
'packages/klevze/Plugins/Forum/Models/ForumSpamLearning.php',
|
||||||
|
'packages/klevze/Plugins/Forum/Models/ForumAiLog.php',
|
||||||
|
'packages/klevze/Plugins/Forum/Models/ForumModerationQueue.php',
|
||||||
|
] as $relativePath) {
|
||||||
|
require_once base_path($relativePath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeForumBoard(string $suffix): ForumBoard
|
||||||
|
{
|
||||||
|
$category = ForumCategory::query()->create([
|
||||||
|
'name' => 'Rate Limit Category ' . $suffix,
|
||||||
|
'title' => 'Rate Limit Category ' . $suffix,
|
||||||
|
'slug' => 'rate-limit-category-' . $suffix,
|
||||||
|
'description' => 'Test category',
|
||||||
|
'position' => 1,
|
||||||
|
'is_active' => true,
|
||||||
|
'parent_id' => null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return ForumBoard::query()->create([
|
||||||
|
'category_id' => $category->id,
|
||||||
|
'title' => 'Rate Limit Board ' . $suffix,
|
||||||
|
'slug' => 'rate-limit-board-' . $suffix,
|
||||||
|
'description' => 'Test board',
|
||||||
|
'position' => 1,
|
||||||
|
'is_active' => true,
|
||||||
|
'is_read_only' => false,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeForumTopic(User $user): ForumTopic
|
||||||
|
{
|
||||||
|
$board = makeForumBoard('reply-limit');
|
||||||
|
|
||||||
|
$legacyThreadId = DB::table('forum_threads')->insertGetId([
|
||||||
|
'category_id' => $board->category_id,
|
||||||
|
'user_id' => $user->id,
|
||||||
|
'title' => 'Existing topic',
|
||||||
|
'slug' => 'existing-topic-reply-limit',
|
||||||
|
'content' => 'Opening post',
|
||||||
|
'views' => 0,
|
||||||
|
'is_locked' => false,
|
||||||
|
'is_pinned' => false,
|
||||||
|
'visibility' => 'public',
|
||||||
|
'last_post_at' => now(),
|
||||||
|
'created_at' => now(),
|
||||||
|
'updated_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$topic = ForumTopic::query()->create([
|
||||||
|
'board_id' => $board->id,
|
||||||
|
'user_id' => $user->id,
|
||||||
|
'legacy_thread_id' => $legacyThreadId,
|
||||||
|
'title' => 'Existing topic',
|
||||||
|
'slug' => 'existing-topic-reply-limit',
|
||||||
|
'views' => 0,
|
||||||
|
'replies_count' => 0,
|
||||||
|
'is_pinned' => false,
|
||||||
|
'is_locked' => false,
|
||||||
|
'is_deleted' => false,
|
||||||
|
'last_post_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$post = ForumPost::query()->create([
|
||||||
|
'thread_id' => $legacyThreadId,
|
||||||
|
'topic_id' => $topic->id,
|
||||||
|
'user_id' => $user->id,
|
||||||
|
'content' => 'Opening post',
|
||||||
|
'is_edited' => false,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$topic->forceFill([
|
||||||
|
'last_post_id' => $post->id,
|
||||||
|
'last_post_at' => $post->created_at,
|
||||||
|
])->save();
|
||||||
|
|
||||||
|
return $topic;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearForumRateLimiters(string $key): void
|
||||||
|
{
|
||||||
|
foreach ([
|
||||||
|
'forum-thread-minute:' . $key,
|
||||||
|
'forum-thread-hour:' . $key,
|
||||||
|
'forum-post-minute:' . $key,
|
||||||
|
'forum-post-hour:' . $key,
|
||||||
|
] as $limiterKey) {
|
||||||
|
RateLimiter::clear($limiterKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function markForumRateLimitUserAsEstablished(User $user): void
|
||||||
|
{
|
||||||
|
$timestamp = now()->subDays(30);
|
||||||
|
|
||||||
|
$user->forceFill([
|
||||||
|
'created_at' => $timestamp,
|
||||||
|
'updated_at' => $timestamp,
|
||||||
|
])->save();
|
||||||
|
}
|
||||||
83
tests/Unit/GeoBehaviorAnalyzerTest.php
Normal file
83
tests/Unit/GeoBehaviorAnalyzerTest.php
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use cPad\Plugins\Forum\Models\ForumBotLog;
|
||||||
|
use cPad\Plugins\Forum\Services\Security\GeoBehaviorAnalyzer;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
uses(Tests\TestCase::class);
|
||||||
|
|
||||||
|
it('scores only rapid login country changes for the same account', function () {
|
||||||
|
config()->set('forum_bot_protection.geo_behavior', [
|
||||||
|
'enabled' => true,
|
||||||
|
'login_actions' => ['login'],
|
||||||
|
'country_headers' => ['CF-IPCountry'],
|
||||||
|
'recent_login_window_minutes' => 60,
|
||||||
|
'country_change_penalty' => 50,
|
||||||
|
]);
|
||||||
|
|
||||||
|
Schema::dropIfExists('forum_bot_logs');
|
||||||
|
Schema::dropIfExists('users');
|
||||||
|
|
||||||
|
Schema::create('users', function (Blueprint $table): void {
|
||||||
|
$table->id();
|
||||||
|
$table->string('email')->nullable();
|
||||||
|
$table->string('password')->nullable();
|
||||||
|
$table->timestamps();
|
||||||
|
$table->softDeletes();
|
||||||
|
});
|
||||||
|
|
||||||
|
Schema::create('forum_bot_logs', function (Blueprint $table): void {
|
||||||
|
$table->id();
|
||||||
|
$table->unsignedBigInteger('user_id')->nullable();
|
||||||
|
$table->string('ip_address', 45)->nullable();
|
||||||
|
$table->string('action', 80);
|
||||||
|
$table->unsignedTinyInteger('risk_score')->default(0);
|
||||||
|
$table->string('decision', 20)->default('allow');
|
||||||
|
$table->json('metadata')->nullable();
|
||||||
|
$table->timestamp('created_at')->nullable();
|
||||||
|
});
|
||||||
|
|
||||||
|
DB::table('users')->insert([
|
||||||
|
'id' => 1,
|
||||||
|
'email' => 'geo@example.com',
|
||||||
|
'password' => 'secret',
|
||||||
|
'created_at' => now(),
|
||||||
|
'updated_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$user = User::query()->findOrFail(1);
|
||||||
|
|
||||||
|
ForumBotLog::query()->create([
|
||||||
|
'user_id' => $user->id,
|
||||||
|
'ip_address' => '127.0.0.1',
|
||||||
|
'action' => 'login',
|
||||||
|
'risk_score' => 0,
|
||||||
|
'decision' => 'allow',
|
||||||
|
'metadata' => ['country_code' => 'SI'],
|
||||||
|
'created_at' => now()->subMinutes(10),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$request = Request::create('/login', 'POST');
|
||||||
|
$request->headers->set('CF-IPCountry', 'SI');
|
||||||
|
|
||||||
|
$unchanged = app(GeoBehaviorAnalyzer::class)->analyze($user, 'login', $request);
|
||||||
|
|
||||||
|
expect($unchanged)->toMatchArray([
|
||||||
|
'score' => 0,
|
||||||
|
'country_code' => 'SI',
|
||||||
|
])->and($unchanged['reasons'])->toBe([]);
|
||||||
|
|
||||||
|
$request->headers->set('CF-IPCountry', 'JP');
|
||||||
|
|
||||||
|
$analysis = app(GeoBehaviorAnalyzer::class)->analyze($user, 'login', $request);
|
||||||
|
|
||||||
|
expect($analysis['score'])->toBe(50)
|
||||||
|
->and($analysis['country_code'])->toBe('JP')
|
||||||
|
->and($analysis['reasons'])->toHaveCount(1)
|
||||||
|
->and($analysis['reasons'][0])->toContain('SI')
|
||||||
|
->and($analysis['reasons'][0])->toContain('JP');
|
||||||
|
});
|
||||||
70
tests/Unit/IPReputationServiceTest.php
Normal file
70
tests/Unit/IPReputationServiceTest.php
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use cPad\Plugins\Forum\Services\Security\IPReputationService;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Cache;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
uses(Tests\TestCase::class);
|
||||||
|
|
||||||
|
it('scores CIDR datacenter and proxy ranges in IP reputation analysis', function () {
|
||||||
|
Cache::flush();
|
||||||
|
|
||||||
|
config()->set('forum_bot_protection.ip', [
|
||||||
|
'cache_ttl_minutes' => 15,
|
||||||
|
'recent_high_risk_window_hours' => 24,
|
||||||
|
'recent_high_risk_threshold' => 3,
|
||||||
|
'recent_high_risk_penalty' => 20,
|
||||||
|
'known_proxy_penalty' => 20,
|
||||||
|
'datacenter_penalty' => 25,
|
||||||
|
'tor_penalty' => 40,
|
||||||
|
'blacklist_penalty' => 100,
|
||||||
|
'known_proxies' => ['198.51.100.0/24'],
|
||||||
|
'datacenter_ranges' => ['203.0.113.0/24'],
|
||||||
|
'provider_ranges' => [
|
||||||
|
'aws' => ['54.240.0.0/12'],
|
||||||
|
],
|
||||||
|
'tor_exit_nodes' => [],
|
||||||
|
]);
|
||||||
|
|
||||||
|
Schema::dropIfExists('forum_bot_ip_blacklist');
|
||||||
|
Schema::dropIfExists('forum_bot_logs');
|
||||||
|
|
||||||
|
Schema::create('forum_bot_ip_blacklist', function (Blueprint $table): void {
|
||||||
|
$table->id();
|
||||||
|
$table->string('ip_address', 45)->unique();
|
||||||
|
$table->string('reason', 255)->nullable();
|
||||||
|
$table->unsignedTinyInteger('risk_score')->default(100);
|
||||||
|
$table->timestamp('expires_at')->nullable();
|
||||||
|
$table->timestamp('created_at')->nullable();
|
||||||
|
});
|
||||||
|
|
||||||
|
Schema::create('forum_bot_logs', function (Blueprint $table): void {
|
||||||
|
$table->id();
|
||||||
|
$table->unsignedBigInteger('user_id')->nullable();
|
||||||
|
$table->string('ip_address', 45)->nullable();
|
||||||
|
$table->string('action', 80);
|
||||||
|
$table->unsignedTinyInteger('risk_score')->default(0);
|
||||||
|
$table->string('decision', 20)->default('allow');
|
||||||
|
$table->json('metadata')->nullable();
|
||||||
|
$table->timestamp('created_at')->nullable();
|
||||||
|
});
|
||||||
|
|
||||||
|
$service = app(IPReputationService::class);
|
||||||
|
|
||||||
|
$proxyResult = $service->analyze('198.51.100.23');
|
||||||
|
$datacenterResult = $service->analyze('203.0.113.77');
|
||||||
|
$providerResult = $service->analyze('54.240.10.20');
|
||||||
|
|
||||||
|
expect($proxyResult['score'])->toBe(20)
|
||||||
|
->and($proxyResult['reasons'])->toContain('IP address is in the proxy watch list.')
|
||||||
|
->and($proxyResult['blocked'])->toBeFalse();
|
||||||
|
|
||||||
|
expect($datacenterResult['score'])->toBe(25)
|
||||||
|
->and($datacenterResult['reasons'])->toContain('IP address belongs to a datacenter or hosting network range.')
|
||||||
|
->and($datacenterResult['blocked'])->toBeFalse();
|
||||||
|
|
||||||
|
expect($providerResult['score'])->toBe(25)
|
||||||
|
->and($providerResult['reasons'])->toContain('IP address belongs to the configured AWS provider range.')
|
||||||
|
->and($providerResult['blocked'])->toBeFalse();
|
||||||
|
});
|
||||||
126
tests/Unit/StudioTagSearchRouteTest.php
Normal file
126
tests/Unit/StudioTagSearchRouteTest.php
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
uses(TestCase::class);
|
||||||
|
|
||||||
|
beforeEach(function (): void {
|
||||||
|
Schema::dropIfExists('tag_interaction_daily_metrics');
|
||||||
|
Schema::dropIfExists('tags');
|
||||||
|
Schema::dropIfExists('users');
|
||||||
|
|
||||||
|
Schema::create('users', function (Blueprint $table): void {
|
||||||
|
$table->id();
|
||||||
|
$table->string('username')->nullable();
|
||||||
|
$table->timestamp('username_changed_at')->nullable();
|
||||||
|
$table->timestamp('last_username_change_at')->nullable();
|
||||||
|
$table->string('onboarding_step')->nullable();
|
||||||
|
$table->string('name')->nullable();
|
||||||
|
$table->string('email')->nullable();
|
||||||
|
$table->timestamp('email_verified_at')->nullable();
|
||||||
|
$table->string('password')->nullable();
|
||||||
|
$table->boolean('is_active')->default(true);
|
||||||
|
$table->string('role')->nullable();
|
||||||
|
$table->rememberToken();
|
||||||
|
$table->timestamps();
|
||||||
|
$table->softDeletes();
|
||||||
|
});
|
||||||
|
|
||||||
|
Schema::create('tags', function (Blueprint $table): void {
|
||||||
|
$table->id();
|
||||||
|
$table->string('name');
|
||||||
|
$table->string('slug')->unique();
|
||||||
|
$table->unsignedInteger('usage_count')->default(0);
|
||||||
|
$table->boolean('is_active')->default(true);
|
||||||
|
$table->timestamps();
|
||||||
|
});
|
||||||
|
|
||||||
|
Schema::create('tag_interaction_daily_metrics', function (Blueprint $table): void {
|
||||||
|
$table->id();
|
||||||
|
$table->date('metric_date');
|
||||||
|
$table->string('surface', 32);
|
||||||
|
$table->string('tag_slug', 120)->default('');
|
||||||
|
$table->string('source_tag_slug', 120)->default('');
|
||||||
|
$table->string('query', 120)->default('');
|
||||||
|
$table->unsignedInteger('clicks')->default(0);
|
||||||
|
$table->unsignedInteger('unique_users')->default(0);
|
||||||
|
$table->unsignedInteger('unique_sessions')->default(0);
|
||||||
|
$table->decimal('avg_position', 8, 2)->default(0);
|
||||||
|
$table->timestamps();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('redirects guests away from the studio tag search route', function (): void {
|
||||||
|
$this->get('/api/studio/tags/search?q=hi')
|
||||||
|
->assertRedirect('/login');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns momentum-ranked tag suggestions for authenticated studio users', function (): void {
|
||||||
|
$now = now();
|
||||||
|
|
||||||
|
$user = new User([
|
||||||
|
'username' => 'studio-user',
|
||||||
|
'username_changed_at' => now()->subDays(120),
|
||||||
|
'last_username_change_at' => now()->subDays(120),
|
||||||
|
'onboarding_step' => 'complete',
|
||||||
|
'name' => 'Studio User',
|
||||||
|
'email' => 'studio@example.com',
|
||||||
|
'email_verified_at' => now(),
|
||||||
|
'password' => 'password',
|
||||||
|
'is_active' => true,
|
||||||
|
]);
|
||||||
|
$user->id = 1;
|
||||||
|
|
||||||
|
DB::table('tags')->insert([
|
||||||
|
['id' => 1, 'name' => 'High Usage', 'slug' => 'high-usage', 'usage_count' => 500, 'is_active' => true, 'created_at' => $now, 'updated_at' => $now],
|
||||||
|
['id' => 2, 'name' => 'High Contrast', 'slug' => 'high-contrast', 'usage_count' => 120, 'is_active' => true, 'created_at' => $now, 'updated_at' => $now],
|
||||||
|
['id' => 3, 'name' => 'Hidden Draft', 'slug' => 'hidden-draft', 'usage_count' => 900, 'is_active' => false, 'created_at' => $now, 'updated_at' => $now],
|
||||||
|
]);
|
||||||
|
|
||||||
|
DB::table('tag_interaction_daily_metrics')->insert([
|
||||||
|
[
|
||||||
|
'metric_date' => $now->toDateString(),
|
||||||
|
'surface' => 'tag_search',
|
||||||
|
'tag_slug' => 'high-contrast',
|
||||||
|
'source_tag_slug' => '',
|
||||||
|
'query' => 'hi',
|
||||||
|
'clicks' => 30,
|
||||||
|
'unique_users' => 0,
|
||||||
|
'unique_sessions' => 11,
|
||||||
|
'avg_position' => 1.1,
|
||||||
|
'created_at' => $now,
|
||||||
|
'updated_at' => $now,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'metric_date' => $now->toDateString(),
|
||||||
|
'surface' => 'tag_search',
|
||||||
|
'tag_slug' => 'high-usage',
|
||||||
|
'source_tag_slug' => '',
|
||||||
|
'query' => 'hi',
|
||||||
|
'clicks' => 3,
|
||||||
|
'unique_users' => 0,
|
||||||
|
'unique_sessions' => 2,
|
||||||
|
'avg_position' => 2.8,
|
||||||
|
'created_at' => $now,
|
||||||
|
'updated_at' => $now,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response = $this->actingAs($user)->getJson('/api/studio/tags/search?q=hi');
|
||||||
|
|
||||||
|
$response->assertOk();
|
||||||
|
|
||||||
|
$data = $response->json();
|
||||||
|
|
||||||
|
expect($data)->toHaveCount(2);
|
||||||
|
expect(array_column($data, 'slug'))->toBe(['high-contrast', 'high-usage']);
|
||||||
|
expect((int) $data[0]['recent_clicks'])->toBe(30);
|
||||||
|
expect($data[0])->toHaveKeys(['id', 'name', 'slug', 'usage_count', 'recent_clicks']);
|
||||||
|
expect($data[0])->not->toHaveKeys(['created_at', 'updated_at', 'is_active']);
|
||||||
|
});
|
||||||
219
tests/Unit/TagDiscoveryServiceTest.php
Normal file
219
tests/Unit/TagDiscoveryServiceTest.php
Normal file
@@ -0,0 +1,219 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Services\Tags\TagDiscoveryService;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
uses(TestCase::class);
|
||||||
|
|
||||||
|
beforeEach(function (): void {
|
||||||
|
Schema::dropIfExists('tag_interaction_daily_metrics');
|
||||||
|
Schema::dropIfExists('artwork_tag');
|
||||||
|
Schema::dropIfExists('artworks');
|
||||||
|
Schema::dropIfExists('tags');
|
||||||
|
|
||||||
|
Schema::create('tags', function (Blueprint $table): void {
|
||||||
|
$table->id();
|
||||||
|
$table->string('name');
|
||||||
|
$table->string('slug')->unique();
|
||||||
|
$table->unsignedInteger('usage_count')->default(0);
|
||||||
|
$table->boolean('is_active')->default(true);
|
||||||
|
$table->timestamps();
|
||||||
|
});
|
||||||
|
|
||||||
|
Schema::create('artworks', function (Blueprint $table): void {
|
||||||
|
$table->id();
|
||||||
|
$table->unsignedBigInteger('user_id')->nullable();
|
||||||
|
$table->string('title')->nullable();
|
||||||
|
$table->string('slug')->nullable();
|
||||||
|
$table->boolean('is_public')->default(true);
|
||||||
|
$table->boolean('is_approved')->default(true);
|
||||||
|
$table->timestamp('published_at')->nullable();
|
||||||
|
$table->softDeletes();
|
||||||
|
$table->timestamps();
|
||||||
|
});
|
||||||
|
|
||||||
|
Schema::create('artwork_tag', function (Blueprint $table): void {
|
||||||
|
$table->unsignedBigInteger('artwork_id');
|
||||||
|
$table->unsignedBigInteger('tag_id');
|
||||||
|
$table->string('source')->nullable();
|
||||||
|
$table->unsignedInteger('confidence')->nullable();
|
||||||
|
});
|
||||||
|
|
||||||
|
Schema::create('tag_interaction_daily_metrics', function (Blueprint $table): void {
|
||||||
|
$table->id();
|
||||||
|
$table->date('metric_date');
|
||||||
|
$table->string('surface', 32);
|
||||||
|
$table->string('tag_slug', 120)->default('');
|
||||||
|
$table->string('source_tag_slug', 120)->default('');
|
||||||
|
$table->string('query', 120)->default('');
|
||||||
|
$table->unsignedInteger('clicks')->default(0);
|
||||||
|
$table->unsignedInteger('unique_users')->default(0);
|
||||||
|
$table->unsignedInteger('unique_sessions')->default(0);
|
||||||
|
$table->decimal('avg_position', 8, 2)->default(0);
|
||||||
|
$table->timestamps();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps slug in related tag payload when transition metrics are joined', function (): void {
|
||||||
|
$now = now();
|
||||||
|
|
||||||
|
DB::table('tags')->insert([
|
||||||
|
['id' => 1, 'name' => 'Primary', 'slug' => 'primary', 'usage_count' => 100, 'is_active' => true, 'created_at' => $now, 'updated_at' => $now],
|
||||||
|
['id' => 2, 'name' => 'Beta', 'slug' => 'beta', 'usage_count' => 80, 'is_active' => true, 'created_at' => $now, 'updated_at' => $now],
|
||||||
|
['id' => 3, 'name' => 'Gamma', 'slug' => 'gamma', 'usage_count' => 60, 'is_active' => true, 'created_at' => $now, 'updated_at' => $now],
|
||||||
|
]);
|
||||||
|
|
||||||
|
DB::table('artworks')->insert([
|
||||||
|
['id' => 10, 'title' => 'One', 'slug' => 'one', 'is_public' => true, 'is_approved' => true, 'published_at' => $now, 'created_at' => $now, 'updated_at' => $now],
|
||||||
|
['id' => 11, 'title' => 'Two', 'slug' => 'two', 'is_public' => true, 'is_approved' => true, 'published_at' => $now, 'created_at' => $now, 'updated_at' => $now],
|
||||||
|
['id' => 12, 'title' => 'Three', 'slug' => 'three', 'is_public' => true, 'is_approved' => true, 'published_at' => $now, 'created_at' => $now, 'updated_at' => $now],
|
||||||
|
]);
|
||||||
|
|
||||||
|
DB::table('artwork_tag')->insert([
|
||||||
|
['artwork_id' => 10, 'tag_id' => 1, 'source' => 'user', 'confidence' => 100],
|
||||||
|
['artwork_id' => 10, 'tag_id' => 2, 'source' => 'user', 'confidence' => 100],
|
||||||
|
['artwork_id' => 11, 'tag_id' => 1, 'source' => 'user', 'confidence' => 100],
|
||||||
|
['artwork_id' => 11, 'tag_id' => 2, 'source' => 'user', 'confidence' => 100],
|
||||||
|
['artwork_id' => 12, 'tag_id' => 1, 'source' => 'user', 'confidence' => 100],
|
||||||
|
['artwork_id' => 12, 'tag_id' => 3, 'source' => 'user', 'confidence' => 100],
|
||||||
|
]);
|
||||||
|
|
||||||
|
DB::table('tag_interaction_daily_metrics')->insert([
|
||||||
|
[
|
||||||
|
'metric_date' => $now->toDateString(),
|
||||||
|
'surface' => 'related_chip',
|
||||||
|
'tag_slug' => 'beta',
|
||||||
|
'source_tag_slug' => 'primary',
|
||||||
|
'query' => '',
|
||||||
|
'clicks' => 25,
|
||||||
|
'unique_users' => 0,
|
||||||
|
'unique_sessions' => 10,
|
||||||
|
'avg_position' => 1.4,
|
||||||
|
'created_at' => $now,
|
||||||
|
'updated_at' => $now,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'metric_date' => $now->toDateString(),
|
||||||
|
'surface' => 'related_chip',
|
||||||
|
'tag_slug' => 'gamma',
|
||||||
|
'source_tag_slug' => 'primary',
|
||||||
|
'query' => '',
|
||||||
|
'clicks' => 4,
|
||||||
|
'unique_users' => 0,
|
||||||
|
'unique_sessions' => 3,
|
||||||
|
'avg_position' => 2.2,
|
||||||
|
'created_at' => $now,
|
||||||
|
'updated_at' => $now,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$primary = \App\Models\Tag::query()->findOrFail(1);
|
||||||
|
|
||||||
|
$relatedTags = app(TagDiscoveryService::class)->relatedTags($primary, 8);
|
||||||
|
|
||||||
|
expect($relatedTags)->toHaveCount(2);
|
||||||
|
expect(isset($relatedTags[0]->slug))->toBeTrue();
|
||||||
|
expect($relatedTags[0]->slug)->toBe('beta');
|
||||||
|
expect((int) $relatedTags[0]->shared_artworks_count)->toBe(2);
|
||||||
|
expect((int) $relatedTags[0]->transition_clicks)->toBe(25);
|
||||||
|
expect(collect($relatedTags)->pluck('slug')->all())->toBe(['beta', 'gamma']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('orders featured tags by recent clicks before usage count', function (): void {
|
||||||
|
$now = now();
|
||||||
|
|
||||||
|
DB::table('tags')->insert([
|
||||||
|
['id' => 1, 'name' => 'Legacy Heavy', 'slug' => 'legacy-heavy', 'usage_count' => 900, 'is_active' => true, 'created_at' => $now, 'updated_at' => $now],
|
||||||
|
['id' => 2, 'name' => 'Momentum First', 'slug' => 'momentum-first', 'usage_count' => 120, 'is_active' => true, 'created_at' => $now, 'updated_at' => $now],
|
||||||
|
['id' => 3, 'name' => 'Quiet', 'slug' => 'quiet', 'usage_count' => 80, 'is_active' => true, 'created_at' => $now, 'updated_at' => $now],
|
||||||
|
]);
|
||||||
|
|
||||||
|
DB::table('tag_interaction_daily_metrics')->insert([
|
||||||
|
[
|
||||||
|
'metric_date' => $now->toDateString(),
|
||||||
|
'surface' => 'tag_search',
|
||||||
|
'tag_slug' => 'legacy-heavy',
|
||||||
|
'source_tag_slug' => '',
|
||||||
|
'query' => 'legacy',
|
||||||
|
'clicks' => 8,
|
||||||
|
'unique_users' => 0,
|
||||||
|
'unique_sessions' => 4,
|
||||||
|
'avg_position' => 2.5,
|
||||||
|
'created_at' => $now,
|
||||||
|
'updated_at' => $now,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'metric_date' => $now->toDateString(),
|
||||||
|
'surface' => 'tag_search',
|
||||||
|
'tag_slug' => 'momentum-first',
|
||||||
|
'source_tag_slug' => '',
|
||||||
|
'query' => 'momentum',
|
||||||
|
'clicks' => 42,
|
||||||
|
'unique_users' => 0,
|
||||||
|
'unique_sessions' => 18,
|
||||||
|
'avg_position' => 1.2,
|
||||||
|
'created_at' => $now,
|
||||||
|
'updated_at' => $now,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$featured = app(TagDiscoveryService::class)->featuredTags(3);
|
||||||
|
|
||||||
|
expect($featured)->toHaveCount(3);
|
||||||
|
expect(collect($featured)->pluck('slug')->all())->toBe(['momentum-first', 'legacy-heavy', 'quiet']);
|
||||||
|
expect((int) $featured[0]->recent_clicks)->toBe(42);
|
||||||
|
expect((int) $featured[1]->recent_clicks)->toBe(8);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fills rising tags from usage fallback without duplicating featured tags', function (): void {
|
||||||
|
$now = now();
|
||||||
|
|
||||||
|
DB::table('tags')->insert([
|
||||||
|
['id' => 1, 'name' => 'Featured Momentum', 'slug' => 'featured-momentum', 'usage_count' => 800, 'is_active' => true, 'created_at' => $now, 'updated_at' => $now],
|
||||||
|
['id' => 2, 'name' => 'Rising Momentum', 'slug' => 'rising-momentum', 'usage_count' => 120, 'is_active' => true, 'created_at' => $now, 'updated_at' => $now],
|
||||||
|
['id' => 3, 'name' => 'Fallback Alpha', 'slug' => 'fallback-alpha', 'usage_count' => 450, 'is_active' => true, 'created_at' => $now, 'updated_at' => $now],
|
||||||
|
['id' => 4, 'name' => 'Fallback Beta', 'slug' => 'fallback-beta', 'usage_count' => 300, 'is_active' => true, 'created_at' => $now, 'updated_at' => $now],
|
||||||
|
]);
|
||||||
|
|
||||||
|
DB::table('tag_interaction_daily_metrics')->insert([
|
||||||
|
[
|
||||||
|
'metric_date' => $now->toDateString(),
|
||||||
|
'surface' => 'tag_search',
|
||||||
|
'tag_slug' => 'featured-momentum',
|
||||||
|
'source_tag_slug' => '',
|
||||||
|
'query' => 'featured',
|
||||||
|
'clicks' => 50,
|
||||||
|
'unique_users' => 0,
|
||||||
|
'unique_sessions' => 20,
|
||||||
|
'avg_position' => 1.1,
|
||||||
|
'created_at' => $now,
|
||||||
|
'updated_at' => $now,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'metric_date' => $now->toDateString(),
|
||||||
|
'surface' => 'tag_search',
|
||||||
|
'tag_slug' => 'rising-momentum',
|
||||||
|
'source_tag_slug' => '',
|
||||||
|
'query' => 'rising',
|
||||||
|
'clicks' => 12,
|
||||||
|
'unique_users' => 0,
|
||||||
|
'unique_sessions' => 6,
|
||||||
|
'avg_position' => 1.8,
|
||||||
|
'created_at' => $now,
|
||||||
|
'updated_at' => $now,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$service = app(TagDiscoveryService::class);
|
||||||
|
$featured = $service->featuredTags(1);
|
||||||
|
$rising = $service->risingTags($featured, 3);
|
||||||
|
|
||||||
|
expect(collect($featured)->pluck('slug')->all())->toBe(['featured-momentum']);
|
||||||
|
expect(collect($rising)->pluck('slug')->all())->toBe(['rising-momentum', 'fallback-alpha', 'fallback-beta']);
|
||||||
|
expect(collect($rising)->contains(fn ($tag) => $tag->slug === 'featured-momentum'))->toBeFalse();
|
||||||
|
});
|
||||||
@@ -20,6 +20,7 @@ export default defineConfig({
|
|||||||
'resources/js/Pages/ArtworkPage.jsx',
|
'resources/js/Pages/ArtworkPage.jsx',
|
||||||
'resources/js/Pages/Home/HomePage.jsx',
|
'resources/js/Pages/Home/HomePage.jsx',
|
||||||
'resources/js/Pages/Community/LatestCommentsPage.jsx',
|
'resources/js/Pages/Community/LatestCommentsPage.jsx',
|
||||||
|
'resources/js/Pages/Community/CommunityActivityPage.jsx',
|
||||||
'resources/js/Pages/Messages/Index.jsx',
|
'resources/js/Pages/Messages/Index.jsx',
|
||||||
'resources/js/profile.jsx',
|
'resources/js/profile.jsx',
|
||||||
'resources/js/feed.jsx',
|
'resources/js/feed.jsx',
|
||||||
|
|||||||
Reference in New Issue
Block a user