Save workspace changes

This commit is contained in:
2026-04-18 17:02:56 +02:00
parent f02ea9a711
commit 87d60af5a9
4220 changed files with 1388603 additions and 1554 deletions

View File

@@ -0,0 +1,39 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
class ArtworkController extends Controller
{
public function index(Request $request)
{
abort(404);
}
public function create(Request $request)
{
abort(404);
}
public function store(Request $request)
{
abort(404);
}
public function edit(Request $request, int $id)
{
abort(404);
}
public function update(Request $request, int $id)
{
abort(404);
}
public function destroy(Request $request, int $id)
{
abort(404);
}
}

View File

@@ -0,0 +1,75 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api\Admin;
use App\Http\Controllers\Controller;
use App\Models\Collection;
use App\Models\CollectionMember;
use App\Services\CollectionCollaborationService;
use App\Services\CollectionModerationService;
use App\Services\CollectionService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
final class CollectionModerationController extends Controller
{
public function __construct(
private readonly CollectionModerationService $moderation,
private readonly CollectionService $collections,
private readonly CollectionCollaborationService $collaborators,
) {
}
public function updateModeration(Request $request, Collection $collection): JsonResponse
{
$data = $request->validate([
'moderation_status' => ['required', 'in:active,under_review,restricted,hidden'],
]);
$collection = $this->moderation->updateStatus($collection->loadMissing('user'), (string) $data['moderation_status']);
return response()->json([
'ok' => true,
'collection' => $this->collections->mapCollectionDetailPayload($collection->loadMissing('user'), true),
]);
}
public function updateInteractions(Request $request, Collection $collection): JsonResponse
{
$data = $request->validate([
'allow_comments' => ['sometimes', 'boolean'],
'allow_submissions' => ['sometimes', 'boolean'],
'allow_saves' => ['sometimes', 'boolean'],
]);
$collection = $this->moderation->updateInteractions($collection->loadMissing('user'), $data);
return response()->json([
'ok' => true,
'collection' => $this->collections->mapCollectionDetailPayload($collection->loadMissing('user'), true),
]);
}
public function unfeature(Request $request, Collection $collection): JsonResponse
{
$collection = $this->moderation->unfeature($collection->loadMissing('user'));
return response()->json([
'ok' => true,
'collection' => $this->collections->mapCollectionDetailPayload($collection->loadMissing('user'), true),
]);
}
public function destroyMember(Request $request, Collection $collection, CollectionMember $member): JsonResponse
{
$this->moderation->removeMember($collection, $member);
return response()->json([
'ok' => true,
'collection' => $this->collections->mapCollectionDetailPayload($collection->fresh()->loadMissing('user'), true),
'members' => $this->collaborators->mapMembers($collection->fresh(), $request->user()),
]);
}
}

View File

@@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api\Admin;
use App\Http\Controllers\Controller;
use App\Services\Analytics\DiscoveryFeedbackReportService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
final class DiscoveryFeedbackReportController extends Controller
{
public function __construct(private readonly DiscoveryFeedbackReportService $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'] ?? 20);
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(),
],
'overview' => $report['overview'],
'daily_feedback' => $report['daily_feedback'],
'trend_summary' => $report['trend_summary'],
'by_surface' => $report['by_surface'],
'by_algo_surface' => $report['by_algo_surface'],
'top_artworks' => $report['top_artworks'],
'latest_aggregated_date' => $report['latest_aggregated_date'],
], Response::HTTP_OK);
}
}

View File

@@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api\Admin;
use App\Http\Controllers\Controller;
use App\Services\Recommendations\RecommendationFeedResolver;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
final class FeedEngineDecisionController extends Controller
{
public function __construct(private readonly RecommendationFeedResolver $feedResolver) {}
public function index(Request $request): JsonResponse
{
$validated = $request->validate([
'user_id' => ['required', 'integer', 'exists:users,id'],
'algo_version' => ['nullable', 'string', 'max:64'],
]);
$userId = (int) $validated['user_id'];
$algoVersion = isset($validated['algo_version']) ? (string) $validated['algo_version'] : null;
return response()->json([
'meta' => [
'generated_at' => now()->toISOString(),
],
'decision' => $this->feedResolver->inspectDecision($userId, $algoVersion),
], Response::HTTP_OK);
}
}

View File

@@ -0,0 +1,117 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api\Admin;
use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Symfony\Component\HttpFoundation\Response;
final class FeedPerformanceReportController extends Controller
{
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:1000'],
]);
$from = (string) ($validated['from'] ?? now()->subDays(29)->toDateString());
$to = (string) ($validated['to'] ?? now()->toDateString());
$limit = (int) ($validated['limit'] ?? 100);
if ($from > $to) {
return response()->json([
'message' => 'Invalid date range: from must be before or equal to to.',
], Response::HTTP_UNPROCESSABLE_ENTITY);
}
$rows = DB::table('feed_daily_metrics')
->selectRaw('algo_version, source')
->selectRaw('SUM(impressions) as impressions')
->selectRaw('SUM(clicks) as clicks')
->selectRaw('SUM(saves) as saves')
->selectRaw('SUM(dwell_0_5) as dwell_0_5')
->selectRaw('SUM(dwell_5_30) as dwell_5_30')
->selectRaw('SUM(dwell_30_120) as dwell_30_120')
->selectRaw('SUM(dwell_120_plus) as dwell_120_plus')
->whereBetween('metric_date', [$from, $to])
->groupBy('algo_version', 'source')
->orderBy('algo_version')
->orderBy('source')
->get();
$byAlgoSource = $rows->map(static function ($row): array {
$impressions = (int) ($row->impressions ?? 0);
$clicks = (int) ($row->clicks ?? 0);
$saves = (int) ($row->saves ?? 0);
return [
'algo_version' => (string) $row->algo_version,
'source' => (string) $row->source,
'impressions' => $impressions,
'clicks' => $clicks,
'saves' => $saves,
'ctr' => round($impressions > 0 ? $clicks / $impressions : 0.0, 6),
'save_rate' => round($clicks > 0 ? $saves / $clicks : 0.0, 6),
'dwell_buckets' => [
'0_5' => (int) ($row->dwell_0_5 ?? 0),
'5_30' => (int) ($row->dwell_5_30 ?? 0),
'30_120' => (int) ($row->dwell_30_120 ?? 0),
'120_plus' => (int) ($row->dwell_120_plus ?? 0),
],
];
})->values();
$topClickedArtworks = DB::table('feed_events as e')
->leftJoin('artworks as a', 'a.id', '=', 'e.artwork_id')
->selectRaw('e.algo_version')
->selectRaw('e.source')
->selectRaw('e.artwork_id')
->selectRaw('a.title as artwork_title')
->selectRaw("SUM(CASE WHEN e.event_type = 'feed_impression' THEN 1 ELSE 0 END) AS impressions")
->selectRaw("SUM(CASE WHEN e.event_type = 'feed_click' THEN 1 ELSE 0 END) AS clicks")
->whereBetween('e.event_date', [$from, $to])
->groupBy('e.algo_version', 'e.source', 'e.artwork_id', 'a.title')
->get()
->map(static function ($row): array {
$impressions = (int) ($row->impressions ?? 0);
$clicks = (int) ($row->clicks ?? 0);
return [
'algo_version' => (string) $row->algo_version,
'source' => (string) $row->source,
'artwork_id' => (int) $row->artwork_id,
'artwork_title' => (string) ($row->artwork_title ?? ''),
'impressions' => $impressions,
'clicks' => $clicks,
'ctr' => round($impressions > 0 ? $clicks / $impressions : 0.0, 6),
];
})
->sort(static function (array $a, array $b): int {
$clickCompare = $b['clicks'] <=> $a['clicks'];
if ($clickCompare !== 0) {
return $clickCompare;
}
return $b['ctr'] <=> $a['ctr'];
})
->take($limit)
->values();
return response()->json([
'meta' => [
'from' => $from,
'to' => $to,
'generated_at' => now()->toISOString(),
'limit' => $limit,
],
'by_algo_source' => $byAlgoSource,
'top_clicked_artworks' => $topClickedArtworks,
], Response::HTTP_OK);
}
}

View File

@@ -0,0 +1,233 @@
<?php
namespace App\Http\Controllers\Api\Admin;
use App\Http\Controllers\Controller;
use App\Models\NovaCard;
use App\Models\Report;
use App\Models\ReportHistory;
use App\Services\NovaCards\NovaCardPublishModerationService;
use App\Support\Moderation\ReportTargetResolver;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
final class ModerationReportQueueController extends Controller
{
public function __construct(
private readonly ReportTargetResolver $targets,
private readonly NovaCardPublishModerationService $moderation,
) {}
public function index(Request $request): JsonResponse
{
$status = (string) $request->query('status', 'open');
$status = in_array($status, ['open', 'reviewing', 'closed'], true) ? $status : 'open';
$group = (string) $request->query('group', '');
$query = Report::query()
->with(['reporter:id,username', 'lastModeratedBy:id,username', 'historyEntries.actor:id,username'])
->where('status', $status)
->orderByDesc('id');
if ($group === 'nova_cards') {
$query->whereIn('target_type', $this->targets->novaCardTargetTypes());
}
$items = $query->paginate(30);
return response()->json([
'data' => collect($items->items())
->map(fn (Report $report): array => $this->serializeReport($report))
->values()
->all(),
'meta' => [
'current_page' => $items->currentPage(),
'last_page' => $items->lastPage(),
'per_page' => $items->perPage(),
'total' => $items->total(),
'from' => $items->firstItem(),
'to' => $items->lastItem(),
],
]);
}
public function update(Request $request, Report $report): JsonResponse
{
$data = $request->validate([
'status' => 'sometimes|in:open,reviewing,closed',
'moderator_note' => 'sometimes|nullable|string|max:2000',
]);
$before = [];
$after = [];
$user = $request->user();
DB::transaction(function () use ($data, $report, $user, &$before, &$after): void {
if (array_key_exists('status', $data) && $data['status'] !== $report->status) {
$before['status'] = (string) $report->status;
$after['status'] = (string) $data['status'];
$report->status = $data['status'];
}
if (array_key_exists('moderator_note', $data)) {
$normalizedNote = is_string($data['moderator_note']) ? trim($data['moderator_note']) : null;
$normalizedNote = $normalizedNote !== '' ? $normalizedNote : null;
if ($normalizedNote !== $report->moderator_note) {
$before['moderator_note'] = $report->moderator_note;
$after['moderator_note'] = $normalizedNote;
$report->moderator_note = $normalizedNote;
}
}
if ($before !== [] || $after !== []) {
$report->last_moderated_by_id = $user?->id;
$report->last_moderated_at = now();
$report->save();
$report->historyEntries()->create([
'actor_user_id' => $user?->id,
'action_type' => 'report_updated',
'summary' => $this->buildUpdateSummary($before, $after),
'note' => $report->moderator_note,
'before_json' => $before !== [] ? $before : null,
'after_json' => $after !== [] ? $after : null,
'created_at' => now(),
]);
}
});
$report = $report->fresh(['reporter:id,username', 'lastModeratedBy:id,username', 'historyEntries.actor:id,username']);
return response()->json([
'report' => $this->serializeReport($report),
]);
}
public function moderateTarget(Request $request, Report $report): JsonResponse
{
$data = $request->validate([
'action' => 'required|in:approve_card,flag_card,reject_card',
'disposition' => 'nullable|in:' . implode(',', array_keys(NovaCardPublishModerationService::DISPOSITION_LABELS)),
]);
$card = $this->targets->resolveModerationCard($report);
abort_unless($card !== null, 422, 'This report does not have a Nova Card moderation target.');
DB::transaction(function () use ($card, $data, $report, $request): void {
$before = [
'card_id' => (int) $card->id,
'moderation_status' => (string) $card->moderation_status,
];
$nextStatus = match ($data['action']) {
'approve_card' => NovaCard::MOD_APPROVED,
'flag_card' => NovaCard::MOD_FLAGGED,
'reject_card' => NovaCard::MOD_REJECTED,
};
$card = $this->moderation->recordStaffOverride(
$card,
$nextStatus,
$request->user(),
'report_queue',
[
'note' => $report->moderator_note,
'report_id' => $report->id,
'disposition' => $data['disposition'] ?? null,
],
);
$report->last_moderated_by_id = $request->user()?->id;
$report->last_moderated_at = now();
$report->save();
$report->historyEntries()->create([
'actor_user_id' => $request->user()?->id,
'action_type' => 'target_moderated',
'summary' => $this->buildTargetModerationSummary($data['action'], $card),
'note' => $report->moderator_note,
'before_json' => $before,
'after_json' => [
'card_id' => (int) $card->id,
'moderation_status' => (string) $card->moderation_status,
'action' => (string) $data['action'],
],
'created_at' => now(),
]);
});
$report = $report->fresh(['reporter:id,username', 'lastModeratedBy:id,username', 'historyEntries.actor:id,username']);
return response()->json([
'report' => $this->serializeReport($report),
]);
}
private function buildUpdateSummary(array $before, array $after): string
{
$parts = [];
if (array_key_exists('status', $after)) {
$parts[] = sprintf('Status %s -> %s', $before['status'], $after['status']);
}
if (array_key_exists('moderator_note', $after)) {
$parts[] = $after['moderator_note'] ? 'Moderator note updated' : 'Moderator note cleared';
}
return $parts !== [] ? implode(' • ', $parts) : 'Report reviewed';
}
private function buildTargetModerationSummary(string $action, NovaCard $card): string
{
return match ($action) {
'approve_card' => sprintf('Approved card #%d', $card->id),
'flag_card' => sprintf('Flagged card #%d', $card->id),
'reject_card' => sprintf('Rejected card #%d', $card->id),
default => sprintf('Updated card #%d', $card->id),
};
}
private function serializeReport(Report $report): array
{
return [
'id' => (int) $report->id,
'status' => (string) $report->status,
'target_type' => (string) $report->target_type,
'target_id' => (int) $report->target_id,
'reason' => (string) $report->reason,
'details' => $report->details,
'moderator_note' => $report->moderator_note,
'created_at' => optional($report->created_at)?->toISOString(),
'updated_at' => optional($report->updated_at)?->toISOString(),
'last_moderated_at' => optional($report->last_moderated_at)?->toISOString(),
'reporter' => $report->reporter ? [
'id' => (int) $report->reporter->id,
'username' => (string) $report->reporter->username,
] : null,
'last_moderated_by' => $report->lastModeratedBy ? [
'id' => (int) $report->lastModeratedBy->id,
'username' => (string) $report->lastModeratedBy->username,
] : null,
'target' => $this->targets->summarize($report),
'history' => $report->historyEntries
->take(8)
->map(fn (ReportHistory $entry): array => [
'id' => (int) $entry->id,
'action_type' => (string) $entry->action_type,
'summary' => $entry->summary,
'note' => $entry->note,
'before' => $entry->before_json,
'after' => $entry->after_json,
'created_at' => optional($entry->created_at)?->toISOString(),
'actor' => $entry->actor ? [
'id' => (int) $entry->actor->id,
'username' => (string) $entry->actor->username,
] : null,
])
->values()
->all(),
];
}
}

View File

@@ -0,0 +1,114 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api\Admin;
use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Symfony\Component\HttpFoundation\Response;
final class SimilarArtworkReportController extends Controller
{
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:1000'],
]);
$from = (string) ($validated['from'] ?? now()->subDays(29)->toDateString());
$to = (string) ($validated['to'] ?? now()->toDateString());
$limit = (int) ($validated['limit'] ?? 100);
if ($from > $to) {
return response()->json([
'message' => 'Invalid date range: from must be before or equal to to.',
], Response::HTTP_UNPROCESSABLE_ENTITY);
}
$byAlgoRows = DB::table('similar_artwork_events')
->selectRaw('algo_version')
->selectRaw("SUM(CASE WHEN event_type = 'impression' THEN 1 ELSE 0 END) AS impressions")
->selectRaw("SUM(CASE WHEN event_type = 'click' THEN 1 ELSE 0 END) AS clicks")
->whereBetween('event_date', [$from, $to])
->groupBy('algo_version')
->orderBy('algo_version')
->get();
$byAlgo = $byAlgoRows->map(static function ($row): array {
$impressions = (int) ($row->impressions ?? 0);
$clicks = (int) ($row->clicks ?? 0);
$ctr = $impressions > 0 ? $clicks / $impressions : 0.0;
return [
'algo_version' => (string) $row->algo_version,
'impressions' => $impressions,
'clicks' => $clicks,
'ctr' => round($ctr, 6),
];
})->values();
$pairRows = DB::table('similar_artwork_events as e')
->leftJoin('artworks as source', 'source.id', '=', 'e.source_artwork_id')
->leftJoin('artworks as similar', 'similar.id', '=', 'e.similar_artwork_id')
->selectRaw('e.algo_version')
->selectRaw('e.source_artwork_id')
->selectRaw('e.similar_artwork_id')
->selectRaw('source.title as source_title')
->selectRaw('similar.title as similar_title')
->selectRaw("SUM(CASE WHEN e.event_type = 'impression' THEN 1 ELSE 0 END) AS impressions")
->selectRaw("SUM(CASE WHEN e.event_type = 'click' THEN 1 ELSE 0 END) AS clicks")
->whereBetween('e.event_date', [$from, $to])
->whereNotNull('e.similar_artwork_id')
->groupBy('e.algo_version', 'e.source_artwork_id', 'e.similar_artwork_id', 'source.title', 'similar.title')
->get();
$topSimilarities = $pairRows
->map(static function ($row): array {
$impressions = (int) ($row->impressions ?? 0);
$clicks = (int) ($row->clicks ?? 0);
$ctr = $impressions > 0 ? $clicks / $impressions : 0.0;
return [
'algo_version' => (string) $row->algo_version,
'source_artwork_id' => (int) $row->source_artwork_id,
'source_title' => (string) ($row->source_title ?? ''),
'similar_artwork_id' => (int) $row->similar_artwork_id,
'similar_title' => (string) ($row->similar_title ?? ''),
'impressions' => $impressions,
'clicks' => $clicks,
'ctr' => round($ctr, 6),
];
})
->sort(function (array $a, array $b): int {
$ctrCompare = $b['ctr'] <=> $a['ctr'];
if ($ctrCompare !== 0) {
return $ctrCompare;
}
$clickCompare = $b['clicks'] <=> $a['clicks'];
if ($clickCompare !== 0) {
return $clickCompare;
}
return $b['impressions'] <=> $a['impressions'];
})
->take($limit)
->values();
return response()->json([
'meta' => [
'from' => $from,
'to' => $to,
'generated_at' => now()->toISOString(),
'limit' => $limit,
],
'by_algo_version' => $byAlgo,
'top_similarities' => $topSimilarities,
], Response::HTTP_OK);
}
}

View File

@@ -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);
}
}

View File

@@ -0,0 +1,83 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api\Admin;
use App\Http\Controllers\Controller;
use App\Models\Upload;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
final class UploadModerationController extends Controller
{
public function pending(): JsonResponse
{
$uploads = Upload::query()
->where('status', 'draft')
->where('moderation_status', 'pending')
->orderBy('created_at')
->get([
'id',
'user_id',
'type',
'status',
'processing_state',
'title',
'preview_path',
'created_at',
'moderation_status',
]);
return response()->json([
'data' => $uploads,
], Response::HTTP_OK);
}
public function approve(string $id, Request $request): JsonResponse
{
$upload = Upload::query()->find($id);
if (! $upload) {
return response()->json(['message' => 'Upload not found.'], Response::HTTP_NOT_FOUND);
}
$upload->moderation_status = 'approved';
$upload->moderated_at = now();
$upload->moderated_by = (int) $request->user()->id;
$upload->moderation_note = $request->input('note');
$upload->save();
return response()->json([
'success' => true,
'id' => (string) $upload->id,
'moderation_status' => (string) $upload->moderation_status,
], Response::HTTP_OK);
}
public function reject(string $id, Request $request): JsonResponse
{
$upload = Upload::query()->find($id);
if (! $upload) {
return response()->json(['message' => 'Upload not found.'], Response::HTTP_NOT_FOUND);
}
$upload->moderation_status = 'rejected';
$upload->status = 'rejected';
$upload->processing_state = 'rejected';
$upload->moderated_at = now();
$upload->moderated_by = (int) $request->user()->id;
$upload->moderation_note = (string) $request->input('note', '');
$upload->save();
return response()->json([
'success' => true,
'id' => (string) $upload->id,
'status' => (string) $upload->status,
'processing_state' => (string) $upload->processing_state,
'moderation_status' => (string) $upload->moderation_status,
], Response::HTTP_OK);
}
}

View File

@@ -0,0 +1,153 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api\Admin;
use App\Http\Controllers\Controller;
use App\Models\User;
use App\Support\UsernamePolicy;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Symfony\Component\HttpFoundation\Response;
final class UsernameApprovalController extends Controller
{
public function pending(): JsonResponse
{
$rows = DB::table('username_approval_requests')
->where('status', 'pending')
->orderBy('created_at')
->get([
'id',
'user_id',
'requested_username',
'context',
'similar_to',
'payload',
'created_at',
]);
return response()->json(['data' => $rows], Response::HTTP_OK);
}
public function approve(int $id, Request $request): JsonResponse
{
$row = DB::table('username_approval_requests')->where('id', $id)->first();
if (! $row) {
return response()->json(['message' => 'Request not found.'], Response::HTTP_NOT_FOUND);
}
if ((string) $row->status !== 'pending') {
return response()->json(['message' => 'Request is not pending.'], Response::HTTP_UNPROCESSABLE_ENTITY);
}
DB::beginTransaction();
try {
DB::table('username_approval_requests')
->where('id', $id)
->update([
'status' => 'approved',
'reviewed_by' => (int) $request->user()->id,
'reviewed_at' => now(),
'review_note' => (string) $request->input('note', ''),
'updated_at' => now(),
]);
if ((string) $row->context === 'profile_update' && ! empty($row->user_id)) {
$this->applyProfileRename((int) $row->user_id, (string) $row->requested_username);
}
DB::commit();
} catch (\Throwable $e) {
DB::rollBack();
return response()->json(['message' => $e->getMessage()], Response::HTTP_UNPROCESSABLE_ENTITY);
}
return response()->json([
'success' => true,
'id' => $id,
'status' => 'approved',
], Response::HTTP_OK);
}
public function reject(int $id, Request $request): JsonResponse
{
$affected = DB::table('username_approval_requests')
->where('id', $id)
->where('status', 'pending')
->update([
'status' => 'rejected',
'reviewed_by' => (int) $request->user()->id,
'reviewed_at' => now(),
'review_note' => (string) $request->input('note', ''),
'updated_at' => now(),
]);
if ($affected === 0) {
return response()->json(['message' => 'Request not found or not pending.'], Response::HTTP_NOT_FOUND);
}
return response()->json([
'success' => true,
'id' => $id,
'status' => 'rejected',
], Response::HTTP_OK);
}
private function applyProfileRename(int $userId, string $requestedUsername): void
{
$user = User::query()->find($userId);
if (! $user) {
return;
}
$requested = UsernamePolicy::normalize($requestedUsername);
if ($requested === '') {
throw new \RuntimeException('Requested username is invalid.');
}
$exists = User::query()
->whereRaw('LOWER(username) = ?', [$requested])
->where('id', '!=', $userId)
->exists();
if ($exists) {
throw new \RuntimeException('Requested username is already taken.');
}
$old = UsernamePolicy::normalize((string) ($user->username ?? ''));
if ($old === $requested) {
return;
}
$user->username = $requested;
$user->username_changed_at = now();
if (Schema::hasColumn('users', 'last_username_change_at')) {
$user->last_username_change_at = now();
}
$user->save();
if ($old !== '') {
DB::table('username_history')->insert([
'user_id' => $userId,
'old_username' => $old,
'changed_at' => now(),
'created_at' => now(),
'updated_at' => now(),
]);
DB::table('username_redirects')->updateOrInsert(
['old_username' => $old],
[
'new_username' => $requested,
'user_id' => $userId,
'created_at' => now(),
'updated_at' => now(),
]
);
}
}
}

View File

@@ -0,0 +1,179 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\Artwork;
use App\Models\ArtworkAward;
use App\Models\ArtworkMedal;
use App\Models\ArtworkMedalStat;
use App\Services\ArtworkMedalService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
final class ArtworkAwardController extends Controller
{
public function __construct(
private readonly ArtworkMedalService $service
) {}
/**
* POST /api/artworks/{id}/award
* Award the artwork with a medal.
*/
public function store(Request $request, int $id): JsonResponse
{
$user = $request->user();
$artwork = Artwork::findOrFail($id);
$this->authorize('award', [ArtworkAward::class, $artwork]);
$data = $request->validate([
'medal' => ['required', 'string', 'in:gold,silver,bronze'],
]);
$this->service->award($artwork, $user, $data['medal']);
// Record activity event
try {
\App\Models\ActivityEvent::record(
actorId: $user->id,
type: \App\Models\ActivityEvent::TYPE_AWARD,
targetType: \App\Models\ActivityEvent::TARGET_ARTWORK,
targetId: $artwork->id,
meta: ['medal' => $data['medal']],
);
} catch (\Throwable) {}
return response()->json(
$this->buildPayload($artwork->id, $user->id),
201
);
}
public function upsert(Request $request, int $id): JsonResponse
{
$user = $request->user();
$artwork = Artwork::findOrFail($id);
$this->authorize('award', [ArtworkAward::class, $artwork]);
$data = $request->validate([
'medal_type' => ['required', 'string', 'in:gold,silver,bronze'],
]);
$existed = ArtworkMedal::query()
->where('artwork_id', $artwork->id)
->where('user_id', $user->id)
->exists();
$this->service->upsert($artwork, $user, $data['medal_type']);
return response()->json(
array_merge($this->buildPayload($artwork->id, $user->id), [
'message' => $existed ? 'Medal updated.' : 'Medal added.',
]),
$existed ? 200 : 201,
);
}
/**
* PUT /api/artworks/{id}/award
* Change an existing award medal.
*/
public function update(Request $request, int $id): JsonResponse
{
$user = $request->user();
$artwork = Artwork::findOrFail($id);
$existingAward = ArtworkMedal::where('artwork_id', $artwork->id)
->where('user_id', $user->id)
->firstOrFail();
$this->authorize('change', $existingAward);
$data = $request->validate([
'medal' => ['required', 'string', 'in:gold,silver,bronze'],
]);
$this->service->changeMedal($artwork, $user, $data['medal']);
return response()->json($this->buildPayload($artwork->id, $user->id));
}
/**
* DELETE /api/artworks/{id}/award
* Remove the user's award for this artwork.
*/
public function destroy(Request $request, int $id): JsonResponse
{
$user = $request->user();
$artwork = Artwork::findOrFail($id);
$existingAward = ArtworkMedal::where('artwork_id', $artwork->id)
->where('user_id', $user->id)
->firstOrFail();
$this->authorize('remove', $existingAward);
$this->service->removeMedal($artwork, $user);
return response()->json($this->buildPayload($artwork->id, $user->id));
}
public function destroyMedal(Request $request, int $id): JsonResponse
{
$user = $request->user();
$artwork = Artwork::findOrFail($id);
$this->service->removeMedal($artwork, $user);
return response()->json(array_merge($this->buildPayload($artwork->id, $user->id), [
'message' => 'Medal removed.',
]));
}
/**
* GET /api/artworks/{id}/awards
* Return award stats + viewer's current award.
*/
public function show(Request $request, int $id): JsonResponse
{
$artwork = Artwork::findOrFail($id);
return response()->json($this->buildPayload($artwork->id, $request->user()?->id));
}
// -------------------------------------------------------------------------
// All authorization is delegated to ArtworkAwardPolicy via $this->authorize().
private function buildPayload(int $artworkId, ?int $userId): array
{
$stat = ArtworkMedalStat::find($artworkId);
$userAward = $userId
? ArtworkMedal::where('artwork_id', $artworkId)
->where('user_id', $userId)
->value('medal_type')
: null;
$medals = [
'gold' => (int) ($stat?->gold_count ?? 0),
'silver' => (int) ($stat?->silver_count ?? 0),
'bronze' => (int) ($stat?->bronze_count ?? 0),
'score' => (int) ($stat?->score_total ?? 0),
'score_7d' => (int) ($stat?->score_7d ?? 0),
'score_30d' => (int) ($stat?->score_30d ?? 0),
'last_medaled_at' => $stat?->last_medaled_at?->toIsoString(),
];
return [
'awards' => $medals,
'medals' => $medals,
'viewer_award' => $userAward,
'current_user_medal' => $userAward,
];
}
}

View File

@@ -0,0 +1,305 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Services\Activity\UserActivityService;
use App\Models\Artwork;
use App\Models\ArtworkComment;
use App\Models\User;
use App\Models\UserMention;
use App\Notifications\ArtworkCommentedNotification;
use App\Notifications\ArtworkMentionedNotification;
use App\Services\ContentSanitizer;
use App\Support\AvatarUrl;
use Carbon\Carbon;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Gate;
/**
* Artwork comment CRUD.
*
* POST /api/artworks/{artworkId}/comments store
* PUT /api/artworks/{artworkId}/comments/{id} update (own comment)
* DELETE /api/artworks/{artworkId}/comments/{id} delete (own or admin)
* GET /api/artworks/{artworkId}/comments list (paginated)
*/
class ArtworkCommentController extends Controller
{
private const MAX_LENGTH = 10_000;
// ─────────────────────────────────────────────────────────────────────────
// List
// ─────────────────────────────────────────────────────────────────────────
public function index(Request $request, int $artworkId): JsonResponse
{
$artwork = Artwork::public()->published()->findOrFail($artworkId);
$page = max(1, (int) $request->query('page', 1));
$perPage = 20;
// Only fetch top-level comments (no parent). Replies are recursively eager-loaded.
$comments = ArtworkComment::with([
'user', 'user.profile',
'approvedReplies',
])
->where('artwork_id', $artwork->id)
->where('is_approved', true)
->whereNull('parent_id')
->orderByDesc('created_at')
->paginate($perPage, ['*'], 'page', $page);
$userId = $request->user()?->id;
$items = $comments->getCollection()->map(fn ($c) => $this->formatComment($c, $userId, true));
return response()->json([
'data' => $items,
'meta' => [
'current_page' => $comments->currentPage(),
'last_page' => $comments->lastPage(),
'total' => $comments->total(),
'per_page' => $comments->perPage(),
],
]);
}
// ─────────────────────────────────────────────────────────────────────────
// Store
// ─────────────────────────────────────────────────────────────────────────
public function store(Request $request, int $artworkId): JsonResponse
{
$artwork = Artwork::public()->published()->findOrFail($artworkId);
$request->validate([
'content' => ['required', 'string', 'min:1', 'max:' . self::MAX_LENGTH],
'parent_id' => ['nullable', 'integer', 'exists:artwork_comments,id'],
]);
$raw = $request->input('content');
$parentId = $request->input('parent_id');
// If replying, validate parent belongs to same artwork and is approved
if ($parentId) {
$parent = ArtworkComment::where('artwork_id', $artwork->id)
->where('is_approved', true)
->find($parentId);
if (! $parent) {
return response()->json([
'errors' => ['parent_id' => ['The comment you are replying to is no longer available.']],
], 422);
}
}
// Validate markdown-lite content
$errors = ContentSanitizer::validate($raw);
if ($errors) {
return response()->json(['errors' => ['content' => $errors]], 422);
}
$rendered = ContentSanitizer::render($raw);
$comment = ArtworkComment::create([
'artwork_id' => $artwork->id,
'user_id' => $request->user()->id,
'parent_id' => $parentId,
'content' => $raw, // legacy column (plain text fallback)
'raw_content' => $raw,
'rendered_content' => $rendered,
'is_approved' => true, // auto-approve; extend with moderation as needed
]);
// Bust the comments cache for this user's 'all' feed
Cache::forget('comments.latest.all.page1');
$comment->load(['user', 'user.profile']);
$this->notifyRecipients($artwork, $comment, $request->user(), $parentId ? (int) $parentId : null);
// Record activity event (fire-and-forget; never break the response)
try {
\App\Models\ActivityEvent::record(
actorId: $request->user()->id,
type: \App\Models\ActivityEvent::TYPE_COMMENT,
targetType: \App\Models\ActivityEvent::TARGET_ARTWORK,
targetId: $artwork->id,
);
} catch (\Throwable) {}
try {
app(UserActivityService::class)->logComment(
(int) $request->user()->id,
(int) $comment->id,
$parentId !== null,
['artwork_id' => (int) $artwork->id],
);
} catch (\Throwable) {}
return response()->json(['data' => $this->formatComment($comment, $request->user()->id, false)], 201);
}
// ─────────────────────────────────────────────────────────────────────────
// Update
// ─────────────────────────────────────────────────────────────────────────
public function update(Request $request, int $artworkId, int $commentId): JsonResponse
{
$comment = ArtworkComment::where('artwork_id', $artworkId)
->findOrFail($commentId);
Gate::authorize('update', $comment);
$request->validate([
'content' => ['required', 'string', 'min:1', 'max:' . self::MAX_LENGTH],
]);
$raw = $request->input('content');
$errors = ContentSanitizer::validate($raw);
if ($errors) {
return response()->json(['errors' => ['content' => $errors]], 422);
}
$rendered = ContentSanitizer::render($raw);
$comment->update([
'content' => $raw,
'raw_content' => $raw,
'rendered_content' => $rendered,
]);
Cache::forget('comments.latest.all.page1');
$comment->load(['user', 'user.profile']);
return response()->json(['data' => $this->formatComment($comment, $request->user()->id, false)]);
}
// ─────────────────────────────────────────────────────────────────────────
// Delete
// ─────────────────────────────────────────────────────────────────────────
public function destroy(Request $request, int $artworkId, int $commentId): JsonResponse
{
$comment = ArtworkComment::where('artwork_id', $artworkId)->findOrFail($commentId);
Gate::authorize('delete', $comment);
$comment->delete();
Cache::forget('comments.latest.all.page1');
return response()->json(['message' => 'Comment deleted.'], 200);
}
// ─────────────────────────────────────────────────────────────────────────
// Helpers
// ─────────────────────────────────────────────────────────────────────────
private function formatComment(ArtworkComment $c, ?int $currentUserId, bool $includeReplies = false): array
{
$user = $c->user;
$userId = (int) ($c->user_id ?? 0);
$avatarHash = $user?->profile?->avatar_hash ?? null;
$data = [
'id' => $c->id,
'parent_id' => $c->parent_id,
'raw_content' => $c->raw_content ?? $c->content,
'rendered_content' => $this->renderCommentContent($c),
'created_at' => $c->created_at?->toIso8601String(),
'time_ago' => $c->created_at ? Carbon::parse($c->created_at)->diffForHumans() : null,
'can_edit' => $currentUserId === $userId,
'can_delete' => $currentUserId === $userId,
'user' => [
'id' => $userId,
'username' => $user?->username,
'display' => $user?->username ?? $user?->name ?? 'User',
'profile_url' => $user?->username ? '/@' . $user->username : '/profile/' . $userId,
'avatar_url' => AvatarUrl::forUser($userId, $avatarHash, 64),
'level' => (int) ($user?->level ?? 1),
'rank' => (string) ($user?->rank ?? 'Newbie'),
],
];
if ($includeReplies && $c->relationLoaded('approvedReplies')) {
$data['replies'] = $c->approvedReplies->map(fn ($r) => $this->formatComment($r, $currentUserId, true))->values()->toArray();
} elseif ($includeReplies && $c->relationLoaded('replies')) {
$data['replies'] = $c->replies->map(fn ($r) => $this->formatComment($r, $currentUserId, true))->values()->toArray();
} else {
$data['replies'] = [];
}
return $data;
}
private function renderCommentContent(ArtworkComment $comment): string
{
$rawContent = (string) ($comment->raw_content ?? $comment->content ?? '');
$renderedContent = $comment->rendered_content;
if (! is_string($renderedContent) || trim($renderedContent) === '') {
$renderedContent = $rawContent !== ''
? ContentSanitizer::render($rawContent)
: nl2br(e(strip_tags((string) ($comment->content ?? ''))));
}
return ContentSanitizer::sanitizeRenderedHtml(
$renderedContent,
$this->commentAuthorCanPublishLinks($comment)
);
}
private function commentAuthorCanPublishLinks(ArtworkComment $comment): bool
{
$level = (int) ($comment->user?->level ?? 1);
$rank = strtolower((string) ($comment->user?->rank ?? 'Newbie'));
return $level > 1 && $rank !== 'newbie';
}
private function notifyRecipients(Artwork $artwork, ArtworkComment $comment, User $actor, ?int $parentId): void
{
$notifiedUserIds = [];
$creatorId = (int) ($artwork->user_id ?? 0);
if ($creatorId > 0 && $creatorId !== (int) $actor->id) {
$creator = User::query()->find($creatorId);
if ($creator) {
$creator->notify(new ArtworkCommentedNotification($artwork, $comment, $actor));
$notifiedUserIds[] = (int) $creator->id;
}
}
if ($parentId) {
$parentUserId = (int) (ArtworkComment::query()->whereKey($parentId)->value('user_id') ?? 0);
if ($parentUserId > 0 && $parentUserId !== (int) $actor->id && ! in_array($parentUserId, $notifiedUserIds, true)) {
$parentUser = User::query()->find($parentUserId);
if ($parentUser) {
$parentUser->notify(new ArtworkCommentedNotification($artwork, $comment, $actor));
$notifiedUserIds[] = (int) $parentUser->id;
}
}
}
User::query()
->whereIn(
'id',
UserMention::query()
->where('comment_id', (int) $comment->id)
->pluck('mentioned_user_id')
->map(fn ($id) => (int) $id)
->unique()
->all()
)
->get()
->each(function (User $mentionedUser) use ($artwork, $comment, $actor): void {
if ((int) $mentionedUser->id === (int) $actor->id) {
return;
}
$mentionedUser->notify(new ArtworkMentionedNotification($artwork, $comment, $actor));
});
}
}

View File

@@ -0,0 +1,77 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Http\Requests\Artworks\ArtworkCreateRequest;
use App\Http\Resources\ArtworkListResource;
use App\Http\Resources\ArtworkResource;
use App\Services\ArtworkService;
use App\Services\Artworks\ArtworkDraftService;
use App\Models\Category;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class ArtworkController extends Controller
{
protected ArtworkService $service;
public function __construct(ArtworkService $service)
{
$this->service = $service;
}
/**
* POST /api/artworks
* Creates a draft artwork placeholder for the upload pipeline.
*/
public function store(ArtworkCreateRequest $request, ArtworkDraftService $drafts)
{
$user = $request->user();
$data = $request->validated();
$categoryId = isset($data['category']) && ctype_digit((string) $data['category'])
? (int) $data['category']
: null;
$result = $drafts->createDraft(
$user,
(string) $data['title'],
isset($data['description']) ? (string) $data['description'] : null,
$categoryId,
(bool) ($data['is_mature'] ?? false),
$data['group'] ?? null,
);
return response()->json([
'artwork_id' => $result->artworkId,
'status' => $result->status,
], Response::HTTP_CREATED);
}
/**
* GET /api/v1/artworks/{slug}
* Returns a single public artwork resource by slug.
*/
public function show(string $slug)
{
$artwork = $this->service->getPublicArtworkBySlug($slug);
// Return the artwork instance (service already loads lightweight relations).
// Log resolved resource for debugging failing test assertions.
// Return the resolved payload directly to avoid JsonResource wrapping inconsistencies
return response()->json((new ArtworkResource($artwork))->resolve(), 200);
}
/**
* GET /api/v1/categories/{slug}/artworks
* Uses route-model binding for Category (slug). Returns paginated list resource.
*/
public function categoryArtworks(Request $request, Category $category)
{
$perPage = (int) $request->get('per_page', 24);
$paginator = $this->service->getCategoryArtworks($category, $perPage);
return ArtworkListResource::collection($paginator);
}
}

View File

@@ -0,0 +1,137 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\Artwork;
use App\Services\ArtworkStatsService;
use App\Services\ThumbnailPresenter;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
/**
* POST /api/art/{id}/download
*
* Records a download event and returns the full-resolution download URL.
*
* Responsibilities:
* 1. Validates the artwork is public and published.
* 2. Inserts a row in artwork_downloads (artwork_id, user_id, ip, user_agent).
* 3. Increments artwork_stats.downloads + forwards to creator stats.
* 4. Returns {"ok": true, "url": "<download_url>"} so the frontend can
* trigger the actual browser download.
*
* The frontend fires this POST on click, then uses the returned URL to
* trigger the file download (or falls back to the pre-resolved URL it
* already has).
*/
final class ArtworkDownloadController extends Controller
{
public function __construct(private readonly ArtworkStatsService $stats) {}
public function __invoke(Request $request, int $id): JsonResponse
{
$artwork = Artwork::public()
->published()
->with(['user:id'])
->where('id', $id)
->first();
if (! $artwork) {
return response()->json(['error' => 'Not found'], 404);
}
// Record the download event — non-blocking, errors are swallowed.
$this->recordDownload($request, $artwork);
// Increment counters — deferred via Redis when available.
try {
$this->stats->incrementDownloads((int) $artwork->id, 1, defer: true);
} catch (\Throwable) {
// Stats failure must never interrupt the download.
}
// Resolve the highest-resolution download URL available.
$url = $this->resolveDownloadUrl($artwork);
// Build a user-friendly download filename: "title-slug.file_ext"
$ext = $artwork->file_ext ?: $artwork->thumb_ext ?: 'webp';
$slug = Str::slug((string) ($artwork->slug ?: $artwork->title)) ?: (string) $artwork->id;
$filename = $slug . '.' . $ext;
return response()->json([
'ok' => true,
'url' => $url,
'filename' => $filename,
'size' => (int) ($artwork->file_size ?? 0),
]);
}
/**
* Insert a row in artwork_downloads.
* Uses a raw insert for the binary(16) IP column.
* Silently ignores failures (analytics should never break user flow).
*/
private function recordDownload(Request $request, Artwork $artwork): void
{
try {
$ip = $request->ip() ?? '0.0.0.0';
$bin = @inet_pton($ip);
DB::table('artwork_downloads')->insert([
'artwork_id' => $artwork->id,
'user_id' => $request->user()?->id,
'ip' => $bin !== false ? $bin : null,
'ip_address' => mb_substr((string) $ip, 0, 45),
'user_agent' => mb_substr((string) $request->userAgent(), 0, 1024),
'referer' => mb_substr((string) $request->headers->get('referer'), 0, 65535),
'created_at' => now(),
]);
} catch (\Throwable) {
// Analytics failure must never interrupt the download.
}
}
/**
* Resolve the original full-resolution CDN URL.
*
* Originals are stored at: {cdn}/original/{h1}/{h2}/{hash}.{file_ext}
* h1 = first 2 chars of hash, h2 = next 2 chars, filename = full hash + file_ext.
* Falls back to XL LG MD thumbnail when hash is unavailable.
*/
private function resolveDownloadUrl(Artwork $artwork): string
{
$filePath = trim((string) ($artwork->file_path ?? ''), '/');
$cdn = rtrim((string) config('cdn.files_url', 'https://files.skinbase.org'), '/');
if ($filePath !== '') {
return $cdn . '/' . $filePath;
}
$hash = $artwork->hash ?? null;
$ext = ltrim((string) ($artwork->file_ext ?: $artwork->thumb_ext ?: 'webp'), '.');
if (!empty($hash)) {
$h = strtolower(preg_replace('/[^a-f0-9]/', '', $hash));
$h1 = substr($h, 0, 2);
$h2 = substr($h, 2, 2);
$prefix = trim((string) config('uploads.object_storage.prefix', 'artworks'), '/');
return sprintf('%s/%s/original/%s/%s/%s.%s', $cdn, $prefix, $h1, $h2, $h, $ext);
}
// Fallback: best available thumbnail size
foreach (['xl', 'lg', 'md'] as $size) {
$thumb = ThumbnailPresenter::present($artwork, $size);
if (!empty($thumb['url'])) {
return (string) $thumb['url'];
}
}
return '';
}
}

View File

@@ -0,0 +1,278 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api;
use App\Events\Achievements\AchievementCheckRequested;
use App\Http\Controllers\Controller;
use App\Models\Artwork;
use App\Notifications\ArtworkLikedNotification;
use App\Services\FollowService;
use App\Services\Activity\UserActivityService;
use App\Services\UserStatsService;
use App\Services\XPService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
final class ArtworkInteractionController extends Controller
{
public function bookmark(Request $request, int $artworkId): JsonResponse
{
$this->toggleSimple(
request: $request,
table: 'artwork_bookmarks',
keyColumns: ['user_id', 'artwork_id'],
keyValues: ['user_id' => (int) $request->user()->id, 'artwork_id' => $artworkId],
insertPayload: ['created_at' => now(), 'updated_at' => now()],
requiredTable: 'artwork_bookmarks'
);
return response()->json($this->statusPayload((int) $request->user()->id, $artworkId));
}
public function favorite(Request $request, int $artworkId): JsonResponse
{
$state = $request->boolean('state', true);
$changed = $this->toggleSimple(
request: $request,
table: 'artwork_favourites',
keyColumns: ['user_id', 'artwork_id'],
keyValues: ['user_id' => (int) $request->user()->id, 'artwork_id' => $artworkId],
insertPayload: ['created_at' => now(), 'updated_at' => now()],
requiredTable: 'artwork_favourites'
);
$this->syncArtworkStats($artworkId);
// Update creator's favorites_received_count
$creatorId = (int) DB::table('artworks')->where('id', $artworkId)->value('user_id');
if ($creatorId) {
$svc = app(UserStatsService::class);
if ($state && $changed) {
$svc->incrementFavoritesReceived($creatorId);
$svc->setLastActiveAt((int) $request->user()->id);
// Record activity event (new favourite only)
try {
\App\Models\ActivityEvent::record(
actorId: (int) $request->user()->id,
type: \App\Models\ActivityEvent::TYPE_FAVORITE,
targetType: \App\Models\ActivityEvent::TARGET_ARTWORK,
targetId: $artworkId,
);
} catch (\Throwable) {}
try {
app(UserActivityService::class)->logFavourite((int) $request->user()->id, $artworkId);
} catch (\Throwable) {}
} elseif (! $state && $changed) {
$svc->decrementFavoritesReceived($creatorId);
}
}
return response()->json($this->statusPayload((int) $request->user()->id, $artworkId));
}
public function like(Request $request, int $artworkId): JsonResponse
{
$changed = $this->toggleSimple(
request: $request,
table: 'artwork_likes',
keyColumns: ['user_id', 'artwork_id'],
keyValues: ['user_id' => (int) $request->user()->id, 'artwork_id' => $artworkId],
insertPayload: ['created_at' => now(), 'updated_at' => now()],
requiredTable: 'artwork_likes'
);
$this->syncArtworkStats($artworkId);
if ($request->boolean('state', true) && $changed) {
$creatorId = (int) DB::table('artworks')->where('id', $artworkId)->value('user_id');
$actorId = (int) $request->user()->id;
try {
app(UserActivityService::class)->logLike($actorId, $artworkId);
} catch (\Throwable) {}
if ($creatorId > 0 && $creatorId !== $actorId) {
app(XPService::class)->awardArtworkLikeReceived($creatorId, $artworkId, $actorId);
$creator = \App\Models\User::query()->find($creatorId);
$artwork = Artwork::query()->find($artworkId);
if ($creator && $artwork) {
$creator->notify(new ArtworkLikedNotification($artwork, $request->user()));
}
event(new AchievementCheckRequested($creatorId));
}
}
return response()->json($this->statusPayload((int) $request->user()->id, $artworkId));
}
public function report(Request $request, int $artworkId): JsonResponse
{
if (! Schema::hasTable('artwork_reports')) {
return response()->json(['message' => 'Reporting unavailable'], 422);
}
$data = $request->validate([
'reason' => ['nullable', 'string', 'max:1000'],
]);
DB::table('artwork_reports')->updateOrInsert(
[
'artwork_id' => $artworkId,
'reporter_user_id' => (int) $request->user()->id,
],
[
'reason' => trim((string) ($data['reason'] ?? '')) ?: null,
'reported_at' => now(),
'created_at' => now(),
'updated_at' => now(),
]
);
return response()->json(['ok' => true, 'reported' => true]);
}
public function follow(Request $request, int $userId): JsonResponse
{
$actorId = (int) $request->user()->id;
if ($actorId === $userId) {
return response()->json(['message' => 'Cannot follow yourself'], 422);
}
$svc = app(FollowService::class);
$state = $request->has('state')
? $request->boolean('state')
: ! $request->isMethod('delete');
if ($state) {
$svc->follow($actorId, $userId);
} else {
$svc->unfollow($actorId, $userId);
}
return response()->json([
'ok' => true,
'is_following' => $state,
'followers_count' => $svc->followersCount($userId),
]);
}
/**
* POST /api/artworks/{id}/share record a share event (Phase 2 tracking).
*/
public function share(Request $request, int $artworkId): JsonResponse
{
$data = $request->validate([
'platform' => ['required', 'string', 'in:facebook,twitter,pinterest,email,copy,embed'],
]);
if (Schema::hasTable('artwork_shares')) {
DB::table('artwork_shares')->insert([
'artwork_id' => $artworkId,
'user_id' => $request->user()?->id,
'platform' => $data['platform'],
'created_at' => now(),
]);
}
return response()->json(['ok' => true]);
}
private function toggleSimple(
Request $request,
string $table,
array $keyColumns,
array $keyValues,
array $insertPayload,
string $requiredTable
): bool {
if (! Schema::hasTable($requiredTable)) {
abort(422, 'Interaction unavailable');
}
$state = $request->boolean('state', true);
$query = DB::table($table);
foreach ($keyColumns as $column) {
$query->where($column, $keyValues[$column]);
}
if ($state) {
if (! $query->exists()) {
DB::table($table)->insert(array_merge($keyValues, $insertPayload));
return true;
}
} else {
return $query->delete() > 0;
}
return false;
}
private function syncArtworkStats(int $artworkId): void
{
if (! Schema::hasTable('artwork_stats')) {
return;
}
$favorites = Schema::hasTable('artwork_favourites')
? (int) DB::table('artwork_favourites')->where('artwork_id', $artworkId)->count()
: 0;
$likes = Schema::hasTable('artwork_likes')
? (int) DB::table('artwork_likes')->where('artwork_id', $artworkId)->count()
: 0;
DB::table('artwork_stats')->updateOrInsert(
['artwork_id' => $artworkId],
[
'favorites' => $favorites,
'rating_count' => $likes,
]
);
}
private function statusPayload(int $viewerId, int $artworkId): array
{
$isBookmarked = Schema::hasTable('artwork_bookmarks')
? DB::table('artwork_bookmarks')->where('user_id', $viewerId)->where('artwork_id', $artworkId)->exists()
: false;
$isFavorited = Schema::hasTable('artwork_favourites')
? DB::table('artwork_favourites')->where('user_id', $viewerId)->where('artwork_id', $artworkId)->exists()
: false;
$isLiked = Schema::hasTable('artwork_likes')
? DB::table('artwork_likes')->where('user_id', $viewerId)->where('artwork_id', $artworkId)->exists()
: false;
$favorites = Schema::hasTable('artwork_favourites')
? (int) DB::table('artwork_favourites')->where('artwork_id', $artworkId)->count()
: 0;
$bookmarks = Schema::hasTable('artwork_bookmarks')
? (int) DB::table('artwork_bookmarks')->where('artwork_id', $artworkId)->count()
: 0;
$likes = Schema::hasTable('artwork_likes')
? (int) DB::table('artwork_likes')->where('artwork_id', $artworkId)->count()
: 0;
return [
'ok' => true,
'is_bookmarked' => $isBookmarked,
'is_favorited' => $isFavorited,
'is_liked' => $isLiked,
'stats' => [
'bookmarks' => $bookmarks,
'favorites' => $favorites,
'likes' => $likes,
],
];
}
}

View File

@@ -0,0 +1,79 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Http\Resources\ArtworkResource;
use App\Models\Artwork;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Str;
class ArtworkNavigationController extends Controller
{
/**
* GET /api/artworks/navigation/{id}
*
* Returns prev/next published artworks by the same author.
*/
public function neighbors(int $id): JsonResponse
{
$artwork = Artwork::published()
->select(['id', 'user_id', 'title', 'slug'])
->find($id);
if (! $artwork) {
return response()->json([
'prev_id' => null, 'next_id' => null,
'prev_url' => null, 'next_url' => null,
'prev_slug' => null, 'next_slug' => null,
]);
}
$scope = Artwork::published()
->select(['id', 'title', 'slug'])
->where('user_id', $artwork->user_id);
$prev = (clone $scope)->where('id', '<', $id)->orderByDesc('id')->first();
$next = (clone $scope)->where('id', '>', $id)->orderBy('id')->first();
// Infinite loop: wrap around when reaching the first or last artwork
if (! $prev) {
$prev = (clone $scope)->where('id', '!=', $id)->orderByDesc('id')->first();
}
if (! $next) {
$next = (clone $scope)->where('id', '!=', $id)->orderBy('id')->first();
}
$prevSlug = $prev ? (Str::slug($prev->slug ?: $prev->title) ?: (string) $prev->id) : null;
$nextSlug = $next ? (Str::slug($next->slug ?: $next->title) ?: (string) $next->id) : null;
return response()->json([
'prev_id' => $prev?->id,
'next_id' => $next?->id,
'prev_url' => $prev ? url('/art/' . $prev->id . '/' . $prevSlug) : null,
'next_url' => $next ? url('/art/' . $next->id . '/' . $nextSlug) : null,
'prev_slug' => $prevSlug,
'next_slug' => $nextSlug,
]);
}
/**
* GET /api/artworks/{id}/page
*
* Returns full artwork resource by numeric ID for client-side (no-reload) navigation.
*/
public function pageData(int $id): JsonResponse
{
$artwork = Artwork::with(['user.profile', 'categories.contentType', 'categories.parent.contentType', 'tags', 'stats'])
->published()
->find($id);
if (! $artwork) {
return response()->json(['error' => 'Not found'], 404);
}
$resource = (new ArtworkResource($artwork))->toArray(request());
return response()->json($resource);
}
}

View File

@@ -0,0 +1,165 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Http\Requests\Artworks\ArtworkTagsStoreRequest;
use App\Http\Requests\Artworks\ArtworkTagsUpdateRequest;
use App\Models\Artwork;
use App\Models\Tag;
use App\Services\TagService;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Symfony\Component\HttpFoundation\Response;
use App\Jobs\AutoTagArtworkJob;
final class ArtworkTagController extends Controller
{
public function __construct(
private readonly TagService $tags,
) {
}
public function index(int $id): JsonResponse
{
$artwork = Artwork::query()->findOrFail($id);
$this->authorizeOrNotFound(request()->user(), $artwork);
$queueConnection = (string) config('queue.default', 'sync');
$visionEnabled = (bool) config('vision.enabled', true);
$queuedCount = 0;
$failedCount = 0;
if (in_array($queueConnection, ['database', 'redis'], true)) {
try {
$queuedCount = (int) DB::table('jobs')
->where('payload', 'like', '%AutoTagArtworkJob%')
->where('payload', 'like', '%' . $artwork->id . '%')
->count();
} catch (\Throwable) {
$queuedCount = 0;
}
try {
$failedCount = (int) DB::table('failed_jobs')
->where('payload', 'like', '%AutoTagArtworkJob%')
->where('payload', 'like', '%' . $artwork->id . '%')
->count();
} catch (\Throwable) {
$failedCount = 0;
}
}
$triggered = false;
$shouldTrigger = request()->boolean('trigger', false);
if ($shouldTrigger && $visionEnabled && ! empty($artwork->hash) && $queuedCount === 0) {
AutoTagArtworkJob::dispatch((int) $artwork->id, (string) $artwork->hash);
$triggered = true;
$queuedCount = max(1, $queuedCount);
}
$tags = $artwork->tags()
->select('tags.id', 'tags.name', 'tags.slug')
->withPivot(['source', 'confidence'])
->orderByDesc('artwork_tag.confidence')
->get()
->map(static function ($tag): array {
$source = (string) ($tag->pivot->source ?? 'manual');
return [
'id' => (int) $tag->id,
'name' => (string) $tag->name,
'slug' => (string) $tag->slug,
'source' => $source,
'confidence' => (float) ($tag->pivot->confidence ?? 0),
'is_ai' => $source === 'ai',
];
})
->values();
return response()->json([
'vision_enabled' => $visionEnabled,
'tags' => $tags,
'ai_tags' => $tags->where('is_ai', true)->values(),
'debug' => [
'queue_connection' => $queueConnection,
'queued_jobs' => $queuedCount,
'failed_jobs' => $failedCount,
'triggered' => $triggered,
'ai_tag_count' => (int) $tags->where('is_ai', true)->count(),
'total_tag_count' => (int) $tags->count(),
],
]);
}
public function store(int $id, ArtworkTagsStoreRequest $request): JsonResponse
{
$artwork = Artwork::query()->findOrFail($id);
$this->authorizeOrNotFound($request->user(), $artwork);
try {
$payload = $request->validated();
$this->tags->attachUserTags($artwork, $payload['tags']);
return response()->json(['ok' => true], Response::HTTP_CREATED);
} catch (\Throwable $e) {
$ref = (string) Str::uuid();
logger()->error('Artwork tag attach failed', ['ref' => $ref, 'artwork_id' => $artwork->id, 'user_id' => $request->user()?->id, 'exception' => $e]);
return response()->json([
'message' => 'Unable to update tags right now.',
'ref' => $ref,
], Response::HTTP_UNPROCESSABLE_ENTITY);
}
}
public function update(int $id, ArtworkTagsUpdateRequest $request): JsonResponse
{
$artwork = Artwork::query()->findOrFail($id);
$this->authorizeOrNotFound($request->user(), $artwork);
try {
$payload = $request->validated();
$this->tags->syncTags($artwork, $payload['tags']);
return response()->json(['ok' => true]);
} catch (\Throwable $e) {
$ref = (string) Str::uuid();
logger()->error('Artwork tag sync failed', ['ref' => $ref, 'artwork_id' => $artwork->id, 'user_id' => $request->user()?->id, 'exception' => $e]);
return response()->json([
'message' => 'Unable to update tags right now.',
'ref' => $ref,
], Response::HTTP_UNPROCESSABLE_ENTITY);
}
}
public function destroy(int $id, Tag $tag): JsonResponse
{
$artwork = Artwork::query()->findOrFail($id);
$this->authorizeOrNotFound(request()->user(), $artwork);
try {
$this->tags->detachTags($artwork, [$tag->id]);
return response()->json(['ok' => true]);
} catch (\Throwable $e) {
$ref = (string) Str::uuid();
logger()->error('Artwork tag detach failed', ['ref' => $ref, 'artwork_id' => $artwork->id, 'tag_id' => $tag->id, 'user_id' => request()->user()?->id, 'exception' => $e]);
return response()->json([
'message' => 'Unable to update tags right now.',
'ref' => $ref,
], Response::HTTP_UNPROCESSABLE_ENTITY);
}
}
private function authorizeOrNotFound($user, Artwork $artwork): void
{
if (! $user) {
abort(404);
}
if (! $user->can('updateTags', $artwork)) {
abort(404);
}
}
}

View File

@@ -0,0 +1,76 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\Artwork;
use App\Services\ArtworkStatsService;
use App\Services\XPService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
/**
* POST /api/art/{id}/view
*
* Fire-and-forget view tracker.
*
* Deduplication strategy (layered):
* 1. Session key (`art_viewed.{id}`) prevents double-counts within the
* same browser session (survives page reloads).
* 2. Route throttle (5 per 10 minutes per IP+artwork) catches bots that
* don't send session cookies.
*
* The frontend should additionally guard with sessionStorage so it only
* calls this endpoint once per page load.
*/
final class ArtworkViewController extends Controller
{
public function __construct(
private readonly ArtworkStatsService $stats,
private readonly XPService $xp,
) {}
public function __invoke(Request $request, int $id): JsonResponse
{
$artwork = Artwork::public()
->published()
->where('id', $id)
->first();
if (! $artwork) {
return response()->json(['error' => 'Not found'], 404);
}
$sessionKey = 'art_viewed.' . $id;
// Already counted this session — return early without touching the DB.
if ($request->hasSession() && $request->session()->has($sessionKey)) {
return response()->json(['ok' => true, 'counted' => false]);
}
// Write persistent event log (auth user_id or null for guests).
$this->stats->logViewEvent((int) $artwork->id, $request->user()?->id);
// Defer to Redis when available, fall back to direct DB increment.
$this->stats->incrementViews((int) $artwork->id, 1, defer: true);
$viewerId = $request->user()?->id;
if ($artwork->user_id !== null && (int) $artwork->user_id !== (int) ($viewerId ?? 0)) {
$this->xp->awardArtworkViewReceived(
(int) $artwork->user_id,
(int) $artwork->id,
$viewerId,
(string) $request->ip(),
);
}
// Mark this session so the artwork is not counted again.
if ($request->hasSession()) {
$request->session()->put($sessionKey, true);
}
return response()->json(['ok' => true, 'counted' => true]);
}
}

View File

@@ -0,0 +1,105 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Http\Resources\ArtworkListResource;
use App\Services\ArtworkService;
use Illuminate\Http\Request;
use Illuminate\Database\Eloquent\ModelNotFoundException;
class BrowseController extends Controller
{
protected ArtworkService $service;
public function __construct(ArtworkService $service)
{
$this->service = $service;
}
/**
* GET /api/v1/browse
* Public browse feed powered by authoritative artworks table.
*/
public function index(Request $request)
{
$perPage = $this->resolvePerPage($request);
$sort = (string) $request->get('sort', 'latest');
$paginator = $this->service->browsePublicArtworks($perPage, $sort);
$paginator->appends([
'limit' => $perPage,
'sort' => $sort,
]);
return ArtworkListResource::collection($paginator);
}
/**
* GET /api/v1/browse/{content_type}
* Browse by content type slug.
*/
public function byContentType(Request $request, string $contentTypeSlug)
{
$perPage = $this->resolvePerPage($request);
$sort = (string) $request->get('sort', 'latest');
try {
$paginator = $this->service->getArtworksByContentType($contentTypeSlug, $perPage, $sort);
} catch (ModelNotFoundException $e) {
abort(404);
}
$paginator->appends([
'limit' => $perPage,
'sort' => $sort,
]);
if ($paginator->count() === 0) {
return response()->json(['message' => 'Gone'], 410);
}
return ArtworkListResource::collection($paginator);
}
/**
* GET /api/v1/browse/{content_type}/{category_path}
* Browse by content type + category path (slug segments).
*/
public function byCategoryPath(Request $request, string $contentTypeSlug, string $categoryPath)
{
$perPage = $this->resolvePerPage($request);
$sort = (string) $request->get('sort', 'latest');
$slugs = array_merge([
strtolower($contentTypeSlug),
], array_values(array_filter(explode('/', trim($categoryPath, '/')))));
try {
$paginator = $this->service->getArtworksByCategoryPath($slugs, $perPage, $sort);
} catch (ModelNotFoundException $e) {
abort(404);
}
$paginator->appends([
'limit' => $perPage,
'sort' => $sort,
]);
if ($paginator->count() === 0) {
return response()->json(['message' => 'Gone'], 410);
}
return ArtworkListResource::collection($paginator);
}
private function resolvePerPage(Request $request): int
{
$limit = (int) $request->query('limit', 0);
$perPage = (int) $request->query('per_page', 0);
$value = $limit > 0 ? $limit : ($perPage > 0 ? $perPage : 24);
return min(max($value, 1), 100);
}
}

View File

@@ -0,0 +1,49 @@
<?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->filled('type') && ! $request->filled('filter')) {
return (string) $request->query('type', 'all');
}
if ($request->boolean('following') && ! $request->filled('filter')) {
return 'following';
}
return (string) $request->query('filter', 'all');
}
}

View File

@@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Jobs\IngestUserDiscoveryEventJob;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
use Symfony\Component\HttpFoundation\Response;
final class DiscoveryEventController extends Controller
{
public function store(Request $request): JsonResponse
{
$payload = $request->validate([
'event_id' => ['nullable', 'uuid'],
'event_type' => ['required', 'string', 'in:view,click,favorite,download,dwell,scroll'],
'artwork_id' => ['required', 'integer', 'exists:artworks,id'],
'occurred_at' => ['nullable', 'date'],
'algo_version' => ['nullable', 'string', 'max:64'],
'meta' => ['nullable', 'array'],
]);
$eventId = (string) ($payload['event_id'] ?? (string) Str::uuid());
$algoVersion = (string) ($payload['algo_version'] ?? config('discovery.algo_version', 'clip-cosine-v1'));
$occurredAt = isset($payload['occurred_at'])
? (string) $payload['occurred_at']
: now()->toIso8601String();
IngestUserDiscoveryEventJob::dispatch(
eventId: $eventId,
userId: (int) $request->user()->id,
artworkId: (int) $payload['artwork_id'],
eventType: (string) $payload['event_type'],
algoVersion: $algoVersion,
occurredAt: $occurredAt,
meta: (array) ($payload['meta'] ?? [])
)->onQueue((string) config('discovery.queue', 'default'));
return response()->json([
'queued' => true,
'event_id' => $eventId,
'algo_version' => $algoVersion,
], Response::HTTP_ACCEPTED);
}
}

View File

@@ -0,0 +1,206 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\Tag;
use App\Models\UserNegativeSignal;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Str;
use Symfony\Component\HttpFoundation\Response;
final class DiscoveryNegativeSignalController extends Controller
{
public function hideArtwork(Request $request): JsonResponse
{
$payload = $request->validate([
'artwork_id' => ['required', 'integer', 'exists:artworks,id'],
'algo_version' => ['nullable', 'string', 'max:64'],
'source' => ['nullable', 'string', 'max:64'],
'meta' => ['nullable', 'array'],
]);
$signal = UserNegativeSignal::query()->updateOrCreate(
[
'user_id' => (int) $request->user()->id,
'signal_type' => 'hide_artwork',
'artwork_id' => (int) $payload['artwork_id'],
],
[
'tag_id' => null,
'algo_version' => $payload['algo_version'] ?? null,
'source' => $payload['source'] ?? 'api',
'meta' => (array) ($payload['meta'] ?? []),
]
);
$this->recordFeedbackEvent(
userId: (int) $request->user()->id,
artworkId: (int) $payload['artwork_id'],
eventType: 'hide_artwork',
algoVersion: isset($payload['algo_version']) ? (string) $payload['algo_version'] : null,
meta: (array) ($payload['meta'] ?? [])
);
return response()->json([
'stored' => true,
'signal_id' => (int) $signal->id,
'signal_type' => 'hide_artwork',
], Response::HTTP_ACCEPTED);
}
public function dislikeTag(Request $request): JsonResponse
{
$payload = $request->validate([
'tag_id' => ['nullable', 'integer', 'exists:tags,id'],
'tag_slug' => ['nullable', 'string', 'max:191'],
'artwork_id' => ['nullable', 'integer', 'exists:artworks,id'],
'algo_version' => ['nullable', 'string', 'max:64'],
'source' => ['nullable', 'string', 'max:64'],
'meta' => ['nullable', 'array'],
]);
$tagId = isset($payload['tag_id']) ? (int) $payload['tag_id'] : null;
if ($tagId === null && ! empty($payload['tag_slug'])) {
$tagId = Tag::query()->where('slug', (string) $payload['tag_slug'])->value('id');
}
abort_if($tagId === null || $tagId <= 0, Response::HTTP_UNPROCESSABLE_ENTITY, 'A valid tag is required.');
$signal = UserNegativeSignal::query()->updateOrCreate(
[
'user_id' => (int) $request->user()->id,
'signal_type' => 'dislike_tag',
'tag_id' => $tagId,
],
[
'artwork_id' => null,
'algo_version' => $payload['algo_version'] ?? null,
'source' => $payload['source'] ?? 'api',
'meta' => (array) ($payload['meta'] ?? []),
]
);
$this->recordFeedbackEvent(
userId: (int) $request->user()->id,
artworkId: isset($payload['artwork_id']) ? (int) $payload['artwork_id'] : (int) (($payload['meta']['artwork_id'] ?? 0)),
eventType: 'dislike_tag',
algoVersion: isset($payload['algo_version']) ? (string) $payload['algo_version'] : null,
meta: array_merge((array) ($payload['meta'] ?? []), ['tag_id' => $tagId])
);
return response()->json([
'stored' => true,
'signal_id' => (int) $signal->id,
'signal_type' => 'dislike_tag',
'tag_id' => $tagId,
], Response::HTTP_ACCEPTED);
}
public function unhideArtwork(Request $request): JsonResponse
{
$payload = $request->validate([
'artwork_id' => ['required', 'integer', 'exists:artworks,id'],
'algo_version' => ['nullable', 'string', 'max:64'],
'meta' => ['nullable', 'array'],
]);
$deleted = UserNegativeSignal::query()
->where('user_id', (int) $request->user()->id)
->where('signal_type', 'hide_artwork')
->where('artwork_id', (int) $payload['artwork_id'])
->delete();
if ($deleted > 0) {
$this->recordFeedbackEvent(
userId: (int) $request->user()->id,
artworkId: (int) $payload['artwork_id'],
eventType: 'unhide_artwork',
algoVersion: isset($payload['algo_version']) ? (string) $payload['algo_version'] : null,
meta: (array) ($payload['meta'] ?? [])
);
}
return response()->json([
'revoked' => $deleted > 0,
'signal_type' => 'hide_artwork',
'artwork_id' => (int) $payload['artwork_id'],
], Response::HTTP_OK);
}
public function undislikeTag(Request $request): JsonResponse
{
$payload = $request->validate([
'tag_id' => ['nullable', 'integer', 'exists:tags,id'],
'tag_slug' => ['nullable', 'string', 'max:191'],
'artwork_id' => ['nullable', 'integer', 'exists:artworks,id'],
'algo_version' => ['nullable', 'string', 'max:64'],
'meta' => ['nullable', 'array'],
]);
$tagId = isset($payload['tag_id']) ? (int) $payload['tag_id'] : null;
if ($tagId === null && ! empty($payload['tag_slug'])) {
$tagId = Tag::query()->where('slug', (string) $payload['tag_slug'])->value('id');
}
abort_if($tagId === null || $tagId <= 0, Response::HTTP_UNPROCESSABLE_ENTITY, 'A valid tag is required.');
$deleted = UserNegativeSignal::query()
->where('user_id', (int) $request->user()->id)
->where('signal_type', 'dislike_tag')
->where('tag_id', $tagId)
->delete();
if ($deleted > 0) {
$this->recordFeedbackEvent(
userId: (int) $request->user()->id,
artworkId: isset($payload['artwork_id']) ? (int) $payload['artwork_id'] : (int) (($payload['meta']['artwork_id'] ?? 0)),
eventType: 'undo_dislike_tag',
algoVersion: isset($payload['algo_version']) ? (string) $payload['algo_version'] : null,
meta: array_merge((array) ($payload['meta'] ?? []), ['tag_id' => $tagId])
);
}
return response()->json([
'revoked' => $deleted > 0,
'signal_type' => 'dislike_tag',
'tag_id' => $tagId,
], Response::HTTP_OK);
}
/**
* @param array<string, mixed> $meta
*/
private function recordFeedbackEvent(int $userId, int $artworkId, string $eventType, ?string $algoVersion = null, array $meta = []): void
{
if ($artworkId <= 0 || ! Schema::hasTable('user_discovery_events')) {
return;
}
$categoryId = DB::table('artwork_category')
->where('artwork_id', $artworkId)
->orderBy('category_id')
->value('category_id');
DB::table('user_discovery_events')->insert([
'event_id' => (string) Str::uuid(),
'user_id' => $userId,
'artwork_id' => $artworkId,
'category_id' => $categoryId !== null ? (int) $categoryId : null,
'event_type' => $eventType,
'event_version' => (string) config('discovery.event_version', 'event-v1'),
'algo_version' => (string) ($algoVersion ?: config('discovery.v2.algo_version', config('discovery.algo_version', 'clip-cosine-v1'))),
'weight' => 0.0,
'event_date' => now()->toDateString(),
'occurred_at' => now()->toDateTimeString(),
'meta' => json_encode($meta, JSON_THROW_ON_ERROR),
'created_at' => now(),
'updated_at' => now(),
]);
}
}

View File

@@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Symfony\Component\HttpFoundation\Response;
final class FeedAnalyticsController extends Controller
{
public function store(Request $request): JsonResponse
{
$payload = $request->validate([
'event_type' => ['required', 'string', 'in:feed_impression,feed_click'],
'artwork_id' => ['required', 'integer', 'exists:artworks,id'],
'position' => ['nullable', 'integer', 'min:1', 'max:500'],
'algo_version' => ['required', 'string', 'max:64'],
'source' => ['required', 'string', 'in:personalized,cold_start,fallback'],
'dwell_seconds' => ['nullable', 'integer', 'min:0', 'max:86400'],
'occurred_at' => ['nullable', 'date'],
]);
$occurredAt = isset($payload['occurred_at']) ? now()->parse((string) $payload['occurred_at']) : now();
DB::table('feed_events')->insert([
'event_date' => $occurredAt->toDateString(),
'event_type' => (string) $payload['event_type'],
'user_id' => (int) $request->user()->id,
'artwork_id' => (int) $payload['artwork_id'],
'position' => isset($payload['position']) ? (int) $payload['position'] : null,
'algo_version' => (string) $payload['algo_version'],
'source' => (string) $payload['source'],
'dwell_seconds' => isset($payload['dwell_seconds']) ? (int) $payload['dwell_seconds'] : null,
'occurred_at' => $occurredAt,
'created_at' => now(),
'updated_at' => now(),
]);
return response()->json(['success' => true], Response::HTTP_OK);
}
}

View File

@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Services\Recommendations\RecommendationFeedResolver;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
final class FeedController extends Controller
{
public function __construct(private readonly RecommendationFeedResolver $feedResolver)
{
}
public function index(Request $request): JsonResponse
{
$payload = $request->validate([
'limit' => ['nullable', 'integer', 'min:1', 'max:50'],
'cursor' => ['nullable', 'string', 'max:512'],
'algo_version' => ['nullable', 'string', 'max:64'],
]);
$result = $this->feedResolver->getFeed(
userId: (int) $request->user()->id,
limit: isset($payload['limit']) ? (int) $payload['limit'] : 24,
cursor: isset($payload['cursor']) ? (string) $payload['cursor'] : null,
algoVersion: isset($payload['algo_version']) ? (string) $payload['algo_version'] : null
);
return response()->json($result);
}
}

View File

@@ -0,0 +1,141 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\User;
use App\Services\FollowService;
use App\Support\AvatarUrl;
use App\Support\UsernamePolicy;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
/**
* API endpoints for the follow system.
*
* POST /api/user/{username}/follow follow a user
* DELETE /api/user/{username}/follow unfollow a user
* GET /api/user/{username}/followers paginated followers list
* GET /api/user/{username}/following paginated following list
*/
final class FollowController extends Controller
{
public function __construct(private readonly FollowService $followService) {}
// ─── POST /api/user/{username}/follow ────────────────────────────────────
public function follow(Request $request, string $username): JsonResponse
{
$target = $this->resolveUser($username);
$actor = Auth::user();
if ($actor->id === $target->id) {
return response()->json(['error' => 'Cannot follow yourself.'], 422);
}
try {
$this->followService->follow((int) $actor->id, (int) $target->id);
} catch (\InvalidArgumentException $e) {
return response()->json(['error' => $e->getMessage()], 422);
}
return response()->json([
'following' => true,
'followers_count' => $this->followService->followersCount((int) $target->id),
'following_count' => $this->followService->followingCount((int) $actor->id),
'context' => $this->followService->relationshipContext((int) $actor->id, (int) $target->id),
]);
}
// ─── DELETE /api/user/{username}/follow ──────────────────────────────────
public function unfollow(Request $request, string $username): JsonResponse
{
$target = $this->resolveUser($username);
$actor = Auth::user();
$this->followService->unfollow((int) $actor->id, (int) $target->id);
return response()->json([
'following' => false,
'followers_count' => $this->followService->followersCount((int) $target->id),
'following_count' => $this->followService->followingCount((int) $actor->id),
'context' => $this->followService->relationshipContext((int) $actor->id, (int) $target->id),
]);
}
// ─── GET /api/user/{username}/followers ──────────────────────────────────
public function followers(Request $request, string $username): JsonResponse
{
$target = $this->resolveUser($username);
$perPage = min((int) $request->query('per_page', 24), 100);
$rows = DB::table('user_followers as uf')
->join('users as u', 'u.id', '=', 'uf.follower_id')
->leftJoin('user_profiles as up', 'up.user_id', '=', 'u.id')
->where('uf.user_id', $target->id)
->whereNull('u.deleted_at')
->orderByDesc('uf.created_at')
->select([
'u.id', 'u.username', 'u.name',
'up.avatar_hash',
'uf.created_at as followed_at',
])
->paginate($perPage)
->through(fn ($row) => [
'id' => $row->id,
'username' => $row->username,
'display_name'=> $row->username ?? $row->name,
'avatar_url' => AvatarUrl::forUser((int) $row->id, $row->avatar_hash, 50),
'profile_url' => '/@' . strtolower((string) ($row->username ?? $row->id)),
'followed_at' => $row->followed_at,
]);
return response()->json($rows);
}
// ─── GET /api/user/{username}/following ──────────────────────────────────
public function following(Request $request, string $username): JsonResponse
{
$target = $this->resolveUser($username);
$perPage = min((int) $request->query('per_page', 24), 100);
$rows = DB::table('user_followers as uf')
->join('users as u', 'u.id', '=', 'uf.user_id')
->leftJoin('user_profiles as up', 'up.user_id', '=', 'u.id')
->where('uf.follower_id', $target->id)
->whereNull('u.deleted_at')
->orderByDesc('uf.created_at')
->select([
'u.id', 'u.username', 'u.name',
'up.avatar_hash',
'uf.created_at as followed_at',
])
->paginate($perPage)
->through(fn ($row) => [
'id' => $row->id,
'username' => $row->username,
'display_name'=> $row->username ?? $row->name,
'avatar_url' => AvatarUrl::forUser((int) $row->id, $row->avatar_hash, 50),
'profile_url' => '/@' . strtolower((string) ($row->username ?? $row->id)),
'followed_at' => $row->followed_at,
]);
return response()->json($rows);
}
// ─── Private helpers ─────────────────────────────────────────────────────
private function resolveUser(string $username): User
{
$normalized = UsernamePolicy::normalize($username);
return User::query()
->whereRaw('LOWER(username) = ?', [$normalized])
->firstOrFail();
}
}

View File

@@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Services\Vision\VectorService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use RuntimeException;
final class ImageSearchController extends Controller
{
public function __construct(private readonly VectorService $vectors)
{
}
public function __invoke(Request $request): JsonResponse
{
$payload = $request->validate([
'image' => ['required', 'file', 'image', 'max:10240'],
'limit' => ['nullable', 'integer', 'min:1', 'max:24'],
]);
if (! $this->vectors->isConfigured()) {
return response()->json([
'data' => [],
'reason' => 'vector_gateway_not_configured',
], 503);
}
$limit = (int) ($payload['limit'] ?? 12);
try {
$items = $this->vectors->searchByUploadedImage($payload['image'], $limit);
} catch (RuntimeException $e) {
return response()->json([
'data' => [],
'reason' => 'vector_gateway_error',
'message' => $e->getMessage(),
], 502);
}
return response()->json([
'data' => $items,
'meta' => [
'source' => 'vector_gateway',
'limit' => $limit,
],
]);
}
}

View File

@@ -0,0 +1,113 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Illuminate\Http\JsonResponse;
use App\Models\ArtworkComment;
use App\Support\AvatarUrl;
use App\Services\ThumbnailPresenter;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Str;
use Carbon\Carbon;
class LatestCommentsApiController extends Controller
{
private const PER_PAGE = 20;
public function index(Request $request): JsonResponse
{
$type = $request->query('type', 'all');
// Validate filter type
if (! in_array($type, ['all', 'following', 'mine'], true)) {
$type = 'all';
}
// 'mine' and 'following' require auth
if (in_array($type, ['mine', 'following'], true) && ! $request->user()) {
return response()->json(['error' => 'Unauthenticated'], 401);
}
$query = ArtworkComment::with(['user', 'user.profile', 'artwork'])
->whereHas('artwork', function ($q) {
$q->public()->published()->whereNull('deleted_at');
})
->orderByDesc('artwork_comments.created_at');
switch ($type) {
case 'mine':
$query->where('artwork_comments.user_id', $request->user()->id);
break;
case 'following':
$followingIds = $request->user()
->following()
->pluck('users.id');
$query->whereIn('artwork_comments.user_id', $followingIds);
break;
default:
// 'all' — cache the first page only
if ((int) $request->query('page', 1) === 1) {
$cacheKey = 'comments.latest.all.page1';
$ttl = 120; // 2 minutes
$paginator = Cache::remember($cacheKey, $ttl, fn () => $query->paginate(self::PER_PAGE));
} else {
$paginator = $query->paginate(self::PER_PAGE);
}
break;
}
if (! isset($paginator)) {
$paginator = $query->paginate(self::PER_PAGE);
}
$items = $paginator->getCollection()->map(function (ArtworkComment $c) {
$art = $c->artwork;
$user = $c->user;
$present = $art ? ThumbnailPresenter::present($art, 'md') : null;
$thumb = $present ? ($present['url'] ?? null) : null;
$userId = (int) ($c->user_id ?? 0);
$avatarHash = $user?->profile?->avatar_hash ?? null;
return [
'comment_id' => $c->getKey(),
'comment_text' => e(strip_tags($c->content ?? '')),
'created_at' => $c->created_at?->toIso8601String(),
'time_ago' => $c->created_at ? Carbon::parse($c->created_at)->diffForHumans() : null,
'commenter' => [
'id' => $userId,
'username' => $user?->username ?? null,
'display' => $user?->username ?? $user?->name ?? 'User',
'profile_url' => $user?->username ? '/@' . $user->username : '/profile/' . $userId,
'avatar_url' => AvatarUrl::forUser($userId, $avatarHash, 64),
],
'artwork' => $art ? [
'id' => $art->id,
'title' => $art->title,
'slug' => $art->slug ?? Str::slug($art->title ?? ''),
'url' => '/art/' . $art->id . '/' . ($art->slug ?? Str::slug($art->title ?? '')),
'thumb' => $thumb,
] : null,
];
});
return response()->json([
'data' => $items,
'meta' => [
'current_page' => $paginator->currentPage(),
'last_page' => $paginator->lastPage(),
'per_page' => $paginator->perPage(),
'total' => $paginator->total(),
'has_more' => $paginator->hasMorePages(),
],
]);
}
}

View File

@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\Leaderboard;
use App\Services\LeaderboardService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
final class LeaderboardController extends Controller
{
public function creators(Request $request, LeaderboardService $leaderboards): JsonResponse
{
return response()->json(
$leaderboards->getLeaderboard(Leaderboard::TYPE_CREATOR, (string) $request->query('period', 'weekly'))
);
}
public function artworks(Request $request, LeaderboardService $leaderboards): JsonResponse
{
return response()->json(
$leaderboards->getLeaderboard(Leaderboard::TYPE_ARTWORK, (string) $request->query('period', 'weekly'))
);
}
public function groups(Request $request, LeaderboardService $leaderboards): JsonResponse
{
return response()->json(
$leaderboards->getLeaderboard(Leaderboard::TYPE_GROUP, (string) $request->query('period', 'weekly'))
);
}
public function stories(Request $request, LeaderboardService $leaderboards): JsonResponse
{
return response()->json(
$leaderboards->getLeaderboard(Leaderboard::TYPE_STORY, (string) $request->query('period', 'weekly'))
);
}
}

View File

@@ -0,0 +1,223 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use GuzzleHttp\Client;
use GuzzleHttp\Exception\TransferException;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class LinkPreviewController extends Controller
{
private const TIMEOUT = 8; // seconds
private const MAX_BYTES = 524_288; // 512 KB enough to get the <head>
private const USER_AGENT = 'Skinbase-LinkPreview/1.0 (+https://skinbase.org)';
/** Blocked IP ranges (SSRF protection). */
private const BLOCKED_CIDRS = [
'0.0.0.0/8',
'10.0.0.0/8',
'100.64.0.0/10',
'127.0.0.0/8',
'169.254.0.0/16',
'172.16.0.0/12',
'192.0.0.0/24',
'192.168.0.0/16',
'198.18.0.0/15',
'198.51.100.0/24',
'203.0.113.0/24',
'240.0.0.0/4',
'::1/128',
'fc00::/7',
'fe80::/10',
];
public function __invoke(Request $request): JsonResponse
{
$request->validate([
'url' => ['required', 'string', 'max:2048'],
]);
$rawUrl = trim((string) $request->input('url'));
// Must be http(s)
if (! preg_match('#^https?://#i', $rawUrl)) {
return response()->json(['error' => 'Invalid URL scheme.'], 422);
}
$parsed = parse_url($rawUrl);
$host = $parsed['host'] ?? '';
if (empty($host)) {
return response()->json(['error' => 'Invalid URL.'], 422);
}
// Resolve hostname and block private/loopback IPs (SSRF protection)
$resolved = gethostbyname($host);
if ($this->isBlockedIp($resolved)) {
return response()->json(['error' => 'URL not allowed.'], 422);
}
try {
$client = new Client([
'timeout' => self::TIMEOUT,
'connect_timeout' => 4,
'allow_redirects' => ['max' => 5, 'strict' => false],
'headers' => [
'User-Agent' => self::USER_AGENT,
'Accept' => 'text/html,application/xhtml+xml',
],
'verify' => true,
]);
$response = $client->get($rawUrl);
$status = $response->getStatusCode();
if ($status < 200 || $status >= 400) {
return response()->json(['error' => 'Could not fetch URL.'], 422);
}
// Read up to MAX_BYTES we only need the HTML <head>
$body = '';
$stream = $response->getBody();
while (! $stream->eof() && strlen($body) < self::MAX_BYTES) {
$body .= $stream->read(4096);
}
$stream->close();
} catch (TransferException $e) {
return response()->json(['error' => 'Could not reach URL.'], 422);
}
$preview = $this->extractMeta($body, $rawUrl);
return response()->json($preview);
}
/** Extract OG / Twitter / fallback meta tags. */
private function extractMeta(string $html, string $originalUrl): array
{
// Limit to roughly the <head> block for speed
$head = substr($html, 0, 50_000);
$og = [];
// OG / Twitter meta tags
preg_match_all(
'/<meta\s[^>]*(?:property|name)\s*=\s*["\']([^"\']+)["\'][^>]*content\s*=\s*["\']([^"\']*)["\'][^>]*>/i',
$head,
$m1,
PREG_SET_ORDER,
);
preg_match_all(
'/<meta\s[^>]*content\s*=\s*["\']([^"\']*)["\'][^>]*(?:property|name)\s*=\s*["\']([^"\']+)["\'][^>]*>/i',
$head,
$m2,
PREG_SET_ORDER,
);
$allMeta = array_merge(
array_map(fn ($r) => ['key' => strtolower($r[1]), 'value' => $r[2]], $m1),
array_map(fn ($r) => ['key' => strtolower($r[2]), 'value' => $r[1]], $m2),
);
$map = [];
foreach ($allMeta as $entry) {
$map[$entry['key']] ??= $entry['value'];
}
// Canonical URL
$canonical = $originalUrl;
if (preg_match('/<link[^>]+rel\s*=\s*["\']canonical["\'][^>]+href\s*=\s*["\']([^"\']+)["\'][^>]*>/i', $head, $mc)) {
$canonical = $mc[1];
} elseif (preg_match('/<link[^>]+href\s*=\s*["\']([^"\']+)["\'][^>]+rel\s*=\s*["\']canonical["\'][^>]*>/i', $head, $mc)) {
$canonical = $mc[1];
}
// Title
$title = $map['og:title']
?? $map['twitter:title']
?? null;
if (! $title && preg_match('/<title[^>]*>([^<]+)<\/title>/i', $head, $mt)) {
$title = trim(html_entity_decode($mt[1]));
}
// Description
$description = $map['og:description']
?? $map['twitter:description']
?? $map['description']
?? null;
// Image
$image = $map['og:image']
?? $map['twitter:image']
?? $map['twitter:image:src']
?? null;
// Resolve relative image URL
if ($image && ! preg_match('#^https?://#i', $image)) {
$parsed = parse_url($originalUrl);
$base = ($parsed['scheme'] ?? 'https') . '://' . ($parsed['host'] ?? '');
$image = $base . '/' . ltrim($image, '/');
}
// Site name
$siteName = $map['og:site_name'] ?? parse_url($originalUrl, PHP_URL_HOST) ?? null;
return [
'url' => $canonical,
'title' => $title ? html_entity_decode($title) : null,
'description' => $description ? html_entity_decode($description) : null,
'image' => $image,
'site_name' => $siteName,
];
}
private function isBlockedIp(string $ip): bool
{
if (! filter_var($ip, FILTER_VALIDATE_IP)) {
return true; // could not resolve
}
foreach (self::BLOCKED_CIDRS as $cidr) {
if ($this->ipInCidr($ip, $cidr)) {
return true;
}
}
return false;
}
private function ipInCidr(string $ip, string $cidr): bool
{
[$subnet, $bits] = explode('/', $cidr) + [1 => 32];
// IPv6
if (str_contains($cidr, ':')) {
if (! filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)) {
return false;
}
$ipBin = inet_pton($ip);
$subnetBin = inet_pton($subnet);
if ($ipBin === false || $subnetBin === false) {
return false;
}
$bits = (int) $bits;
$mask = str_repeat("\xff", (int) ($bits / 8));
$remain = $bits % 8;
if ($remain) {
$mask .= chr(0xff << (8 - $remain));
}
$mask = str_pad($mask, strlen($subnetBin), "\x00");
return ($ipBin & $mask) === ($subnetBin & $mask);
}
// IPv4
if (! filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
return false;
}
$ipLong = ip2long($ip);
$subnetLong = ip2long($subnet);
$maskLong = $bits == 32 ? -1 : ~((1 << (32 - (int) $bits)) - 1);
return ($ipLong & $maskLong) === ($subnetLong & $maskLong);
}
}

View File

@@ -0,0 +1,41 @@
<?php
namespace App\Http\Controllers\Api\Messaging;
use App\Http\Controllers\Controller;
use App\Models\MessageAttachment;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
use Symfony\Component\HttpFoundation\StreamedResponse;
class AttachmentController extends Controller
{
public function show(Request $request, int $id)
{
$attachment = MessageAttachment::query()
->with('message:id,conversation_id')
->findOrFail($id);
$conversationId = (int) ($attachment->message?->conversation_id ?? 0);
abort_if($conversationId <= 0, 404, 'Attachment not available.');
$authorized = \App\Models\ConversationParticipant::query()
->where('conversation_id', $conversationId)
->where('user_id', $request->user()->id)
->whereNull('left_at')
->exists();
abort_unless($authorized, 403, 'You are not allowed to access this attachment.');
$diskName = (string) config('messaging.attachments.disk', 'local');
$disk = Storage::disk($diskName);
return new StreamedResponse(function () use ($disk, $attachment): void {
echo $disk->get($attachment->storage_path);
}, 200, [
'Content-Type' => $attachment->mime,
'Content-Disposition' => 'inline; filename="' . addslashes($attachment->original_name) . '"',
'Content-Length' => (string) $attachment->size_bytes,
]);
}
}

View File

@@ -0,0 +1,468 @@
<?php
namespace App\Http\Controllers\Api\Messaging;
use App\Events\ConversationUpdated;
use App\Http\Controllers\Controller;
use App\Http\Requests\Messaging\ManageConversationParticipantRequest;
use App\Http\Requests\Messaging\RenameConversationRequest;
use App\Http\Requests\Messaging\StoreConversationRequest;
use App\Models\Conversation;
use App\Models\ConversationParticipant;
use App\Models\User;
use App\Services\Messaging\ConversationReadService;
use App\Services\Messaging\ConversationStateService;
use App\Services\Messaging\SendMessageAction;
use App\Services\Messaging\UnreadCounterService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
class ConversationController extends Controller
{
public function __construct(
private readonly ConversationStateService $conversationState,
private readonly ConversationReadService $conversationReads,
private readonly SendMessageAction $sendMessage,
private readonly UnreadCounterService $unreadCounters,
) {}
// ── GET /api/messages/conversations ─────────────────────────────────────
public function index(Request $request): JsonResponse
{
$user = $request->user();
$page = max(1, (int) $request->integer('page', 1));
$cacheVersion = (int) Cache::get($this->cacheVersionKey($user->id), 1);
$cacheKey = $this->conversationListCacheKey($user->id, $page, $cacheVersion);
$conversations = Cache::remember($cacheKey, now()->addSeconds(20), function () use ($user, $page) {
$query = Conversation::query()
->select('conversations.*')
->join('conversation_participants as cp_me', function ($join) use ($user) {
$join->on('cp_me.conversation_id', '=', 'conversations.id')
->where('cp_me.user_id', '=', $user->id)
->whereNull('cp_me.left_at');
})
->where('conversations.is_active', true)
->with([
'allParticipants' => fn ($q) => $q->whereNull('left_at')->with(['user:id,username']),
'latestMessage.sender:id,username',
])
->orderByDesc('cp_me.is_pinned')
->orderByDesc('cp_me.pinned_at')
->orderByDesc('last_message_at')
->orderByDesc('conversations.id');
$this->unreadCounters->applyUnreadCountSelect($query, $user, 'cp_me');
return $query->paginate(20, ['conversations.*'], 'page', $page);
});
$conversations->through(function ($conv) use ($user) {
$conv->my_participant = $conv->allParticipants
->firstWhere('user_id', $user->id);
return $conv;
});
return response()->json([
...$conversations->toArray(),
'summary' => [
'unread_total' => $this->unreadCounters->totalUnreadForUser($user),
],
]);
}
// ── GET /api/messages/conversation/{id} ─────────────────────────────────
public function show(Request $request, int $id): JsonResponse
{
$conv = $this->findAuthorized($request, $id);
$conv->load([
'allParticipants.user:id,username',
'creator:id,username',
]);
return response()->json($conv);
}
// ── POST /api/messages/conversation ─────────────────────────────────────
public function store(StoreConversationRequest $request): JsonResponse
{
$user = $request->user();
$data = $request->validated();
if ($data['type'] === 'direct') {
return $this->createDirect($request, $user, $data);
}
return $this->createGroup($request, $user, $data);
}
// ── POST /api/messages/{conversation_id}/read ────────────────────────────
public function markRead(Request $request, int $id): JsonResponse
{
$conversation = $this->findAuthorized($request, $id);
$participant = $this->conversationReads->markConversationRead(
$conversation,
$request->user(),
$request->integer('message_id') ?: null,
);
return response()->json([
'ok' => true,
'last_read_at' => optional($participant->last_read_at)?->toIso8601String(),
'last_read_message_id' => $participant->last_read_message_id,
'unread_total' => $this->unreadCounters->totalUnreadForUser($request->user()),
]);
}
// ── POST /api/messages/{conversation_id}/archive ─────────────────────────
public function archive(Request $request, int $id): JsonResponse
{
$conversation = $this->findAuthorized($request, $id);
$participant = $this->participantRecord($request, $id);
$participant->update(['is_archived' => ! $participant->is_archived]);
$this->broadcastConversationUpdate($conversation, 'conversation.archived');
return response()->json(['is_archived' => $participant->is_archived]);
}
// ── POST /api/messages/{conversation_id}/mute ────────────────────────────
public function mute(Request $request, int $id): JsonResponse
{
$conversation = $this->findAuthorized($request, $id);
$participant = $this->participantRecord($request, $id);
$participant->update(['is_muted' => ! $participant->is_muted]);
$this->broadcastConversationUpdate($conversation, 'conversation.muted');
return response()->json(['is_muted' => $participant->is_muted]);
}
public function pin(Request $request, int $id): JsonResponse
{
$conversation = $this->findAuthorized($request, $id);
$participant = $this->participantRecord($request, $id);
$participant->update(['is_pinned' => true, 'pinned_at' => now()]);
$this->broadcastConversationUpdate($conversation, 'conversation.pinned');
return response()->json(['is_pinned' => true]);
}
public function unpin(Request $request, int $id): JsonResponse
{
$conversation = $this->findAuthorized($request, $id);
$participant = $this->participantRecord($request, $id);
$participant->update(['is_pinned' => false, 'pinned_at' => null]);
$this->broadcastConversationUpdate($conversation, 'conversation.unpinned');
return response()->json(['is_pinned' => false]);
}
// ── DELETE /api/messages/{conversation_id}/leave ─────────────────────────
public function leave(Request $request, int $id): JsonResponse
{
$conv = $this->findAuthorized($request, $id);
$participant = $this->participantRecord($request, $id);
$participantUserIds = ConversationParticipant::where('conversation_id', $id)
->whereNull('left_at')
->pluck('user_id')
->all();
if ($conv->isGroup()) {
// Last admin protection
$adminCount = ConversationParticipant::where('conversation_id', $id)
->where('role', 'admin')
->whereNull('left_at')
->count();
if ($adminCount === 1 && $participant->role === 'admin') {
$otherMember = ConversationParticipant::where('conversation_id', $id)
->where('user_id', '!=', $request->user()->id)
->whereNull('left_at')
->first();
if ($otherMember) {
$otherMember->update(['role' => 'admin']);
}
}
}
$participant->update(['left_at' => now()]);
$this->conversationState->touchConversationCachesForUsers($participantUserIds);
$this->broadcastConversationUpdate($conv, 'conversation.left', $participantUserIds);
return response()->json(['ok' => true]);
}
// ── POST /api/messages/{conversation_id}/add-user ────────────────────────
public function addUser(ManageConversationParticipantRequest $request, int $id): JsonResponse
{
$conv = $this->findAuthorized($request, $id);
$this->requireAdmin($request, $id);
$participantUserIds = ConversationParticipant::where('conversation_id', $id)
->whereNull('left_at')
->pluck('user_id')
->all();
$data = $request->validated();
$existing = ConversationParticipant::where('conversation_id', $id)
->where('user_id', $data['user_id'])
->first();
if ($existing) {
if ($existing->left_at) {
$existing->update(['left_at' => null, 'joined_at' => now()]);
}
} else {
ConversationParticipant::create([
'conversation_id' => $id,
'user_id' => $data['user_id'],
'role' => 'member',
'joined_at' => now(),
]);
}
$participantUserIds[] = (int) $data['user_id'];
$this->conversationState->touchConversationCachesForUsers($participantUserIds);
$this->broadcastConversationUpdate($conv, 'conversation.participant_added', $participantUserIds);
return response()->json(['ok' => true]);
}
// ── DELETE /api/messages/{conversation_id}/remove-user ───────────────────
public function removeUser(ManageConversationParticipantRequest $request, int $id): JsonResponse
{
$this->requireAdmin($request, $id);
$data = $request->validated();
// Cannot remove the conversation creator
$conv = Conversation::findOrFail($id);
abort_if($conv->created_by === (int) $data['user_id'], 403, 'Cannot remove the conversation creator.');
$targetParticipant = ConversationParticipant::where('conversation_id', $id)
->where('user_id', $data['user_id'])
->whereNull('left_at')
->first();
if ($targetParticipant && $targetParticipant->role === 'admin') {
$adminCount = ConversationParticipant::where('conversation_id', $id)
->where('role', 'admin')
->whereNull('left_at')
->count();
abort_if($adminCount <= 1, 422, 'Cannot remove the last admin from this conversation.');
}
$participantUserIds = ConversationParticipant::where('conversation_id', $id)
->whereNull('left_at')
->pluck('user_id')
->all();
ConversationParticipant::where('conversation_id', $id)
->where('user_id', $data['user_id'])
->whereNull('left_at')
->update(['left_at' => now()]);
$this->conversationState->touchConversationCachesForUsers($participantUserIds);
$this->broadcastConversationUpdate($conv, 'conversation.participant_removed', $participantUserIds);
return response()->json(['ok' => true]);
}
// ── POST /api/messages/{conversation_id}/rename ──────────────────────────
public function rename(RenameConversationRequest $request, int $id): JsonResponse
{
$conv = $this->findAuthorized($request, $id);
abort_unless($conv->isGroup(), 422, 'Only group conversations can be renamed.');
$this->requireAdmin($request, $id);
$data = $request->validated();
$conv->update(['title' => $data['title']]);
$participantUserIds = ConversationParticipant::where('conversation_id', $id)
->whereNull('left_at')
->pluck('user_id')
->all();
$this->conversationState->touchConversationCachesForUsers($participantUserIds);
$this->broadcastConversationUpdate($conv, 'conversation.renamed', $participantUserIds);
return response()->json(['title' => $conv->title]);
}
// ── Private helpers ──────────────────────────────────────────────────────
private function createDirect(Request $request, User $user, array $data): JsonResponse
{
$recipient = User::findOrFail($data['recipient_id']);
abort_if($recipient->id === $user->id, 422, 'You cannot message yourself.');
if (! $recipient->allowsMessagesFrom($user)) {
abort(403, 'This user does not accept messages from you.');
}
$this->assertNotBlockedBetween($user, $recipient);
// Reuse existing conversation if one exists
$conv = Conversation::findDirect($user->id, $recipient->id);
if (! $conv) {
$conv = DB::transaction(function () use ($user, $recipient) {
$conv = Conversation::create([
'uuid' => (string) \Illuminate\Support\Str::uuid(),
'type' => 'direct',
'created_by' => $user->id,
'is_active' => true,
]);
ConversationParticipant::insert([
['conversation_id' => $conv->id, 'user_id' => $user->id, 'role' => 'admin', 'joined_at' => now()],
['conversation_id' => $conv->id, 'user_id' => $recipient->id, 'role' => 'member', 'joined_at' => now()],
]);
return $conv;
});
}
$this->sendMessage->execute($conv, $user, [
'body' => $data['body'],
'client_temp_id' => $data['client_temp_id'] ?? null,
]);
return response()->json($conv->fresh()->load('allParticipants.user:id,username'), 201);
}
private function createGroup(Request $request, User $user, array $data): JsonResponse
{
$participantIds = array_unique(array_merge([$user->id], $data['participant_ids']));
$conv = DB::transaction(function () use ($user, $data, $participantIds) {
$conv = Conversation::create([
'uuid' => (string) \Illuminate\Support\Str::uuid(),
'type' => 'group',
'title' => $data['title'],
'created_by' => $user->id,
'is_active' => true,
]);
$rows = array_map(fn ($uid) => [
'conversation_id' => $conv->id,
'user_id' => $uid,
'role' => $uid === $user->id ? 'admin' : 'member',
'joined_at' => now(),
], $participantIds);
ConversationParticipant::insert($rows);
return $conv;
});
$this->sendMessage->execute($conv, $user, [
'body' => $data['body'],
'client_temp_id' => $data['client_temp_id'] ?? null,
]);
return response()->json($conv->fresh()->load('allParticipants.user:id,username'), 201);
}
private function findAuthorized(Request $request, int $id): Conversation
{
$conv = Conversation::findOrFail($id);
$this->authorize('view', $conv);
return $conv;
}
private function participantRecord(Request $request, int $conversationId): ConversationParticipant
{
return ConversationParticipant::where('conversation_id', $conversationId)
->where('user_id', $request->user()->id)
->whereNull('left_at')
->firstOrFail();
}
private function assertParticipant(Request $request, int $id): void
{
abort_unless(
ConversationParticipant::where('conversation_id', $id)
->where('user_id', $request->user()->id)
->whereNull('left_at')
->exists(),
403,
'You are not a participant of this conversation.'
);
}
private function requireAdmin(Request $request, int $id): void
{
$conversation = Conversation::findOrFail($id);
$this->authorize('manageParticipants', $conversation);
}
private function touchConversationCachesForUsers(array $userIds): void
{
$this->conversationState->touchConversationCachesForUsers($userIds);
}
private function cacheVersionKey(int $userId): string
{
return "messages:conversations:version:{$userId}";
}
private function conversationListCacheKey(int $userId, int $page, int $version): string
{
return "messages:conversations:user:{$userId}:page:{$page}:v:{$version}";
}
private function broadcastConversationUpdate(Conversation $conversation, string $reason, ?array $participantIds = null): void
{
$participantIds ??= $this->conversationState->activeParticipantIds($conversation);
$this->conversationState->touchConversationCachesForUsers($participantIds);
foreach ($participantIds as $participantId) {
event(new ConversationUpdated((int) $participantId, $conversation, $reason));
}
}
private function assertNotBlockedBetween(User $sender, User $recipient): void
{
if (! Schema::hasTable('user_blocks')) {
return;
}
$blocked = false;
if (Schema::hasColumns('user_blocks', ['user_id', 'blocked_user_id'])) {
$blocked = DB::table('user_blocks')
->where(function ($q) use ($sender, $recipient) {
$q->where('user_id', $sender->id)->where('blocked_user_id', $recipient->id);
})
->orWhere(function ($q) use ($sender, $recipient) {
$q->where('user_id', $recipient->id)->where('blocked_user_id', $sender->id);
})
->exists();
} elseif (Schema::hasColumns('user_blocks', ['blocker_id', 'blocked_id'])) {
$blocked = DB::table('user_blocks')
->where(function ($q) use ($sender, $recipient) {
$q->where('blocker_id', $sender->id)->where('blocked_id', $recipient->id);
})
->orWhere(function ($q) use ($sender, $recipient) {
$q->where('blocker_id', $recipient->id)->where('blocked_id', $sender->id);
})
->exists();
}
abort_if($blocked, 403, 'Messaging is not available between these users.');
}
}

View File

@@ -0,0 +1,314 @@
<?php
namespace App\Http\Controllers\Api\Messaging;
use App\Events\ConversationUpdated;
use App\Events\MessageDeleted;
use App\Events\MessageUpdated;
use App\Http\Controllers\Controller;
use App\Http\Requests\Messaging\StoreMessageRequest;
use App\Http\Requests\Messaging\ToggleMessageReactionRequest;
use App\Http\Requests\Messaging\UpdateMessageRequest;
use App\Models\Conversation;
use App\Models\ConversationParticipant;
use App\Models\Message;
use App\Models\MessageReaction;
use App\Services\Messaging\ConversationDeltaService;
use App\Services\Messaging\ConversationStateService;
use App\Services\Messaging\MessagingPayloadFactory;
use App\Services\Messaging\MessageSearchIndexer;
use App\Services\Messaging\SendMessageAction;
use App\Services\Messaging\UnreadCounterService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
class MessageController extends Controller
{
private const PAGE_SIZE = 30;
public function __construct(
private readonly ConversationDeltaService $conversationDelta,
private readonly ConversationStateService $conversationState,
private readonly MessagingPayloadFactory $payloadFactory,
private readonly SendMessageAction $sendMessage,
private readonly UnreadCounterService $unreadCounters,
) {}
// ── GET /api/messages/{conversation_id} ──────────────────────────────────
public function index(Request $request, int $conversationId): JsonResponse
{
$conversation = $this->findConversationOrFail($conversationId);
$cursor = $request->integer('cursor') ?: $request->integer('before_id');
$afterId = $request->integer('after_id');
if ($afterId) {
$messages = $this->conversationDelta->messagesAfter($conversation, $request->user(), $afterId);
return response()->json([
'data' => $messages,
'next_cursor' => null,
]);
}
$query = Message::withTrashed()
->where('conversation_id', $conversationId)
->with(['sender:id,username', 'reactions', 'attachments'])
->orderByDesc('created_at')
->orderByDesc('id');
if ($cursor) {
$query->where('id', '<', $cursor);
}
$chunk = $query->limit(self::PAGE_SIZE + 1)->get();
$hasMore = $chunk->count() > self::PAGE_SIZE;
$messages = $chunk->take(self::PAGE_SIZE)->reverse()->values();
$nextCursor = $hasMore && $messages->isNotEmpty() ? (int) $messages->first()->id : null;
return response()->json([
'data' => $messages->map(fn (Message $message) => $this->payloadFactory->message($message, (int) $request->user()->id))->values(),
'next_cursor' => $nextCursor,
]);
}
public function delta(Request $request, int $conversationId): JsonResponse
{
$conversation = $this->findConversationOrFail($conversationId);
$afterMessageId = max(0, (int) $request->integer('after_message_id'));
abort_if($afterMessageId < 1, 422, 'after_message_id is required.');
return response()->json([
'data' => $this->conversationDelta->messagesAfter($conversation, $request->user(), $afterMessageId),
'conversation' => $this->payloadFactory->conversationSummary($conversation->fresh(), (int) $request->user()->id),
'summary' => [
'unread_total' => $this->unreadCounters->totalUnreadForUser($request->user()),
],
]);
}
// ── POST /api/messages/{conversation_id} ─────────────────────────────────
public function store(StoreMessageRequest $request, int $conversationId): JsonResponse
{
$conversation = $this->findConversationOrFail($conversationId);
$data = $request->validated();
$data['attachments'] = $request->file('attachments', []);
$body = trim((string) ($data['body'] ?? ''));
abort_if($body === '' && empty($data['attachments']), 422, 'Message body or attachment is required.');
$message = $this->sendMessage->execute($conversation, $request->user(), $data);
return response()->json($this->payloadFactory->message($message, (int) $request->user()->id), 201);
}
// ── POST /api/messages/{conversation_id}/react ───────────────────────────
public function react(ToggleMessageReactionRequest $request, int $conversationId, int $messageId): JsonResponse
{
$this->findConversationOrFail($conversationId);
$data = $request->validated();
$this->assertAllowedReaction($data['reaction']);
$existing = MessageReaction::where([
'message_id' => $messageId,
'user_id' => $request->user()->id,
'reaction' => $data['reaction'],
])->first();
if ($existing) {
$existing->delete();
} else {
MessageReaction::create([
'message_id' => $messageId,
'user_id' => $request->user()->id,
'reaction' => $data['reaction'],
]);
}
return response()->json($this->reactionSummary($messageId, (int) $request->user()->id));
}
// ── DELETE /api/messages/{conversation_id}/react ─────────────────────────
public function unreact(ToggleMessageReactionRequest $request, int $conversationId, int $messageId): JsonResponse
{
$this->findConversationOrFail($conversationId);
$data = $request->validated();
$this->assertAllowedReaction($data['reaction']);
MessageReaction::where([
'message_id' => $messageId,
'user_id' => $request->user()->id,
'reaction' => $data['reaction'],
])->delete();
return response()->json($this->reactionSummary($messageId, (int) $request->user()->id));
}
public function reactByMessage(ToggleMessageReactionRequest $request, int $messageId): JsonResponse
{
$message = Message::query()->findOrFail($messageId);
$this->findConversationOrFail((int) $message->conversation_id);
$data = $request->validated();
$this->assertAllowedReaction($data['reaction']);
$existing = MessageReaction::where([
'message_id' => $messageId,
'user_id' => $request->user()->id,
'reaction' => $data['reaction'],
])->first();
if ($existing) {
$existing->delete();
} else {
MessageReaction::create([
'message_id' => $messageId,
'user_id' => $request->user()->id,
'reaction' => $data['reaction'],
]);
}
return response()->json($this->reactionSummary($messageId, (int) $request->user()->id));
}
public function unreactByMessage(ToggleMessageReactionRequest $request, int $messageId): JsonResponse
{
$message = Message::query()->findOrFail($messageId);
$this->findConversationOrFail((int) $message->conversation_id);
$data = $request->validated();
$this->assertAllowedReaction($data['reaction']);
MessageReaction::where([
'message_id' => $messageId,
'user_id' => $request->user()->id,
'reaction' => $data['reaction'],
])->delete();
return response()->json($this->reactionSummary($messageId, (int) $request->user()->id));
}
// ── PATCH /api/messages/message/{messageId} ───────────────────────────────
public function update(UpdateMessageRequest $request, int $messageId): JsonResponse
{
$message = Message::findOrFail($messageId);
$this->authorize('update', $message);
abort_if($message->deleted_at !== null, 422, 'Cannot edit a deleted message.');
$data = $request->validated();
$message->update([
'body' => $data['body'],
'edited_at' => now(),
]);
app(MessageSearchIndexer::class)->updateMessage($message);
$participantUserIds = $this->conversationState->activeParticipantIds((int) $message->conversation_id);
$this->conversationState->touchConversationCachesForUsers($participantUserIds);
DB::afterCommit(function () use ($message, $participantUserIds): void {
event(new MessageUpdated($message->fresh(['sender:id,username,name', 'attachments', 'reactions'])));
$conversation = Conversation::find($message->conversation_id);
if ($conversation) {
foreach ($participantUserIds as $participantId) {
event(new ConversationUpdated((int) $participantId, $conversation, 'message.updated'));
}
}
});
return response()->json($this->payloadFactory->message($message->fresh(['sender:id,username,name', 'attachments', 'reactions']), (int) $request->user()->id));
}
// ── DELETE /api/messages/message/{messageId} ──────────────────────────────
public function destroy(Request $request, int $messageId): JsonResponse
{
$message = Message::findOrFail($messageId);
$this->authorize('delete', $message);
$participantUserIds = $this->conversationState->activeParticipantIds((int) $message->conversation_id);
app(MessageSearchIndexer::class)->deleteMessage($message);
$message->delete();
$this->conversationState->touchConversationCachesForUsers($participantUserIds);
DB::afterCommit(function () use ($message, $participantUserIds): void {
$message->refresh();
event(new MessageDeleted($message));
$conversation = Conversation::find($message->conversation_id);
if ($conversation) {
foreach ($participantUserIds as $participantId) {
event(new ConversationUpdated((int) $participantId, $conversation, 'message.deleted'));
}
}
});
return response()->json(['ok' => true]);
}
// ── Private helpers ──────────────────────────────────────────────────────
private function assertParticipant(Request $request, int $conversationId): void
{
abort_unless(
ConversationParticipant::where('conversation_id', $conversationId)
->where('user_id', $request->user()->id)
->whereNull('left_at')
->exists(),
403,
'You are not a participant of this conversation.'
);
}
private function touchConversationCachesForUsers(array $userIds): void
{
$this->conversationState->touchConversationCachesForUsers($userIds);
}
private function assertAllowedReaction(string $reaction): void
{
$allowed = (array) config('messaging.reactions.allowed', []);
abort_unless(in_array($reaction, $allowed, true), 422, 'Reaction is not allowed.');
}
private function reactionSummary(int $messageId, int $userId): array
{
$rows = MessageReaction::query()
->selectRaw('reaction, count(*) as aggregate_count')
->where('message_id', $messageId)
->groupBy('reaction')
->get();
$summary = [];
foreach ($rows as $row) {
$summary[(string) $row->reaction] = (int) $row->aggregate_count;
}
$mine = MessageReaction::query()
->where('message_id', $messageId)
->where('user_id', $userId)
->pluck('reaction')
->values()
->all();
$summary['me'] = $mine;
return $summary;
}
private function findConversationOrFail(int $conversationId): Conversation
{
$conversation = Conversation::query()->findOrFail($conversationId);
$this->authorize('view', $conversation);
return $conversation;
}
}

View File

@@ -0,0 +1,150 @@
<?php
namespace App\Http\Controllers\Api\Messaging;
use App\Http\Controllers\Controller;
use App\Models\ConversationParticipant;
use App\Models\Message;
use App\Services\Messaging\MessageSearchIndexer;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Meilisearch\Client;
class MessageSearchController extends Controller
{
public function __construct(
private readonly MessageSearchIndexer $indexer,
) {}
public function index(Request $request): JsonResponse
{
$user = $request->user();
$data = $request->validate([
'q' => 'required|string|min:1|max:200',
'conversation_id' => 'nullable|integer|exists:conversations,id',
'cursor' => 'nullable|integer|min:0',
]);
$allowedConversationIds = ConversationParticipant::query()
->where('user_id', $user->id)
->whereNull('left_at')
->pluck('conversation_id')
->map(fn ($id) => (int) $id)
->all();
$conversationId = isset($data['conversation_id']) ? (int) $data['conversation_id'] : null;
if ($conversationId !== null && ! in_array($conversationId, $allowedConversationIds, true)) {
abort(403, 'You are not a participant of this conversation.');
}
if (empty($allowedConversationIds)) {
return response()->json(['data' => [], 'next_cursor' => null]);
}
$limit = max(1, (int) config('messaging.search.page_size', 20));
$offset = max(0, (int) ($data['cursor'] ?? 0));
$hits = collect();
$estimated = 0;
try {
$client = new Client(
config('scout.meilisearch.host'),
config('scout.meilisearch.key')
);
$prefix = (string) config('scout.prefix', '');
$indexName = $prefix . (string) config('messaging.search.index', 'messages');
$conversationFilter = $conversationId !== null
? "conversation_id = {$conversationId}"
: 'conversation_id IN [' . implode(',', $allowedConversationIds) . ']';
$result = $client
->index($indexName)
->search((string) $data['q'], [
'limit' => $limit,
'offset' => $offset,
'sort' => ['created_at:desc'],
'filter' => $conversationFilter,
]);
$hits = collect($result->getHits() ?? []);
$estimated = (int) ($result->getEstimatedTotalHits() ?? $hits->count());
if ($hits->isEmpty()) {
[$hits, $estimated] = $this->fallbackHits($allowedConversationIds, $conversationId, (string) $data['q'], $offset, $limit);
}
} catch (\Throwable) {
[$hits, $estimated] = $this->fallbackHits($allowedConversationIds, $conversationId, (string) $data['q'], $offset, $limit);
}
$messageIds = $hits->pluck('id')->map(fn ($id) => (int) $id)->all();
$messages = Message::query()
->whereIn('id', $messageIds)
->whereIn('conversation_id', $allowedConversationIds)
->whereNull('deleted_at')
->with(['sender:id,username', 'attachments'])
->get()
->keyBy('id');
$ordered = $hits
->map(function (array $hit) use ($messages) {
$message = $messages->get((int) ($hit['id'] ?? 0));
if (! $message) {
return null;
}
return [
'id' => $message->id,
'conversation_id' => $message->conversation_id,
'sender_id' => $message->sender_id,
'sender' => $message->sender,
'body' => $message->body,
'created_at' => optional($message->created_at)?->toISOString(),
'has_attachments' => $message->attachments->isNotEmpty(),
];
})
->filter()
->values();
$nextCursor = ($offset + $limit) < $estimated ? ($offset + $limit) : null;
return response()->json([
'data' => $ordered,
'next_cursor' => $nextCursor,
]);
}
private function fallbackHits(array $allowedConversationIds, ?int $conversationId, string $queryString, int $offset, int $limit): array
{
$query = Message::query()
->select('id')
->whereNull('deleted_at')
->whereIn('conversation_id', $allowedConversationIds)
->when($conversationId !== null, fn ($builder) => $builder->where('conversation_id', $conversationId))
->where('body', 'like', '%' . $queryString . '%')
->orderByDesc('created_at')
->orderByDesc('id');
$estimated = (clone $query)->count();
$hits = $query->offset($offset)->limit($limit)->get()->map(fn ($row) => ['id' => (int) $row->id]);
return [$hits, $estimated];
}
public function rebuild(Request $request): JsonResponse
{
abort_unless($request->user()?->isAdmin(), 403, 'Admin access required.');
$conversationId = $request->integer('conversation_id');
if ($conversationId > 0) {
$this->indexer->rebuildConversation($conversationId);
return response()->json(['queued' => true, 'scope' => 'conversation']);
}
$this->indexer->rebuildAll();
return response()->json(['queued' => true, 'scope' => 'all']);
}
}

View File

@@ -0,0 +1,42 @@
<?php
namespace App\Http\Controllers\Api\Messaging;
use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
/**
* Manages per-user messaging privacy preference.
*
* GET /api/messages/settings return current setting
* PATCH /api/messages/settings update setting
*/
class MessagingSettingsController extends Controller
{
public function show(Request $request): JsonResponse
{
$realtimeReady = (bool) config('messaging.realtime', false)
&& config('broadcasting.default') === 'reverb'
&& filled(config('broadcasting.connections.reverb.key'));
return response()->json([
'allow_messages_from' => $request->user()->allow_messages_from ?? 'everyone',
'realtime_enabled' => $realtimeReady,
]);
}
public function update(Request $request): JsonResponse
{
$data = $request->validate([
'allow_messages_from' => 'required|in:everyone,followers,mutual_followers,nobody',
]);
$request->user()->update($data);
return response()->json([
'allow_messages_from' => $request->user()->allow_messages_from,
'realtime_enabled' => (bool) config('messaging.realtime', false),
]);
}
}

View File

@@ -0,0 +1,33 @@
<?php
namespace App\Http\Controllers\Api\Messaging;
use App\Http\Controllers\Controller;
use App\Models\Conversation;
use App\Services\Messaging\MessagingPresenceService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class PresenceController extends Controller
{
public function __construct(
private readonly MessagingPresenceService $presence,
) {}
public function heartbeat(Request $request): JsonResponse
{
$conversationId = $request->integer('conversation_id') ?: null;
if ($conversationId) {
$conversation = Conversation::query()->findOrFail($conversationId);
$this->authorize('view', $conversation);
}
$this->presence->touch($request->user(), $conversationId);
return response()->json([
'ok' => true,
'conversation_id' => $conversationId,
]);
}
}

View File

@@ -0,0 +1,105 @@
<?php
namespace App\Http\Controllers\Api\Messaging;
use App\Events\TypingStarted;
use App\Events\TypingStopped;
use App\Http\Controllers\Controller;
use App\Models\Conversation;
use App\Models\ConversationParticipant;
use Illuminate\Cache\Repository;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
class TypingController extends Controller
{
public function start(Request $request, int $conversationId): JsonResponse
{
$this->findConversationOrFail($conversationId);
$ttl = max(5, (int) config('messaging.typing.ttl_seconds', 8));
$this->store()->put($this->key($conversationId, (int) $request->user()->id), 1, now()->addSeconds($ttl));
if ((bool) config('messaging.realtime', false)) {
event(new TypingStarted($conversationId, $request->user()));
}
return response()->json(['ok' => true]);
}
public function stop(Request $request, int $conversationId): JsonResponse
{
$this->findConversationOrFail($conversationId);
$this->store()->forget($this->key($conversationId, (int) $request->user()->id));
if ((bool) config('messaging.realtime', false)) {
event(new TypingStopped($conversationId, $request->user()));
}
return response()->json(['ok' => true]);
}
public function index(Request $request, int $conversationId): JsonResponse
{
$this->findConversationOrFail($conversationId);
$userId = (int) $request->user()->id;
$participants = ConversationParticipant::query()
->where('conversation_id', $conversationId)
->whereNull('left_at')
->where('user_id', '!=', $userId)
->with('user:id,username')
->get();
$typing = $participants
->filter(fn ($p) => $this->store()->has($this->key($conversationId, (int) $p->user_id)))
->map(fn ($p) => [
'user_id' => (int) $p->user_id,
'username' => (string) ($p->user->username ?? ''),
])
->values();
return response()->json(['typing' => $typing]);
}
private function assertParticipant(Request $request, int $conversationId): void
{
abort_unless(
ConversationParticipant::query()
->where('conversation_id', $conversationId)
->where('user_id', $request->user()->id)
->whereNull('left_at')
->exists(),
403,
'You are not a participant of this conversation.'
);
}
private function key(int $conversationId, int $userId): string
{
return "typing:{$conversationId}:{$userId}";
}
private function store(): Repository
{
$store = (string) config('messaging.typing.cache_store', 'redis');
if ($store === 'redis' && ! class_exists('Redis')) {
return Cache::store();
}
try {
return Cache::store($store);
} catch (\Throwable) {
return Cache::store();
}
}
private function findConversationOrFail(int $conversationId): Conversation
{
$conversation = Conversation::query()->findOrFail($conversationId);
$this->authorize('view', $conversation);
return $conversation;
}
}

View File

@@ -0,0 +1,37 @@
<?php
namespace App\Http\Controllers\Api;
use App\Services\NotificationService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use App\Http\Controllers\Controller;
/**
* GET /api/notifications digestd notification list
* POST /api/notifications/read-all mark all unread as read
* POST /api/notifications/{id}/read mark single as read
*/
class NotificationController extends Controller
{
public function __construct(private NotificationService $notifications) {}
public function index(Request $request): JsonResponse
{
return response()->json(
$this->notifications->listForUser($request->user(), (int) $request->query('page', 1), 20)
);
}
public function readAll(Request $request): JsonResponse
{
$this->notifications->markAllRead($request->user());
return response()->json(['message' => 'All notifications marked as read.']);
}
public function markRead(Request $request, string $id): JsonResponse
{
$this->notifications->markRead($request->user(), $id);
return response()->json(['message' => 'Notification marked as read.']);
}
}

View File

@@ -0,0 +1,72 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api\NovaCards;
use App\Http\Controllers\Controller;
use App\Models\NovaCard;
use App\Services\NovaCards\NovaCardAiAssistService;
use App\Services\NovaCards\NovaCardPresenter;
use App\Services\NovaCards\NovaCardRelatedCardsService;
use App\Services\NovaCards\NovaCardRisingService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class NovaCardDiscoveryController extends Controller
{
public function __construct(
private readonly NovaCardPresenter $presenter,
private readonly NovaCardRisingService $rising,
private readonly NovaCardRelatedCardsService $related,
private readonly NovaCardAiAssistService $aiAssist,
) {
}
/**
* GET /api/cards/rising
* Returns recently published cards gaining traction fast.
*/
public function rising(Request $request): JsonResponse
{
$limit = min((int) $request->query('limit', 18), 36);
$cards = $this->rising->risingCards($limit);
return response()->json([
'data' => $this->presenter->cards($cards->all(), false, $request->user()),
]);
}
/**
* GET /api/cards/{id}/related
* Returns related cards for a given card.
*/
public function related(Request $request, int $id): JsonResponse
{
$card = NovaCard::query()->publiclyVisible()->findOrFail($id);
$limit = min((int) $request->query('limit', 8), 16);
$relatedCards = $this->related->related($card, $limit);
return response()->json([
'data' => $this->presenter->cards($relatedCards->all(), false, $request->user()),
]);
}
/**
* GET /api/cards/{id}/ai-suggest
* Returns AI-assist suggestions for the given draft card.
* The creator must own the card.
*/
public function suggest(Request $request, int $id): JsonResponse
{
$card = NovaCard::query()
->where('user_id', $request->user()->id)
->findOrFail($id);
$suggestions = $this->aiAssist->allSuggestions($card);
return response()->json([
'data' => $suggestions,
]);
}
}

View File

@@ -0,0 +1,189 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api\NovaCards;
use App\Events\NovaCards\NovaCardBackgroundUploaded;
use App\Events\NovaCards\NovaCardPublished;
use App\Http\Controllers\Controller;
use App\Http\Requests\NovaCards\SaveNovaCardDraftRequest;
use App\Http\Requests\NovaCards\StoreNovaCardDraftRequest;
use App\Http\Requests\NovaCards\UploadNovaCardBackgroundRequest;
use App\Models\NovaCard;
use App\Services\NovaCards\NovaCardBackgroundService;
use App\Services\NovaCards\NovaCardDraftService;
use App\Services\NovaCards\NovaCardPresenter;
use App\Services\NovaCards\NovaCardPublishService;
use App\Services\NovaCards\NovaCardRenderService;
use Carbon\Carbon;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class NovaCardDraftController extends Controller
{
public function __construct(
private readonly NovaCardDraftService $drafts,
private readonly NovaCardBackgroundService $backgrounds,
private readonly NovaCardRenderService $renders,
private readonly NovaCardPublishService $publishes,
private readonly NovaCardPresenter $presenter,
) {
}
public function store(StoreNovaCardDraftRequest $request): JsonResponse
{
$card = $this->drafts->createDraft($request->user(), $request->validated());
return response()->json([
'data' => $this->presenter->card($card, true, $request->user()),
], Response::HTTP_CREATED);
}
public function show(Request $request, int $id): JsonResponse
{
$card = $this->editableCard($request, $id);
return response()->json([
'data' => $this->presenter->card($card, true, $request->user()),
]);
}
public function update(SaveNovaCardDraftRequest $request, int $id): JsonResponse
{
$card = $this->editableCard($request, $id);
$card = $this->drafts->autosave($card, $request->validated());
return response()->json([
'data' => $this->presenter->card($card, true, $request->user()),
]);
}
public function autosave(SaveNovaCardDraftRequest $request, int $id): JsonResponse
{
$card = $this->editableCard($request, $id);
$card = $this->drafts->autosave($card, $request->validated());
return response()->json([
'data' => $this->presenter->card($card, true, $request->user()),
'meta' => [
'saved_at' => now()->toISOString(),
],
]);
}
public function background(UploadNovaCardBackgroundRequest $request, int $id): JsonResponse
{
$card = $this->editableCard($request, $id);
$background = $this->backgrounds->storeUploadedBackground($request->user(), $request->file('background'));
$card = $this->drafts->autosave($card, [
'background_type' => 'upload',
'background_image_id' => $background->id,
'project_json' => [
'background' => [
'type' => 'upload',
'background_image_id' => $background->id,
],
],
]);
event(new NovaCardBackgroundUploaded($card, $background));
return response()->json([
'data' => $this->presenter->card($card, true, $request->user()),
'background' => [
'id' => (int) $background->id,
'processed_url' => $background->processedUrl(),
'width' => (int) $background->width,
'height' => (int) $background->height,
],
], Response::HTTP_CREATED);
}
public function render(Request $request, int $id): JsonResponse
{
$card = $this->editableCard($request, $id);
$result = $this->renders->render($card->loadMissing('backgroundImage'));
return response()->json([
'data' => $this->presenter->card($card->fresh()->loadMissing(['user.profile', 'category', 'template', 'backgroundImage', 'tags']), true, $request->user()),
'render' => $result,
]);
}
public function publish(SaveNovaCardDraftRequest $request, int $id): JsonResponse
{
$card = $this->editableCard($request, $id);
$validated = $request->validated();
if ($validated !== []) {
$card = $this->drafts->autosave($card, $validated);
}
if (trim((string) $card->title) === '' || trim((string) $card->quote_text) === '') {
return response()->json([
'message' => 'Title and quote text are required before publishing.',
], Response::HTTP_UNPROCESSABLE_ENTITY);
}
$publishMode = (string) ($validated['publish_mode'] ?? 'now');
if ($publishMode === 'schedule') {
if (empty($validated['scheduled_for'])) {
return response()->json([
'message' => 'Choose a date and time for scheduled publishing.',
], Response::HTTP_UNPROCESSABLE_ENTITY);
}
try {
$card = $this->publishes->schedule(
$card->loadMissing('backgroundImage'),
Carbon::parse((string) $validated['scheduled_for']),
isset($validated['scheduling_timezone']) ? (string) $validated['scheduling_timezone'] : null,
);
} catch (\InvalidArgumentException $exception) {
return response()->json([
'message' => $exception->getMessage(),
], Response::HTTP_UNPROCESSABLE_ENTITY);
}
return response()->json([
'data' => $this->presenter->card($card, true, $request->user()),
]);
}
$card = $this->publishes->publishNow($card->loadMissing('backgroundImage'));
event(new NovaCardPublished($card));
return response()->json([
'data' => $this->presenter->card($card, true, $request->user()),
]);
}
public function destroy(Request $request, int $id): JsonResponse
{
$card = $this->editableCard($request, $id);
if ($card->status === NovaCard::STATUS_PUBLISHED && in_array($card->visibility, [NovaCard::VISIBILITY_PUBLIC, NovaCard::VISIBILITY_UNLISTED], true)) {
return response()->json([
'message' => 'Published cards cannot be deleted from the draft API.',
], Response::HTTP_UNPROCESSABLE_ENTITY);
}
$card->delete();
return response()->json([
'ok' => true,
]);
}
private function editableCard(Request $request, int $id): NovaCard
{
return NovaCard::query()
->with(['user.profile', 'category', 'template', 'backgroundImage', 'tags'])
->where('user_id', $request->user()->id)
->findOrFail($id);
}
}

View File

@@ -0,0 +1,61 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api\NovaCards;
use App\Jobs\UpdateNovaCardStatsJob;
use App\Events\NovaCards\NovaCardDownloaded;
use App\Events\NovaCards\NovaCardShared;
use App\Http\Controllers\Controller;
use App\Models\NovaCard;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class NovaCardEngagementController extends Controller
{
public function share(Request $request, int $id): JsonResponse
{
$card = $this->card($request, $id);
$card->increment('shares_count');
$card->refresh();
UpdateNovaCardStatsJob::dispatch($card->id);
event(new NovaCardShared($card, $request->user()?->id));
return response()->json([
'ok' => true,
'shares_count' => (int) $card->shares_count,
]);
}
public function download(Request $request, int $id): JsonResponse
{
$card = $this->card($request, $id);
abort_unless($card->allow_download && $card->previewUrl() !== null, 404);
$card->increment('downloads_count');
$card->refresh();
UpdateNovaCardStatsJob::dispatch($card->id);
event(new NovaCardDownloaded($card, $request->user()?->id));
return response()->json([
'ok' => true,
'downloads_count' => (int) $card->downloads_count,
'download_url' => $card->previewUrl(),
]);
}
private function card(Request $request, int $id): NovaCard
{
$card = NovaCard::query()
->with(['user.profile', 'category', 'template', 'backgroundImage', 'tags'])
->published()
->findOrFail($id);
abort_unless($card->canBeViewedBy($request->user()), 404);
return $card;
}
}

View File

@@ -0,0 +1,73 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api\NovaCards;
use App\Http\Controllers\Controller;
use App\Models\NovaCard;
use App\Models\NovaCardExport;
use App\Services\NovaCards\NovaCardExportService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class NovaCardExportController extends Controller
{
public function __construct(
private readonly NovaCardExportService $exports,
) {
}
/**
* Request an export for the given card.
*
* POST /api/cards/{id}/export
*/
public function store(Request $request, int $id): JsonResponse
{
$card = NovaCard::query()
->where(function ($q) use ($request): void {
// Owner can export any status; others can only export published cards.
$q->where('user_id', $request->user()->id)
->orWhere(function ($inner) use ($request): void {
$inner->where('status', NovaCard::STATUS_PUBLISHED)
->where('visibility', NovaCard::VISIBILITY_PUBLIC)
->where('allow_export', true);
});
})
->findOrFail($id);
$data = $request->validate([
'export_type' => ['required', 'string', 'in:' . implode(',', array_keys(NovaCardExportService::EXPORT_SPECS))],
'options' => ['sometimes', 'array'],
]);
$export = $this->exports->requestExport(
$request->user(),
$card,
$data['export_type'],
(array) ($data['options'] ?? []),
);
return response()->json([
'data' => $this->exports->getStatus($export),
], $export->wasRecentlyCreated ? Response::HTTP_ACCEPTED : Response::HTTP_OK);
}
/**
* Poll export status.
*
* GET /api/cards/exports/{exportId}
*/
public function show(Request $request, int $exportId): JsonResponse
{
$export = NovaCardExport::query()
->where('user_id', $request->user()->id)
->findOrFail($exportId);
return response()->json([
'data' => $this->exports->getStatus($export),
]);
}
}

View File

@@ -0,0 +1,336 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api\NovaCards;
use App\Jobs\UpdateNovaCardStatsJob;
use App\Http\Controllers\Controller;
use App\Models\NovaCard;
use App\Models\NovaCardChallenge;
use App\Models\NovaCardReaction;
use App\Models\NovaCardVersion;
use App\Services\NovaCards\NovaCardChallengeService;
use App\Services\NovaCards\NovaCardCollectionService;
use App\Services\NovaCards\NovaCardDraftService;
use App\Services\NovaCards\NovaCardLineageService;
use App\Services\NovaCards\NovaCardPresenter;
use App\Services\NovaCards\NovaCardReactionService;
use App\Services\NovaCards\NovaCardVersionService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class NovaCardInteractionController extends Controller
{
public function __construct(
private readonly NovaCardReactionService $reactions,
private readonly NovaCardCollectionService $collections,
private readonly NovaCardDraftService $drafts,
private readonly NovaCardVersionService $versions,
private readonly NovaCardChallengeService $challenges,
private readonly NovaCardLineageService $lineage,
private readonly NovaCardPresenter $presenter,
) {
}
public function lineage(Request $request, int $id): JsonResponse
{
$card = $this->visibleCard($request, $id);
return response()->json([
'data' => $this->lineage->resolve($card, $request->user()),
]);
}
public function like(Request $request, int $id): JsonResponse
{
$card = $this->visibleCard($request, $id);
$state = $this->reactions->setReaction($request->user(), $card, NovaCardReaction::TYPE_LIKE, true);
return response()->json(['ok' => true, ...$state]);
}
public function unlike(Request $request, int $id): JsonResponse
{
$card = $this->visibleCard($request, $id);
$state = $this->reactions->setReaction($request->user(), $card, NovaCardReaction::TYPE_LIKE, false);
return response()->json(['ok' => true, ...$state]);
}
public function favorite(Request $request, int $id): JsonResponse
{
$card = $this->visibleCard($request, $id);
$state = $this->reactions->setReaction($request->user(), $card, NovaCardReaction::TYPE_FAVORITE, true);
return response()->json(['ok' => true, ...$state]);
}
public function unfavorite(Request $request, int $id): JsonResponse
{
$card = $this->visibleCard($request, $id);
$state = $this->reactions->setReaction($request->user(), $card, NovaCardReaction::TYPE_FAVORITE, false);
return response()->json(['ok' => true, ...$state]);
}
public function collections(Request $request): JsonResponse
{
return response()->json([
'data' => $this->collections->listCollections($request->user()),
]);
}
public function storeCollection(Request $request): JsonResponse
{
$payload = $request->validate([
'name' => ['required', 'string', 'min:2', 'max:120'],
'slug' => ['nullable', 'string', 'max:140'],
'description' => ['nullable', 'string', 'max:500'],
'visibility' => ['nullable', 'in:private,public'],
]);
$collection = $this->collections->createCollection($request->user(), $payload);
return response()->json([
'collection' => [
'id' => (int) $collection->id,
'slug' => (string) $collection->slug,
'name' => (string) $collection->name,
'description' => $collection->description,
'visibility' => (string) $collection->visibility,
'cards_count' => (int) $collection->cards_count,
],
], Response::HTTP_CREATED);
}
public function updateCollection(Request $request, int $id): JsonResponse
{
$collection = $this->ownedCollection($request, $id);
$payload = $request->validate([
'name' => ['required', 'string', 'min:2', 'max:120'],
'slug' => ['nullable', 'string', 'max:140'],
'description' => ['nullable', 'string', 'max:500'],
'visibility' => ['nullable', 'in:private,public'],
]);
$collection->update([
'name' => $payload['name'],
'slug' => $payload['slug'] ?: $collection->slug,
'description' => $payload['description'] ?? null,
'visibility' => $payload['visibility'] ?? $collection->visibility,
]);
return response()->json([
'collection' => $this->presenter->collection($collection->fresh(['user', 'items.card.user.profile', 'items.card.category', 'items.card.template', 'items.card.backgroundImage', 'items.card.tags']), $request->user(), true),
]);
}
public function storeCollectionItem(Request $request, int $id): JsonResponse
{
$collection = $this->ownedCollection($request, $id);
$payload = $request->validate([
'card_id' => ['required', 'integer'],
'note' => ['nullable', 'string', 'max:500'],
'sort_order' => ['nullable', 'integer', 'min:0'],
]);
$card = $this->visibleCard($request, (int) $payload['card_id']);
$this->collections->addCardToCollection($collection, $card, $payload['note'] ?? null, $payload['sort_order'] ?? null);
return response()->json([
'collection' => $this->presenter->collection($collection->fresh(['user', 'items.card.user.profile', 'items.card.category', 'items.card.template', 'items.card.backgroundImage', 'items.card.tags']), $request->user(), true),
], Response::HTTP_CREATED);
}
public function destroyCollectionItem(Request $request, int $id, int $cardId): JsonResponse
{
$collection = $this->ownedCollection($request, $id);
$card = NovaCard::query()->findOrFail($cardId);
$this->collections->removeCardFromCollection($collection, $card);
return response()->json([
'collection' => $this->presenter->collection($collection->fresh(['user', 'items.card.user.profile', 'items.card.category', 'items.card.template', 'items.card.backgroundImage', 'items.card.tags']), $request->user(), true),
]);
}
public function challenges(Request $request): JsonResponse
{
return response()->json([
'data' => $this->presenter->options()['challenge_feed'] ?? [],
]);
}
public function assets(Request $request): JsonResponse
{
return response()->json([
'data' => $this->presenter->options()['asset_packs'] ?? [],
]);
}
public function templates(Request $request): JsonResponse
{
return response()->json([
'packs' => $this->presenter->options()['template_packs'] ?? [],
'templates' => $this->presenter->options()['templates'] ?? [],
]);
}
public function save(Request $request, int $id): JsonResponse
{
$card = $this->visibleCard($request, $id);
$payload = $request->validate([
'collection_id' => ['nullable', 'integer'],
'note' => ['nullable', 'string', 'max:500'],
]);
$collection = $this->collections->saveCard($request->user(), $card, $payload['collection_id'] ?? null, $payload['note'] ?? null);
return response()->json([
'ok' => true,
'saves_count' => (int) $card->fresh()->saves_count,
'collection' => [
'id' => (int) $collection->id,
'name' => (string) $collection->name,
'slug' => (string) $collection->slug,
],
]);
}
public function unsave(Request $request, int $id): JsonResponse
{
$card = $this->visibleCard($request, $id);
$payload = $request->validate([
'collection_id' => ['nullable', 'integer'],
]);
$this->collections->unsaveCard($request->user(), $card, $payload['collection_id'] ?? null);
return response()->json([
'ok' => true,
'saves_count' => (int) $card->fresh()->saves_count,
]);
}
public function remix(Request $request, int $id): JsonResponse
{
$source = $this->visibleCard($request, $id);
abort_unless($source->allow_remix, 422, 'This card does not allow remixes.');
$card = $this->drafts->createRemix($request->user(), $source);
$source->increment('remixes_count');
UpdateNovaCardStatsJob::dispatch($source->id);
return response()->json([
'data' => $this->presenter->card($card, true, $request->user()),
], Response::HTTP_CREATED);
}
public function duplicate(Request $request, int $id): JsonResponse
{
$source = $this->ownedCard($request, $id);
$card = $this->drafts->createDuplicate($request->user(), $source->loadMissing('tags'));
return response()->json([
'data' => $this->presenter->card($card, true, $request->user()),
], Response::HTTP_CREATED);
}
public function versions(Request $request, int $id): JsonResponse
{
$card = $this->ownedCard($request, $id);
$versions = $card->versions()
->latest('version_number')
->get()
->map(fn (NovaCardVersion $version): array => [
'id' => (int) $version->id,
'version_number' => (int) $version->version_number,
'label' => $version->label,
'created_at' => $version->created_at?->toISOString(),
'snapshot_json' => is_array($version->snapshot_json) ? $version->snapshot_json : [],
])
->values()
->all();
return response()->json(['data' => $versions]);
}
public function restoreVersion(Request $request, int $id, int $versionId): JsonResponse
{
$card = $this->ownedCard($request, $id);
$version = $card->versions()->findOrFail($versionId);
$card = $this->versions->restore($card, $version, $request->user());
return response()->json([
'data' => $this->presenter->card($card, true, $request->user()),
]);
}
public function submitChallenge(Request $request, int $challengeId, int $id): JsonResponse
{
$payload = $request->validate([
'note' => ['nullable', 'string', 'max:500'],
]);
return $this->submitChallengeWithPayload($request, $challengeId, $id, $payload['note'] ?? null);
}
public function submitChallengeByChallenge(Request $request, int $id): JsonResponse
{
$payload = $request->validate([
'card_id' => ['required', 'integer'],
'note' => ['nullable', 'string', 'max:500'],
]);
return $this->submitChallengeWithPayload($request, $id, (int) $payload['card_id'], $payload['note'] ?? null);
}
private function submitChallengeWithPayload(Request $request, int $challengeId, int $cardId, ?string $note = null): JsonResponse
{
$card = $this->ownedCard($request, $cardId);
abort_unless($card->status === NovaCard::STATUS_PUBLISHED, 422, 'Publish the card before entering a challenge.');
$challenge = NovaCardChallenge::query()
->where('status', NovaCardChallenge::STATUS_ACTIVE)
->findOrFail($challengeId);
$entry = $this->challenges->submit($request->user(), $challenge, $card, $note);
UpdateNovaCardStatsJob::dispatch($card->id);
return response()->json([
'entry' => [
'id' => (int) $entry->id,
'challenge_id' => (int) $entry->challenge_id,
'card_id' => (int) $entry->card_id,
'status' => (string) $entry->status,
],
'challenge_entries_count' => (int) $card->fresh()->challenge_entries_count,
]);
}
private function visibleCard(Request $request, int $id): NovaCard
{
$card = NovaCard::query()
->with(['user.profile', 'category', 'template', 'backgroundImage', 'tags', 'originalCard', 'rootCard'])
->published()
->findOrFail($id);
abort_unless($card->canBeViewedBy($request->user()), 404);
return $card;
}
private function ownedCard(Request $request, int $id): NovaCard
{
return NovaCard::query()
->with(['user.profile', 'category', 'template', 'backgroundImage', 'tags', 'versions'])
->where('user_id', $request->user()->id)
->findOrFail($id);
}
private function ownedCollection(Request $request, int $id): \App\Models\NovaCardCollection
{
return \App\Models\NovaCardCollection::query()
->where('user_id', $request->user()->id)
->findOrFail($id);
}
}

View File

@@ -0,0 +1,131 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api\NovaCards;
use App\Http\Controllers\Controller;
use App\Models\NovaCard;
use App\Models\NovaCardCreatorPreset;
use App\Services\NovaCards\NovaCardCreatorPresetService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class NovaCardPresetController extends Controller
{
public function __construct(
private readonly NovaCardCreatorPresetService $presets,
) {
}
public function index(Request $request): JsonResponse
{
$type = $request->query('type');
$items = $this->presets->listForUser(
$request->user(),
is_string($type) && in_array($type, NovaCardCreatorPreset::TYPES, true) ? $type : null,
);
return response()->json([
'data' => $items->map(fn ($p) => $this->presets->toArray($p))->values()->all(),
]);
}
public function store(Request $request): JsonResponse
{
$data = $request->validate([
'name' => ['required', 'string', 'max:120'],
'preset_type' => ['required', 'string', 'in:' . implode(',', NovaCardCreatorPreset::TYPES)],
'config_json' => ['required', 'array'],
'is_default' => ['sometimes', 'boolean'],
]);
$preset = $this->presets->create($request->user(), $data);
return response()->json([
'data' => $this->presets->toArray($preset),
], Response::HTTP_CREATED);
}
public function update(Request $request, int $id): JsonResponse
{
$preset = NovaCardCreatorPreset::query()
->where('user_id', $request->user()->id)
->findOrFail($id);
$data = $request->validate([
'name' => ['sometimes', 'string', 'max:120'],
'config_json' => ['sometimes', 'array'],
'is_default' => ['sometimes', 'boolean'],
]);
$preset = $this->presets->update($request->user(), $preset, $data);
return response()->json([
'data' => $this->presets->toArray($preset),
]);
}
public function destroy(Request $request, int $id): JsonResponse
{
$preset = NovaCardCreatorPreset::query()->findOrFail($id);
$this->presets->delete($request->user(), $preset);
return response()->json([
'ok' => true,
]);
}
/**
* Capture a preset from an existing published card.
*/
public function captureFromCard(Request $request, int $cardId): JsonResponse
{
$card = NovaCard::query()
->where('user_id', $request->user()->id)
->findOrFail($cardId);
$data = $request->validate([
'name' => ['required', 'string', 'max:120'],
'preset_type' => ['required', 'string', 'in:' . implode(',', NovaCardCreatorPreset::TYPES)],
]);
$preset = $this->presets->captureFromCard(
$request->user(),
$card,
$data['name'],
$data['preset_type'],
);
return response()->json([
'data' => $this->presets->toArray($preset),
], Response::HTTP_CREATED);
}
/**
* Apply a saved preset to a draft card, returning a project_json patch.
*/
public function applyToCard(Request $request, int $presetId, int $cardId): JsonResponse
{
$preset = NovaCardCreatorPreset::query()
->where('user_id', $request->user()->id)
->findOrFail($presetId);
$card = NovaCard::query()
->where('user_id', $request->user()->id)
->whereIn('status', [NovaCard::STATUS_DRAFT, NovaCard::STATUS_PUBLISHED])
->findOrFail($cardId);
$currentProject = is_array($card->project_json) ? $card->project_json : [];
$patch = $this->presets->applyToProjectPatch($preset, $currentProject);
return response()->json([
'data' => [
'preset' => $this->presets->toArray($preset),
'project_patch' => $patch,
],
]);
}
}

View File

@@ -0,0 +1,44 @@
<?php
namespace App\Http\Controllers\Api\Posts;
use App\Http\Controllers\Controller;
use App\Models\Post;
use App\Services\Posts\PostAnalyticsService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
/**
* POST /api/posts/{id}/impression record an impression (throttled)
* GET /api/posts/{id}/analytics owner analytics summary
*/
class PostAnalyticsController extends Controller
{
public function __construct(private PostAnalyticsService $analytics) {}
public function impression(Request $request, int $id): JsonResponse
{
$post = Post::where('status', Post::STATUS_PUBLISHED)->findOrFail($id);
// Session key: authenticated user ID or hashed IP
$sessionKey = $request->user()
? 'u:' . $request->user()->id
: 'ip:' . md5($request->ip());
$counted = $this->analytics->trackImpression($post, $sessionKey);
return response()->json(['counted' => $counted]);
}
public function show(Request $request, int $id): JsonResponse
{
$post = Post::findOrFail($id);
// Only the post owner can view analytics
if ($request->user()?->id !== $post->user_id) {
abort(403, 'You do not own this post.');
}
return response()->json(['data' => $this->analytics->getSummary($post)]);
}
}

View File

@@ -0,0 +1,124 @@
<?php
namespace App\Http\Controllers\Api\Posts;
use App\Events\Posts\PostCommented;
use App\Http\Controllers\Controller;
use App\Http\Requests\Posts\CreateCommentRequest;
use App\Models\Post;
use App\Models\PostComment;
use App\Services\ContentSanitizer;
use App\Services\Posts\PostCountersService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\RateLimiter;
class PostCommentController extends Controller
{
public function __construct(private PostCountersService $counters) {}
// ─────────────────────────────────────────────────────────────────────────
// List
// ─────────────────────────────────────────────────────────────────────────
public function index(Request $request, int $postId): JsonResponse
{
$post = Post::findOrFail($postId);
$page = max(1, (int) $request->query('page', 1));
$comments = PostComment::with(['user', 'user.profile'])
->where('post_id', $post->id)
->orderByDesc('is_highlighted') // highlighted first
->orderBy('created_at')
->paginate(20, ['*'], 'page', $page);
$formatted = $comments->getCollection()->map(fn ($c) => $this->formatComment($c));
return response()->json([
'data' => $formatted,
'meta' => [
'total' => $comments->total(),
'current_page' => $comments->currentPage(),
'last_page' => $comments->lastPage(),
'per_page' => $comments->perPage(),
],
]);
}
// ─────────────────────────────────────────────────────────────────────────
// Store
// ─────────────────────────────────────────────────────────────────────────
public function store(CreateCommentRequest $request, int $postId): JsonResponse
{
$user = $request->user();
// Rate limit: 30 comments per hour
$key = 'comment_post:' . $user->id;
if (RateLimiter::tooManyAttempts($key, 30)) {
$seconds = RateLimiter::availableIn($key);
return response()->json([
'message' => "You're commenting too quickly. Please wait {$seconds} seconds.",
], 429);
}
RateLimiter::hit($key, 3600);
$post = Post::findOrFail($postId);
$body = ContentSanitizer::render($request->input('body'));
$comment = PostComment::create([
'post_id' => $post->id,
'user_id' => $user->id,
'body' => $body,
]);
$this->counters->incrementComments($post);
// Fire event for notification
if ($post->user_id !== $user->id) {
event(new PostCommented($post, $comment, $user));
}
$comment->load(['user', 'user.profile']);
return response()->json(['comment' => $this->formatComment($comment)], 201);
}
// ─────────────────────────────────────────────────────────────────────────
// Destroy
// ─────────────────────────────────────────────────────────────────────────
public function destroy(Request $request, int $postId, int $commentId): JsonResponse
{
$comment = PostComment::where('post_id', $postId)->findOrFail($commentId);
Gate::authorize('delete', $comment);
$comment->delete();
$this->counters->decrementComments(Post::findOrFail($postId));
return response()->json(['message' => 'Comment deleted.']);
}
// ─────────────────────────────────────────────────────────────────────────
// Format
// ─────────────────────────────────────────────────────────────────────────
private function formatComment(PostComment $comment): array
{
return [
'id' => $comment->id,
'body' => $comment->body,
'is_highlighted' => (bool) $comment->is_highlighted,
'created_at' => $comment->created_at->toISOString(),
'author' => [
'id' => $comment->user->id,
'username' => $comment->user->username,
'name' => $comment->user->name,
'avatar' => $comment->user->profile?->avatar_url ?? null,
'level' => (int) ($comment->user->level ?? 1),
'rank' => (string) ($comment->user->rank ?? 'Newbie'),
],
];
}
}

View File

@@ -0,0 +1,55 @@
<?php
namespace App\Http\Controllers\Api\Posts;
use App\Http\Controllers\Controller;
use App\Models\Post;
use App\Models\PostComment;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
/**
* POST /api/posts/{post_id}/comments/{comment_id}/highlight
* DELETE /api/posts/{post_id}/comments/{comment_id}/highlight
*
* Only the post owner may highlight/un-highlight.
* Only 1 highlighted comment per post is allowed at a time.
*/
class PostCommentHighlightController extends Controller
{
public function highlight(Request $request, int $postId, int $commentId): JsonResponse
{
$post = Post::findOrFail($postId);
$comment = PostComment::where('post_id', $postId)->findOrFail($commentId);
if ($request->user()->id !== $post->user_id) {
abort(403, 'Only the post owner can highlight comments.');
}
DB::transaction(function () use ($post, $comment) {
// Remove any existing highlight on this post
PostComment::where('post_id', $post->id)
->where('is_highlighted', true)
->update(['is_highlighted' => false]);
$comment->update(['is_highlighted' => true]);
});
return response()->json(['message' => 'Comment highlighted.', 'comment_id' => $comment->id]);
}
public function unhighlight(Request $request, int $postId, int $commentId): JsonResponse
{
$post = Post::findOrFail($postId);
$comment = PostComment::where('post_id', $postId)->findOrFail($commentId);
if ($request->user()->id !== $post->user_id) {
abort(403, 'Only the post owner can remove comment highlights.');
}
$comment->update(['is_highlighted' => false]);
return response()->json(['message' => 'Highlight removed.', 'comment_id' => $comment->id]);
}
}

View File

@@ -0,0 +1,92 @@
<?php
namespace App\Http\Controllers\Api\Posts;
use App\Http\Controllers\Controller;
use App\Http\Requests\Posts\CreatePostRequest;
use App\Http\Requests\Posts\UpdatePostRequest;
use App\Models\Post;
use App\Services\Posts\PostFeedService;
use App\Services\Posts\PostService;
use Carbon\Carbon;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\RateLimiter;
class PostController extends Controller
{
public function __construct(
private PostService $postService,
private PostFeedService $feedService,
) {}
// ─────────────────────────────────────────────────────────────────────────
// Create
// ─────────────────────────────────────────────────────────────────────────
public function store(CreatePostRequest $request): JsonResponse
{
$user = $request->user();
// Rate limit: 10 post creations per hour
$key = 'create_post:' . $user->id;
if (RateLimiter::tooManyAttempts($key, 10)) {
$seconds = RateLimiter::availableIn($key);
return response()->json([
'message' => "You're posting too quickly. Please wait {$seconds} seconds.",
], 429);
}
RateLimiter::hit($key, 3600);
Gate::authorize('create', Post::class);
$post = $this->postService->createPost(
user: $user,
type: $request->input('type', Post::TYPE_TEXT),
visibility: $request->input('visibility', Post::VISIBILITY_PUBLIC),
body: $request->input('body'),
targets: $request->input('targets', []),
linkPreview: $request->input('link_preview'),
taggedUsers: $request->input('tagged_users'), publishAt: $request->filled('publish_at') ? Carbon::parse($request->input('publish_at')) : null, );
$post->load(['user', 'user.profile', 'targets', 'targets.artwork', 'targets.artwork.user', 'targets.artwork.user.profile', 'reactions']);
return response()->json([
'post' => $this->feedService->formatPost($post, $user->id),
], 201);
}
// ─────────────────────────────────────────────────────────────────────────
// Update
// ─────────────────────────────────────────────────────────────────────────
public function update(UpdatePostRequest $request, int $id): JsonResponse
{
$post = Post::findOrFail($id);
Gate::authorize('update', $post);
$updated = $this->postService->updatePost(
post: $post,
body: $request->input('body'),
visibility: $request->input('visibility'),
);
return response()->json([
'post' => $this->feedService->formatPost($updated->load(['user', 'user.profile', 'targets', 'targets.artwork', 'targets.artwork.user', 'targets.artwork.user.profile', 'reactions']), $request->user()?->id),
]);
}
// ─────────────────────────────────────────────────────────────────────────
// Delete
// ─────────────────────────────────────────────────────────────────────────
public function destroy(int $id): JsonResponse
{
$post = Post::findOrFail($id);
Gate::authorize('delete', $post);
$this->postService->deletePost($post);
return response()->json(['message' => 'Post deleted.']);
}
}

View File

@@ -0,0 +1,60 @@
<?php
namespace App\Http\Controllers\Api\Posts;
use App\Http\Controllers\Controller;
use App\Models\User;
use App\Services\Posts\PostFeedService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class PostFeedController extends Controller
{
public function __construct(private PostFeedService $feedService) {}
// ─────────────────────────────────────────────────────────────────────────
// Profile feed — GET /api/posts/profile/{username}
// ─────────────────────────────────────────────────────────────────────────
public function profile(Request $request, string $username): JsonResponse
{
$profileUser = User::where('username', $username)->firstOrFail();
$viewerId = $request->user()?->id;
$page = max(1, (int) $request->query('page', 1));
$paginated = $this->feedService->getProfileFeed($profileUser, $viewerId, $page);
$formatted = collect($paginated['data'])
->map(fn ($post) => $this->feedService->formatPost($post, $viewerId))
->values();
return response()->json([
'data' => $formatted,
'meta' => $paginated['meta'],
]);
}
// ─────────────────────────────────────────────────────────────────────────
// Following feed — GET /api/posts/following
// ─────────────────────────────────────────────────────────────────────────
public function following(Request $request): JsonResponse
{
$user = $request->user();
$page = max(1, (int) $request->query('page', 1));
$filter = $request->query('filter', 'all');
$result = $this->feedService->getFollowingFeed($user, $page, $filter);
$viewerId = $user->id;
$formatted = array_map(
fn ($post) => $this->feedService->formatPost($post, $viewerId),
$result['data'],
);
return response()->json([
'data' => $formatted,
'meta' => $result['meta'],
]);
}
}

View File

@@ -0,0 +1,67 @@
<?php
namespace App\Http\Controllers\Api\Posts;
use App\Http\Controllers\Controller;
use App\Models\Post;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Gate;
/**
* POST /api/posts/{id}/pin
* DELETE /api/posts/{id}/pin
*/
class PostPinController extends Controller
{
private const MAX_PINNED = 3;
public function pin(Request $request, int $id): JsonResponse
{
$post = Post::where('status', Post::STATUS_PUBLISHED)->findOrFail($id);
Gate::authorize('update', $post);
$user = $request->user();
// Count existing pinned posts
$pinnedCount = Post::where('user_id', $user->id)
->where('is_pinned', true)
->count();
if ($post->is_pinned) {
return response()->json(['message' => 'Post is already pinned.'], 409);
}
if ($pinnedCount >= self::MAX_PINNED) {
return response()->json([
'message' => 'You can pin a maximum of ' . self::MAX_PINNED . ' posts.',
], 422);
}
$nextOrder = Post::where('user_id', $user->id)
->where('is_pinned', true)
->max('pinned_order') ?? 0;
$post->update([
'is_pinned' => true,
'pinned_order' => $nextOrder + 1,
]);
return response()->json(['message' => 'Post pinned.', 'post_id' => $post->id]);
}
public function unpin(int $id): JsonResponse
{
$post = Post::findOrFail($id);
Gate::authorize('update', $post);
if (! $post->is_pinned) {
return response()->json(['message' => 'Post is not pinned.'], 409);
}
$post->update(['is_pinned' => false, 'pinned_order' => null]);
return response()->json(['message' => 'Post unpinned.', 'post_id' => $post->id]);
}
}

View File

@@ -0,0 +1,75 @@
<?php
namespace App\Http\Controllers\Api\Posts;
use App\Http\Controllers\Controller;
use App\Models\Post;
use App\Models\PostReaction;
use App\Services\Posts\PostCountersService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\RateLimiter;
class PostReactionController extends Controller
{
public function __construct(private PostCountersService $counters) {}
/**
* POST /api/posts/{id}/reactions
* payload: { reaction: 'like' }
*/
public function store(Request $request, int $id): JsonResponse
{
$user = $request->user();
$key = 'react_post:' . $user->id;
if (RateLimiter::tooManyAttempts($key, 60)) {
return response()->json(['message' => 'Too many reactions. Please slow down.'], 429);
}
RateLimiter::hit($key, 3600);
$post = Post::findOrFail($id);
$reaction = $request->input('reaction', 'like');
$existing = PostReaction::where('post_id', $post->id)
->where('user_id', $user->id)
->where('reaction', $reaction)
->first();
if ($existing) {
return response()->json(['message' => 'Already reacted.', 'reactions_count' => $post->reactions_count], 200);
}
PostReaction::create([
'post_id' => $post->id,
'user_id' => $user->id,
'reaction' => $reaction,
]);
$this->counters->incrementReactions($post);
$post->refresh();
return response()->json(['reactions_count' => $post->reactions_count, 'viewer_liked' => true], 201);
}
/**
* DELETE /api/posts/{id}/reactions/{reaction}
*/
public function destroy(Request $request, int $id, string $reaction = 'like'): JsonResponse
{
$user = $request->user();
$post = Post::findOrFail($id);
$deleted = PostReaction::where('post_id', $post->id)
->where('user_id', $user->id)
->where('reaction', $reaction)
->delete();
if ($deleted) {
$this->counters->decrementReactions($post);
$post->refresh();
}
return response()->json(['reactions_count' => $post->reactions_count, 'viewer_liked' => false]);
}
}

View File

@@ -0,0 +1,49 @@
<?php
namespace App\Http\Controllers\Api\Posts;
use App\Http\Controllers\Controller;
use App\Models\Post;
use App\Models\PostReport;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Gate;
class PostReportController extends Controller
{
/**
* POST /api/posts/{id}/report
* payload: { reason, message? }
*/
public function store(Request $request, int $id): JsonResponse
{
$user = $request->user();
$post = Post::findOrFail($id);
Gate::authorize('report', $post);
$request->validate([
'reason' => ['required', 'string', 'max:64'],
'message' => ['nullable', 'string', 'max:1000'],
]);
// Unique report per user+post
$existing = PostReport::where('post_id', $post->id)
->where('reporter_user_id', $user->id)
->exists();
if ($existing) {
return response()->json(['message' => 'You have already reported this post.'], 409);
}
PostReport::create([
'post_id' => $post->id,
'reporter_user_id' => $user->id,
'reason' => $request->input('reason'),
'message' => $request->input('message'),
'status' => 'open',
]);
return response()->json(['message' => 'Report submitted. Thank you for helping keep Skinbase safe.'], 201);
}
}

View File

@@ -0,0 +1,69 @@
<?php
namespace App\Http\Controllers\Api\Posts;
use App\Http\Controllers\Controller;
use App\Models\Post;
use App\Models\PostSave;
use App\Services\Posts\PostCountersService;
use App\Services\Posts\PostFeedService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
/**
* POST /api/posts/{id}/save
* DELETE /api/posts/{id}/save
* GET /api/posts/saved
*/
class PostSaveController extends Controller
{
public function __construct(
private PostCountersService $counters,
private PostFeedService $feedService,
) {}
public function save(Request $request, int $id): JsonResponse
{
$post = Post::where('status', Post::STATUS_PUBLISHED)->findOrFail($id);
$user = $request->user();
if (PostSave::where('post_id', $post->id)->where('user_id', $user->id)->exists()) {
return response()->json(['message' => 'Already saved.', 'saved' => true], 200);
}
PostSave::create(['post_id' => $post->id, 'user_id' => $user->id]);
$this->counters->incrementSaves($post);
return response()->json(['message' => 'Post saved.', 'saved' => true, 'saves_count' => $post->fresh()->saves_count]);
}
public function unsave(Request $request, int $id): JsonResponse
{
$post = Post::findOrFail($id);
$user = $request->user();
$save = PostSave::where('post_id', $post->id)->where('user_id', $user->id)->first();
if (! $save) {
return response()->json(['message' => 'Not saved.', 'saved' => false], 200);
}
$save->delete();
$this->counters->decrementSaves($post);
return response()->json(['message' => 'Post unsaved.', 'saved' => false, 'saves_count' => $post->fresh()->saves_count]);
}
public function index(Request $request): JsonResponse
{
$user = $request->user();
$page = max(1, (int) $request->query('page', 1));
$result = $this->feedService->getSavedFeed($user, $page);
$formatted = array_map(
fn ($post) => $this->feedService->formatPost($post, $user->id),
$result['data'],
);
return response()->json(['data' => array_values($formatted), 'meta' => $result['meta']]);
}
}

View File

@@ -0,0 +1,85 @@
<?php
namespace App\Http\Controllers\Api\Posts;
use App\Http\Controllers\Controller;
use App\Models\Post;
use App\Services\Posts\PostFeedService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
/**
* GET /api/feed/search?q=...
*
* Searches posts body + hashtags via Meilisearch (Laravel Scout).
* Falls back to a simple LIKE query if Scout is unavailable.
*/
class PostSearchController extends Controller
{
public function __construct(private PostFeedService $feedService) {}
public function search(Request $request): JsonResponse
{
$request->validate([
'q' => ['required', 'string', 'min:2', 'max:100'],
'page' => ['nullable', 'integer', 'min:1'],
]);
$query = trim($request->input('q'));
$page = max(1, (int) $request->query('page', 1));
$perPage = 20;
$viewerId = $request->user()?->id;
// Scout search (Meilisearch)
try {
$results = Post::search($query)
->where('visibility', Post::VISIBILITY_PUBLIC)
->where('status', Post::STATUS_PUBLISHED)
->paginate($perPage, 'page', $page);
// Load relations
$results->load($this->feedService->publicEagerLoads());
$formatted = $results->getCollection()
->map(fn ($post) => $this->feedService->formatPost($post, $viewerId))
->values();
return response()->json([
'data' => $formatted,
'query' => $query,
'meta' => [
'total' => $results->total(),
'current_page' => $results->currentPage(),
'last_page' => $results->lastPage(),
'per_page' => $results->perPage(),
],
]);
} catch (\Exception $e) {
// Fallback: basic LIKE search on body
$paginated = Post::with($this->feedService->publicEagerLoads())
->where('status', Post::STATUS_PUBLISHED)
->where('visibility', Post::VISIBILITY_PUBLIC)
->where(function ($q) use ($query) {
$q->where('body', 'like', '%' . $query . '%')
->orWhereHas('hashtags', fn ($hq) => $hq->where('tag', 'like', '%' . mb_strtolower($query) . '%'));
})
->orderByDesc('created_at')
->paginate($perPage, ['*'], 'page', $page);
$formatted = $paginated->getCollection()
->map(fn ($post) => $this->feedService->formatPost($post, $viewerId))
->values();
return response()->json([
'data' => $formatted,
'query' => $query,
'meta' => [
'total' => $paginated->total(),
'current_page' => $paginated->currentPage(),
'last_page' => $paginated->lastPage(),
'per_page' => $paginated->perPage(),
],
]);
}
}
}

View File

@@ -0,0 +1,58 @@
<?php
namespace App\Http\Controllers\Api\Posts;
use App\Events\Posts\ArtworkShared;
use App\Http\Controllers\Controller;
use App\Http\Requests\Posts\ShareArtworkRequest;
use App\Models\Artwork;
use App\Services\Posts\PostFeedService;
use App\Services\Posts\PostShareService;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\RateLimiter;
class PostShareController extends Controller
{
public function __construct(
private PostShareService $shareService,
private PostFeedService $feedService,
) {}
/**
* POST /api/posts/share/artwork/{artwork_id}
* payload: { body?, visibility }
*/
public function shareArtwork(ShareArtworkRequest $request, int $artworkId): JsonResponse
{
$user = $request->user();
$artwork = Artwork::findOrFail($artworkId);
// Rate limit: 10 artwork shares per hour
$key = 'share_artwork:' . $user->id;
if (RateLimiter::tooManyAttempts($key, 10)) {
$seconds = RateLimiter::availableIn($key);
return response()->json([
'message' => "You're sharing too quickly. Please wait {$seconds} seconds.",
], 429);
}
RateLimiter::hit($key, 3600);
$post = $this->shareService->shareArtwork(
user: $user,
artwork: $artwork,
body: $request->input('body'),
visibility: $request->input('visibility', 'public'),
);
$post->load(['user', 'user.profile', 'targets', 'targets.artwork', 'targets.artwork.user', 'targets.artwork.user.profile', 'reactions']);
// Notify original artwork owner (unless self-share)
if ($artwork->user_id !== $user->id) {
event(new ArtworkShared($post, $artwork, $user));
}
return response()->json([
'post' => $this->feedService->formatPost($post, $user->id),
], 201);
}
}

View File

@@ -0,0 +1,73 @@
<?php
namespace App\Http\Controllers\Api\Posts;
use App\Http\Controllers\Controller;
use App\Services\Posts\PostFeedService;
use App\Services\Posts\PostHashtagService;
use App\Services\Posts\PostTrendingService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
/**
* GET /api/feed/trending
* GET /api/feed/hashtag/{tag}
* GET /api/feed/hashtags/trending
*/
class PostTrendingFeedController extends Controller
{
public function __construct(
private PostTrendingService $trendingService,
private PostFeedService $feedService,
private PostHashtagService $hashtagService,
) {}
public function trending(Request $request): JsonResponse
{
$page = max(1, (int) $request->query('page', 1));
$viewer = $request->user()?->id;
$result = $this->trendingService->getTrending($viewer, $page);
$formatted = array_map(
fn ($post) => $this->feedService->formatPost($post, $viewer),
$result['data'],
);
return response()->json(['data' => array_values($formatted), 'meta' => $result['meta']]);
}
public function hashtag(Request $request, string $tag): JsonResponse
{
$tag = mb_strtolower(preg_replace('/[^A-Za-z0-9_]/', '', $tag));
if (strlen($tag) < 2 || strlen($tag) > 64) {
return response()->json(['message' => 'Invalid hashtag.'], 422);
}
$page = max(1, (int) $request->query('page', 1));
$viewer = $request->user()?->id;
$result = $this->feedService->getHashtagFeed($tag, $viewer, $page);
$formatted = array_map(
fn ($post) => $this->feedService->formatPost($post, $viewer),
$result['data'],
);
return response()->json([
'tag' => $tag,
'data' => array_values($formatted),
'meta' => $result['meta'],
]);
}
public function trendingHashtags(): JsonResponse
{
$tags = Cache::remember('trending_hashtags', 300, function () {
return $this->hashtagService->trending(10, 24);
});
return response()->json(['hashtags' => $tags]);
}
}

View File

@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Services\Activity\UserActivityService;
use App\Support\UsernamePolicy;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use App\Models\User;
final class ProfileActivityController extends Controller
{
public function __construct(private readonly UserActivityService $activities) {}
public function __invoke(Request $request, string $username): JsonResponse
{
$normalized = UsernamePolicy::normalize($username);
$user = User::query()
->with('profile:user_id,avatar_hash')
->whereRaw('LOWER(username) = ?', [$normalized])
->where('is_active', true)
->whereNull('deleted_at')
->firstOrFail();
return response()->json(
$this->activities->feedForUser(
$user,
(string) $request->query('filter', 'all'),
(int) $request->query('page', 1),
(int) $request->query('per_page', UserActivityService::DEFAULT_PER_PAGE),
)
);
}
}

View File

@@ -0,0 +1,253 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\Artwork;
use App\Models\User;
use Carbon\CarbonInterface;
use App\Services\ThumbnailPresenter;
use App\Support\UsernamePolicy;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
/**
* ProfileApiController
* JSON API endpoints for Profile page v2 tabs.
*/
final class ProfileApiController extends Controller
{
/**
* GET /api/profile/{username}/artworks
* Returns cursor-paginated artworks for the profile page tabs.
* Supports: sort=latest|trending|rising|views|favs, cursor=...
*/
public function artworks(Request $request, string $username): JsonResponse
{
$user = $this->resolveUser($username);
if (! $user) {
return response()->json(['error' => 'User not found'], 404);
}
$isOwner = Auth::check() && Auth::id() === $user->id;
$sort = $request->input('sort', 'latest');
$query = Artwork::with([
'user:id,name,username,level,rank',
'user.profile:user_id,avatar_hash',
'group:id,name,slug,avatar_path',
'stats:artwork_id,views,downloads,favorites',
'categories' => function ($query) {
$query->select('categories.id', 'categories.content_type_id', 'categories.parent_id', 'categories.name', 'categories.slug', 'categories.sort_order')
->with(['contentType:id,slug,name']);
},
])
->where('user_id', $user->id)
->whereNull('deleted_at');
if (! $isOwner) {
$query->where('is_public', true)->where('is_approved', true)->whereNotNull('published_at');
}
$query = match ($sort) {
'trending' => $query->orderByDesc('ranking_score'),
'rising' => $query->orderByDesc('heat_score'),
'views' => $query->orderByDesc('view_count'),
'favs' => $query->orderByDesc('favourite_count'),
default => $query->orderByDesc('published_at'),
};
$perPage = 24;
$paginator = $query->cursorPaginate($perPage);
$data = collect($paginator->items())
->map(fn (Artwork $art) => $this->mapArtworkCardPayload($art))
->values();
return response()->json([
'data' => $data,
'next_cursor' => $paginator->nextCursor()?->encode(),
'has_more' => $paginator->hasMorePages(),
]);
}
/**
* GET /api/profile/{username}/favourites
* Returns cursor-paginated favourites for the profile.
*/
public function favourites(Request $request, string $username): JsonResponse
{
$favouriteTable = $this->resolveFavouriteTable();
if ($favouriteTable === null) {
return response()->json(['data' => [], 'next_cursor' => null, 'has_more' => false]);
}
$user = $this->resolveUser($username);
if (! $user) {
return response()->json(['error' => 'User not found'], 404);
}
$perPage = 24;
$offset = max(0, (int) base64_decode((string) $request->input('cursor', ''), true));
$favIds = DB::table($favouriteTable . ' as af')
->join('artworks as a', 'a.id', '=', 'af.artwork_id')
->where('af.user_id', $user->id)
->whereNull('a.deleted_at')
->where('a.is_public', true)
->where('a.is_approved', true)
->whereNotNull('a.published_at')
->orderByDesc('af.created_at')
->orderByDesc('af.artwork_id')
->offset($offset)
->limit($perPage + 1)
->pluck('a.id');
$hasMore = $favIds->count() > $perPage;
$favIds = $favIds->take($perPage);
if ($favIds->isEmpty()) {
return response()->json(['data' => [], 'next_cursor' => null, 'has_more' => false]);
}
$indexed = Artwork::with([
'user:id,name,username,level,rank',
'user.profile:user_id,avatar_hash',
'group:id,name,slug,avatar_path',
'stats:artwork_id,views,downloads,favorites',
'categories' => function ($query) {
$query->select('categories.id', 'categories.content_type_id', 'categories.parent_id', 'categories.name', 'categories.slug', 'categories.sort_order')
->with(['contentType:id,slug,name']);
},
])
->whereIn('id', $favIds)
->get()
->keyBy('id');
$data = $favIds
->filter(fn ($id) => $indexed->has($id))
->map(fn ($id) => $this->mapArtworkCardPayload($indexed[$id]))
->values();
return response()->json([
'data' => $data,
'next_cursor' => $hasMore ? base64_encode((string) ($offset + $perPage)) : null,
'has_more' => $hasMore,
]);
}
/**
* GET /api/profile/{username}/stats
* Returns profile statistics.
*/
public function stats(Request $request, string $username): JsonResponse
{
$user = $this->resolveUser($username);
if (! $user) {
return response()->json(['error' => 'User not found'], 404);
}
$stats = null;
if (Schema::hasTable('user_statistics')) {
$stats = DB::table('user_statistics')->where('user_id', $user->id)->first();
}
$followerCount = 0;
if (Schema::hasTable('user_followers')) {
$followerCount = DB::table('user_followers')->where('user_id', $user->id)->count();
}
return response()->json([
'stats' => $stats,
'follower_count' => $followerCount,
]);
}
private function resolveUser(string $username): ?User
{
$normalized = UsernamePolicy::normalize($username);
return User::query()->whereRaw('LOWER(username) = ?', [$normalized])->first();
}
private function resolveFavouriteTable(): ?string
{
foreach (['artwork_favourites', 'user_favorites', 'artworks_favourites', 'favourites'] as $table) {
if (Schema::hasTable($table)) {
return $table;
}
}
return null;
}
/**
* @return array<string, mixed>
*/
private function mapArtworkCardPayload(Artwork $art): array
{
$present = ThumbnailPresenter::present($art, 'md');
$category = $art->categories->first();
$contentType = $category?->contentType;
$stats = $art->stats;
$group = $art->group;
$isGroupPublisher = $group !== null;
$displayName = $isGroupPublisher ? ($group->name ?? 'Skinbase') : ($art->user?->name ?? 'Skinbase');
$username = $isGroupPublisher ? null : ($art->user?->username ?? null);
$avatarUrl = $isGroupPublisher ? $group->avatarUrl() : ($art->user?->profile?->avatar_url ?? null);
$profileUrl = $isGroupPublisher
? $group->publicUrl()
: ($username ? '/@' . $username : null);
$publisherType = $isGroupPublisher ? 'group' : 'user';
return [
'id' => $art->id,
'name' => $art->title,
'thumb' => $present['url'],
'thumb_srcset' => $present['srcset'] ?? $present['url'],
'width' => $art->width,
'height' => $art->height,
'username' => $username,
'uname' => $displayName,
'avatar_url' => $avatarUrl,
'profile_url' => $profileUrl,
'published_as_type' => $publisherType,
'publisher' => [
'type' => $publisherType,
'id' => $isGroupPublisher ? (int) $group->id : (int) ($art->user?->id ?? 0),
'name' => $displayName,
'username' => $username ?? '',
'avatar_url' => $avatarUrl,
'profile_url' => $profileUrl,
],
'user_id' => $art->user_id,
'author_level' => $isGroupPublisher ? 0 : (int) ($art->user?->level ?? 1),
'author_rank' => $isGroupPublisher ? '' : (string) ($art->user?->rank ?? 'Newbie'),
'content_type' => $contentType?->name,
'content_type_slug' => $contentType?->slug,
'category' => $category?->name,
'category_slug' => $category?->slug,
'views' => (int) ($stats?->views ?? $art->view_count ?? 0),
'downloads' => (int) ($stats?->downloads ?? 0),
'likes' => (int) ($stats?->favorites ?? $art->favourite_count ?? 0),
'published_at' => $this->formatIsoDate($art->published_at),
];
}
private function formatIsoDate(mixed $value): ?string
{
if ($value instanceof CarbonInterface) {
return $value->toISOString();
}
if ($value instanceof \DateTimeInterface) {
return $value->format(DATE_ATOM);
}
return is_string($value) ? $value : null;
}
}

View File

@@ -0,0 +1,149 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Http\Resources\ArtworkListResource;
use App\Models\Artwork;
use App\Models\Category;
use App\Models\ContentType;
use App\Services\ContentTypes\ContentTypeSlugResolver;
use App\Services\RankingService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
/**
* RankController
*
* Serves pre-computed ranked artwork lists.
*
* Endpoints:
* GET /api/rank/global?type=trending|new_hot|best
* GET /api/rank/category/{id}?type=trending|new_hot|best
* GET /api/rank/type/{contentType}?type=trending|new_hot|best
*/
class RankController extends Controller
{
public function __construct(
private readonly RankingService $ranking,
private readonly ContentTypeSlugResolver $contentTypeResolver,
) {}
/**
* GET /api/rank/global
*
* Returns: { data: [...], meta: { list_type, computed_at, model_version, fallback } }
*/
public function global(Request $request): AnonymousResourceCollection|JsonResponse
{
$listType = $this->resolveListType($request);
$result = $this->ranking->getList('global', null, $listType);
return $this->buildResponse($result, $listType);
}
/**
* GET /api/rank/category/{id}
*/
public function byCategory(Request $request, int $id): AnonymousResourceCollection|JsonResponse
{
if (! Category::where('id', $id)->where('is_active', true)->exists()) {
return response()->json(['message' => 'Category not found.'], 404);
}
$listType = $this->resolveListType($request);
$result = $this->ranking->getList('category', $id, $listType);
return $this->buildResponse($result, $listType);
}
/**
* GET /api/rank/type/{contentType}
*
* {contentType} is accepted as either a slug (string) or numeric id.
*/
public function byContentType(Request $request, string $contentType): AnonymousResourceCollection|JsonResponse
{
$ct = is_numeric($contentType)
? ContentType::find((int) $contentType)
: $this->contentTypeResolver->resolve($contentType)->contentType;
if ($ct === null) {
return response()->json(['message' => 'Content type not found.'], 404);
}
$listType = $this->resolveListType($request);
$result = $this->ranking->getList('content_type', $ct->id, $listType);
return $this->buildResponse($result, $listType);
}
// ── Private helpers ────────────────────────────────────────────────────
/**
* Validate and normalise the ?type query param.
* Defaults to 'trending'.
*/
private function resolveListType(Request $request): string
{
$allowed = ['trending', 'new_hot', 'best'];
$type = $request->query('type', 'trending');
return in_array($type, $allowed, true) ? $type : 'trending';
}
/**
* Hydrate artwork IDs into Eloquent models (no N+1) and wrap in resources.
*
* @param array{ids: int[], computed_at: string|null, model_version: string, fallback: bool} $result
*/
private function buildResponse(array $result, string $listType = 'trending'): AnonymousResourceCollection
{
$ids = $result['ids'];
$artworks = collect();
if (! empty($ids)) {
// Single whereIn query — no N+1
$keyed = Artwork::whereIn('id', $ids)
->with([
'user:id,name',
'user.profile:user_id,avatar_hash',
'categories' => function ($q): void {
$q->select(
'categories.id',
'categories.content_type_id',
'categories.parent_id',
'categories.name',
'categories.slug',
'categories.sort_order'
)->with(['parent:id,parent_id,content_type_id,name,slug', 'contentType:id,slug,name']);
},
])
->get()
->keyBy('id');
// Restore the ranked order
$artworks = collect($ids)
->filter(fn ($id) => $keyed->has($id))
->map(fn ($id) => $keyed[$id]);
}
$collection = ArtworkListResource::collection($artworks);
// Attach ranking meta as additional data
$collection->additional([
'meta' => [
'list_type' => $listType,
'computed_at' => $result['computed_at'],
'model_version' => $result['model_version'],
'fallback' => $result['fallback'],
'count' => $artworks->count(),
],
]);
return $collection;
}
}

View File

@@ -0,0 +1,192 @@
<?php
namespace App\Http\Controllers\Api;
use App\Enums\ReactionType;
use App\Http\Controllers\Controller;
use App\Models\ArtworkComment;
use App\Models\ArtworkReaction;
use App\Models\CommentReaction;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
/**
* Handles reaction toggling for artworks and comments.
*
* POST /api/artworks/{id}/reactions toggle artwork reaction
* POST /api/comments/{id}/reactions toggle comment reaction
* GET /api/artworks/{id}/reactions list artwork reactions
* GET /api/comments/{id}/reactions list comment reactions
*/
class ReactionController extends Controller
{
// ─────────────────────────────────────────────────────────────────────────
// Artwork reactions
// ─────────────────────────────────────────────────────────────────────────
public function artworkReactions(Request $request, int $artworkId): JsonResponse
{
return $this->listReactions('artwork', $artworkId, $request->user()?->id);
}
public function toggleArtworkReaction(Request $request, int $artworkId): JsonResponse
{
$this->validateExists('artworks', $artworkId);
$slug = $this->validateReactionSlug($request);
return $this->toggle(
model: new ArtworkReaction(),
where: ['artwork_id' => $artworkId, 'user_id' => $request->user()->id, 'reaction' => $slug],
countWhere: ['artwork_id' => $artworkId],
entityId: $artworkId,
entityType: 'artwork',
userId: $request->user()->id,
slug: $slug,
);
}
// ─────────────────────────────────────────────────────────────────────────
// Comment reactions
// ─────────────────────────────────────────────────────────────────────────
public function commentReactions(Request $request, int $commentId): JsonResponse
{
return $this->listReactions('comment', $commentId, $request->user()?->id);
}
public function toggleCommentReaction(Request $request, int $commentId): JsonResponse
{
// Make sure comment exists and belongs to a public artwork
$comment = ArtworkComment::with('artwork')
->where('id', $commentId)
->whereHas('artwork', fn ($q) => $q->public()->published())
->firstOrFail();
$slug = $this->validateReactionSlug($request);
return $this->toggle(
model: new CommentReaction(),
where: ['comment_id' => $commentId, 'user_id' => $request->user()->id, 'reaction' => $slug],
countWhere: ['comment_id' => $commentId],
entityId: $commentId,
entityType: 'comment',
userId: $request->user()->id,
slug: $slug,
);
}
// ─────────────────────────────────────────────────────────────────────────
// Shared internals
// ─────────────────────────────────────────────────────────────────────────
private function toggle(
\Illuminate\Database\Eloquent\Model $model,
array $where,
array $countWhere,
int $entityId,
string $entityType,
int $userId,
string $slug,
): JsonResponse {
$table = $model->getTable();
$existing = DB::table($table)->where($where)->first();
if ($existing) {
// Toggle off
DB::table($table)->where($where)->delete();
$active = false;
} else {
// Toggle on
DB::table($table)->insertOrIgnore(array_merge($where, [
'created_at' => now(),
]));
$active = true;
}
// Return fresh totals per reaction type
$totals = $this->getTotals($table, $countWhere, $userId);
return response()->json([
'entity_type' => $entityType,
'entity_id' => $entityId,
'reaction' => $slug,
'active' => $active,
'totals' => $totals,
]);
}
private function listReactions(string $entityType, int $entityId, ?int $userId): JsonResponse
{
if ($entityType === 'artwork') {
$table = 'artwork_reactions';
$where = ['artwork_id' => $entityId];
} else {
$table = 'comment_reactions';
$where = ['comment_id' => $entityId];
}
$totals = $this->getTotals($table, $where, $userId);
return response()->json([
'entity_type' => $entityType,
'entity_id' => $entityId,
'totals' => $totals,
]);
}
/**
* Return per-slug totals and whether the current user has each reaction.
*/
private function getTotals(string $table, array $where, ?int $userId): array
{
$rows = DB::table($table)
->where($where)
->selectRaw('reaction, COUNT(*) as total')
->groupBy('reaction')
->get()
->keyBy('reaction');
$totals = [];
foreach (ReactionType::cases() as $type) {
$slug = $type->value;
$count = (int) ($rows[$slug]->total ?? 0);
// Check if current user has this reaction
$mine = false;
if ($userId && $count > 0) {
$mine = DB::table($table)
->where($where)
->where('reaction', $slug)
->where('user_id', $userId)
->exists();
}
$totals[$slug] = [
'emoji' => $type->emoji(),
'label' => $type->label(),
'count' => $count,
'mine' => $mine,
];
}
return $totals;
}
private function validateReactionSlug(Request $request): string
{
$request->validate([
'reaction' => ['required', 'string', 'in:' . implode(',', ReactionType::values())],
]);
return $request->input('reaction');
}
private function validateExists(string $table, int $id): void
{
if (! DB::table($table)->where('id', $id)->exists()) {
throw new ModelNotFoundException("No [{$table}] record found with id [{$id}].");
}
}
}

View File

@@ -0,0 +1,43 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\Report;
use App\Support\Moderation\ReportTargetResolver;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Validation\Rule;
class ReportController extends Controller
{
public function __construct(private readonly ReportTargetResolver $targets) {}
public function store(Request $request): JsonResponse
{
$user = $request->user();
$data = $request->validate([
'target_type' => ['required', Rule::in($this->targets->supportedTargetTypes())],
'target_id' => 'required|integer|min:1',
'reason' => 'required|string|max:120',
'details' => 'nullable|string|max:4000',
]);
$targetType = $data['target_type'];
$targetId = (int) $data['target_id'];
$this->targets->validateForReporter($user, $targetType, $targetId);
$report = Report::query()->create([
'reporter_id' => $user->id,
'target_type' => $targetType,
'target_id' => $targetId,
'reason' => $data['reason'],
'details' => $data['details'] ?? null,
'status' => 'open',
]);
return response()->json(['id' => $report->id, 'status' => $report->status], 201);
}
}

View File

@@ -0,0 +1,109 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api\Search;
use App\Http\Controllers\Controller;
use App\Http\Resources\ArtworkListResource;
use App\Models\Artwork;
use App\Models\Tag;
use App\Services\ArtworkSearchService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
/**
* Artwork search endpoints powered by Meilisearch.
*
* GET /api/search/artworks?q=&tags[]=&category=&orientation=&sort=
* GET /api/search/artworks/tag/{slug}
* GET /api/search/artworks/category/{cat}
* GET /api/search/artworks/related/{id}
*/
class ArtworkSearchController extends Controller
{
public function __construct(
private readonly ArtworkSearchService $search
) {}
/**
* GET /api/search/artworks
*/
public function index(Request $request): JsonResponse
{
$validated = $request->validate([
'q' => ['nullable', 'string', 'max:200'],
'tags' => ['nullable', 'array', 'max:10'],
'tags.*' => ['string', 'max:80'],
'category' => ['nullable', 'string', 'max:80'],
'orientation' => ['nullable', 'in:landscape,portrait,square'],
'resolution' => ['nullable', 'string', 'max:20'],
'author_id' => ['nullable', 'integer', 'min:1'],
'sort' => ['nullable', 'string', 'regex:/^(created_at|downloads|likes|views):(asc|desc)$/'],
'per_page' => ['nullable', 'integer', 'min:1', 'max:100'],
]);
$results = $this->search->search(
q: (string) ($validated['q'] ?? ''),
filters: array_filter([
'tags' => $validated['tags'] ?? [],
'category' => $validated['category'] ?? null,
'orientation' => $validated['orientation'] ?? null,
'resolution' => $validated['resolution'] ?? null,
'author_id' => $validated['author_id'] ?? null,
'sort' => $validated['sort'] ?? null,
]),
perPage: (int) ($validated['per_page'] ?? 24),
);
// Eager-load relations needed by ArtworkListResource
$results->getCollection()->loadMissing(['user', 'categories.contentType']);
return ArtworkListResource::collection($results)->response();
}
/**
* GET /api/search/artworks/tag/{slug}
*/
public function byTag(Request $request, string $slug): JsonResponse
{
$tag = Tag::where('slug', $slug)->first();
if (! $tag) {
return response()->json(['message' => 'Tag not found.'], 404);
}
$results = $this->search->byTag($slug, (int) $request->query('per_page', 24));
return response()->json([
'tag' => ['id' => $tag->id, 'name' => $tag->name, 'slug' => $tag->slug],
'results' => $results,
]);
}
/**
* GET /api/search/artworks/category/{cat}
*/
public function byCategory(Request $request, string $cat): JsonResponse
{
$results = $this->search->byCategory($cat, (int) $request->query('per_page', 24));
return response()->json($results);
}
/**
* GET /api/search/artworks/related/{id}
*/
public function related(int $id): JsonResponse
{
$artwork = Artwork::with(['tags'])->find($id);
if (! $artwork) {
return response()->json(['message' => 'Artwork not found.'], 404);
}
$results = $this->search->related($artwork, 12);
return response()->json($results);
}
}

View File

@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api\Search;
use App\Http\Controllers\Controller;
use App\Services\GroupDiscoveryService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
final class GroupSearchController extends Controller
{
public function __construct(private readonly GroupDiscoveryService $groups) {}
public function __invoke(Request $request): JsonResponse
{
$q = trim((string) $request->query('q', ''));
if (mb_strlen($q) < 2) {
return response()->json(['data' => []]);
}
$perPage = min(max((int) $request->query('per_page', 6), 1), 12);
$items = array_map(function (array $group): array {
$group['group_type'] = $group['type'] ?? null;
$group['type'] = 'group';
return $group;
}, $this->groups->searchCards($q, $request->user(), $perPage));
return response()->json([
'data' => $items,
]);
}
}

View File

@@ -0,0 +1,61 @@
<?php
namespace App\Http\Controllers\Api\Search;
use App\Http\Controllers\Controller;
use App\Models\User;
use App\Support\AvatarUrl;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
final class UserSearchController extends Controller
{
/**
* GET /api/search/users?q=gregor&per_page=4
*
* Public, rate-limited. Strips a leading @ from the query so that
* typing "@gregor" and "gregor" both work.
*/
public function __invoke(Request $request): JsonResponse
{
$raw = trim((string) $request->query('q', ''));
$q = ltrim($raw, '@');
if (strlen($q) < 2) {
return response()->json(['data' => []]);
}
$perPage = min((int) $request->query('per_page', 4), 8);
$users = User::query()
->where('is_active', 1)
->whereNull('deleted_at')
->where(function ($qb) use ($q) {
$qb->whereRaw('LOWER(username) LIKE ?', ['%' . strtolower($q) . '%'])
->orWhereRaw('LOWER(name) LIKE ?', ['%' . strtolower($q) . '%']);
})
->with(['profile', 'statistics'])
->orderByRaw('LOWER(username) = ? DESC', [strtolower($q)]) // exact match first
->orderBy('username')
->limit($perPage)
->get(['id', 'username', 'name']);
$data = $users->map(function (User $user) {
$username = strtolower((string) ($user->username ?? ''));
$avatarHash = $user->profile?->avatar_hash;
$uploadsCount = (int) ($user->statistics?->uploads_count ?? 0);
return [
'id' => $user->id,
'type' => 'user',
'username' => $username,
'name' => $user->name ?? $username,
'avatar_url' => AvatarUrl::forUser((int) $user->id, $avatarHash, 64),
'uploads_count' => $uploadsCount,
'profile_url' => '/@' . $username,
];
});
return response()->json(['data' => $data]);
}
}

View File

@@ -0,0 +1,55 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\Artwork;
use App\Services\Vision\VectorService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use RuntimeException;
final class SimilarAiArtworksController extends Controller
{
public function __construct(private readonly VectorService $vectors)
{
}
public function __invoke(Request $request, int $id): JsonResponse
{
$artwork = Artwork::query()->public()->published()->find($id);
if ($artwork === null) {
return response()->json(['error' => 'Artwork not found'], 404);
}
if (! $this->vectors->isConfigured()) {
return response()->json([
'data' => [],
'reason' => 'vector_gateway_not_configured',
], 503);
}
$limit = max(1, min(24, (int) $request->query('limit', 12)));
try {
$items = $this->vectors->similarToArtwork($artwork, $limit);
} catch (RuntimeException $e) {
return response()->json([
'data' => [],
'reason' => 'vector_gateway_error',
'message' => $e->getMessage(),
], 502);
}
return response()->json([
'data' => $items,
'meta' => [
'source' => 'vector_gateway',
'artwork_id' => $artwork->id,
'limit' => $limit,
],
]);
}
}

View File

@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Symfony\Component\HttpFoundation\Response;
final class SimilarArtworkAnalyticsController extends Controller
{
public function store(Request $request): JsonResponse
{
$payload = $request->validate([
'event_type' => ['required', 'string', 'in:impression,click'],
'algo_version' => ['required', 'string', 'max:64'],
'source_artwork_id' => ['required', 'integer', 'exists:artworks,id'],
'similar_artwork_id' => ['nullable', 'integer', 'exists:artworks,id'],
'position' => ['nullable', 'integer', 'min:1', 'max:100'],
'items_count' => ['nullable', 'integer', 'min:0', 'max:100'],
]);
DB::table('similar_artwork_events')->insert([
'event_date' => now()->toDateString(),
'event_type' => (string) $payload['event_type'],
'algo_version' => (string) $payload['algo_version'],
'source_artwork_id' => (int) $payload['source_artwork_id'],
'similar_artwork_id' => isset($payload['similar_artwork_id']) ? (int) $payload['similar_artwork_id'] : null,
'position' => isset($payload['position']) ? (int) $payload['position'] : null,
'items_count' => isset($payload['items_count']) ? (int) $payload['items_count'] : null,
'occurred_at' => now(),
'created_at' => now(),
'updated_at' => now(),
]);
return response()->json(['success' => true], Response::HTTP_OK);
}
}

View File

@@ -0,0 +1,197 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\Artwork;
use App\Services\ArtworkSearchService;
use App\Services\Recommendations\HybridSimilarArtworksService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
/**
* GET /api/art/{id}/similar
*
* Returns up to 12 similar artworks using the hybrid recommender (precomputed lists)
* with a Meilisearch-based fallback if no precomputed data exists.
*
* Query params:
* ?type=similar (default) | visual | tags | behavior
*
* Priority (default):
* 1. Hybrid precomputed (tag + behavior + optional vector)
* 2. Meilisearch tag-overlap fallback (legacy)
*/
final class SimilarArtworksController extends Controller
{
private const LIMIT = 12;
public function __construct(
private readonly ArtworkSearchService $search,
private readonly HybridSimilarArtworksService $hybridService,
) {}
public function __invoke(Request $request, int $id): JsonResponse
{
$artwork = Artwork::public()
->published()
->with(['tags:id,slug', 'categories:id,slug'])
->find($id);
if (! $artwork) {
return response()->json(['error' => 'Artwork not found'], 404);
}
$type = $request->query('type');
$validTypes = ['similar', 'visual', 'tags', 'behavior'];
if ($type !== null && ! in_array($type, $validTypes, true)) {
$type = null; // ignore invalid, fall through to default
}
// Service handles its own caching (6h TTL), no extra controller-level cache
$hybridResults = $this->hybridService->forArtwork($artwork->id, self::LIMIT, $type);
if ($hybridResults->isNotEmpty()) {
// Eager-load relations needed for formatting
$ids = $hybridResults->pluck('id')->all();
$loaded = Artwork::query()
->whereIn('id', $ids)
->with(['tags:id,slug', 'stats', 'user:id,name', 'user.profile:user_id,avatar_hash'])
->get()
->keyBy('id');
$items = $hybridResults->values()->map(function (Artwork $a) use ($loaded) {
$full = $loaded->get($a->id) ?? $a;
return $this->formatArtwork($full);
})->all();
return response()->json(['data' => $items]);
}
// Fall back to Meilisearch tag-overlap search
$items = $this->findSimilarViaSearch($artwork);
return response()->json(['data' => $items]);
}
private function formatArtwork(Artwork $artwork): array
{
return [
'id' => $artwork->id,
'title' => $artwork->title,
'slug' => $artwork->slug,
'thumb' => $artwork->thumbUrl('md'),
'url' => '/art/' . $artwork->id . '/' . $artwork->slug,
'author' => $artwork->user?->name ?? 'Artist',
'author_avatar' => $artwork->user?->profile?->avatar_url,
'author_id' => $artwork->user_id,
'orientation' => $this->orientation($artwork),
'width' => $artwork->width,
'height' => $artwork->height,
];
}
/**
* Legacy Meilisearch-based similar artworks (fallback).
*/
private function findSimilarViaSearch(Artwork $artwork): array
{
$tagSlugs = $artwork->tags->pluck('slug')->values()->all();
$categorySlugs = $artwork->categories->pluck('slug')->values()->all();
$srcOrientation = $this->orientation($artwork);
$filterParts = [
'is_public = true',
'is_approved = true',
'id != ' . $artwork->id,
'author_id != ' . $artwork->user_id,
];
if ($tagSlugs !== []) {
$tagFilter = implode(' OR ', array_map(
fn (string $t): string => 'tags = "' . addslashes($t) . '"',
$tagSlugs
));
$filterParts[] = '(' . $tagFilter . ')';
} elseif ($categorySlugs !== []) {
$catFilter = implode(' OR ', array_map(
fn (string $c): string => 'category = "' . addslashes($c) . '"',
$categorySlugs
));
$filterParts[] = '(' . $catFilter . ')';
}
$results = Artwork::search('')
->options([
'filter' => implode(' AND ', $filterParts),
'sort' => ['trending_score_7d:desc', 'created_at:desc'],
])
->paginate(200, 'page', 1);
$collection = $results->getCollection();
$collection->load(['tags:id,slug', 'stats', 'user:id,name', 'user.profile:user_id,avatar_hash']);
$srcTagSet = array_flip($tagSlugs);
$srcW = (int) ($artwork->width ?? 0);
$srcH = (int) ($artwork->height ?? 0);
$scored = $collection->map(function (Artwork $candidate) use (
$srcTagSet, $tagSlugs, $srcOrientation, $srcW, $srcH
): array {
$cTagSlugs = $candidate->tags->pluck('slug')->all();
$cTagSet = array_flip($cTagSlugs);
$common = count(array_intersect_key($srcTagSet, $cTagSet));
$total = max(1, count($srcTagSet) + count($cTagSet) - $common);
$tagOverlap = $common / $total;
$orientBonus = $this->orientation($candidate) === $srcOrientation ? 0.10 : 0.0;
$cW = (int) ($candidate->width ?? 0);
$cH = (int) ($candidate->height ?? 0);
$resBonus = ($srcW > 0 && $srcH > 0 && $cW > 0 && $cH > 0
&& abs($cW - $srcW) / $srcW <= 0.25
&& abs($cH - $srcH) / $srcH <= 0.25
) ? 0.05 : 0.0;
$views = max(0, (int) ($candidate->stats?->views ?? 0));
$popularity = min(0.15, log(1 + $views) / 13.0);
$publishedAt = $candidate->published_at ?? $candidate->created_at ?? now();
$ageDays = max(0.0, (float) $publishedAt->diffInSeconds(now()) / 86400);
$freshness = exp(-$ageDays / 60.0) * 0.10;
$score = $tagOverlap * 0.60
+ $orientBonus
+ $resBonus
+ $popularity
+ $freshness;
return ['score' => $score, 'artwork' => $candidate];
})->all();
usort($scored, fn ($a, $b) => $b['score'] <=> $a['score']);
return array_values(
array_map(fn (array $item): array => array_merge(
$this->formatArtwork($item['artwork']),
['score' => round((float) $item['score'], 5)]
), array_slice($scored, 0, self::LIMIT))
);
}
private function orientation(Artwork $artwork): string
{
if (! $artwork->width || ! $artwork->height) {
return 'square';
}
return match (true) {
$artwork->width > $artwork->height => 'landscape',
$artwork->height > $artwork->width => 'portrait',
default => 'square',
};
}
}

View File

@@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Services\ActivityService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
final class SocialActivityController extends Controller
{
public function __construct(private readonly ActivityService $activity) {}
public function index(Request $request): JsonResponse
{
$filter = (string) $request->query('filter', 'all');
if ($this->activity->requiresAuthentication($filter) && ! $request->user()) {
return response()->json(['error' => 'Unauthenticated'], 401);
}
return response()->json(
$this->activity->communityFeed(
viewer: $request->user(),
filter: $filter,
page: (int) $request->query('page', 1),
perPage: (int) $request->query('per_page', 20),
actorUserId: $request->filled('user_id') ? (int) $request->query('user_id') : null,
)
);
}
}

View File

@@ -0,0 +1,212 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\Artwork;
use App\Models\Story;
use App\Models\StoryBookmark;
use App\Services\SocialService;
use Carbon\Carbon;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Str;
final class SocialCompatibilityController extends Controller
{
public function __construct(private readonly SocialService $social) {}
public function like(Request $request): JsonResponse
{
$payload = $request->validate([
'entity_type' => ['required', 'string', 'in:artwork,story'],
'entity_id' => ['required', 'integer'],
'state' => ['nullable', 'boolean'],
]);
$state = array_key_exists('state', $payload)
? (bool) $payload['state']
: ! $request->isMethod('delete');
if ($payload['entity_type'] === 'story') {
$story = Story::published()->findOrFail((int) $payload['entity_id']);
$result = $this->social->toggleStoryLike($request->user(), $story, $state);
return response()->json([
'ok' => (bool) ($result['ok'] ?? true),
'liked' => (bool) ($result['liked'] ?? false),
'likes_count' => (int) ($result['likes_count'] ?? 0),
'is_liked' => (bool) ($result['liked'] ?? false),
'stats' => [
'likes' => (int) ($result['likes_count'] ?? 0),
],
]);
}
$artworkId = (int) $payload['entity_id'];
abort_unless(Artwork::public()->published()->whereKey($artworkId)->exists(), 404);
return app(ArtworkInteractionController::class)->like(
$request->merge(['state' => $state]),
$artworkId,
);
}
public function comments(Request $request): JsonResponse
{
$payload = $request->validate([
'entity_type' => ['required', 'string', 'in:artwork,story'],
'entity_id' => ['required', 'integer'],
'content' => [$request->isMethod('get') ? 'nullable' : 'required', 'string', 'min:1', 'max:10000'],
'parent_id' => ['nullable', 'integer'],
]);
if ($payload['entity_type'] === 'story') {
if ($request->isMethod('get')) {
$story = Story::published()->findOrFail((int) $payload['entity_id']);
return response()->json(
$this->social->listStoryComments($story, $request->user()?->id, (int) $request->query('page', 1), 20)
);
}
$story = Story::published()->findOrFail((int) $payload['entity_id']);
$comment = $this->social->addStoryComment(
$request->user(),
$story,
(string) $payload['content'],
isset($payload['parent_id']) ? (int) $payload['parent_id'] : null,
);
return response()->json([
'data' => $this->social->formatComment($comment, (int) $request->user()->id, true),
], 201);
}
$artworkId = (int) $payload['entity_id'];
abort_unless(Artwork::public()->published()->whereKey($artworkId)->exists(), 404);
if ($request->isMethod('get')) {
return app(ArtworkCommentController::class)->index($request, $artworkId);
}
return app(ArtworkCommentController::class)->store(
$request->merge([
'content' => $payload['content'],
'parent_id' => $payload['parent_id'] ?? null,
]),
$artworkId,
);
}
public function bookmark(Request $request): JsonResponse
{
$payload = $request->validate([
'entity_type' => ['required', 'string', 'in:artwork,story'],
'entity_id' => ['required', 'integer'],
'state' => ['nullable', 'boolean'],
]);
$state = array_key_exists('state', $payload)
? (bool) $payload['state']
: ! $request->isMethod('delete');
if ($payload['entity_type'] === 'story') {
$story = Story::published()->findOrFail((int) $payload['entity_id']);
$result = $this->social->toggleStoryBookmark($request->user(), $story, $state);
return response()->json([
'ok' => (bool) ($result['ok'] ?? true),
'bookmarked' => (bool) ($result['bookmarked'] ?? false),
'bookmarks_count' => (int) ($result['bookmarks_count'] ?? 0),
'is_bookmarked' => (bool) ($result['bookmarked'] ?? false),
'stats' => [
'bookmarks' => (int) ($result['bookmarks_count'] ?? 0),
],
]);
}
$artworkId = (int) $payload['entity_id'];
abort_unless(Artwork::public()->published()->whereKey($artworkId)->exists(), 404);
return app(ArtworkInteractionController::class)->bookmark(
$request->merge(['state' => $state]),
$artworkId,
);
}
public function bookmarks(Request $request): JsonResponse
{
$payload = $request->validate([
'entity_type' => ['nullable', 'string', 'in:artwork,story'],
'per_page' => ['nullable', 'integer', 'min:1', 'max:50'],
]);
$perPage = (int) ($payload['per_page'] ?? 20);
$userId = (int) $request->user()->id;
$type = $payload['entity_type'] ?? null;
$items = collect();
if ($type === null || $type === 'artwork') {
$items = $items->concat(
Schema::hasTable('artwork_bookmarks')
? DB::table('artwork_bookmarks')
->join('artworks', 'artworks.id', '=', 'artwork_bookmarks.artwork_id')
->where('artwork_bookmarks.user_id', $userId)
->where('artworks.is_public', true)
->where('artworks.is_approved', true)
->select([
'artwork_bookmarks.created_at as saved_at',
'artworks.id',
'artworks.title',
'artworks.slug',
])
->latest('artwork_bookmarks.created_at')
->limit($perPage)
->get()
->map(fn ($row) => [
'type' => 'artwork',
'id' => (int) $row->id,
'title' => (string) $row->title,
'url' => route('art.show', ['id' => (int) $row->id, 'slug' => Str::slug((string) ($row->slug ?: $row->title)) ?: (string) $row->id]),
'saved_at' => Carbon::parse($row->saved_at)->toIso8601String(),
])
: collect()
);
}
if ($type === null || $type === 'story') {
$items = $items->concat(
StoryBookmark::query()
->with('story:id,slug,title')
->where('user_id', $userId)
->latest('created_at')
->limit($perPage)
->get()
->filter(fn (StoryBookmark $bookmark) => $bookmark->story !== null)
->map(fn (StoryBookmark $bookmark) => [
'type' => 'story',
'id' => (int) $bookmark->story->id,
'title' => (string) $bookmark->story->title,
'url' => route('stories.show', ['slug' => $bookmark->story->slug]),
'saved_at' => $bookmark->created_at?->toIso8601String(),
])
);
}
return response()->json([
'data' => $items
->sortByDesc('saved_at')
->take($perPage)
->values()
->all(),
]);
}
}

View File

@@ -0,0 +1,191 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\Story;
use App\Models\StoryTag;
use App\Models\User;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
/**
* Stories API JSON endpoints for React frontend.
*
* GET /api/stories list published stories (paginated)
* GET /api/stories/{slug} single story detail
* GET /api/stories/tag/{tag} stories by tag
* GET /api/stories/author/{author} stories by author
* GET /api/stories/featured featured stories
*/
final class StoriesApiController extends Controller
{
/**
* List published stories (paginated).
* GET /api/stories?page=1&per_page=12
*/
public function index(Request $request): JsonResponse
{
$perPage = min((int) $request->get('per_page', 12), 50);
$page = (int) $request->get('page', 1);
$cacheKey = "stories:api:list:{$perPage}:{$page}";
$stories = Cache::remember($cacheKey, 300, fn () =>
Story::published()
->with('creator.profile', 'tags')
->orderByDesc('published_at')
->paginate($perPage, ['*'], 'page', $page)
);
return response()->json([
'data' => $stories->getCollection()->map(fn (Story $s) => $this->formatCard($s)),
'meta' => [
'current_page' => $stories->currentPage(),
'last_page' => $stories->lastPage(),
'per_page' => $stories->perPage(),
'total' => $stories->total(),
],
]);
}
/**
* Single story detail.
* GET /api/stories/{slug}
*/
public function show(string $slug): JsonResponse
{
$story = Cache::remember('stories:api:' . $slug, 600, fn () =>
Story::published()
->with('creator.profile', 'tags')
->where('slug', $slug)
->firstOrFail()
);
return response()->json($this->formatFull($story));
}
/**
* Featured story.
* GET /api/stories/featured
*/
public function featured(): JsonResponse
{
$story = Cache::remember('stories:api:featured', 300, fn () =>
Story::published()->featured()
->with('creator.profile', 'tags')
->orderByDesc('published_at')
->first()
);
if (! $story) {
return response()->json(null);
}
return response()->json($this->formatFull($story));
}
/**
* Stories by tag.
* GET /api/stories/tag/{tag}?page=1
*/
public function byTag(Request $request, string $tag): JsonResponse
{
$storyTag = StoryTag::where('slug', $tag)->firstOrFail();
$page = (int) $request->get('page', 1);
$stories = Cache::remember("stories:api:tag:{$tag}:{$page}", 300, fn () =>
Story::published()
->with('creator.profile', 'tags')
->whereHas('tags', fn ($q) => $q->where('story_tags.id', $storyTag->id))
->orderByDesc('published_at')
->paginate(12, ['*'], 'page', $page)
);
return response()->json([
'tag' => ['id' => $storyTag->id, 'slug' => $storyTag->slug, 'name' => $storyTag->name],
'data' => $stories->getCollection()->map(fn (Story $s) => $this->formatCard($s)),
'meta' => [
'current_page' => $stories->currentPage(),
'last_page' => $stories->lastPage(),
'per_page' => $stories->perPage(),
'total' => $stories->total(),
],
]);
}
/**
* Stories by author.
* GET /api/stories/author/{username}?page=1
*/
public function byAuthor(Request $request, string $username): JsonResponse
{
$author = User::query()->whereRaw('LOWER(username) = ?', [strtolower($username)])->firstOrFail();
$page = (int) $request->get('page', 1);
$stories = Cache::remember("stories:api:author:{$author->id}:{$page}", 300, fn () =>
Story::published()
->with('creator.profile', 'tags')
->where('creator_id', $author->id)
->orderByDesc('published_at')
->paginate(12, ['*'], 'page', $page)
);
return response()->json([
'author' => $this->formatCreator($author),
'data' => $stories->getCollection()->map(fn (Story $s) => $this->formatCard($s)),
'meta' => [
'current_page' => $stories->currentPage(),
'last_page' => $stories->lastPage(),
'per_page' => $stories->perPage(),
'total' => $stories->total(),
],
]);
}
// ── Private formatters ────────────────────────────────────────────────
private function formatCard(Story $story): array
{
return [
'id' => $story->id,
'slug' => $story->slug,
'url' => $story->url,
'title' => $story->title,
'excerpt' => $story->excerpt,
'cover_image' => $story->cover_url,
'author' => $story->creator ? $this->formatCreator($story->creator) : null,
'tags' => $story->tags->map(fn ($t) => ['id' => $t->id, 'slug' => $t->slug, 'name' => $t->name, 'url' => $t->url]),
'views' => $story->views,
'featured' => $story->featured,
'reading_time' => $story->reading_time,
'published_at' => $story->published_at?->toIso8601String(),
];
}
private function formatFull(Story $story): array
{
return array_merge($this->formatCard($story), [
'content' => $story->content,
]);
}
private function formatCreator(User $creator): array
{
$avatarHash = $creator->profile?->avatar_hash;
return [
'id' => $creator->id,
'name' => $creator->username ?? $creator->name,
'avatar_url' => $avatarHash
? \App\Support\AvatarUrl::forUser((int) $creator->id, $avatarHash, 96)
: \App\Support\AvatarUrl::default(),
'bio' => $creator->profile?->about,
'profile_url' => '/@' . strtolower((string) ($creator->username ?? $creator->id)),
];
}
}

View File

@@ -0,0 +1,58 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\Story;
use App\Models\StoryComment;
use App\Services\SocialService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
final class StoryCommentController extends Controller
{
public function __construct(private readonly SocialService $social) {}
public function index(Request $request, int $storyId): JsonResponse
{
$story = Story::published()->findOrFail($storyId);
return response()->json(
$this->social->listStoryComments($story, $request->user()?->id, (int) $request->query('page', 1), 20)
);
}
public function store(Request $request, int $storyId): JsonResponse
{
$story = Story::published()->findOrFail($storyId);
$payload = $request->validate([
'content' => ['required', 'string', 'min:1', 'max:10000'],
'parent_id' => ['nullable', 'integer'],
]);
$comment = $this->social->addStoryComment(
$request->user(),
$story,
(string) $payload['content'],
isset($payload['parent_id']) ? (int) $payload['parent_id'] : null,
);
return response()->json([
'data' => $this->social->formatComment($comment, $request->user()->id, true),
], 201);
}
public function destroy(Request $request, int $storyId, int $commentId): JsonResponse
{
$comment = StoryComment::query()
->where('story_id', $storyId)
->findOrFail($commentId);
$this->social->deleteStoryComment($request->user(), $comment);
return response()->json(['message' => 'Comment deleted.']);
}
}

View File

@@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\Story;
use App\Services\SocialService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
final class StoryInteractionController extends Controller
{
public function __construct(private readonly SocialService $social) {}
public function like(Request $request, int $storyId): JsonResponse
{
$story = Story::published()->findOrFail($storyId);
return response()->json(
$this->social->toggleStoryLike($request->user(), $story, $request->boolean('state', true))
);
}
public function bookmark(Request $request, int $storyId): JsonResponse
{
$story = Story::published()->findOrFail($storyId);
return response()->json(
$this->social->toggleStoryBookmark($request->user(), $story, $request->boolean('state', true))
);
}
}

View File

@@ -0,0 +1,217 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Services\Recommendation\UserPreferenceBuilder;
use App\Support\AvatarUrl;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
/**
* GET /api/user/suggestions/creators
*
* Returns up to 12 creators the authenticated user might want to follow.
*
* Ranking algorithm (Phase 1 no embeddings):
* 1. Creators followed by people you follow (mutual-follow signal)
* 2. Creators whose recent works overlap your top tags
* 3. High-quality creators (followers_count / artworks_count) in your categories
*
* Exclusions: yourself, already-followed creators.
*
* Cached per user for config('recommendations.ttl.creator_suggestions') seconds (default 30 min).
*/
final class SuggestedCreatorsController extends Controller
{
private const LIMIT = 12;
public function __construct(private readonly UserPreferenceBuilder $prefBuilder) {}
public function __invoke(Request $request): JsonResponse
{
$user = $request->user();
$ttl = (int) config('recommendations.ttl.creator_suggestions', 30 * 60);
$cacheKey = "creator_suggestions:{$user->id}";
$data = Cache::remember($cacheKey, $ttl, function () use ($user) {
return $this->buildSuggestions($user);
});
return response()->json(['data' => $data]);
}
private function buildSuggestions(\App\Models\User $user): array
{
try {
$profile = $this->prefBuilder->build($user);
$followingIds = $profile->strongCreatorIds;
$topTagSlugs = array_slice($profile->topTagSlugs, 0, 10);
// ── 1. Mutual-follow candidates ───────────────────────────────────
$mutualCandidates = [];
if ($followingIds !== []) {
$rows = DB::table('user_followers as uf')
->join('users as u', 'u.id', '=', 'uf.user_id')
->leftJoin('user_profiles as up', 'up.user_id', '=', 'u.id')
->leftJoin('user_statistics as us', 'us.user_id', '=', 'u.id')
->whereIn('uf.follower_id', $followingIds)
->where('uf.user_id', '!=', $user->id)
->whereNotIn('uf.user_id', array_merge($followingIds, [$user->id]))
->where('u.is_active', true)
->selectRaw('
u.id,
u.name,
u.username,
up.avatar_hash,
COALESCE(us.followers_count, 0) as followers_count,
COALESCE(us.uploads_count, 0) as artworks_count,
COUNT(*) as mutual_weight
')
->groupBy('u.id', 'u.name', 'u.username', 'up.avatar_hash', 'us.followers_count', 'us.uploads_count')
->orderByDesc('mutual_weight')
->limit(20)
->get();
foreach ($rows as $row) {
$mutualCandidates[(int) $row->id] = [
'id' => (int) $row->id,
'name' => $row->name,
'username' => $row->username,
'avatar_hash' => $row->avatar_hash,
'followers_count' => (int) $row->followers_count,
'artworks_count' => (int) $row->artworks_count,
'score' => (float) $row->mutual_weight * 3.0,
'reason' => 'Popular among creators you follow',
];
}
}
// ── 2. Tag-affinity candidates ────────────────────────────────────
$tagCandidates = [];
if ($topTagSlugs !== []) {
$tagFilter = implode(',', array_fill(0, count($topTagSlugs), '?'));
$rows = DB::table('tags as t')
->join('artwork_tag as at', 'at.tag_id', '=', 't.id')
->join('artworks as a', 'a.id', '=', 'at.artwork_id')
->join('users as u', 'u.id', '=', 'a.user_id')
->leftJoin('user_profiles as up', 'up.user_id', '=', 'u.id')
->leftJoin('user_statistics as us', 'us.user_id', '=', 'u.id')
->whereIn('t.slug', $topTagSlugs)
->where('a.is_public', true)
->where('a.is_approved', true)
->whereNull('a.deleted_at')
->where('u.id', '!=', $user->id)
->whereNotIn('u.id', array_merge($followingIds, [$user->id]))
->where('u.is_active', true)
->selectRaw('
u.id,
u.name,
u.username,
up.avatar_hash,
COALESCE(us.followers_count, 0) as followers_count,
COALESCE(us.uploads_count, 0) as artworks_count,
COUNT(DISTINCT t.id) as matched_tags
')
->groupBy('u.id', 'u.name', 'u.username', 'up.avatar_hash', 'us.followers_count', 'us.uploads_count')
->orderByDesc('matched_tags')
->limit(20)
->get();
foreach ($rows as $row) {
if (isset($mutualCandidates[(int) $row->id])) {
// Boost mutual candidate that also matches tags
$mutualCandidates[(int) $row->id]['score'] += (float) $row->matched_tags;
continue;
}
$tagCandidates[(int) $row->id] = [
'id' => (int) $row->id,
'name' => $row->name,
'username' => $row->username,
'avatar_hash' => $row->avatar_hash,
'followers_count' => (int) $row->followers_count,
'artworks_count' => (int) $row->artworks_count,
'score' => (float) $row->matched_tags * 2.0,
'reason' => 'Matches your interests',
];
}
}
// ── 3. Merge & rank ───────────────────────────────────────────────
$combined = array_values(array_merge($mutualCandidates, $tagCandidates));
usort($combined, fn ($a, $b) => $b['score'] <=> $a['score']);
$top = array_slice($combined, 0, self::LIMIT);
if (count($top) < self::LIMIT) {
$topIds = array_column($top, 'id');
$excluded = array_unique(array_merge($followingIds, [$user->id], $topIds));
$top = array_merge($top, $this->highQualityFallback($excluded, self::LIMIT - count($top)));
}
return array_map(fn (array $c): array => [
'id' => $c['id'],
'name' => $c['name'],
'username' => $c['username'],
'url' => $c['username'] ? '/@' . $c['username'] : '/profile/' . $c['id'],
'avatar' => AvatarUrl::forUser((int) $c['id'], $c['avatar_hash'] ?? null, 64),
'followers_count' => (int) ($c['followers_count'] ?? 0),
'artworks_count' => (int) ($c['artworks_count'] ?? 0),
'reason' => $c['reason'] ?? null,
], $top);
} catch (\Throwable $e) {
Log::warning('SuggestedCreatorsController: failed', [
'user_id' => $user->id,
'error' => $e->getMessage(),
]);
return [];
}
}
/**
* @param array<int, int> $excludedIds
* @return array<int, array<string, mixed>>
*/
private function highQualityFallback(array $excludedIds, int $limit): array
{
if ($limit <= 0) {
return [];
}
$rows = DB::table('users as u')
->join('user_profiles as up', 'up.user_id', '=', 'u.id')
->leftJoin('user_statistics as us', 'us.user_id', '=', 'u.id')
->whereNotIn('u.id', $excludedIds)
->where('u.is_active', true)
->selectRaw('
u.id,
u.name,
u.username,
up.avatar_hash,
COALESCE(us.followers_count, 0) as followers_count,
COALESCE(us.uploads_count, 0) as artworks_count
')
->orderByDesc('followers_count')
->limit($limit)
->get();
return $rows->map(fn ($r) => [
'id' => (int) $r->id,
'name' => $r->name,
'username' => $r->username,
'avatar_hash' => $r->avatar_hash,
'followers_count' => (int) $r->followers_count,
'artworks_count' => (int) $r->artworks_count,
'score' => (float) $r->followers_count * 0.1,
'reason' => 'Popular creator',
])->all();
}
}

View File

@@ -0,0 +1,147 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Services\Recommendation\UserPreferenceBuilder;
use App\Services\Tags\TagDiscoveryService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
/**
* GET /api/user/suggestions/tags
*
* Returns up to 20 tag suggestions for the authenticated user.
*
* Sources:
* 1. Tags from the user's favourited artworks and awards (affinity-ranked)
* 2. Trending tags from global activity (fallback / discovery)
*
* Does NOT require the user to follow tags. This endpoint provides the foundation
* for a future "follow tags" feature while being useful immediately as discovery input.
*
* Cached per user for config('recommendations.ttl.tag_suggestions') seconds (default 60 min).
*/
final class SuggestedTagsController extends Controller
{
private const LIMIT = 20;
public function __construct(
private readonly UserPreferenceBuilder $prefBuilder,
private readonly TagDiscoveryService $tagDiscoveryService,
) {}
public function __invoke(Request $request): JsonResponse
{
$user = $request->user();
$ttl = (int) config('recommendations.ttl.tag_suggestions', 60 * 60);
$cacheKey = "tag_suggestions:{$user->id}";
$data = Cache::remember($cacheKey, $ttl, function () use ($user) {
return $this->buildSuggestions($user);
});
return response()->json(['data' => $data]);
}
private function buildSuggestions(\App\Models\User $user): array
{
try {
$profile = $this->prefBuilder->build($user);
$knownTagSlugs = $profile->topTagSlugs; // already in user's profile skip
// ── Personalised tags (with normalised weights) ───────────────────
$personalised = [];
foreach ($profile->tagWeights as $slug => $weight) {
if ($weight > 0.0) {
$personalised[$slug] = (float) $weight;
}
}
arsort($personalised);
// ── Trending tags (global, last 7 days) ───────────────────────────
$trending = $this->trendingTags(40);
// ── Merge: personalised first, then trending discovery ─────────────
$merged = [];
foreach ($personalised as $slug => $weight) {
$merged[$slug] = [
'slug' => $slug,
'score' => $weight * 2.0, // boost personal signal
'source' => 'affinity',
];
}
foreach ($trending as $row) {
$slug = (string) $row->slug;
if (isset($merged[$slug])) {
$merged[$slug]['score'] += (float) $row->trend_score;
} else {
$merged[$slug] = [
'slug' => $slug,
'score' => (float) $row->trend_score,
'source' => 'trending',
];
}
}
uasort($merged, fn ($a, $b) => $b['score'] <=> $a['score']);
$top = array_slice(array_values($merged), 0, self::LIMIT);
// ── Hydrate with DB info ──────────────────────────────────────────
$slugs = array_column($top, 'slug');
$tagRows = DB::table('tags')
->whereIn('slug', $slugs)
->where('is_active', true)
->get(['id', 'name', 'slug', 'usage_count'])
->keyBy('slug');
$result = [];
foreach ($top as $item) {
$tag = $tagRows->get($item['slug']);
if ($tag === null) {
continue;
}
$result[] = [
'id' => (int) $tag->id,
'name' => (string) $tag->name,
'slug' => (string) $tag->slug,
'usage_count' => (int) $tag->usage_count,
'source' => (string) $item['source'],
];
}
return $result;
} catch (\Throwable $e) {
Log::warning('SuggestedTagsController: failed', [
'user_id' => $user->id,
'error' => $e->getMessage(),
]);
return [];
}
}
/**
* @return \Illuminate\Support\Collection<int, object{slug:string, trend_score:float}>
*/
private function trendingTags(int $limit): \Illuminate\Support\Collection
{
return $this->tagDiscoveryService
->popularTags($limit, 7)
->map(static fn ($tag) => (object) [
'slug' => (string) $tag->slug,
'trend_score' => max(
(float) ($tag->recent_clicks ?? 0),
(float) ($tag->usage_count ?? 0) / 1000
),
]);
}
}

View File

@@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Http\Requests\Tags\PopularTagsRequest;
use App\Http\Requests\Tags\TagSearchRequest;
use App\Services\Tags\TagDiscoveryService;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Cache;
final class TagController extends Controller
{
public function __construct(private readonly TagDiscoveryService $tagDiscoveryService) {}
public function search(TagSearchRequest $request): JsonResponse
{
$q = trim((string) ($request->validated()['q'] ?? ''));
// Short results cached for 2 min; empty-query (popular suggestions) for 5 min.
$ttl = $q === '' ? 300 : 120;
$cacheKey = 'tags.search.v2.' . ($q === '' ? '__empty__' : md5($q));
$legacyCacheKey = 'tags.search.' . ($q === '' ? '__empty__' : md5($q));
$data = Cache::remember($cacheKey, $ttl, function () use ($q): mixed {
return $this->tagDiscoveryService->searchSuggestions($q, 20);
});
Cache::put($legacyCacheKey, $data, $ttl);
return response()->json(['data' => $data]);
}
public function popular(PopularTagsRequest $request): JsonResponse
{
$limit = (int) ($request->validated()['limit'] ?? 20);
$cacheKey = 'tags.popular.v2.' . $limit;
$legacyCacheKey = 'tags.popular.' . $limit;
$data = Cache::remember($cacheKey, 300, function () use ($limit): mixed {
return $this->tagDiscoveryService->popularTags($limit);
});
Cache::put($legacyCacheKey, $data, 300);
return response()->json(['data' => $data]);
}
}

View File

@@ -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);
}
}

View File

@@ -0,0 +1,808 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Http\Requests\Uploads\UploadFinishRequest;
use App\Http\Requests\Uploads\UploadInitRequest;
use App\Http\Requests\Uploads\UploadChunkRequest;
use App\Http\Requests\Uploads\UploadCancelRequest;
use App\Http\Requests\Uploads\UploadStatusRequest;
use App\Jobs\GenerateDerivativesJob;
use App\Jobs\AutoTagArtworkJob;
use App\Jobs\GenerateArtworkEmbeddingJob;
use App\Repositories\Uploads\UploadSessionRepository;
use App\Services\Uploads\UploadChunkService;
use App\Services\Uploads\UploadCancelService;
use App\Services\Uploads\UploadAuditService;
use App\Services\Uploads\UploadPipelineService;
use App\Services\Uploads\UploadQuotaService;
use App\Services\Uploads\UploadSessionStatus;
use App\Services\Uploads\UploadStatusService;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Symfony\Component\HttpFoundation\Response;
use Throwable;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Validator;
use App\Services\Upload\Contracts\UploadDraftServiceInterface;
use Carbon\Carbon;
use App\Uploads\Jobs\VirusScanJob;
use App\Uploads\Services\PublishService;
use App\Services\Activity\UserActivityService;
use App\Services\ArtworkAttributionService;
use App\Services\Maturity\ArtworkMaturityService;
use App\Uploads\Exceptions\UploadNotFoundException;
use App\Uploads\Exceptions\UploadOwnershipException;
use App\Uploads\Exceptions\UploadPublishValidationException;
use App\Uploads\Services\ArchiveInspectorService;
use App\Uploads\Services\DraftQuotaService;
use App\Uploads\Exceptions\DraftQuotaException;
use App\Models\Artwork;
use App\Models\Group;
use App\Services\GroupArtworkReviewService;
use Illuminate\Support\Str;
final class UploadController extends Controller
{
public function init(
UploadInitRequest $request,
UploadPipelineService $pipeline,
UploadQuotaService $quota,
UploadAuditService $audit
)
{
$user = $request->user();
try {
$quota->enforce($user->id);
} catch (Throwable $e) {
return response()->json([
'message' => $e->getMessage(),
], Response::HTTP_TOO_MANY_REQUESTS);
}
$result = $pipeline->initSession($user->id, (string) $request->ip());
$audit->log($user->id, 'upload_init_issued', (string) $request->ip(), [
'session_id' => $result->sessionId,
]);
return response()->json([
'session_id' => $result->sessionId,
'upload_token' => $result->token,
'status' => $result->status,
], Response::HTTP_OK);
}
public function finish(
UploadFinishRequest $request,
UploadPipelineService $pipeline,
UploadSessionRepository $sessions,
UploadAuditService $audit
) {
$user = $request->user();
$sessionId = (string) $request->validated('session_id');
$artworkId = (int) $request->validated('artwork_id');
$originalFileName = $request->validated('file_name');
$archiveSessionId = $request->validated('archive_session_id');
$archiveOriginalFileName = $request->validated('archive_file_name');
$additionalScreenshotSessions = collect($request->validated('additional_screenshot_sessions', []))
->filter(fn ($payload) => is_array($payload) && is_string($payload['session_id'] ?? null))
->values();
$session = $sessions->getOrFail($sessionId);
$request->artwork();
$validated = $pipeline->validateAndHash($sessionId);
if (! $validated->validation->ok || ! $validated->hash) {
return response()->json([
'message' => 'Upload validation failed.',
'reason' => $validated->validation->reason,
], Response::HTTP_UNPROCESSABLE_ENTITY);
}
if ($pipeline->originalHashExists($validated->hash)) {
return response()->json([
'message' => 'Duplicate upload is not allowed. This file already exists.',
'reason' => 'duplicate_hash',
'hash' => $validated->hash,
], Response::HTTP_CONFLICT);
}
$scan = $pipeline->scan($sessionId);
if (! $scan->ok) {
return response()->json([
'message' => 'Upload scan failed.',
'reason' => $scan->reason,
], Response::HTTP_UNPROCESSABLE_ENTITY);
}
$validatedArchive = null;
if (is_string($archiveSessionId) && trim($archiveSessionId) !== '') {
$validatedArchive = $pipeline->validateAndHashArchive($archiveSessionId);
if (! $validatedArchive->validation->ok || ! $validatedArchive->hash) {
return response()->json([
'message' => 'Archive validation failed.',
'reason' => $validatedArchive->validation->reason,
], Response::HTTP_UNPROCESSABLE_ENTITY);
}
$archiveScan = $pipeline->scan($archiveSessionId);
if (! $archiveScan->ok) {
return response()->json([
'message' => 'Archive scan failed.',
'reason' => $archiveScan->reason,
], Response::HTTP_UNPROCESSABLE_ENTITY);
}
}
$validatedAdditionalScreenshots = [];
foreach ($additionalScreenshotSessions as $payload) {
$screenshotSessionId = (string) ($payload['session_id'] ?? '');
if ($screenshotSessionId === '') {
continue;
}
$validatedScreenshot = $pipeline->validateAndHash($screenshotSessionId);
if (! $validatedScreenshot->validation->ok || ! $validatedScreenshot->hash) {
return response()->json([
'message' => 'Screenshot validation failed.',
'reason' => $validatedScreenshot->validation->reason,
], Response::HTTP_UNPROCESSABLE_ENTITY);
}
$screenshotScan = $pipeline->scan($screenshotSessionId);
if (! $screenshotScan->ok) {
return response()->json([
'message' => 'Screenshot scan failed.',
'reason' => $screenshotScan->reason,
], Response::HTTP_UNPROCESSABLE_ENTITY);
}
$validatedAdditionalScreenshots[] = [
'session_id' => $screenshotSessionId,
'hash' => $validatedScreenshot->hash,
'file_name' => is_string($payload['file_name'] ?? null) ? $payload['file_name'] : null,
];
}
try {
$status = DB::transaction(function () use ($pipeline, $sessionId, $validated, $artworkId, $originalFileName, $archiveSessionId, $validatedArchive, $archiveOriginalFileName, $validatedAdditionalScreenshots) {
if ((bool) config('uploads.queue_derivatives', false)) {
GenerateDerivativesJob::dispatch(
$sessionId,
$validated->hash,
$artworkId,
is_string($originalFileName) ? $originalFileName : null,
is_string($archiveSessionId) ? $archiveSessionId : null,
$validatedArchive?->hash,
is_string($archiveOriginalFileName) ? $archiveOriginalFileName : null,
$validatedAdditionalScreenshots
)->afterCommit();
return 'queued';
}
$pipeline->processAndPublish(
$sessionId,
$validated->hash,
$artworkId,
is_string($originalFileName) ? $originalFileName : null,
is_string($archiveSessionId) ? $archiveSessionId : null,
$validatedArchive?->hash,
is_string($archiveOriginalFileName) ? $archiveOriginalFileName : null,
$validatedAdditionalScreenshots
);
// Derivatives are available now; dispatch AI auto-tagging.
AutoTagArtworkJob::dispatch($artworkId, $validated->hash)->afterCommit();
GenerateArtworkEmbeddingJob::dispatch($artworkId, $validated->hash)->afterCommit();
return UploadSessionStatus::PROCESSED;
});
$audit->log($user->id, 'upload_finished', $session->ip, [
'session_id' => $sessionId,
'hash' => $validated->hash,
'artwork_id' => $artworkId,
'status' => $status,
'archive_session_id' => is_string($archiveSessionId) ? $archiveSessionId : null,
'additional_screenshot_session_ids' => array_values(array_map(static fn (array $payload): string => (string) $payload['session_id'], $validatedAdditionalScreenshots)),
]);
return response()->json([
'artwork_id' => $artworkId,
'status' => $status,
], Response::HTTP_OK);
} catch (Throwable $e) {
Log::error('Upload finish failed', [
'session_id' => $sessionId,
'artwork_id' => $artworkId,
'error' => $e->getMessage(),
]);
return response()->json([
'message' => 'Upload finish failed.',
], Response::HTTP_INTERNAL_SERVER_ERROR);
}
}
public function chunk(UploadChunkRequest $request, UploadChunkService $chunks)
{
$user = $request->user();
$chunkFile = $request->file('chunk');
// Debug: log uploaded file object details to help diagnose missing chunk
try {
if (! $chunkFile) {
logger()->warning('Chunk upload: no file present on request', [
'session_id' => (string) $request->input('session_id'),
'headers' => $request->headers->all(),
]);
} else {
logger()->warning('Chunk upload file details', [
'session_id' => (string) $request->input('session_id'),
'client_name' => $chunkFile->getClientOriginalName() ?? null,
'client_size' => $chunkFile->getSize() ?? null,
'error' => $chunkFile->getError(),
'realpath' => $chunkFile->getRealPath(),
]);
}
} catch (\Throwable $e) {
logger()->warning('Chunk upload debug logging failed', ['error' => $e->getMessage()]);
}
try {
// Use getPathname() — this returns the PHP temp filename even when
// getRealPath() may be false (platform/stream wrappers can cause
// getRealPath() to return false). getPathname() is safe for reading
// the uploaded chunk file.
$chunkPath = $chunkFile ? $chunkFile->getPathname() : '';
$result = $chunks->appendChunk(
(string) $request->input('session_id'),
(string) $chunkPath,
(int) $request->input('offset'),
(int) $request->input('chunk_size'),
(int) $request->input('total_size'),
(int) $user->id,
(string) $request->ip()
);
return response()->json([
'session_id' => $result->sessionId,
'status' => $result->status,
'received_bytes' => $result->receivedBytes,
'total_bytes' => $result->totalBytes,
'progress' => $result->progress,
], Response::HTTP_OK);
} catch (\Throwable $e) {
logger()->warning('Upload chunk failed', [
'session_id' => (string) $request->input('session_id'),
'error' => $e->getMessage(),
]);
// Include the underlying error message in the response during debugging
// so the frontend can show a useful description. Remove or hide this
// in production if you prefer more generic errors.
return response()->json([
'message' => 'Upload chunk failed.',
'error' => $e->getMessage(),
], Response::HTTP_UNPROCESSABLE_ENTITY);
}
}
public function status(string $id, UploadStatusRequest $request, UploadStatusService $statusService, UploadAuditService $audit)
{
$user = $request->user();
$payload = $statusService->get($id);
$audit->log($user->id, 'upload_status_checked', (string) $request->ip(), [
'session_id' => $id,
'status' => $payload['status'],
]);
return response()->json([
'session_id' => $payload['session_id'],
'status' => $payload['status'],
'progress' => $payload['progress'],
'failure_reason' => $payload['failure_reason'],
'received_bytes' => $payload['received_bytes'] ?? 0,
], Response::HTTP_OK);
}
public function cancel(UploadCancelRequest $request, UploadCancelService $cancel)
{
$user = $request->user();
try {
$result = $cancel->cancel(
(string) $request->input('session_id'),
(int) $user->id,
(string) $request->ip()
);
return response()->json([
'session_id' => $result['session_id'],
'status' => $result['status'],
], Response::HTTP_OK);
} catch (\Throwable $e) {
logger()->warning('Upload cancel failed', [
'session_id' => (string) $request->input('session_id'),
'error' => $e->getMessage(),
]);
return response()->json([
'message' => 'Upload cancel failed.',
], Response::HTTP_INTERNAL_SERVER_ERROR);
}
}
/**
* Preload an upload draft: validate main file, create draft and store files.
*
* Returns JSON: { upload_id, status, expires_at }
*/
public function preload(Request $request, UploadDraftServiceInterface $draftService, ArchiveInspectorService $archiveInspector, DraftQuotaService $draftQuotaService)
{
$user = $request->user();
$request->validate([
'main' => ['required', 'file'],
'screenshots' => ['sometimes', 'array'],
'screenshots.*' => ['file', 'image', 'max:5120'],
]);
$main = $request->file('main');
// Detect type from mime
$mime = (string) $main->getClientMimeType();
$type = null;
if (str_starts_with($mime, 'image/')) {
$type = 'image';
} elseif (in_array($mime, ['application/zip', 'application/x-zip-compressed', 'application/x-tar', 'application/x-gzip', 'application/x-rar-compressed', 'application/octet-stream'])) {
$type = 'archive';
}
if ($type === null) {
return response()->json([
'message' => 'Invalid main file type.',
'errors' => [
'main' => ['The main file must be an image or archive.'],
],
], Response::HTTP_UNPROCESSABLE_ENTITY);
}
if ($type === 'archive') {
$validator = Validator::make($request->all(), [
'screenshots' => ['required', 'array', 'min:1'],
'screenshots.*' => ['file', 'image', 'max:5120'],
]);
if ($validator->fails()) {
return response()->json([
'message' => 'The given data was invalid.',
'errors' => $validator->errors(),
], Response::HTTP_UNPROCESSABLE_ENTITY);
}
$inspection = $archiveInspector->inspect((string) $main->getPathname());
if (! $inspection->valid) {
return response()->json([
'message' => 'Archive inspection failed.',
'reason' => $inspection->reason,
'stats' => $inspection->stats,
], Response::HTTP_UNPROCESSABLE_ENTITY);
}
}
$incomingFiles = [$main];
if ($type === 'archive' && $request->hasFile('screenshots')) {
foreach ($request->file('screenshots') as $screenshot) {
$incomingFiles[] = $screenshot;
}
}
$mainHash = $draftService->calculateHash((string) $main->getPathname());
try {
$warnings = $draftQuotaService->assertCanCreateDraft($user, [
'files' => $incomingFiles,
'main_hash' => $mainHash,
]);
} catch (DraftQuotaException $e) {
return response()->json([
'message' => $e->machineCode(),
'code' => $e->machineCode(),
], $e->httpStatus());
}
// Create draft record (meta-only) and store main file via service
$draft = $draftService->createDraft(['user_id' => $user->id, 'type' => $type]);
try {
$mainInfo = $draftService->storeMainFile($draft['id'], $main);
// If archive, allow optional screenshots to be uploaded in the same request
if ($type === 'archive' && $request->hasFile('screenshots')) {
foreach ($request->file('screenshots') as $ss) {
try {
$draftService->storeScreenshot($draft['id'], $ss);
} catch (Throwable $e) {
// Keep controller thin: log and continue
logger()->warning('Screenshot store failed during preload', ['error' => $e->getMessage(), 'draft' => $draft['id']]);
}
}
}
// Set expiration (default 7 days) and return info
$ttlDays = (int) config('uploads.draft_ttl_days', 7);
$expiresAt = Carbon::now()->addDays($ttlDays);
$draftService->setExpiration($draft['id'], $expiresAt);
VirusScanJob::dispatch($draft['id']);
$response = [
'upload_id' => $draft['id'],
'status' => 'draft',
'expires_at' => $expiresAt->toISOString(),
];
if (! empty($warnings)) {
$response['warnings'] = array_values($warnings);
}
return response()->json($response, Response::HTTP_OK);
} catch (Throwable $e) {
logger()->error('Upload preload failed', ['error' => $e->getMessage()]);
return response()->json(['message' => 'Preload failed.'], Response::HTTP_INTERNAL_SERVER_ERROR);
}
}
public function autosave(string $id, Request $request)
{
$user = $request->user();
$upload = DB::table('uploads')->where('id', $id)->first();
if (! $upload) {
return response()->json(['message' => 'Upload draft not found.'], Response::HTTP_NOT_FOUND);
}
if ((int) $upload->user_id !== (int) $user->id) {
return response()->json(['message' => 'Forbidden.'], Response::HTTP_FORBIDDEN);
}
if ((string) $upload->status !== 'draft') {
return response()->json([
'message' => 'Only draft uploads can be autosaved.',
], Response::HTTP_UNPROCESSABLE_ENTITY);
}
$validated = $request->validate([
'title' => ['nullable', 'string', 'max:255'],
'category_id' => ['nullable', 'exists:categories,id'],
'description' => ['nullable', 'string'],
'tags' => ['nullable', 'array'],
'license' => ['nullable', 'string'],
'nsfw' => ['nullable', 'boolean'],
]);
$updates = [];
foreach (['title', 'category_id', 'description', 'tags', 'license', 'nsfw'] as $field) {
if (array_key_exists($field, $validated)) {
$updates[$field] = $validated[$field];
}
}
$dirty = [];
foreach ($updates as $field => $value) {
$current = $upload->{$field} ?? null;
if ($field === 'tags') {
$current = $current ? json_decode((string) $current, true) : null;
}
if ($field === 'nsfw') {
$current = is_null($current) ? null : (bool) $current;
$value = is_null($value) ? null : (bool) $value;
}
if ($current !== $value) {
$dirty[$field] = $value;
}
}
if (array_key_exists('tags', $dirty)) {
$dirty['tags'] = json_encode($dirty['tags']);
}
if (! empty($dirty)) {
$dirty['updated_at'] = now();
DB::table('uploads')->where('id', $id)->update($dirty);
$upload = DB::table('uploads')->where('id', $id)->first();
}
return response()->json([
'success' => true,
'updated_at' => (string) ($upload->updated_at ?? now()->toDateTimeString()),
], Response::HTTP_OK);
}
public function processingStatus(string $id, Request $request)
{
$user = $request->user();
$upload = DB::table('uploads')->where('id', $id)->first();
if (! $upload) {
return response()->json(['message' => 'Upload not found.'], Response::HTTP_NOT_FOUND);
}
if ((int) $upload->user_id !== (int) $user->id) {
return response()->json(['message' => 'Forbidden.'], Response::HTTP_FORBIDDEN);
}
$status = (string) ($upload->status ?? 'draft');
$isScanned = (bool) ($upload->is_scanned ?? false);
$previewReady = ! empty($upload->preview_path);
$hasTags = (bool) ($upload->has_tags ?? false);
$processingState = (string) ($upload->processing_state ?? 'pending_scan');
return response()->json([
'id' => (string) $upload->id,
'status' => $status,
'is_scanned' => $isScanned,
'preview_ready' => $previewReady,
'has_tags' => $hasTags,
'processing_state' => $processingState,
], Response::HTTP_OK);
}
public function publish(string $id, Request $request, PublishService $publishService, ArtworkAttributionService $attribution, ArtworkMaturityService $maturity)
{
$user = $request->user();
$validated = $request->validate([
'title' => ['nullable', 'string', 'max:150'],
'description' => ['nullable', 'string'],
'category' => ['nullable', 'integer', 'exists:categories,id'],
'tags' => ['nullable', 'array', 'max:15'],
'tags.*' => ['string', 'max:64'],
'is_mature' => ['nullable', 'boolean'],
'nsfw' => ['nullable', 'boolean'],
// Scheduled-publishing fields
'mode' => ['nullable', 'string', 'in:now,schedule'],
'publish_at' => ['nullable', 'string', 'date'],
'timezone' => ['nullable', 'string', 'max:64'],
'visibility' => ['nullable', 'string', 'in:public,unlisted,private'],
'group' => ['nullable', 'string', 'max:90'],
'primary_author_user_id' => ['nullable', 'integer', 'min:1'],
'contributor_user_ids' => ['nullable', 'array', 'max:20'],
'contributor_user_ids.*' => ['integer', 'min:1'],
'contributor_credits' => ['nullable', 'array', 'max:20'],
'contributor_credits.*.user_id' => ['required', 'integer', 'min:1'],
'contributor_credits.*.credit_role' => ['nullable', 'string', 'max:80'],
'contributor_credits.*.is_primary' => ['nullable', 'boolean'],
]);
$mode = $validated['mode'] ?? 'now';
$visibility = $validated['visibility'] ?? 'public';
// Resolve the UTC publish_at datetime for schedule mode
$publishAt = null;
if ($mode === 'schedule' && ! empty($validated['publish_at'])) {
try {
$publishAt = \Carbon\Carbon::parse($validated['publish_at'])->utc();
// Must be at least 1 minute in the future (server-side guard)
if ($publishAt->lte(now()->addMinute())) {
return response()->json([
'message' => 'Scheduled publish time must be at least 1 minute in the future.',
], Response::HTTP_UNPROCESSABLE_ENTITY);
}
} catch (\Throwable) {
return response()->json(['message' => 'Invalid publish_at datetime.'], Response::HTTP_UNPROCESSABLE_ENTITY);
}
}
if (ctype_digit($id)) {
$artworkId = (int) $id;
$artwork = Artwork::query()->find($artworkId);
if (! $artwork) {
return response()->json(['message' => 'Artwork not found.'], Response::HTTP_NOT_FOUND);
}
if ((int) $artwork->user_id !== (int) $user->id) {
return response()->json(['message' => 'Forbidden.'], Response::HTTP_FORBIDDEN);
}
$title = trim((string) ($validated['title'] ?? $artwork->title ?? ''));
if ($title === '') {
$title = 'Untitled artwork';
}
$slugBase = Str::slug($title);
if ($slugBase === '') {
$slugBase = 'artwork';
}
$artwork->title = $title;
if (array_key_exists('description', $validated)) {
$artwork->description = $validated['description'];
}
if (array_key_exists('is_mature', $validated) || array_key_exists('nsfw', $validated)) {
$artwork->is_mature = (bool) ($validated['is_mature'] ?? $validated['nsfw'] ?? false);
}
$artwork->slug = Str::limit($slugBase, 160, '');
$artwork->artwork_timezone = $validated['timezone'] ?? null;
$artwork->uploaded_by_user_id = $artwork->uploaded_by_user_id ?: (int) $user->id;
$artwork->primary_author_user_id = $artwork->primary_author_user_id ?: (int) $user->id;
// Sync category if provided
$categoryId = isset($validated['category']) ? (int) $validated['category'] : null;
if ($categoryId && \App\Models\Category::where('id', $categoryId)->exists()) {
$artwork->categories()->sync([$categoryId]);
}
// Sync tags if provided
if (!empty($validated['tags']) && is_array($validated['tags'])) {
$tagIds = [];
foreach ($validated['tags'] as $tagSlug) {
$tag = \App\Models\Tag::firstOrCreate(
['slug' => Str::slug($tagSlug)],
['name' => $tagSlug, 'is_active' => true, 'usage_count' => 0]
);
$tagIds[$tag->id] = ['source' => 'user', 'confidence' => 1.0];
}
$artwork->tags()->sync($tagIds);
}
$artwork->save();
$maturity->applyUploaderDeclaration($artwork, (bool) $artwork->is_mature);
$artwork = $attribution->apply($artwork->fresh(['group.members']), $user, $validated);
if ($mode === 'schedule' && $publishAt) {
// Scheduled: store publish_at but don't make public yet
$artwork->visibility = $visibility;
$artwork->is_public = false;
$artwork->is_approved = true;
$artwork->publish_at = $publishAt;
$artwork->artwork_status = 'scheduled';
$artwork->published_at = null;
$artwork->save();
try {
$artwork->unsearchable();
} catch (\Throwable $e) {
Log::warning('Failed to remove scheduled artwork from search index', [
'artwork_id' => (int) $artwork->id,
'error' => $e->getMessage(),
]);
}
return response()->json([
'success' => true,
'artwork_id' => (int) $artwork->id,
'status' => 'scheduled',
'slug' => (string) $artwork->slug,
'publish_at' => $publishAt->toISOString(),
'published_at' => null,
], Response::HTTP_OK);
}
// Publish immediately
$artwork->visibility = $visibility;
$artwork->is_public = ($visibility !== 'private');
$artwork->is_approved = true;
$artwork->published_at = now();
$artwork->artwork_status = 'published';
$artwork->publish_at = null;
$artwork->save();
try {
if ((bool) $artwork->is_public && (bool) $artwork->is_approved && !empty($artwork->published_at)) {
$artwork->searchable();
} else {
$artwork->unsearchable();
}
} catch (\Throwable $e) {
Log::warning('Failed to sync artwork search index after publish', [
'artwork_id' => (int) $artwork->id,
'error' => $e->getMessage(),
]);
}
// Record upload activity event
try {
\App\Models\ActivityEvent::record(
actorId: (int) $user->id,
type: \App\Models\ActivityEvent::TYPE_UPLOAD,
targetType: \App\Models\ActivityEvent::TARGET_ARTWORK,
targetId: (int) $artwork->id,
);
} catch (\Throwable) {}
try {
app(UserActivityService::class)->logUpload((int) $user->id, (int) $artwork->id);
} catch (\Throwable) {}
return response()->json([
'success' => true,
'artwork_id' => (int) $artwork->id,
'status' => 'published',
'slug' => (string) $artwork->slug,
'published_at' => optional($artwork->published_at)->toISOString(),
], Response::HTTP_OK);
}
try {
$upload = $publishService->publish($id, $user);
return response()->json([
'success' => true,
'upload_id' => (string) $upload->id,
'status' => (string) $upload->status,
'published_at' => optional($upload->published_at)->toISOString(),
'final_path' => (string) ($upload->final_path ?? ''),
], Response::HTTP_OK);
} catch (UploadOwnershipException $e) {
return response()->json(['message' => $e->getMessage()], Response::HTTP_FORBIDDEN);
} catch (UploadNotFoundException $e) {
return response()->json(['message' => $e->getMessage()], Response::HTTP_NOT_FOUND);
} catch (UploadPublishValidationException $e) {
return response()->json(['message' => $e->getMessage()], Response::HTTP_UNPROCESSABLE_ENTITY);
}
}
public function submitForReview(string $id, Request $request, GroupArtworkReviewService $reviews)
{
$user = $request->user();
$validated = $request->validate([
'title' => ['nullable', 'string', 'max:150'],
'description' => ['nullable', 'string'],
'category' => ['nullable', 'integer', 'exists:categories,id'],
'tags' => ['nullable', 'array', 'max:15'],
'tags.*' => ['string', 'max:64'],
'is_mature' => ['nullable', 'boolean'],
'nsfw' => ['nullable', 'boolean'],
'timezone' => ['nullable', 'string', 'max:64'],
'visibility' => ['nullable', 'string', 'in:public,unlisted,private'],
'group' => ['required', 'string', 'max:90'],
'primary_author_user_id' => ['nullable', 'integer', 'min:1'],
'contributor_user_ids' => ['nullable', 'array', 'max:20'],
'contributor_user_ids.*' => ['integer', 'min:1'],
'contributor_credits' => ['nullable', 'array', 'max:20'],
'contributor_credits.*.user_id' => ['required', 'integer', 'min:1'],
'contributor_credits.*.credit_role' => ['nullable', 'string', 'max:80'],
'contributor_credits.*.is_primary' => ['nullable', 'boolean'],
]);
if (! ctype_digit($id)) {
return response()->json(['message' => 'Artwork review submission requires an artwork draft id.'], Response::HTTP_UNPROCESSABLE_ENTITY);
}
$artwork = Artwork::query()->find((int) $id);
if (! $artwork) {
return response()->json(['message' => 'Artwork not found.'], Response::HTTP_NOT_FOUND);
}
if ((int) $artwork->user_id !== (int) $user->id && (int) ($artwork->uploaded_by_user_id ?? 0) !== (int) $user->id) {
return response()->json(['message' => 'Forbidden.'], Response::HTTP_FORBIDDEN);
}
$group = Group::query()->with('members')->where('slug', (string) $validated['group'])->first();
if (! $group) {
return response()->json(['message' => 'Group not found.'], Response::HTTP_NOT_FOUND);
}
$artwork = $reviews->submit($group, $artwork, $user, $validated);
return response()->json([
'success' => true,
'artwork_id' => (int) $artwork->id,
'status' => 'submitted_for_review',
'group_review_status' => (string) $artwork->group_review_status,
], Response::HTTP_OK);
}
}

View File

@@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\Artwork;
use App\Services\TagNormalizer;
use App\Services\Vision\VisionService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
/**
* Synchronous Vision tag suggestions for the upload wizard.
*
* POST /api/uploads/{id}/vision-suggest
*
* Calls the Vision gateway (/analyze/all) synchronously and returns
* normalised tag suggestions immediately without going through the queue.
* The queue-based AutoTagArtworkJob still runs in the background and writes
* to the DB; this endpoint gives the user instant pre-fill on Step 2.
*/
final class UploadVisionSuggestController extends Controller
{
public function __construct(
private readonly VisionService $vision,
private readonly TagNormalizer $normalizer,
) {}
public function __invoke(int $id, Request $request): JsonResponse
{
if (! $this->vision->isEnabled()) {
return response()->json(['tags' => [], 'vision_enabled' => false]);
}
$artwork = Artwork::query()->findOrFail($id);
$this->authorizeOrNotFound($request->user(), $artwork);
$limit = (int) $request->query('limit', 10);
return response()->json($this->vision->suggestTags($artwork, $this->normalizer, $limit));
}
private function authorizeOrNotFound(mixed $user, Artwork $artwork): void
{
if (! $user) {
abort(404);
}
if ((int) $artwork->user_id !== (int) $user->id) {
abort(404);
}
}
}

View File

@@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Services\AchievementService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
final class UserAchievementsController extends Controller
{
public function __invoke(Request $request, AchievementService $achievements): JsonResponse
{
return response()->json($achievements->summary($request->user()->id));
}
}

View File

@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Services\UserSuggestionService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
final class UserSuggestionsController extends Controller
{
public function __construct(private readonly UserSuggestionService $suggestions)
{
}
public function __invoke(Request $request): JsonResponse
{
$limit = min((int) $request->query('limit', 8), 24);
return response()->json([
'data' => $this->suggestions->suggestFor($request->user(), $limit),
]);
}
}

View File

@@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Services\XPService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
final class UserXpController extends Controller
{
public function __invoke(Request $request, XPService $xp): JsonResponse
{
return response()->json($xp->summary($request->user()->id));
}
}

View File

@@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Http\Requests\UsernameRequest;
use App\Models\User;
use App\Support\UsernamePolicy;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
final class UsernameAvailabilityController extends Controller
{
public function __invoke(Request $request): JsonResponse
{
$candidate = UsernamePolicy::normalize((string) $request->query('username', ''));
$validator = validator(
['username' => $candidate],
['username' => UsernameRequest::formatRules()]
);
if ($validator->fails()) {
return response()->json([
'available' => false,
'normalized' => $candidate,
'errors' => $validator->errors()->toArray(),
], 422);
}
$ignoreUserId = $request->user()?->id;
$exists = User::query()
->whereRaw('LOWER(username) = ?', [$candidate])
->when($ignoreUserId !== null, fn ($q) => $q->where('id', '!=', (int) $ignoreUserId))
->exists();
return response()->json([
'available' => ! $exists,
'normalized' => $candidate,
]);
}
}

View File

@@ -0,0 +1,156 @@
<?php
namespace App\Http\Controllers;
use App\Http\Controllers\Web\BrowseGalleryController;
use App\Http\Controllers\Controller;
use App\Http\Requests\ArtworkIndexRequest;
use App\Models\Artwork;
use App\Models\Category;
use App\Services\ContentTypes\ContentTypeSlugResolver;
use Illuminate\Http\Request;
use Illuminate\Http\RedirectResponse;
use Illuminate\View\View;
class ArtworkController extends Controller
{
public function __construct(private readonly ContentTypeSlugResolver $contentTypeResolver)
{
}
/**
* Browse artworks with optional category filtering.
* Uses cursor pagination (no offset pagination) and only returns public, approved, not-deleted items.
*/
public function index(ArtworkIndexRequest $request, ?Category $category = null): View
{
$perPage = (int) ($request->get('per_page', 24));
$query = Artwork::public()->published();
if ($category) {
$query->whereHas('categories', function ($q) use ($category) {
$q->where('categories.id', $category->id);
});
}
if ($request->filled('q')) {
$q = $request->get('q');
$query->where(function ($sub) use ($q) {
$sub->where('title', 'like', '%' . $q . '%')
->orWhere('description', 'like', '%' . $q . '%');
});
}
$sort = $request->get('sort', 'latest');
if ($sort === 'oldest') {
$query->orderBy('published_at', 'asc');
} else {
$query->orderByDesc('published_at');
}
// Important: do NOT eager-load artwork_stats in listings
$artworks = $query->cursorPaginate($perPage);
return view('artworks.index', [
'artworks' => $artworks,
'category' => $category,
]);
}
/**
* Show a single artwork by slug. Resolve the slug manually to avoid implicit
* route-model binding exceptions when the slug does not correspond to an artwork.
*/
public function show(Request $request, string $contentTypeSlug, string $categoryPath, $artwork = null)
{
$resolution = $this->contentTypeResolver->resolve($contentTypeSlug);
if (! $resolution->found() || $resolution->contentType === null) {
abort(404);
}
$resolvedContentTypeSlug = strtolower((string) $resolution->contentType->slug);
if ($resolution->requiresRedirect()) {
return $this->redirectToCanonicalArtworkPath($request, $resolvedContentTypeSlug, $categoryPath, $artwork, 301);
}
// Manually resolve artwork by slug when provided. The route may bind
// the 'artwork' parameter to an Artwork model or pass the slug string.
$foundArtwork = null;
$artworkSlug = null;
if ($artwork instanceof Artwork) {
$foundArtwork = $artwork;
$artworkSlug = $artwork->slug;
} elseif ($artwork) {
$artworkSlug = (string) $artwork;
$foundArtwork = $this->findArtworkForCategoryPath($resolvedContentTypeSlug, $categoryPath, $artworkSlug);
}
// When the URL can represent a nested category path (e.g. /skins/audio/winamp),
// prefer category rendering over artwork slug collisions so same-level groups
// behave consistently.
if (! empty($artworkSlug)) {
$combinedPath = trim($categoryPath . '/' . $artworkSlug, '/');
$resolvedCategory = Category::findByPath($resolvedContentTypeSlug, $combinedPath);
if ($resolvedCategory) {
return app(BrowseGalleryController::class)->content(request(), $resolvedContentTypeSlug, $combinedPath);
}
}
// If no artwork was found, treat the request as a category path.
// The route places the artwork slug in the last segment, so include it.
// Delegate to BrowseGalleryController to render the same modern gallery
// layout used by routes like /skins/audio.
if (! $foundArtwork) {
$combinedPath = $categoryPath;
if ($artworkSlug) {
$combinedPath = trim($categoryPath . '/' . $artworkSlug, '/');
}
return app(BrowseGalleryController::class)->content(request(), $resolvedContentTypeSlug, $combinedPath);
}
if (! $foundArtwork->is_public || ! $foundArtwork->is_approved || $foundArtwork->trashed()) {
abort(404);
}
// Delegate to the canonical ArtworkPageController which builds all
// required view data ($meta, thumbnails, related items, comments, etc.)
return app(\App\Http\Controllers\Web\ArtworkPageController::class)->show(
$request,
(int) $foundArtwork->id,
$foundArtwork->slug,
);
}
private function findArtworkForCategoryPath(string $contentTypeSlug, string $categoryPath, string $artworkSlug): ?Artwork
{
$segments = array_values(array_filter(explode('/', trim($categoryPath, '/'))));
$category = Category::findByPath(strtolower($contentTypeSlug), $segments);
$query = Artwork::query()->where('slug', $artworkSlug);
if ($category) {
$query->whereHas('categories', function ($categoryQuery) use ($category): void {
$categoryQuery->where('categories.id', $category->id);
});
}
return $query
->orderByDesc('published_at')
->orderByDesc('id')
->first();
}
private function redirectToCanonicalArtworkPath(Request $request, string $contentTypeSlug, string $categoryPath, Artwork|string|null $artwork, int $status = 301): RedirectResponse
{
$artworkSlug = $artwork instanceof Artwork ? $artwork->slug : (string) $artwork;
$target = url('/' . trim($contentTypeSlug . '/' . trim($categoryPath, '/') . '/' . trim($artworkSlug, '/'), '/'));
$queryString = $request->getQueryString();
if ($queryString) {
$target .= '?' . $queryString;
}
return redirect()->to($target, $status);
}
}

View File

@@ -0,0 +1,150 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers;
use App\Models\Artwork;
use App\Models\ArtworkDownload;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Schema;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
final class ArtworkDownloadController extends Controller
{
/**
* Allowed original file extensions for secure server-side download.
*
* @var array<int, string>
*/
private const ALLOWED_EXTENSIONS = [
'jpg',
'jpeg',
'png',
'gif',
'webp',
'bmp',
'tiff',
'zip',
'rar',
'7z',
'tar',
'gz',
];
public function __invoke(Request $request, int $id): BinaryFileResponse
{
$artwork = Artwork::query()->find($id);
if (! $artwork) {
abort(404);
}
$filePath = $this->resolveOriginalPath($artwork);
$ext = strtolower(ltrim((string) pathinfo($filePath, PATHINFO_EXTENSION), '.'));
if ($filePath === '' || ! in_array($ext, self::ALLOWED_EXTENSIONS, true)) {
abort(404);
}
$this->recordDownload($request, $artwork->id);
$this->incrementDownloadCountIfAvailable($artwork->id);
if (! File::isFile($filePath)) {
Log::warning('Artwork original file missing for download.', [
'artwork_id' => $artwork->id,
'ext' => $ext,
'resolved_path' => $filePath,
]);
abort(404);
}
$downloadName = $this->buildDownloadFilename((string) $artwork->file_name, $ext);
return response()->download($filePath, $downloadName);
}
private function resolveOriginalPath(Artwork $artwork): string
{
$relative = trim((string) $artwork->file_path, '/');
$prefix = trim((string) config('uploads.object_storage.prefix', 'artworks'), '/') . '/original/';
if ($relative !== '' && str_starts_with($relative, $prefix)) {
$suffix = substr($relative, strlen($prefix));
$root = rtrim((string) config('uploads.local_originals_root'), DIRECTORY_SEPARATOR);
return $root . DIRECTORY_SEPARATOR . str_replace(['/', '\\'], DIRECTORY_SEPARATOR, (string) $suffix);
}
$hash = strtolower((string) $artwork->hash);
$ext = strtolower(ltrim((string) $artwork->file_ext, '.'));
if (! $this->isValidHash($hash) || $ext === '') {
return '';
}
$root = rtrim((string) config('uploads.local_originals_root'), DIRECTORY_SEPARATOR);
return $root
. DIRECTORY_SEPARATOR . substr($hash, 0, 2)
. DIRECTORY_SEPARATOR . substr($hash, 2, 2)
. DIRECTORY_SEPARATOR . $hash . '.' . $ext;
}
private function recordDownload(Request $request, int $artworkId): void
{
try {
$ipAddress = $request->ip();
$ipBinary = $ipAddress ? @inet_pton($ipAddress) : false;
ArtworkDownload::query()->create([
'artwork_id' => $artworkId,
'user_id' => $request->user()?->id,
'ip' => $ipBinary !== false ? $ipBinary : null,
'ip_address' => $ipAddress,
'user_agent' => mb_substr((string) $request->userAgent(), 0, 1024),
'referer' => mb_substr((string) $request->headers->get('referer'), 0, 65535),
]);
} catch (\Throwable $exception) {
Log::warning('Failed to record artwork download analytics.', [
'artwork_id' => $artworkId,
'error' => $exception->getMessage(),
]);
}
}
private function incrementDownloadCountIfAvailable(int $artworkId): void
{
if (! Schema::hasColumn('artworks', 'download_count')) {
return;
}
Artwork::query()->whereKey($artworkId)->increment('download_count');
}
private function isValidHash(string $hash): bool
{
return $hash !== '' && preg_match('/^[a-f0-9]+$/', $hash) === 1;
}
private function buildDownloadFilename(string $fileName, string $ext): string
{
$name = trim($fileName);
$name = str_replace(['/', '\\'], '-', $name);
$name = preg_replace('/[\x00-\x1F\x7F]/', '', $name) ?? '';
$name = preg_replace('/\s+/', ' ', $name) ?? '';
$name = trim((string) $name, ". \t\n\r\0\x0B");
if ($name === '') {
$name = 'artwork';
}
if (strtolower((string) pathinfo($name, PATHINFO_EXTENSION)) !== $ext) {
$name .= '.' . $ext;
}
return $name;
}
}

View File

@@ -0,0 +1,56 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use App\Http\Requests\Auth\LoginRequest;
use App\Services\Security\CaptchaVerifier;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\View\View;
class AuthenticatedSessionController extends Controller
{
/**
* Display the login view.
*/
public function __construct(
private readonly CaptchaVerifier $captchaVerifier,
) {
}
public function create(): View
{
return view('auth.login', [
'requiresCaptcha' => session('bot_captcha_required', false),
'captcha' => $this->captchaVerifier->frontendConfig(),
]);
}
/**
* Handle an incoming authentication request.
*/
public function store(LoginRequest $request): RedirectResponse
{
$request->authenticate();
$request->session()->regenerate();
return redirect()->intended(route('dashboard'));
}
/**
* Destroy an authenticated session.
*/
public function destroy(Request $request): RedirectResponse
{
Auth::guard('web')->logout();
$request->session()->invalidate();
$request->session()->regenerateToken();
return redirect('/');
}
}

View File

@@ -0,0 +1,40 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Validation\ValidationException;
use Illuminate\View\View;
class ConfirmablePasswordController extends Controller
{
/**
* Show the confirm password view.
*/
public function show(): View
{
return view('auth.confirm-password');
}
/**
* Confirm the user's password.
*/
public function store(Request $request): RedirectResponse
{
if (! Auth::guard('web')->validate([
'email' => $request->user()->email,
'password' => $request->password,
])) {
throw ValidationException::withMessages([
'password' => __('auth.password'),
]);
}
$request->session()->put('auth.password_confirmed_at', time());
return redirect()->intended(route('dashboard', absolute: false));
}
}

View File

@@ -0,0 +1,24 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
class EmailVerificationNotificationController extends Controller
{
/**
* Send a new email verification notification.
*/
public function store(Request $request): RedirectResponse
{
if ($request->user()->hasVerifiedEmail()) {
return redirect()->intended(route('dashboard', absolute: false));
}
$request->user()->sendEmailVerificationNotification();
return back()->with('status', 'verification-link-sent');
}
}

View File

@@ -0,0 +1,21 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class EmailVerificationPromptController extends Controller
{
/**
* Display the email verification prompt.
*/
public function __invoke(Request $request): RedirectResponse|View
{
return $request->user()->hasVerifiedEmail()
? redirect()->intended(route('dashboard', absolute: false))
: view('auth.verify-email');
}
}

View File

@@ -0,0 +1,62 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use App\Models\User;
use Illuminate\Auth\Events\PasswordReset;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Password;
use Illuminate\Support\Str;
use Illuminate\Validation\Rules;
use Illuminate\View\View;
class NewPasswordController extends Controller
{
/**
* Display the password reset view.
*/
public function create(Request $request): View
{
return view('auth.reset-password', ['request' => $request]);
}
/**
* Handle an incoming new password request.
*
* @throws \Illuminate\Validation\ValidationException
*/
public function store(Request $request): RedirectResponse
{
$request->validate([
'token' => ['required'],
'email' => ['required', 'email'],
'password' => ['required', 'confirmed', Rules\Password::defaults()],
]);
// Here we will attempt to reset the user's password. If it is successful we
// will update the password on an actual user model and persist it to the
// database. Otherwise we will parse the error and return the response.
$status = Password::reset(
$request->only('email', 'password', 'password_confirmation', 'token'),
function (User $user) use ($request) {
$user->forceFill([
'password' => Hash::make($request->password),
'remember_token' => Str::random(60),
])->save();
event(new PasswordReset($user));
}
);
// If the password was successfully reset, we will redirect the user back to
// the application's home authenticated view. If there is an error we can
// redirect them back to where they came from with their error message.
return $status == Password::PASSWORD_RESET
? redirect()->route('login')->with('status', __($status))
: back()->withInput($request->only('email'))
->withErrors(['email' => __($status)]);
}
}

View File

@@ -0,0 +1,253 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use App\Models\SocialAccount;
use App\Models\User;
use Illuminate\Database\UniqueConstraintViolationException;
use Illuminate\Http\RedirectResponse;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str;
use Laravel\Socialite\Contracts\User as SocialiteUser;
use Laravel\Socialite\Facades\Socialite;
use Throwable;
class OAuthController extends Controller
{
/** Providers enabled for OAuth login. */
private const ALLOWED_PROVIDERS = ['google', 'discord'];
/**
* Redirect the user to the provider's OAuth page.
*/
public function redirectToProvider(string $provider): RedirectResponse
{
$this->abortIfInvalidProvider($provider);
return Socialite::driver($provider)->redirect();
}
/**
* Handle the provider callback and authenticate the user.
*/
public function handleProviderCallback(string $provider): RedirectResponse
{
$this->abortIfInvalidProvider($provider);
try {
/** @var SocialiteUser $socialUser */
$socialUser = Socialite::driver($provider)->user();
} catch (Throwable) {
return redirect()->route('login')
->withErrors(['oauth' => 'Authentication failed. Please try again.']);
}
$providerId = (string) $socialUser->getId();
$providerEmail = $this->resolveEmail($socialUser);
$verified = $this->isEmailVerifiedByProvider($provider, $socialUser);
// ── 1. Provider account already linked → login ───────────────────────
$existing = SocialAccount::query()
->where('provider', $provider)
->where('provider_id', $providerId)
->with('user')
->first();
if ($existing !== null && $existing->user !== null) {
return $this->loginAndRedirect($existing->user);
}
// ── 2. Email match → link to existing account ────────────────────────
// Covers both verified and unverified users: if the OAuth provider
// has confirmed this email we can safely link it and mark it verified,
// preventing a duplicate-email insert when the user had started
// registration via email but never finished verification.
if ($providerEmail !== null && $verified) {
$userByEmail = User::query()
->where('email', strtolower($providerEmail))
->first();
if ($userByEmail !== null) {
// If their email was not yet verified, promote it now — the
// OAuth provider has already verified it on our behalf.
if ($userByEmail->email_verified_at === null) {
$userByEmail->forceFill([
'email_verified_at' => now(),
'is_active' => true,
// Keep their onboarding step unless already complete
'onboarding_step' => $userByEmail->onboarding_step === 'email'
? 'username'
: ($userByEmail->onboarding_step ?? 'username'),
])->save();
}
$this->createSocialAccount($userByEmail, $provider, $providerId, $providerEmail, $socialUser->getAvatar());
return $this->loginAndRedirect($userByEmail);
}
}
// ── 3. Provider email not verified → reject auto-link ────────────────
if ($providerEmail !== null && ! $verified) {
return redirect()->route('login')
->withErrors(['oauth' => 'Your ' . ucfirst($provider) . ' email is not verified. Please verify it and try again.']);
}
// ── 4. No email at all → cannot proceed ──────────────────────────────
if ($providerEmail === null) {
return redirect()->route('login')
->withErrors(['oauth' => 'Could not retrieve your email address from ' . ucfirst($provider) . '. Please register with email.']);
}
// ── 5. New user creation ──────────────────────────────────────────────
try {
$user = $this->createOAuthUser($socialUser, $provider, $providerId, $providerEmail);
} catch (UniqueConstraintViolationException) {
// Race condition: another request inserted the same email between
// the lookup above and this insert. Fetch and link instead.
$user = User::query()->where('email', strtolower($providerEmail))->first();
if ($user === null) {
return redirect()->route('login')
->withErrors(['oauth' => 'An error occurred during sign in. Please try again.']);
}
$this->createSocialAccount($user, $provider, $providerId, $providerEmail, $socialUser->getAvatar());
}
return $this->loginAndRedirect($user);
}
// ─── Private helpers ─────────────────────────────────────────────────────
private function abortIfInvalidProvider(string $provider): void
{
abort_unless(in_array($provider, self::ALLOWED_PROVIDERS, true), 404);
}
/**
* Create social_accounts row linked to a user.
*/
private function createSocialAccount(
User $user,
string $provider,
string $providerId,
?string $providerEmail,
?string $avatar
): void {
SocialAccount::query()->updateOrCreate(
['provider' => $provider, 'provider_id' => $providerId],
[
'user_id' => $user->id,
'provider_email' => $providerEmail !== null ? strtolower($providerEmail) : null,
'avatar' => $avatar,
]
);
}
/**
* Create a brand-new user from OAuth data.
*/
private function createOAuthUser(
SocialiteUser $socialUser,
string $provider,
string $providerId,
string $providerEmail
): User {
$user = DB::transaction(function () use ($socialUser, $provider, $providerId, $providerEmail): User {
$name = $this->resolveDisplayName($socialUser, $providerEmail);
$user = User::query()->create([
'username' => null,
'name' => $name,
'email' => strtolower($providerEmail),
'email_verified_at' => now(),
'password' => Hash::make(Str::random(64)),
'is_active' => true,
'onboarding_step' => 'username',
'username_changed_at' => now(),
'last_username_change_at' => now(),
]);
$this->createSocialAccount(
$user,
$provider,
$providerId,
$providerEmail,
$socialUser->getAvatar()
);
return $user;
});
return $user;
}
/**
* Login the user and redirect appropriately.
*/
private function loginAndRedirect(User $user): RedirectResponse
{
Auth::login($user, remember: true);
request()->session()->regenerate();
$step = strtolower((string) ($user->onboarding_step ?? ''));
if (in_array($step, ['username', 'password'], true)) {
return redirect()->route('setup.username.create');
}
return redirect()->intended(route('dashboard', absolute: false));
}
/**
* Resolve a usable display name from the social user.
*/
private function resolveDisplayName(SocialiteUser $socialUser, string $email): string
{
$name = trim((string) ($socialUser->getName() ?? ''));
if ($name !== '') {
return $name;
}
return Str::before($email, '@');
}
/**
* Best-effort email resolution. Apple can return null email on repeat logins.
*/
private function resolveEmail(SocialiteUser $socialUser): ?string
{
$email = $socialUser->getEmail();
if ($email === null || $email === '') {
return null;
}
return strtolower(trim($email));
}
/**
* Determine whether the provider has verified the user's email.
*
* - Google: returns email_verified flag in raw data
* - Discord: returns verified flag in raw data
* - Apple: only issues tokens for verified Apple IDs
*/
private function isEmailVerifiedByProvider(string $provider, SocialiteUser $socialUser): bool
{
$raw = (array) ($socialUser->getRaw() ?? []);
return match ($provider) {
'google' => filter_var($raw['email_verified'] ?? false, FILTER_VALIDATE_BOOLEAN),
'discord' => (bool) ($raw['verified'] ?? false),
'apple' => true, // Apple only issues tokens for verified Apple IDs
default => false,
};
}
}

View File

@@ -0,0 +1,29 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Illuminate\Validation\Rules\Password;
class PasswordController extends Controller
{
/**
* Update the user's password.
*/
public function update(Request $request): RedirectResponse
{
$validated = $request->validateWithBag('updatePassword', [
'current_password' => ['required', 'current_password'],
'password' => ['required', Password::defaults(), 'confirmed'],
]);
$request->user()->update([
'password' => Hash::make($validated['password']),
]);
return back()->with('status', 'password-updated');
}
}

View File

@@ -0,0 +1,44 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Password;
use Illuminate\View\View;
class PasswordResetLinkController extends Controller
{
/**
* Display the password reset link request view.
*/
public function create(): View
{
return view('auth.forgot-password');
}
/**
* Handle an incoming password reset link request.
*
* @throws \Illuminate\Validation\ValidationException
*/
public function store(Request $request): RedirectResponse
{
$request->validate([
'email' => ['required', 'email'],
]);
// We will send the password reset link to this user. Once we have attempted
// to send the link, we will examine the response then see the message we
// need to show to the user. Finally, we'll send out a proper response.
$status = Password::sendResetLink(
$request->only('email')
);
return $status == Password::RESET_LINK_SENT
? back()->with('status', __($status))
: back()->withInput($request->only('email'))
->withErrors(['email' => __($status)]);
}
}

View File

@@ -0,0 +1,336 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Jobs\SendVerificationEmailJob;
use App\Http\Controllers\Controller;
use App\Models\EmailSendEvent;
use App\Models\User;
use App\Services\Auth\DisposableEmailService;
use App\Services\Auth\RegistrationVerificationTokenService;
use App\Services\Security\CaptchaVerifier;
use App\Services\Security\TurnstileVerifier;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\Str;
use Illuminate\View\View;
class RegisteredUserController extends Controller
{
public function __construct(
private readonly CaptchaVerifier $captchaVerifier,
private readonly TurnstileVerifier $turnstileVerifier,
private readonly DisposableEmailService $disposableEmailService,
private readonly RegistrationVerificationTokenService $verificationTokenService,
)
{
}
/**
* Display the registration view.
*/
public function create(Request $request): View
{
return view('auth.register', [
'prefillEmail' => (string) $request->query('email', ''),
'requiresCaptcha' => $this->shouldRequireCaptcha($request->ip()),
'captcha' => $this->captchaVerifier->frontendConfig(),
]);
}
public function notice(Request $request): View
{
$email = (string) session('registration_email', '');
$remaining = $email === '' ? 0 : $this->resendRemainingSeconds($email);
return view('auth.register-notice', [
'email' => $email,
'resendSeconds' => $remaining,
]);
}
/**
* Handle an incoming registration request.
*
* @throws \Illuminate\Validation\ValidationException
*/
public function store(Request $request): RedirectResponse
{
$rules = [
'email' => ['required', 'string', 'lowercase', 'email', 'max:255'],
'website' => ['nullable', 'max:0'],
];
$rules[$this->captchaVerifier->inputName()] = ['nullable', 'string'];
$validated = $request->validate($rules);
$email = strtolower(trim((string) $validated['email']));
$ip = $request->ip();
$this->trackRegisterAttempt($ip);
if ($this->shouldRequireCaptcha($ip)) {
$verified = $this->captchaVerifier->verify(
(string) $request->input($this->captchaVerifier->inputName(), ''),
$ip
);
if ($this->turnstileVerifier->isEnabled()) {
$verified = $this->turnstileVerifier->verify(
(string) $request->input($this->captchaVerifier->inputName(), ''),
$ip
);
}
if (! $verified) {
return back()
->withInput($request->except('website'))
->withErrors(['captcha' => 'Captcha verification failed. Please try again.']);
}
}
if ($this->disposableEmailService->isDisposableEmail($email)) {
$this->logEmailEvent($email, $ip, null, 'blocked', 'disposable');
return back()
->withInput($request->except('website'))
->withErrors(['email' => 'Please use a real email provider.']);
}
$user = User::query()->where('email', $email)->first();
if ($user && $user->email_verified_at !== null) {
$this->logEmailEvent($email, $ip, (int) $user->id, 'blocked', 'already-verified');
return $this->redirectToRegisterNotice($email);
}
if (! $user) {
$user = User::query()->create([
'username' => null,
'name' => Str::before($email, '@'),
'email' => $email,
'password' => Hash::make(Str::random(64)),
'is_active' => false,
'onboarding_step' => 'email',
'username_changed_at' => now(),
'last_username_change_at' => now(),
]);
}
if ($this->isWithinEmailCooldown($user)) {
$this->logEmailEvent($email, $ip, (int) $user->id, 'blocked', 'cooldown');
return $this->redirectToRegisterNotice($email);
}
$token = $this->verificationTokenService->createForUser((int) $user->id);
$event = $this->logEmailEvent($email, $ip, (int) $user->id, 'queued', null);
SendVerificationEmailJob::dispatch(
emailEventId: (int) $event->id,
email: $email,
token: $token,
userId: (int) $user->id,
ip: $ip
);
$this->markVerificationEmailSent($user);
return $this->redirectToRegisterNotice($email);
}
public function resendVerification(Request $request): RedirectResponse
{
$validated = $request->validate([
'email' => ['required', 'string', 'lowercase', 'email', 'max:255'],
]);
$email = strtolower(trim((string) $validated['email']));
$ip = $request->ip();
$user = User::query()
->where('email', $email)
->whereNull('email_verified_at')
->where('onboarding_step', 'email')
->first();
if (! $user) {
$this->logEmailEvent($email, $ip, null, 'blocked', 'missing');
return $this->redirectToRegisterNotice($email);
}
if ($this->isWithinEmailCooldown($user)) {
$this->logEmailEvent($email, $ip, (int) $user->id, 'blocked', 'cooldown');
return $this->redirectToRegisterNotice($email);
}
$token = $this->verificationTokenService->createForUser((int) $user->id);
$event = $this->logEmailEvent($email, $ip, (int) $user->id, 'queued', null);
SendVerificationEmailJob::dispatch(
emailEventId: (int) $event->id,
email: $email,
token: $token,
userId: (int) $user->id,
ip: $ip
);
$this->markVerificationEmailSent($user);
return $this->redirectToRegisterNotice($email);
}
private function redirectToRegisterNotice(string $email): RedirectResponse
{
return redirect(route('register.notice', absolute: false))
->with('status', $this->genericSuccessMessage())
->with('registration_email', $email);
}
private function genericSuccessMessage(): string
{
return (string) config('registration.generic_success_message', 'If that email is valid, we sent a verification link.');
}
private function logEmailEvent(string $email, ?string $ip, ?int $userId, string $status, ?string $reason): EmailSendEvent
{
return EmailSendEvent::query()->create([
'type' => 'verify_email',
'email' => $email,
'ip' => $ip,
'user_id' => $userId,
'status' => $status,
'reason' => $reason,
'created_at' => now(),
]);
}
private function shouldRequireCaptcha(?string $ip): bool
{
if (! $this->captchaVerifier->isEnabled()) {
if (! $this->turnstileVerifier->isEnabled()) {
return false;
}
if (! (bool) config('registration.enable_turnstile', true)) {
return false;
}
return $this->turnstileVerifier->isEnabled() && $this->shouldRequireCaptchaForIp($ip);
}
return $this->shouldRequireCaptchaForIp($ip);
}
private function shouldRequireCaptchaForIp(?string $ip): bool
{
if (! $this->captchaVerifier->isEnabled() && ! $this->turnstileVerifier->isEnabled()) {
return false;
}
if ($ip === null || $ip === '') {
return false;
}
$threshold = max(1, (int) config('registration.turnstile_suspicious_attempts', 2));
$attempts = (int) cache()->get($this->registerAttemptCacheKey($ip), 0);
if ($attempts >= $threshold) {
return true;
}
$minuteLimit = max(1, (int) config('registration.ip_per_minute_limit', 3));
$dailyLimit = max(1, (int) config('registration.ip_per_day_limit', 20));
if (RateLimiter::tooManyAttempts($this->registerIpRateKey($ip), $minuteLimit)) {
return true;
}
return RateLimiter::tooManyAttempts($this->registerIpDailyRateKey($ip), $dailyLimit);
}
private function trackRegisterAttempt(?string $ip): void
{
if ($ip === null || $ip === '') {
return;
}
$key = $this->registerAttemptCacheKey($ip);
$windowMinutes = max(1, (int) config('registration.turnstile_attempt_window_minutes', 30));
$seconds = $windowMinutes * 60;
$attempts = (int) cache()->get($key, 0);
cache()->put($key, $attempts + 1, $seconds);
}
private function registerAttemptCacheKey(string $ip): string
{
return 'register:attempts:' . sha1($ip);
}
private function registerIpRateKey(string $ip): string
{
return 'register:ip:' . $ip;
}
private function registerIpDailyRateKey(string $ip): string
{
return 'register:ip:daily:' . $ip;
}
private function isWithinEmailCooldown(User $user): bool
{
if ($user->last_verification_sent_at === null) {
return false;
}
$cooldownMinutes = max(1, (int) config('registration.email_cooldown_minutes', 30));
return $user->last_verification_sent_at->gt(now()->subMinutes($cooldownMinutes));
}
private function markVerificationEmailSent(User $user): void
{
$now = now();
$windowStartedAt = $user->verification_send_window_started_at;
if (! $windowStartedAt || $windowStartedAt->lt($now->copy()->subDay())) {
$user->verification_send_window_started_at = $now;
$user->verification_send_count_24h = 1;
} else {
$user->verification_send_count_24h = ((int) $user->verification_send_count_24h) + 1;
}
$user->last_verification_sent_at = $now;
$user->save();
}
private function resendCooldownSeconds(): int
{
return max(60, ((int) config('registration.email_cooldown_minutes', 30)) * 60);
}
private function resendRemainingSeconds(string $email): int
{
$user = User::query()
->where('email', strtolower(trim($email)))
->whereNull('email_verified_at')
->first();
if (! $user || $user->last_verification_sent_at === null) {
return 0;
}
$remaining = $user->last_verification_sent_at
->copy()
->addSeconds($this->resendCooldownSeconds())
->diffInSeconds(now(), false);
return $remaining >= 0 ? 0 : abs((int) $remaining);
}
}

View File

@@ -0,0 +1,51 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use App\Models\User;
use App\Services\Auth\RegistrationVerificationTokenService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
class RegistrationVerificationController extends Controller
{
public function __construct(
private readonly RegistrationVerificationTokenService $tokenService
)
{
}
public function __invoke(string $token): RedirectResponse
{
$record = $this->tokenService->findValidRecord($token);
if (! $record) {
return redirect(route('login', absolute: false))
->withErrors(['email' => 'Verification link is invalid.']);
}
$user = User::query()->find((int) $record->user_id);
if (! $user) {
DB::table('user_verification_tokens')->where('id', $record->id)->delete();
return redirect(route('login', absolute: false))
->withErrors(['email' => 'Verification link is invalid.']);
}
$user->forceFill([
'email_verified_at' => $user->email_verified_at ?? now(),
'onboarding_step' => 'verified',
'is_active' => true,
])->save();
DB::table('user_verification_tokens')
->where('id', $record->id)
->delete();
Auth::login($user);
return redirect('/setup/password')->with('status', 'Email verified. Continue with password setup.');
}
}

View File

@@ -0,0 +1,45 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Illuminate\View\View;
class SetupPasswordController extends Controller
{
public function create(Request $request): View
{
return view('auth.setup-password', [
'email' => (string) ($request->user()?->email ?? ''),
]);
}
public function store(Request $request): RedirectResponse
{
$validated = $request->validate([
'password' => [
'required',
'string',
'min:10',
'regex:/\d/',
'regex:/[^\w\s]/',
'confirmed',
],
], [
'password.min' => 'Your password must be at least 10 characters.',
'password.regex' => 'Your password must include at least one number and one symbol.',
'password.confirmed' => 'Password confirmation does not match.',
]);
$request->user()->forceFill([
'password' => Hash::make((string) $validated['password']),
'onboarding_step' => 'password',
'needs_password_reset' => false,
])->save();
return redirect('/setup/username')->with('status', 'Password saved. Choose your public username to finish setup.');
}
}

View File

@@ -0,0 +1,95 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use App\Http\Requests\UsernameRequest;
use App\Services\UsernameApprovalService;
use App\Support\UsernamePolicy;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\View\View;
class SetupUsernameController extends Controller
{
public function __construct(private readonly UsernameApprovalService $usernameApprovalService)
{
}
public function create(Request $request): View
{
return view('auth.setup-username', [
'username' => (string) ($request->user()?->username ?? ''),
]);
}
public function store(Request $request): RedirectResponse
{
$normalized = UsernamePolicy::normalize((string) $request->input('username', ''));
$request->merge(['username' => $normalized]);
$validated = $request->validate([
'username' => UsernameRequest::rulesFor((int) $request->user()->id),
], [
'username.required' => 'Please choose a username to continue.',
'username.unique' => 'This username is already taken.',
'username.regex' => 'Use only letters, numbers, and underscores.',
'username.min' => 'Username must be at least 3 characters.',
'username.max' => 'Username must be at most 20 characters.',
]);
$candidate = (string) $validated['username'];
$user = $request->user();
$similar = UsernamePolicy::similarReserved($candidate);
if ($similar !== null && ! UsernamePolicy::hasApprovedOverride($candidate, (int) $user->id)) {
$this->usernameApprovalService->submit($user, $candidate, 'onboarding_username', [
'current_username' => (string) ($user->username ?? ''),
]);
return back()
->withInput()
->with('status', 'Your request has been submitted for manual username review.')
->withErrors([
'username' => 'This username is too similar to a reserved name and requires manual approval.',
]);
}
DB::transaction(function () use ($user, $candidate): void {
$oldUsername = (string) ($user->username ?? '');
if ($oldUsername !== '' && strtolower($oldUsername) !== strtolower($candidate) && Schema::hasTable('username_history')) {
DB::table('username_history')->insert([
'user_id' => (int) $user->id,
'old_username' => strtolower($oldUsername),
'changed_at' => now(),
'created_at' => now(),
'updated_at' => now(),
]);
}
if ($oldUsername !== '' && strtolower($oldUsername) !== strtolower($candidate) && Schema::hasTable('username_redirects')) {
DB::table('username_redirects')->updateOrInsert(
['old_username' => strtolower($oldUsername)],
[
'new_username' => strtolower($candidate),
'user_id' => (int) $user->id,
'created_at' => now(),
'updated_at' => now(),
]
);
}
$user->forceFill([
'username' => strtolower($candidate),
'onboarding_step' => 'complete',
'username_changed_at' => now(),
'last_username_change_at' => now(),
])->save();
});
return redirect('/@' . strtolower($candidate));
}
}

View File

@@ -0,0 +1,27 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use Illuminate\Auth\Events\Verified;
use Illuminate\Foundation\Auth\EmailVerificationRequest;
use Illuminate\Http\RedirectResponse;
class VerifyEmailController extends Controller
{
/**
* Mark the authenticated user's email address as verified.
*/
public function __invoke(EmailVerificationRequest $request): RedirectResponse
{
if ($request->user()->hasVerifiedEmail()) {
return redirect()->intended(route('dashboard', absolute: false).'?verified=1');
}
if ($request->user()->markEmailAsVerified()) {
event(new Verified($request->user()));
}
return redirect()->intended(route('dashboard', absolute: false).'?verified=1');
}
}

View File

@@ -0,0 +1,201 @@
<?php
namespace App\Http\Controllers;
use App\Models\Category;
use App\Services\ThumbnailService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
class CategoryController extends Controller
{
public function index(Request $request): JsonResponse
{
$search = trim((string) $request->query('q', ''));
$sort = (string) $request->query('sort', 'popular');
$page = max(1, (int) $request->query('page', 1));
$perPage = min(60, max(12, (int) $request->query('per_page', 24)));
$categories = collect(Cache::remember('categories.directory.v1', 3600, function (): array {
$publishedArtworkScope = DB::table('artwork_category as artwork_category')
->join('artworks as artworks', 'artworks.id', '=', 'artwork_category.artwork_id')
->leftJoin('artwork_stats as artwork_stats', 'artwork_stats.artwork_id', '=', 'artworks.id')
->whereColumn('artwork_category.category_id', 'categories.id')
->where('artworks.is_public', true)
->where('artworks.is_approved', true)
->whereNull('artworks.deleted_at');
$categories = Category::query()
->select([
'categories.id',
'categories.content_type_id',
'categories.parent_id',
'categories.name',
'categories.slug',
])
->selectSub(
(clone $publishedArtworkScope)->selectRaw('COUNT(DISTINCT artworks.id)'),
'artwork_count'
)
->selectSub(
(clone $publishedArtworkScope)
->whereNotNull('artworks.hash')
->whereNotNull('artworks.thumb_ext')
->orderByDesc(DB::raw('COALESCE(artwork_stats.views, 0)'))
->orderByDesc(DB::raw('COALESCE(artwork_stats.favorites, 0)'))
->orderByDesc(DB::raw('COALESCE(artwork_stats.downloads, 0)'))
->orderByDesc(DB::raw('COALESCE(artworks.published_at, artworks.created_at)'))
->orderByDesc('artworks.id')
->limit(1)
->select('artworks.hash'),
'cover_hash'
)
->selectSub(
(clone $publishedArtworkScope)
->whereNotNull('artworks.hash')
->whereNotNull('artworks.thumb_ext')
->orderByDesc(DB::raw('COALESCE(artwork_stats.views, 0)'))
->orderByDesc(DB::raw('COALESCE(artwork_stats.favorites, 0)'))
->orderByDesc(DB::raw('COALESCE(artwork_stats.downloads, 0)'))
->orderByDesc(DB::raw('COALESCE(artworks.published_at, artworks.created_at)'))
->orderByDesc('artworks.id')
->limit(1)
->select('artworks.thumb_ext'),
'cover_ext'
)
->selectSub(
(clone $publishedArtworkScope)
->selectRaw('COALESCE(SUM(COALESCE(artwork_stats.views, 0) + (COALESCE(artwork_stats.favorites, 0) * 3) + (COALESCE(artwork_stats.downloads, 0) * 2)), 0)'),
'popular_score'
)
->with(['contentType:id,name,slug'])
->active()
->orderBy('categories.name')
->get();
return $this->transformCategories($categories);
}));
$filtered = $this->filterAndSortCategories($categories, $search, $sort);
$total = $filtered->count();
$lastPage = max(1, (int) ceil($total / $perPage));
$currentPage = min($page, $lastPage);
$offset = ($currentPage - 1) * $perPage;
$pageItems = $filtered->slice($offset, $perPage)->values();
$popularCategories = $this->filterAndSortCategories($categories, '', 'popular')->take(4)->values();
return response()->json([
'data' => $pageItems,
'meta' => [
'current_page' => $currentPage,
'last_page' => $lastPage,
'per_page' => $perPage,
'total' => $total,
],
'summary' => [
'total_categories' => $categories->count(),
'total_artworks' => $categories->sum(fn (array $category): int => (int) ($category['artwork_count'] ?? 0)),
],
'popular_categories' => $search === '' ? $popularCategories : [],
]);
}
/**
* @param Collection<int, array<string, mixed>> $categories
* @return Collection<int, array<string, mixed>>
*/
private function filterAndSortCategories(Collection $categories, string $search, string $sort): Collection
{
$filtered = $categories;
if ($search !== '') {
$needle = mb_strtolower($search);
$filtered = $filtered->filter(function (array $category) use ($needle): bool {
return str_contains(mb_strtolower((string) ($category['name'] ?? '')), $needle);
});
}
return $filtered->sort(function (array $left, array $right) use ($sort): int {
if ($sort === 'az') {
return strcasecmp((string) ($left['name'] ?? ''), (string) ($right['name'] ?? ''));
}
if ($sort === 'artworks') {
$countCompare = ((int) ($right['artwork_count'] ?? 0)) <=> ((int) ($left['artwork_count'] ?? 0));
return $countCompare !== 0
? $countCompare
: strcasecmp((string) ($left['name'] ?? ''), (string) ($right['name'] ?? ''));
}
$scoreCompare = ((int) ($right['popular_score'] ?? 0)) <=> ((int) ($left['popular_score'] ?? 0));
if ($scoreCompare !== 0) {
return $scoreCompare;
}
$countCompare = ((int) ($right['artwork_count'] ?? 0)) <=> ((int) ($left['artwork_count'] ?? 0));
if ($countCompare !== 0) {
return $countCompare;
}
return strcasecmp((string) ($left['name'] ?? ''), (string) ($right['name'] ?? ''));
})->values();
}
/**
* @param Collection<int, Category> $categories
* @return array<int, array<string, mixed>>
*/
private function transformCategories(Collection $categories): array
{
$categoryMap = $categories->keyBy('id');
$pathCache = [];
$buildPath = function (Category $category) use (&$buildPath, &$pathCache, $categoryMap): string {
if (isset($pathCache[$category->id])) {
return $pathCache[$category->id];
}
if ($category->parent_id && $categoryMap->has($category->parent_id)) {
$pathCache[$category->id] = $buildPath($categoryMap->get($category->parent_id)) . '/' . $category->slug;
return $pathCache[$category->id];
}
$pathCache[$category->id] = $category->slug;
return $pathCache[$category->id];
};
return $categories
->map(function (Category $category) use ($buildPath): array {
$contentTypeSlug = strtolower((string) ($category->contentType?->slug ?? 'categories'));
$path = $buildPath($category);
$coverImage = null;
if (! empty($category->cover_hash) && ! empty($category->cover_ext)) {
$coverImage = ThumbnailService::fromHash((string) $category->cover_hash, (string) $category->cover_ext, 'md');
}
return [
'id' => (int) $category->id,
'name' => (string) $category->name,
'slug' => (string) $category->slug,
'url' => '/' . $contentTypeSlug . '/' . $path,
'content_type' => [
'name' => (string) ($category->contentType?->name ?? 'Categories'),
'slug' => $contentTypeSlug,
],
'cover_image' => $coverImage ?: 'https://files.skinbase.org/default/missing_md.webp',
'artwork_count' => (int) ($category->artwork_count ?? 0),
'popular_score' => (int) ($category->popular_score ?? 0),
];
})
->values()
->all();
}
}

View File

@@ -0,0 +1,122 @@
<?php
namespace App\Http\Controllers;
use App\Models\Category;
use App\Services\ArtworkService;
use App\Services\ContentTypes\ContentTypeSlugResolver;
use Illuminate\Http\Request;
class CategoryPageController extends Controller
{
public function __construct(
private ArtworkService $artworkService,
private ContentTypeSlugResolver $contentTypeResolver,
)
{
}
public function show(Request $request, string $contentTypeSlug, ?string $categoryPath = null)
{
$resolution = $this->contentTypeResolver->resolve($contentTypeSlug);
if (! $resolution->found() || $resolution->contentType === null) {
abort(404);
}
$contentType = $resolution->contentType;
if ($resolution->requiresRedirect()) {
$target = url('/' . trim($contentType->slug . '/' . trim((string) $categoryPath, '/'), '/'));
$queryString = $request->getQueryString();
if ($queryString) {
$target .= '?' . $queryString;
}
return redirect()->to($target, 301);
}
$sort = (string) $request->get('sort', 'latest');
if ($categoryPath === null || $categoryPath === '') {
// No category path: show content-type landing page (e.g., /wallpapers)
$rootCategories = $contentType->rootCategories()->orderBy('sort_order')->orderBy('name')->get();
$page_title = $contentType->name;
$page_meta_description = $contentType->description ?? ($contentType->name . ' artworks on Skinbase');
// Load artworks for this content type (show gallery on the root page)
$perPage = 40;
$artworks = $this->artworkService->getArtworksByContentType($contentType->slug, $perPage, $sort);
return view('legacy::content-type', compact(
'contentType',
'rootCategories',
'artworks',
'page_title',
'page_meta_description'
));
}
$segments = array_filter(explode('/', $categoryPath));
$slugs = array_values(array_map('strtolower', $segments));
if (empty($slugs)) {
return redirect('/browse-categories');
}
// If the first slug exists but under a different content type, redirect to its canonical URL
$firstSlug = $slugs[0];
$globalRoot = Category::whereNull('parent_id')->where('slug', $firstSlug)->first();
if ($globalRoot && $globalRoot->contentType && $globalRoot->contentType->slug !== strtolower($contentType->slug)) {
$redirectPath = '/' . $globalRoot->contentType->slug . '/' . implode('/', $slugs);
return redirect($redirectPath, 301);
}
// Resolve category by path using the helper that validates parent chain and content type
$category = Category::findByPath($contentType->slug, $slugs);
if (! $category) {
abort(404);
}
$subcategories = $category->children()->orderBy('sort_order')->orderBy('name')->get();
$rootCategories = $contentType->rootCategories()->orderBy('sort_order')->orderBy('name')->get();
// Collect category ids for the category + all descendants recursively
$collected = [];
$gather = function (Category $cat) use (&$gather, &$collected) {
$collected[] = $cat->id;
foreach ($cat->children as $child) {
$gather($child);
}
};
// Ensure children relation is loaded to avoid N+1 recursion
$category->load('children');
$gather($category);
// Load artworks via ArtworkService to support arbitrary-depth category paths
$perPage = 40;
try {
// service expects an array with contentType slug first, then category slugs
$pathSlugs = array_merge([strtolower($contentTypeSlug)], $slugs);
$artworks = $this->artworkService->getArtworksByCategoryPath($pathSlugs, $perPage, $sort);
} catch (\Throwable $e) {
abort(404);
}
$page_title = $category->name;
$page_meta_description = $category->description ?? ($contentType->name . ' artworks on Skinbase');
$page_meta_keywords = strtolower($contentType->slug) . ', skinbase, artworks, wallpapers, skins, photography';
// resolved category and breadcrumbs are used by the view
return view('legacy::category-slug', compact(
'contentType',
'category',
'subcategories',
'rootCategories',
'artworks',
'page_title',
'page_meta_description',
'page_meta_keywords'
));
}
}

View File

@@ -0,0 +1,118 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers;
use App\Models\Collection;
use App\Models\CollectionMember;
use App\Models\User;
use App\Services\CollectionCollaborationService;
use App\Services\CollectionService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class CollectionCollaborationController extends Controller
{
public function __construct(
private readonly CollectionCollaborationService $collaborators,
private readonly CollectionService $collections,
) {
}
public function store(Request $request, Collection $collection): JsonResponse
{
$this->authorize('manageMembers', $collection);
$data = $request->validate([
'username' => ['required', 'string', 'min:3', 'max:20'],
'role' => ['required', 'in:editor,contributor,viewer'],
'note' => ['nullable', 'string', 'max:320'],
'expires_in_days' => ['nullable', 'integer', 'min:1', 'max:90'],
'expires_at' => ['nullable', 'date', 'after:now'],
]);
$invitee = User::query()
->whereRaw('LOWER(username) = ?', [strtolower((string) $data['username'])])
->firstOrFail();
$member = $this->collaborators->inviteMember(
$collection,
$request->user(),
$invitee,
(string) $data['role'],
$data['note'] ?? null,
isset($data['expires_in_days']) ? (int) $data['expires_in_days'] : null,
$data['expires_at'] ?? null,
);
return response()->json([
'ok' => true,
'member' => $member,
'members' => $this->collaborators->mapMembers($collection, $request->user()),
]);
}
public function transfer(Request $request, Collection $collection, CollectionMember $member): JsonResponse
{
$collection = $this->collaborators->transferOwnership($collection, $member, $request->user());
return response()->json([
'ok' => true,
'collection' => $this->collections->mapCollectionDetailPayload($collection->loadMissing('user'), true),
'members' => $this->collaborators->mapMembers($collection, $request->user()),
]);
}
public function update(Request $request, Collection $collection, CollectionMember $member): JsonResponse
{
$this->authorize('manageMembers', $collection);
abort_unless((int) $member->collection_id === (int) $collection->id, 404);
$data = $request->validate([
'role' => ['required', 'in:editor,contributor,viewer'],
]);
$this->collaborators->updateMemberRole($member, $request->user(), (string) $data['role']);
return response()->json([
'ok' => true,
'members' => $this->collaborators->mapMembers($collection, $request->user()),
]);
}
public function destroy(Request $request, Collection $collection, CollectionMember $member): JsonResponse
{
$this->authorize('manageMembers', $collection);
abort_unless((int) $member->collection_id === (int) $collection->id, 404);
$this->collaborators->revokeMember($member, $request->user());
return response()->json([
'ok' => true,
'members' => $this->collaborators->mapMembers($collection, $request->user()),
]);
}
public function accept(Request $request, CollectionMember $member): JsonResponse
{
$collection = $member->collection;
$this->collaborators->acceptInvite($member, $request->user());
return response()->json([
'ok' => true,
'members' => $this->collaborators->mapMembers($collection, $request->user()),
]);
}
public function decline(Request $request, CollectionMember $member): JsonResponse
{
$collection = $member->collection;
$this->collaborators->declineInvite($member, $request->user());
return response()->json([
'ok' => true,
'members' => $this->collaborators->mapMembers($collection, $request->user()),
]);
}
}

View File

@@ -0,0 +1,65 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers;
use App\Models\Collection;
use App\Models\CollectionComment;
use App\Services\CollectionCommentService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class CollectionCommentController extends Controller
{
public function __construct(
private readonly CollectionCommentService $comments,
) {
}
public function index(Request $request, Collection $collection): JsonResponse
{
abort_unless($collection->canBeViewedBy($request->user()), 404);
return response()->json([
'data' => $this->comments->mapComments($collection, $request->user()),
]);
}
public function store(Request $request, Collection $collection): JsonResponse
{
$this->authorize('comment', $collection);
$data = $request->validate([
'body' => ['required', 'string', 'min:2', 'max:4000'],
'parent_id' => ['nullable', 'integer', 'min:1'],
]);
$parent = null;
if (! empty($data['parent_id'])) {
$parent = CollectionComment::query()->findOrFail((int) $data['parent_id']);
abort_unless((int) $parent->collection_id === (int) $collection->id, 404);
}
$this->comments->create($collection->loadMissing('user'), $request->user(), (string) $data['body'], $parent);
return response()->json([
'ok' => true,
'comments' => $this->comments->mapComments($collection, $request->user()),
'comments_count' => (int) $collection->fresh()->comments_count,
]);
}
public function destroy(Request $request, Collection $collection, CollectionComment $comment): JsonResponse
{
abort_unless((int) $comment->collection_id === (int) $collection->id, 404);
$this->comments->delete($comment->load('collection'), $request->user());
return response()->json([
'ok' => true,
'comments' => $this->comments->mapComments($collection, $request->user()),
'comments_count' => (int) $collection->fresh()->comments_count,
]);
}
}

Some files were not shown because too many files have changed in this diff Show More