more fixes

This commit is contained in:
2026-03-12 07:22:38 +01:00
parent 547215cbe8
commit 4f576ceb04
226 changed files with 14380 additions and 4453 deletions

View File

@@ -0,0 +1,108 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Http;
use Klevze\ControlPanel\Facades\FileManager;
use Klevze\ControlPanel\Core\Utils\Translation as TranslationUtil;
class ExportMissingTranslations extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'translations:export-missing {file=admin} {--out=}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Export missing translations for a file (e.g. admin) into a CSV';
private $translationURL = "https://cPad.dev/api/translation/get/list";
private $token = 'Ddt06xvjYX1TK792H4jAtld8UhgVORYIpkB7nBX6';
public function handle(): int
{
$type = $this->argument('file') ?? 'admin';
$this->info('Exporting missing translations for: ' . $type);
// Gather files to scan
$files = [];
$files = array_merge(
FileManager::getFileList(app_path(), true),
FileManager::getFileList(base_path('packages'), true),
FileManager::getFileList(resource_path(), true)
);
$tempTranslations = [];
foreach ($files as $file) {
$res = TranslationUtil::findTranslations($file, $type);
if (!empty($res) && is_array($res)) {
$tempTranslations[] = $res;
}
}
$tempTranslations = collect($tempTranslations)->collapse();
$missing = [];
foreach ($tempTranslations as $keycode => $row) {
$exists = DB::table('translations')->where('keycode', $keycode)->where('file', $type)->exists();
if (! $exists) {
$missing[] = $keycode;
}
}
$this->info('Found ' . count($missing) . ' missing keys');
// Fetch suggested translations from external service for sl and en
$suggestions = [];
if (!empty($missing)) {
$payload = [
'keys' => $missing,
'languages' => ['sl', 'en'],
];
try {
$resp = Http::withToken($this->token)->post($this->translationURL, $payload);
if ($resp->successful()) {
$suggestions = $resp->json();
} else {
$this->warn('Translation suggestion service returned ' . $resp->status());
}
} catch (\Throwable $e) {
$this->warn('Failed to call suggestion service: ' . $e->getMessage());
}
}
// Build CSV
$out = $this->option('out') ?: storage_path('app/translations_missing_' . $type . '.csv');
$fh = fopen($out, 'w');
if (! $fh) {
$this->error('Failed to open output file: ' . $out);
return 1;
}
// Header
fputcsv($fh, ['file','keycode','suggested_sl','suggested_en','placeholder']);
foreach ($missing as $key) {
$s_sl = $suggestions[$key]['sl'] ?? '';
$s_en = $suggestions[$key]['en'] ?? '';
$placeholder = $type . '.' . $key;
fputcsv($fh, [$type, $key, $s_sl, $s_en, $placeholder]);
}
fclose($fh);
$this->info('CSV exported to: ' . $out);
return 0;
}
}

View File

@@ -0,0 +1,206 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\Story;
use App\Models\StoryTag;
use App\Models\User;
use App\Notifications\StoryStatusNotification;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
use Illuminate\Validation\Rule;
use Illuminate\View\View;
class StoryAdminController extends Controller
{
public function index(): View
{
$stories = Story::query()
->with(['creator'])
->latest('created_at')
->paginate(25);
return view('admin.stories.index', ['stories' => $stories]);
}
public function review(): View
{
$stories = Story::query()
->with(['creator'])
->where('status', 'pending_review')
->orderByDesc('submitted_for_review_at')
->paginate(25);
return view('admin.stories.review', ['stories' => $stories]);
}
public function create(): View
{
return view('admin.stories.create', [
'creators' => User::query()->orderBy('username')->limit(200)->get(['id', 'username']),
'tags' => StoryTag::query()->orderBy('name')->get(['id', 'name']),
]);
}
public function store(Request $request): RedirectResponse
{
$validated = $request->validate([
'creator_id' => ['required', 'integer', 'exists:users,id'],
'title' => ['required', 'string', 'max:255'],
'excerpt' => ['nullable', 'string', 'max:500'],
'cover_image' => ['nullable', 'string', 'max:500'],
'content' => ['required', 'string'],
'story_type' => ['required', 'in:creator_story,tutorial,interview,project_breakdown,announcement,resource'],
'status' => ['required', Rule::in(['draft', 'pending_review', 'published', 'scheduled', 'archived', 'rejected'])],
'tags' => ['nullable', 'array'],
'tags.*' => ['integer', 'exists:story_tags,id'],
]);
$story = Story::query()->create([
'creator_id' => (int) $validated['creator_id'],
'title' => $validated['title'],
'slug' => $this->uniqueSlug($validated['title']),
'excerpt' => $validated['excerpt'] ?? null,
'cover_image' => $validated['cover_image'] ?? null,
'content' => $validated['content'],
'story_type' => $validated['story_type'],
'reading_time' => max(1, (int) ceil(str_word_count(strip_tags((string) $validated['content'])) / 200)),
'status' => $validated['status'],
'published_at' => $validated['status'] === 'published' ? now() : null,
'submitted_for_review_at' => $validated['status'] === 'pending_review' ? now() : null,
]);
if (! empty($validated['tags'])) {
$story->tags()->sync($validated['tags']);
}
return redirect()->route('admin.stories.edit', ['story' => $story->id])
->with('status', 'Story created.');
}
public function edit(Story $story): View
{
$story->load('tags');
return view('admin.stories.edit', [
'story' => $story,
'creators' => User::query()->orderBy('username')->limit(200)->get(['id', 'username']),
'tags' => StoryTag::query()->orderBy('name')->get(['id', 'name']),
]);
}
public function update(Request $request, Story $story): RedirectResponse
{
$validated = $request->validate([
'creator_id' => ['required', 'integer', 'exists:users,id'],
'title' => ['required', 'string', 'max:255'],
'excerpt' => ['nullable', 'string', 'max:500'],
'cover_image' => ['nullable', 'string', 'max:500'],
'content' => ['required', 'string'],
'story_type' => ['required', 'in:creator_story,tutorial,interview,project_breakdown,announcement,resource'],
'status' => ['required', Rule::in(['draft', 'pending_review', 'published', 'scheduled', 'archived', 'rejected'])],
'tags' => ['nullable', 'array'],
'tags.*' => ['integer', 'exists:story_tags,id'],
]);
$story->update([
'creator_id' => (int) $validated['creator_id'],
'title' => $validated['title'],
'excerpt' => $validated['excerpt'] ?? null,
'cover_image' => $validated['cover_image'] ?? null,
'content' => $validated['content'],
'story_type' => $validated['story_type'],
'reading_time' => max(1, (int) ceil(str_word_count(strip_tags((string) $validated['content'])) / 200)),
'status' => $validated['status'],
'published_at' => $validated['status'] === 'published' ? ($story->published_at ?? now()) : $story->published_at,
'submitted_for_review_at' => $validated['status'] === 'pending_review' ? ($story->submitted_for_review_at ?? now()) : $story->submitted_for_review_at,
]);
$story->tags()->sync($validated['tags'] ?? []);
return back()->with('status', 'Story updated.');
}
public function destroy(Story $story): RedirectResponse
{
$story->delete();
return redirect()->route('admin.stories.index')->with('status', 'Story deleted.');
}
public function publish(Story $story): RedirectResponse
{
$story->update([
'status' => 'published',
'published_at' => $story->published_at ?? now(),
'reviewed_at' => now(),
]);
$story->creator?->notify(new StoryStatusNotification($story, 'published'));
return back()->with('status', 'Story published.');
}
public function show(Story $story): View
{
return view('admin.stories.show', [
'story' => $story->load(['creator', 'tags']),
]);
}
public function approve(Request $request, Story $story): RedirectResponse
{
$story->update([
'status' => 'published',
'published_at' => $story->published_at ?? now(),
'reviewed_at' => now(),
'reviewed_by_id' => (int) $request->user()->id,
'rejected_reason' => null,
]);
$story->creator?->notify(new StoryStatusNotification($story, 'approved'));
return back()->with('status', 'Story approved and published.');
}
public function reject(Request $request, Story $story): RedirectResponse
{
$validated = $request->validate([
'reason' => ['required', 'string', 'max:1000'],
]);
$story->update([
'status' => 'rejected',
'reviewed_at' => now(),
'reviewed_by_id' => (int) $request->user()->id,
'rejected_reason' => $validated['reason'],
]);
$story->creator?->notify(new StoryStatusNotification($story, 'rejected', $validated['reason']));
return back()->with('status', 'Story rejected and creator notified.');
}
public function moderateComments(): View
{
return view('admin.stories.comments-moderation');
}
private function uniqueSlug(string $title): string
{
$base = Str::slug($title);
$slug = $base;
$n = 2;
while (Story::query()->where('slug', $slug)->exists()) {
$slug = $base . '-' . $n;
$n++;
}
return $slug;
}
}

View File

@@ -10,6 +10,7 @@ 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
@@ -124,6 +125,9 @@ final class UsernameApprovalController extends Controller
$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 !== '') {

View File

@@ -86,7 +86,9 @@ final class ArtworkDownloadController extends Controller
'artwork_id' => $artwork->id,
'user_id' => $request->user()?->id,
'ip' => $bin !== false ? $bin : null,
'user_agent' => mb_substr((string) $request->userAgent(), 0, 512),
'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) {

View File

@@ -48,7 +48,7 @@ class NotificationController extends Controller
public function readAll(Request $request): JsonResponse
{
$request->user()->unreadNotifications->markAsRead();
$request->user()->unreadNotifications()->update(['read_at' => now()]);
return response()->json(['message' => 'All notifications marked as read.']);
}

View File

@@ -6,6 +6,7 @@ use App\Http\Controllers\Controller;
use App\Models\ConversationParticipant;
use App\Models\Message;
use App\Models\Report;
use App\Models\Story;
use App\Models\User;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
@@ -17,7 +18,7 @@ class ReportController extends Controller
$user = $request->user();
$data = $request->validate([
'target_type' => 'required|in:message,conversation,user',
'target_type' => 'required|in:message,conversation,user,story',
'target_id' => 'required|integer|min:1',
'reason' => 'required|string|max:120',
'details' => 'nullable|string|max:4000',
@@ -49,6 +50,10 @@ class ReportController extends Controller
User::query()->findOrFail($targetId);
}
if ($targetType === 'story') {
Story::query()->findOrFail($targetId);
}
$report = Report::query()->create([
'reporter_id' => $user->id,
'target_type' => $targetType,

View File

@@ -7,7 +7,7 @@ namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\Story;
use App\Models\StoryTag;
use App\Models\StoryAuthor;
use App\Models\User;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
@@ -36,7 +36,7 @@ final class StoriesApiController extends Controller
$stories = Cache::remember($cacheKey, 300, fn () =>
Story::published()
->with('author', 'tags')
->with('creator.profile', 'tags')
->orderByDesc('published_at')
->paginate($perPage, ['*'], 'page', $page)
);
@@ -60,7 +60,7 @@ final class StoriesApiController extends Controller
{
$story = Cache::remember('stories:api:' . $slug, 600, fn () =>
Story::published()
->with('author', 'tags')
->with('creator.profile', 'tags')
->where('slug', $slug)
->firstOrFail()
);
@@ -76,7 +76,7 @@ final class StoriesApiController extends Controller
{
$story = Cache::remember('stories:api:featured', 300, fn () =>
Story::published()->featured()
->with('author', 'tags')
->with('creator.profile', 'tags')
->orderByDesc('published_at')
->first()
);
@@ -99,8 +99,8 @@ final class StoriesApiController extends Controller
$stories = Cache::remember("stories:api:tag:{$tag}:{$page}", 300, fn () =>
Story::published()
->with('author', 'tags')
->whereHas('tags', fn ($q) => $q->where('stories_tags.id', $storyTag->id))
->with('creator.profile', 'tags')
->whereHas('tags', fn ($q) => $q->where('story_tags.id', $storyTag->id))
->orderByDesc('published_at')
->paginate(12, ['*'], 'page', $page)
);
@@ -123,21 +123,20 @@ final class StoriesApiController extends Controller
*/
public function byAuthor(Request $request, string $username): JsonResponse
{
$author = StoryAuthor::whereHas('user', fn ($q) => $q->where('username', $username))->first()
?? StoryAuthor::where('name', $username)->firstOrFail();
$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('author', 'tags')
->where('author_id', $author->id)
->with('creator.profile', 'tags')
->where('creator_id', $author->id)
->orderByDesc('published_at')
->paginate(12, ['*'], 'page', $page)
);
return response()->json([
'author' => $this->formatAuthor($author),
'author' => $this->formatCreator($author),
'data' => $stories->getCollection()->map(fn (Story $s) => $this->formatCard($s)),
'meta' => [
'current_page' => $stories->currentPage(),
@@ -159,7 +158,7 @@ final class StoriesApiController extends Controller
'title' => $story->title,
'excerpt' => $story->excerpt,
'cover_image' => $story->cover_url,
'author' => $story->author ? $this->formatAuthor($story->author) : null,
'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,
@@ -175,14 +174,18 @@ final class StoriesApiController extends Controller
]);
}
private function formatAuthor(StoryAuthor $author): array
private function formatCreator(User $creator): array
{
$avatarHash = $creator->profile?->avatar_hash;
return [
'id' => $author->id,
'name' => $author->name,
'avatar_url' => $author->avatar_url,
'bio' => $author->bio,
'profile_url' => $author->profile_url,
'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,133 @@
<?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',
];
public function __invoke(Request $request, int $id): BinaryFileResponse
{
$artwork = Artwork::query()->find($id);
if (! $artwork) {
abort(404);
}
$hash = strtolower((string) $artwork->hash);
$ext = strtolower(ltrim((string) $artwork->file_ext, '.'));
if (! $this->isValidHash($hash) || ! in_array($ext, self::ALLOWED_EXTENSIONS, true)) {
abort(404);
}
$filePath = $this->resolveOriginalPath($hash, $ext);
if (! File::isFile($filePath)) {
Log::warning('Artwork original file missing for download.', [
'artwork_id' => $artwork->id,
'hash' => $hash,
'ext' => $ext,
'resolved_path' => $filePath,
]);
abort(404);
}
$this->recordDownload($request, $artwork->id);
$this->incrementDownloadCountIfAvailable($artwork->id);
$downloadName = $this->buildDownloadFilename((string) $artwork->file_name, $ext);
return response()->download($filePath, $downloadName);
}
private function resolveOriginalPath(string $hash, string $ext): string
{
$firstDir = substr($hash, 0, 2);
$secondDir = substr($hash, 2, 2);
$root = rtrim((string) config('uploads.storage_root'), DIRECTORY_SEPARATOR);
return $root
. DIRECTORY_SEPARATOR . 'original'
. DIRECTORY_SEPARATOR . $firstDir
. DIRECTORY_SEPARATOR . $secondDir
. 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

@@ -28,7 +28,7 @@ class AuthenticatedSessionController extends Controller
$request->session()->regenerate();
return redirect()->intended(route('dashboard', absolute: false));
return redirect()->intended('/');
}
/**

View File

@@ -169,6 +169,7 @@ class OAuthController extends Controller
'is_active' => true,
'onboarding_step' => 'username',
'username_changed_at' => now(),
'last_username_change_at' => now(),
]);
$this->createSocialAccount(

View File

@@ -105,6 +105,7 @@ class RegisteredUserController extends Controller
'is_active' => false,
'onboarding_step' => 'email',
'username_changed_at' => now(),
'last_username_change_at' => now(),
]);
}

View File

@@ -35,7 +35,7 @@ class SetupUsernameController extends Controller
], [
'username.required' => 'Please choose a username to continue.',
'username.unique' => 'This username is already taken.',
'username.regex' => 'Use only letters, numbers, underscores, or hyphens.',
'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.',
]);
@@ -86,6 +86,7 @@ class SetupUsernameController extends Controller
'username' => strtolower($candidate),
'onboarding_step' => 'complete',
'username_changed_at' => now(),
'last_username_change_at' => now(),
])->save();
});

View File

@@ -0,0 +1,284 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers;
use App\Models\Artwork;
use App\Models\Story;
use App\Models\User;
use App\Support\AvatarUrl;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
final class DashboardController extends Controller
{
public function index(Request $request)
{
$user = $request->user();
return view('dashboard', [
'page_title' => 'Dashboard',
'dashboard_user_name' => $user?->username ?: $user?->name ?: 'Creator',
'dashboard_is_creator' => Artwork::query()->where('user_id', $user->id)->exists(),
]);
}
public function activity(Request $request): JsonResponse
{
$user = $request->user();
$notificationItems = $user->notifications()
->latest()
->limit(12)
->get()
->map(function ($notification): array {
return [
'id' => (string) $notification->id,
'type' => 'notification',
'message' => $this->notificationMessage((array) $notification->data),
'reference_id' => (string) ($notification->id ?? ''),
'created_at' => $notification->created_at?->toIso8601String(),
'is_unread' => $notification->read_at === null,
'actor' => null,
];
});
$followItems = DB::table('user_followers as uf')
->join('users as follower', 'follower.id', '=', 'uf.follower_id')
->leftJoin('user_profiles as fp', 'fp.user_id', '=', 'follower.id')
->where('uf.user_id', $user->id)
->select([
'uf.follower_id as actor_id',
'follower.username as actor_username',
'follower.name as actor_name',
'fp.avatar_hash as actor_avatar_hash',
'uf.created_at',
])
->orderByDesc('uf.created_at')
->limit(10)
->get()
->map(function ($row): array {
return [
'id' => 'follow-' . (string) $row->actor_id . '-' . Carbon::parse((string) $row->created_at)->timestamp,
'type' => 'new_follower',
'message' => 'started following you',
'reference_id' => (string) $row->actor_id,
'created_at' => Carbon::parse((string) $row->created_at)->toIso8601String(),
'is_unread' => false,
'actor' => [
'id' => (int) $row->actor_id,
'name' => $row->actor_name,
'username' => $row->actor_username,
'avatar' => AvatarUrl::forUser((int) $row->actor_id, $row->actor_avatar_hash, 64),
],
];
});
$commentItems = DB::table('artwork_comments as c')
->join('artworks as a', 'a.id', '=', 'c.artwork_id')
->join('users as commenter', 'commenter.id', '=', 'c.user_id')
->leftJoin('user_profiles as cp', 'cp.user_id', '=', 'commenter.id')
->where('a.user_id', $user->id)
->where('c.user_id', '!=', $user->id)
->where('c.is_approved', true)
->whereNull('c.deleted_at')
->select([
'c.id as comment_id',
'c.created_at',
'a.id as artwork_id',
'a.slug as artwork_slug',
'a.title as artwork_title',
'commenter.id as actor_id',
'commenter.username as actor_username',
'commenter.name as actor_name',
'cp.avatar_hash as actor_avatar_hash',
])
->orderByDesc('c.created_at')
->limit(10)
->get()
->map(function ($row): array {
return [
'id' => 'comment-' . (string) $row->comment_id,
'type' => 'comment',
'message' => 'commented on your artwork',
'reference_id' => (string) $row->artwork_id,
'created_at' => Carbon::parse((string) $row->created_at)->toIso8601String(),
'is_unread' => false,
'actor' => [
'id' => (int) $row->actor_id,
'name' => $row->actor_name,
'username' => $row->actor_username,
'avatar' => AvatarUrl::forUser((int) $row->actor_id, $row->actor_avatar_hash, 64),
],
'context' => [
'artwork_id' => (int) $row->artwork_id,
'artwork_title' => $row->artwork_title,
'artwork_url' => '/art/' . $row->artwork_id . '/' . $row->artwork_slug,
],
];
});
$items = collect()
->concat($notificationItems)
->concat($followItems)
->concat($commentItems)
->sortByDesc(fn (array $item) => (string) ($item['created_at'] ?? ''))
->take(20)
->values();
return response()->json([
'data' => $items,
]);
}
public function analytics(Request $request): JsonResponse
{
$user = $request->user();
$artworksCount = Artwork::query()->where('user_id', $user->id)->count();
$storyAggregate = Story::query()
->where('creator_id', $user->id)
->selectRaw('COUNT(*) as total_stories, COALESCE(SUM(views),0) as total_story_views, COALESCE(SUM(likes_count),0) as total_story_likes')
->first();
$stats = $user->statistics;
$followersCount = (int) ($stats?->followers_count ?? $user->followers()->count());
$artworkLikes = (int) ($stats?->favorites_received_count ?? 0);
$storyLikes = (int) ($storyAggregate?->total_story_likes ?? 0);
return response()->json([
'data' => [
'is_creator' => $artworksCount > 0,
'total_artworks' => $artworksCount,
'total_stories' => (int) ($storyAggregate?->total_stories ?? 0),
'total_story_views' => (int) ($storyAggregate?->total_story_views ?? 0),
'total_followers' => $followersCount,
'total_likes' => $artworkLikes + $storyLikes,
],
]);
}
public function trendingArtworks(): JsonResponse
{
$cacheKey = 'dashboard:trending-artworks:v1';
$data = Cache::remember($cacheKey, 300, function (): array {
return Artwork::query()
->select('artworks.*')
->leftJoin('artwork_stats as s', 's.artwork_id', '=', 'artworks.id')
->public()
->with(['user.profile', 'stats'])
->orderByRaw('COALESCE(s.ranking_score, 0) DESC')
->orderByRaw('COALESCE(s.heat_score, 0) DESC')
->orderByRaw('COALESCE(s.favorites, 0) DESC')
->orderByRaw('COALESCE(s.views, 0) DESC')
->orderByRaw('COALESCE(s.comments_count, 0) DESC')
->limit(8)
->get()
->map(function (Artwork $artwork): array {
return [
'id' => $artwork->id,
'title' => $artwork->title,
'url' => '/art/' . $artwork->id . '/' . $artwork->slug,
'thumbnail' => $artwork->thumbUrl('md') ?? $artwork->thumbnail_url,
'likes' => (int) ($artwork->stats?->favorites ?? 0),
'views' => (int) ($artwork->stats?->views ?? 0),
'comments' => (int) ($artwork->stats?->comments_count ?? 0),
'creator' => [
'id' => (int) $artwork->user_id,
'username' => $artwork->user?->username,
'name' => $artwork->user?->name,
'url' => $artwork->user?->username ? '/@' . $artwork->user->username : null,
],
];
})
->values()
->all();
});
return response()->json(['data' => $data]);
}
public function recommendedCreators(Request $request): JsonResponse
{
$user = $request->user();
$cacheKey = 'dashboard:recommended-creators:' . $user->id . ':v1';
$data = Cache::remember($cacheKey, 600, function () use ($user): array {
$followingIds = DB::table('user_followers')
->where('follower_id', $user->id)
->pluck('user_id')
->map(fn ($id) => (int) $id)
->all();
$excludeIds = array_values(array_unique(array_merge([$user->id], $followingIds)));
return User::query()
->from('users')
->leftJoin('user_profiles as up', 'up.user_id', '=', 'users.id')
->leftJoin('user_statistics as us', 'us.user_id', '=', 'users.id')
->where('users.is_active', true)
->whereNotIn('users.id', $excludeIds)
->whereExists(function ($q): void {
$q->select(DB::raw(1))
->from('artworks')
->whereColumn('artworks.user_id', 'users.id')
->where('artworks.is_public', true)
->where('artworks.is_approved', true)
->whereNull('artworks.deleted_at');
})
->select([
'users.id',
'users.username',
'users.name',
'up.avatar_hash',
DB::raw('COALESCE(us.followers_count, 0) as followers_count'),
DB::raw('COALESCE(us.uploads_count, 0) as uploads_count'),
])
->orderByDesc('followers_count')
->orderByDesc('uploads_count')
->limit(6)
->get()
->map(function ($row): array {
$username = (string) ($row->username ?? '');
return [
'id' => (int) $row->id,
'username' => $username,
'name' => $row->name,
'url' => $username !== '' ? '/@' . $username : null,
'avatar' => AvatarUrl::forUser((int) $row->id, $row->avatar_hash, 64),
'followers_count' => (int) $row->followers_count,
'uploads_count' => (int) $row->uploads_count,
];
})
->values()
->all();
});
return response()->json(['data' => $data]);
}
private function notificationMessage(array $payload): string
{
$title = trim((string) ($payload['title'] ?? ''));
if ($title !== '') {
return $title;
}
$message = trim((string) ($payload['message'] ?? ''));
if ($message !== '') {
return $message;
}
$type = trim((string) ($payload['type'] ?? 'Notification'));
return $type !== '' ? $type : 'New notification';
}
}

View File

@@ -0,0 +1,157 @@
<?php
namespace App\Http\Controllers\News;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use cPad\Plugins\News\Models\NewsArticle;
use cPad\Plugins\News\Models\NewsCategory;
use cPad\Plugins\News\Models\NewsTag;
use cPad\Plugins\News\Models\NewsView;
class NewsController extends Controller
{
// -----------------------------------------------------------------------
// Homepage — /news
// -----------------------------------------------------------------------
public function index(Request $request)
{
$perPage = config('news.articles_per_page', 12);
$featured = NewsArticle::with('author', 'category')
->published()
->featured()
->orderByDesc('published_at')
->first();
$query = NewsArticle::with('author', 'category')
->published()
->orderByDesc('published_at');
if ($featured) {
$query->where('id', '!=', $featured->id);
}
$articles = $query->paginate($perPage);
$categories = NewsCategory::active()->withCount('publishedArticles')->ordered()->get();
$trending = NewsArticle::published()
->orderByDesc('views')
->limit(config('news.trending_limit', 5))
->get(['id', 'title', 'slug', 'views', 'published_at']);
$tags = NewsTag::has('articles')->orderBy('name')->get();
return view('news.index', [
'featured' => $featured,
'articles' => $articles,
'categories' => $categories,
'trending' => $trending,
'tags' => $tags,
]);
}
// -----------------------------------------------------------------------
// Category page — /news/category/{slug}
// -----------------------------------------------------------------------
public function category(Request $request, string $slug)
{
$category = NewsCategory::where('slug', $slug)->where('is_active', true)->firstOrFail();
$perPage = config('news.articles_per_page', 12);
$articles = NewsArticle::with('author', 'category')
->published()
->byCategory($category->id)
->orderByDesc('published_at')
->paginate($perPage);
$categories = NewsCategory::active()->withCount('publishedArticles')->ordered()->get();
return view('news.category', [
'category' => $category,
'articles' => $articles,
'categories' => $categories,
]);
}
// -----------------------------------------------------------------------
// Tag page — /news/tag/{slug}
// -----------------------------------------------------------------------
public function tag(Request $request, string $slug)
{
$tag = NewsTag::where('slug', $slug)->firstOrFail();
$perPage = config('news.articles_per_page', 12);
$articles = NewsArticle::with('author', 'category')
->published()
->whereHas('tags', fn ($q) => $q->where('news_tags.slug', $slug))
->orderByDesc('published_at')
->paginate($perPage);
$categories = NewsCategory::active()->withCount('publishedArticles')->ordered()->get();
return view('news.tag', [
'tag' => $tag,
'articles' => $articles,
'categories' => $categories,
]);
}
// -----------------------------------------------------------------------
// Article page — /news/{slug}
// -----------------------------------------------------------------------
public function show(Request $request, string $slug)
{
$article = NewsArticle::with('author', 'category', 'tags')
->published()
->where('slug', $slug)
->firstOrFail();
// Track view (once per session / IP)
$this->trackView($request, $article);
// Related articles (same category, excluding current)
$related = NewsArticle::with('author')
->published()
->when($article->category_id, fn ($q) => $q->where('category_id', $article->category_id))
->where('id', '!=', $article->id)
->orderByDesc('published_at')
->limit(config('news.related_limit', 4))
->get();
return view('news.show', [
'article' => $article,
'related' => $related,
]);
}
// -----------------------------------------------------------------------
// Helpers
// -----------------------------------------------------------------------
private function trackView(Request $request, NewsArticle $article): void
{
$ip = $request->ip();
$userId = Auth::id();
$session = 'news_view_' . $article->id;
if ($request->session()->has($session)) {
return;
}
NewsView::create([
'article_id' => $article->id,
'user_id' => $userId,
'ip' => $ip,
'created_at' => now(),
]);
$article->incrementViews();
$request->session()->put($session, true);
}
}

View File

@@ -0,0 +1,74 @@
<?php
namespace App\Http\Controllers\News;
use App\Http\Controllers\Controller;
use Illuminate\Http\Response;
use cPad\Plugins\News\Models\NewsArticle;
class NewsRssController extends Controller
{
/**
* Generate RSS 2.0 feed for published news articles.
* Endpoint: GET /rss/news
*/
public function feed(): Response
{
$articles = NewsArticle::with('author', 'category')
->published()
->orderByDesc('published_at')
->limit(config('news.rss_limit', 25))
->get();
$xml = $this->buildRss($articles);
return response($xml, 200, [
'Content-Type' => 'application/rss+xml; charset=UTF-8',
]);
}
private function buildRss($articles): string
{
$siteUrl = config('app.url');
$title = e(config('news.rss_title', 'News'));
$description = e(config('news.rss_description', 'Latest news.'));
$now = now()->toRfc2822String();
$items = '';
foreach ($articles as $article) {
$link = e(url('/news/' . $article->slug));
$pubDate = $article->published_at?->toRfc2822String() ?? $now;
$articleTitle = e($article->title);
$excerpt = e(strip_tags((string) ($article->excerpt ?? '')));
$category = e((string) ($article->category?->name ?? ''));
$author = e((string) ($article->author?->name ?? ''));
$items .= <<<ITEM
<item>
<title><![CDATA[{$articleTitle}]]></title>
<link>{$link}</link>
<guid isPermaLink="true">{$link}</guid>
<description><![CDATA[{$excerpt}]]></description>
<pubDate>{$pubDate}</pubDate>
<author>{$author}</author>
<category>{$category}</category>
</item>
ITEM;
}
return <<<XML
<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
<channel>
<title>{$title}</title>
<link>{$siteUrl}/news</link>
<description>{$description}</description>
<language>en-us</language>
<lastBuildDate>{$now}</lastBuildDate>
<atom:link href="{$siteUrl}/rss/news" rel="self" type="application/rss+xml"/>
{$items} </channel>
</rss>
XML;
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -341,8 +341,23 @@ final class StudioArtworksApiController extends Controller
// 4. Update the artwork's file-serving fields (hash drives thumbnail URLs)
$origExt = strtolower(pathinfo($originalPath, PATHINFO_EXTENSION) ?: '');
$displayFileName = $origFilename;
$clientName = basename(str_replace('\\', '/', (string) $file->getClientOriginalName()));
$clientName = preg_replace('/[\x00-\x1F\x7F]/', '', (string) $clientName) ?? '';
$clientName = trim((string) $clientName);
if ($clientName !== '') {
$clientExt = strtolower((string) pathinfo($clientName, PATHINFO_EXTENSION));
if ($clientExt === '' && $origExt !== '') {
$clientName .= '.' . $origExt;
}
$displayFileName = $clientName;
}
$artwork->update([
'file_name' => $origFilename,
'file_name' => $displayFileName,
'file_path' => '',
'file_size' => $size,
'mime_type' => $origMime,

View File

@@ -31,11 +31,15 @@ class AvatarController extends Controller
$file = $request->file('avatar');
try {
$hash = $this->service->storeFromUploadedFile($user->id, $file);
$hash = $this->service->storeFromUploadedFile(
(int) $user->id,
$file,
(string) $request->input('avatar_position', 'center')
);
return response()->json([
'success' => true,
'hash' => $hash,
'url' => AvatarUrl::forUser((int) $user->id, $hash, 128),
'url' => AvatarUrl::forUser((int) $user->id, $hash, 256),
], 200);
} catch (RuntimeException $e) {
logger()->warning('Avatar upload validation failed', [

View File

@@ -4,9 +4,20 @@ namespace App\Http\Controllers\User;
use App\Http\Controllers\Controller;
use App\Http\Requests\ProfileUpdateRequest;
use App\Http\Requests\Settings\RequestEmailChangeRequest;
use App\Http\Requests\Settings\UpdateAccountSectionRequest;
use App\Http\Requests\Settings\UpdateNotificationsSectionRequest;
use App\Http\Requests\Settings\UpdatePersonalSectionRequest;
use App\Http\Requests\Settings\UpdateProfileSectionRequest;
use App\Http\Requests\Settings\UpdateSecurityPasswordRequest;
use App\Http\Requests\Settings\VerifyEmailChangeRequest;
use App\Mail\EmailChangedSecurityAlertMail;
use App\Mail\EmailChangeVerificationCodeMail;
use App\Models\Artwork;
use App\Models\ProfileComment;
use App\Models\Story;
use App\Models\User;
use App\Services\AvatarService;
use App\Services\ArtworkService;
use App\Services\FollowService;
use App\Services\ThumbnailPresenter;
@@ -14,6 +25,7 @@ use App\Services\ThumbnailService;
use App\Services\UsernameApprovalService;
use App\Services\UserStatsService;
use App\Support\AvatarUrl;
use App\Support\CoverUrl;
use App\Support\UsernamePolicy;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
@@ -24,6 +36,7 @@ use Illuminate\Support\Facades\Redirect;
use Illuminate\Support\Facades\Schema;
use Illuminate\View\View;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Mail;
use Illuminate\Validation\Rules\Password as PasswordRule;
use Inertia\Inertia;
@@ -127,6 +140,16 @@ class ProfileController extends Controller
public function editSettings(Request $request)
{
$user = $request->user();
$cooldownDays = $this->usernameCooldownDays();
$lastUsernameChangeAt = $this->lastUsernameChangeAt($user);
$usernameCooldownRemainingDays = 0;
if ($lastUsernameChangeAt !== null) {
$nextAllowedChangeAt = $lastUsernameChangeAt->copy()->addDays($cooldownDays);
if ($nextAllowedChangeAt->isFuture()) {
$usernameCooldownRemainingDays = now()->diffInDays($nextAllowedChangeAt);
}
}
// Parse birth date parts
$birthDay = null;
@@ -176,9 +199,15 @@ class ProfileController extends Controller
// Avatar URL
$avatarHash = $profileData['avatar_hash'] ?? $user->icon ?? null;
$avatarUrl = !empty($avatarHash)
? AvatarUrl::forUser((int) $user->id, $avatarHash, 128)
? AvatarUrl::forUser((int) $user->id, $avatarHash, 256)
: AvatarUrl::default();
$emailNotifications = (bool) ($profileData['email_notifications'] ?? $profileData['mlist'] ?? $user->mlist ?? true);
$uploadNotifications = (bool) ($profileData['upload_notifications'] ?? $profileData['friend_upload_notice'] ?? $user->friend_upload_notice ?? true);
$followerNotifications = (bool) ($profileData['follower_notifications'] ?? true);
$commentNotifications = (bool) ($profileData['comment_notifications'] ?? true);
$newsletter = (bool) ($profileData['newsletter'] ?? $profileData['mlist'] ?? $user->mlist ?? false);
return Inertia::render('Settings/ProfileEdit', [
'user' => [
'id' => $user->id,
@@ -190,16 +219,23 @@ class ProfileController extends Controller
'signature' => $user->signature ?? null,
'description' => $user->description ?? null,
'gender' => $user->gender ?? null,
'birthday' => $user->birth ?? null,
'country_code' => $user->country_code ?? null,
'mlist' => $user->mlist ?? false,
'friend_upload_notice' => $user->friend_upload_notice ?? false,
'auto_post_upload' => $user->auto_post_upload ?? false,
'email_notifications' => $emailNotifications,
'upload_notifications' => $uploadNotifications,
'follower_notifications' => $followerNotifications,
'comment_notifications' => $commentNotifications,
'newsletter' => $newsletter,
'last_username_change_at' => $user->last_username_change_at,
'username_changed_at' => $user->username_changed_at,
],
'avatarUrl' => $avatarUrl,
'birthDay' => $birthDay,
'birthMonth' => $birthMonth,
'birthYear' => $birthYear,
'usernameCooldownDays' => $cooldownDays,
'usernameCooldownRemainingDays' => $usernameCooldownRemainingDays,
'usernameCooldownActive' => $usernameCooldownRemainingDays > 0,
'countries' => $countries->values(),
'flash' => [
'status' => session('status'),
@@ -208,6 +244,331 @@ class ProfileController extends Controller
])->rootView('settings');
}
public function updateProfileSection(UpdateProfileSectionRequest $request, AvatarService $avatarService): RedirectResponse|JsonResponse
{
$user = $request->user();
$validated = $request->validated();
$user->name = (string) $validated['display_name'];
$user->save();
$profileUpdates = [
'website' => $validated['website'] ?? null,
'about' => $validated['bio'] ?? null,
'signature' => $validated['signature'] ?? null,
'description' => $validated['description'] ?? null,
];
$avatarUrl = AvatarUrl::forUser((int) $user->id, null, 256);
if (!empty($validated['remove_avatar'])) {
$avatarService->removeAvatar((int) $user->id);
$avatarUrl = AvatarUrl::default();
}
if ($request->hasFile('avatar')) {
$hash = $avatarService->storeFromUploadedFile(
(int) $user->id,
$request->file('avatar'),
(string) ($validated['avatar_position'] ?? 'center')
);
$avatarUrl = AvatarUrl::forUser((int) $user->id, $hash, 256);
}
$this->persistProfileUpdates((int) $user->id, $profileUpdates);
return $this->settingsResponse(
$request,
'Profile updated successfully.',
['avatarUrl' => $avatarUrl]
);
}
public function updateAccountSection(UpdateAccountSectionRequest $request): RedirectResponse|JsonResponse
{
return $this->updateUsername($request);
}
public function updateUsername(UpdateAccountSectionRequest $request): RedirectResponse|JsonResponse
{
$user = $request->user();
$validated = $request->validated();
$incomingUsername = UsernamePolicy::normalize((string) $validated['username']);
$currentUsername = UsernamePolicy::normalize((string) ($user->username ?? ''));
if ($incomingUsername !== '' && $incomingUsername !== $currentUsername) {
$similar = UsernamePolicy::similarReserved($incomingUsername);
if ($similar !== null && ! UsernamePolicy::hasApprovedOverride($incomingUsername, (int) $user->id)) {
$this->usernameApprovalService->submit($user, $incomingUsername, 'profile_update', [
'current_username' => $currentUsername,
]);
return $this->usernameValidationError($request, 'This username is too similar to a reserved name and requires manual approval.');
}
$cooldownDays = $this->usernameCooldownDays();
$isAdmin = method_exists($user, 'isAdmin') ? $user->isAdmin() : false;
$lastUsernameChangeAt = $this->lastUsernameChangeAt($user);
if (! $isAdmin && $lastUsernameChangeAt !== null && $lastUsernameChangeAt->gt(now()->subDays($cooldownDays))) {
$remainingDays = now()->diffInDays($lastUsernameChangeAt->copy()->addDays($cooldownDays));
return $this->usernameValidationError($request, "You can change your username again in {$remainingDays} days.");
}
$user->username = $incomingUsername;
$user->username_changed_at = now();
if (Schema::hasColumn('users', 'last_username_change_at')) {
$user->last_username_change_at = now();
}
$this->storeUsernameHistory((int) $user->id, $currentUsername);
$this->storeUsernameRedirect((int) $user->id, $currentUsername, $incomingUsername);
}
$user->save();
return $this->settingsResponse($request, 'Account updated successfully.');
}
public function requestEmailChange(RequestEmailChangeRequest $request): RedirectResponse|JsonResponse
{
if (! Schema::hasTable('email_changes')) {
return response()->json([
'errors' => [
'new_email' => ['Email change is not available right now.'],
],
], 422);
}
$user = $request->user();
$validated = $request->validated();
$newEmail = strtolower((string) $validated['new_email']);
$code = str_pad((string) random_int(0, 999999), 6, '0', STR_PAD_LEFT);
$expiresInMinutes = 10;
DB::table('email_changes')->where('user_id', (int) $user->id)->delete();
DB::table('email_changes')->insert([
'user_id' => (int) $user->id,
'new_email' => $newEmail,
'verification_code' => hash('sha256', $code),
'expires_at' => now()->addMinutes($expiresInMinutes),
'created_at' => now(),
'updated_at' => now(),
]);
Mail::to($newEmail)->queue(new EmailChangeVerificationCodeMail($code, $expiresInMinutes));
return $this->settingsResponse($request, 'Verification code sent to your new email address.');
}
public function verifyEmailChange(VerifyEmailChangeRequest $request): RedirectResponse|JsonResponse
{
if (! Schema::hasTable('email_changes')) {
return response()->json([
'errors' => [
'code' => ['Email change verification is not available right now.'],
],
], 422);
}
$user = $request->user();
$validated = $request->validated();
$codeHash = hash('sha256', (string) $validated['code']);
$change = DB::table('email_changes')
->where('user_id', (int) $user->id)
->whereNull('used_at')
->orderByDesc('id')
->first();
if (! $change) {
return response()->json(['errors' => ['code' => ['No pending email change request found.']]], 422);
}
if (now()->greaterThan($change->expires_at)) {
DB::table('email_changes')->where('id', $change->id)->delete();
return response()->json(['errors' => ['code' => ['Verification code has expired. Please request a new one.']]], 422);
}
if (! hash_equals((string) $change->verification_code, $codeHash)) {
return response()->json(['errors' => ['code' => ['Verification code is invalid.']]], 422);
}
$newEmail = strtolower((string) $change->new_email);
$oldEmail = strtolower((string) ($user->email ?? ''));
DB::transaction(function () use ($user, $change, $newEmail): void {
$lockedUser = User::query()->whereKey((int) $user->id)->lockForUpdate()->firstOrFail();
$lockedUser->email = $newEmail;
$lockedUser->email_verified_at = now();
$lockedUser->save();
DB::table('email_changes')
->where('id', (int) $change->id)
->update([
'used_at' => now(),
'updated_at' => now(),
]);
DB::table('email_changes')
->where('user_id', (int) $user->id)
->where('id', '!=', (int) $change->id)
->delete();
});
if ($oldEmail !== '' && $oldEmail !== $newEmail) {
Mail::to($oldEmail)->queue(new EmailChangedSecurityAlertMail($newEmail));
}
return $this->settingsResponse($request, 'Email updated successfully.', [
'email' => $newEmail,
]);
}
public function updatePersonalSection(UpdatePersonalSectionRequest $request): RedirectResponse|JsonResponse
{
$validated = $request->validated();
$profileUpdates = [
'birthdate' => $validated['birthday'] ?? null,
'country_code' => $validated['country'] ?? null,
];
if (!empty($validated['gender'])) {
$profileUpdates['gender'] = strtoupper((string) $validated['gender']);
}
$this->persistProfileUpdates((int) $request->user()->id, $profileUpdates);
return $this->settingsResponse($request, 'Personal details saved successfully.');
}
public function updateNotificationsSection(UpdateNotificationsSectionRequest $request): RedirectResponse|JsonResponse
{
$validated = $request->validated();
$userId = (int) $request->user()->id;
$profileUpdates = [
'email_notifications' => (bool) $validated['email_notifications'],
'upload_notifications' => (bool) $validated['upload_notifications'],
'follower_notifications' => (bool) $validated['follower_notifications'],
'comment_notifications' => (bool) $validated['comment_notifications'],
'newsletter' => (bool) $validated['newsletter'],
// Legacy compatibility mappings.
'mlist' => (bool) $validated['newsletter'],
'friend_upload_notice' => (bool) $validated['upload_notifications'],
];
$this->persistProfileUpdates($userId, $profileUpdates);
return $this->settingsResponse($request, 'Notification settings saved successfully.');
}
public function updateSecurityPassword(UpdateSecurityPasswordRequest $request): RedirectResponse|JsonResponse
{
$validated = $request->validated();
$user = $request->user();
$user->password = Hash::make((string) $validated['new_password']);
$user->save();
return $this->settingsResponse($request, 'Password updated successfully.');
}
private function settingsResponse(Request $request, string $message, array $payload = []): RedirectResponse|JsonResponse
{
if ($request->expectsJson()) {
return response()->json([
'success' => true,
'message' => $message,
...$payload,
]);
}
return Redirect::back()->with('status', $message);
}
private function persistProfileUpdates(int $userId, array $updates): void
{
if ($updates === [] || !Schema::hasTable('user_profiles')) {
return;
}
$filtered = [];
foreach ($updates as $column => $value) {
if (Schema::hasColumn('user_profiles', $column)) {
$filtered[$column] = $value;
}
}
if ($filtered === []) {
return;
}
DB::table('user_profiles')->updateOrInsert(['user_id' => $userId], $filtered);
}
private function usernameCooldownDays(): int
{
return max(1, (int) config('usernames.rename_cooldown_days', 30));
}
private function lastUsernameChangeAt(User $user): ?\Illuminate\Support\Carbon
{
return $user->last_username_change_at ?? $user->username_changed_at;
}
private function usernameValidationError(Request $request, string $message): RedirectResponse|JsonResponse
{
$error = ['username' => [$message]];
if ($request->expectsJson()) {
return response()->json(['errors' => $error], 422);
}
return Redirect::back()->withErrors($error);
}
private function storeUsernameHistory(int $userId, string $oldUsername): void
{
if ($oldUsername === '' || ! Schema::hasTable('username_history')) {
return;
}
$payload = [
'user_id' => $userId,
'old_username' => $oldUsername,
'created_at' => now(),
];
if (Schema::hasColumn('username_history', 'changed_at')) {
$payload['changed_at'] = now();
}
if (Schema::hasColumn('username_history', 'updated_at')) {
$payload['updated_at'] = now();
}
DB::table('username_history')->insert($payload);
}
private function storeUsernameRedirect(int $userId, string $oldUsername, string $newUsername): void
{
if ($oldUsername === '' || ! Schema::hasTable('username_redirects')) {
return;
}
DB::table('username_redirects')->updateOrInsert(
['old_username' => $oldUsername],
[
'new_username' => $newUsername,
'user_id' => $userId,
'updated_at' => now(),
'created_at' => now(),
]
);
}
public function update(ProfileUpdateRequest $request, \App\Services\AvatarService $avatarService): RedirectResponse|JsonResponse
{
$user = $request->user();
@@ -238,10 +599,11 @@ class ProfileController extends Controller
return Redirect::back()->withErrors($error);
}
$cooldownDays = (int) config('usernames.rename_cooldown_days', 90);
$cooldownDays = $this->usernameCooldownDays();
$isAdmin = method_exists($user, 'isAdmin') ? $user->isAdmin() : false;
$lastUsernameChangeAt = $this->lastUsernameChangeAt($user);
if (! $isAdmin && $user->username_changed_at !== null && $user->username_changed_at->gt(now()->subDays($cooldownDays))) {
if (! $isAdmin && $lastUsernameChangeAt !== null && $lastUsernameChangeAt->gt(now()->subDays($cooldownDays))) {
$error = ['username' => ["Username can only be changed once every {$cooldownDays} days."]];
if ($request->expectsJson()) {
return response()->json(['errors' => $error], 422);
@@ -251,26 +613,12 @@ class ProfileController extends Controller
$user->username = $incomingUsername;
$user->username_changed_at = now();
DB::table('username_history')->insert([
'user_id' => (int) $user->id,
'old_username' => $currentUsername,
'changed_at' => now(),
'created_at' => now(),
'updated_at' => now(),
]);
if ($currentUsername !== '') {
DB::table('username_redirects')->updateOrInsert(
['old_username' => $currentUsername],
[
'new_username' => $incomingUsername,
'user_id' => (int) $user->id,
'updated_at' => now(),
'created_at' => now(),
]
);
if (Schema::hasColumn('users', 'last_username_change_at')) {
$user->last_username_change_at = now();
}
$this->storeUsernameHistory((int) $user->id, $currentUsername);
$this->storeUsernameRedirect((int) $user->id, $currentUsername, $incomingUsername);
}
}
@@ -579,6 +927,37 @@ class ProfileController extends Controller
]);
}
$creatorStories = Story::query()
->published()
->with(['tags'])
->where('creator_id', $user->id)
->latest('published_at')
->limit(6)
->get([
'id',
'slug',
'title',
'excerpt',
'cover_image',
'reading_time',
'views',
'likes_count',
'comments_count',
'published_at',
])
->map(fn (Story $story) => [
'id' => $story->id,
'slug' => $story->slug,
'title' => $story->title,
'excerpt' => $story->excerpt,
'cover_url' => $story->cover_url,
'reading_time' => $story->reading_time,
'views' => (int) $story->views,
'likes_count' => (int) $story->likes_count,
'comments_count' => (int) $story->comments_count,
'published_at' => $story->published_at?->toISOString(),
]);
// ── Profile data ─────────────────────────────────────────────────────
$profile = $user->profile;
@@ -593,15 +972,8 @@ class ProfileController extends Controller
$countryName = $countryName ?? strtoupper((string) $profile->country_code);
}
// ── Hero background artwork ─────────────────────────────────────────
$heroBgUrl = Artwork::public()
->published()
->where('user_id', $user->id)
->whereNotNull('hash')
->whereNotNull('thumb_ext')
->inRandomOrder()
->limit(1)
->first()?->thumbUrl('lg');
// ── Cover image hero (preferred) ────────────────────────────────────
$heroBgUrl = CoverUrl::forUser($user->cover_hash, $user->cover_ext, $user->updated_at?->timestamp ?? time());
// ── Increment profile views (async-safe, ignore errors) ──────────────
if (! $isOwner) {
@@ -645,6 +1017,8 @@ class ProfileController extends Controller
'username' => $user->username,
'name' => $user->name,
'avatar_url' => $avatarUrl,
'cover_url' => $heroBgUrl,
'cover_position'=> (int) ($user->cover_position ?? 50),
'created_at' => $user->created_at?->toISOString(),
'last_visit_at' => $user->last_visit_at ? (string) $user->last_visit_at : null,
],
@@ -666,6 +1040,7 @@ class ProfileController extends Controller
'viewerIsFollowing' => $viewerIsFollowing,
'heroBgUrl' => $heroBgUrl,
'profileComments' => $profileComments->values(),
'creatorStories' => $creatorStories->values(),
'countryName' => $countryName,
'isOwner' => $isOwner,
'auth' => $authData,

View File

@@ -0,0 +1,252 @@
<?php
namespace App\Http\Controllers\User;
use App\Http\Controllers\Controller;
use App\Support\CoverUrl;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\File;
use Intervention\Image\Drivers\Gd\Driver as GdDriver;
use Intervention\Image\Drivers\Imagick\Driver as ImagickDriver;
use Intervention\Image\Encoders\JpegEncoder;
use Intervention\Image\Encoders\PngEncoder;
use Intervention\Image\Encoders\WebpEncoder;
use Intervention\Image\ImageManager;
use RuntimeException;
class ProfileCoverController extends Controller
{
private const ALLOWED_MIME_TYPES = ['image/jpeg', 'image/png', 'image/webp'];
private const MAX_FILE_SIZE_KB = 5120;
private const TARGET_WIDTH = 1920;
private const TARGET_HEIGHT = 480;
private const MIN_UPLOAD_WIDTH = 640;
private const MIN_UPLOAD_HEIGHT = 160;
private ?ImageManager $manager = null;
public function __construct()
{
try {
$this->manager = extension_loaded('gd')
? new ImageManager(new GdDriver())
: new ImageManager(new ImagickDriver());
} catch (\Throwable) {
$this->manager = null;
}
}
public function upload(Request $request): JsonResponse
{
$user = $request->user();
if (! $user) {
return response()->json(['error' => 'Unauthorized'], 401);
}
$validated = $request->validate([
'cover' => [
'required',
'file',
'image',
'max:' . self::MAX_FILE_SIZE_KB,
'mimes:jpg,jpeg,png,webp',
'mimetypes:image/jpeg,image/png,image/webp',
],
]);
/** @var UploadedFile $file */
$file = $validated['cover'];
try {
$stored = $this->storeCoverFile($file);
$this->deleteCoverFile((string) $user->cover_hash, (string) $user->cover_ext);
$user->forceFill([
'cover_hash' => $stored['hash'],
'cover_ext' => $stored['ext'],
'cover_position' => 50,
])->save();
return response()->json([
'success' => true,
'cover_url' => CoverUrl::forUser($user->cover_hash, $user->cover_ext, time()),
'cover_position' => (int) $user->cover_position,
]);
} catch (RuntimeException $e) {
return response()->json([
'error' => 'Validation failed',
'message' => $e->getMessage(),
], 422);
} catch (\Throwable $e) {
logger()->error('Profile cover upload failed', [
'user_id' => (int) $user->id,
'message' => $e->getMessage(),
]);
return response()->json(['error' => 'Processing failed'], 500);
}
}
public function updatePosition(Request $request): JsonResponse
{
$user = $request->user();
if (! $user) {
return response()->json(['error' => 'Unauthorized'], 401);
}
$validated = $request->validate([
'position' => ['required', 'integer', 'min:0', 'max:100'],
]);
if (! $user->cover_hash || ! $user->cover_ext) {
return response()->json(['error' => 'No cover image to update.'], 422);
}
$user->forceFill([
'cover_position' => (int) $validated['position'],
])->save();
return response()->json([
'success' => true,
'cover_position' => (int) $user->cover_position,
]);
}
public function destroy(Request $request): JsonResponse
{
$user = $request->user();
if (! $user) {
return response()->json(['error' => 'Unauthorized'], 401);
}
$this->deleteCoverFile((string) $user->cover_hash, (string) $user->cover_ext);
$user->forceFill([
'cover_hash' => null,
'cover_ext' => null,
'cover_position' => 50,
])->save();
return response()->json([
'success' => true,
'cover_url' => null,
'cover_position' => 50,
]);
}
private function storageRoot(): string
{
return rtrim((string) config('uploads.storage_root'), DIRECTORY_SEPARATOR);
}
private function coverDirectory(string $hash): string
{
$p1 = substr($hash, 0, 2);
$p2 = substr($hash, 2, 2);
return $this->storageRoot()
. DIRECTORY_SEPARATOR . 'covers'
. DIRECTORY_SEPARATOR . $p1
. DIRECTORY_SEPARATOR . $p2;
}
private function coverPath(string $hash, string $ext): string
{
return $this->coverDirectory($hash) . DIRECTORY_SEPARATOR . $hash . '.' . $ext;
}
/**
* @return array{hash: string, ext: string}
*/
private function storeCoverFile(UploadedFile $file): array
{
$this->assertImageManager();
$uploadPath = (string) ($file->getRealPath() ?: $file->getPathname());
if ($uploadPath === '' || ! is_readable($uploadPath)) {
throw new RuntimeException('Unable to resolve uploaded image path.');
}
$raw = file_get_contents($uploadPath);
if ($raw === false || $raw === '') {
throw new RuntimeException('Unable to read uploaded image.');
}
$finfo = new \finfo(FILEINFO_MIME_TYPE);
$mime = strtolower((string) $finfo->buffer($raw));
if (! in_array($mime, self::ALLOWED_MIME_TYPES, true)) {
throw new RuntimeException('Unsupported image mime type.');
}
$size = @getimagesizefromstring($raw);
if (! is_array($size) || ($size[0] ?? 0) < 1 || ($size[1] ?? 0) < 1) {
throw new RuntimeException('Uploaded file is not a valid image.');
}
$width = (int) ($size[0] ?? 0);
$height = (int) ($size[1] ?? 0);
if ($width < self::MIN_UPLOAD_WIDTH || $height < self::MIN_UPLOAD_HEIGHT) {
throw new RuntimeException(sprintf(
'Image is too small. Minimum required size is %dx%d.',
self::MIN_UPLOAD_WIDTH,
self::MIN_UPLOAD_HEIGHT,
));
}
$ext = $mime === 'image/jpeg' ? 'jpg' : ($mime === 'image/png' ? 'png' : 'webp');
$image = $this->manager->read($raw);
$processed = $image->cover(self::TARGET_WIDTH, self::TARGET_HEIGHT, 'center');
$encoded = $this->encodeByExtension($processed, $ext);
$hash = hash('sha256', $encoded);
$dir = $this->coverDirectory($hash);
if (! File::exists($dir)) {
File::makeDirectory($dir, 0755, true);
}
File::put($this->coverPath($hash, $ext), $encoded);
return ['hash' => $hash, 'ext' => $ext];
}
private function encodeByExtension($image, string $ext): string
{
return match ($ext) {
'jpg' => (string) $image->encode(new JpegEncoder(85)),
'png' => (string) $image->encode(new PngEncoder()),
default => (string) $image->encode(new WebpEncoder(85)),
};
}
private function deleteCoverFile(string $hash, string $ext): void
{
$trimHash = trim($hash);
$trimExt = strtolower(trim($ext));
if ($trimHash === '' || $trimExt === '') {
return;
}
$path = $this->coverPath($trimHash, $trimExt);
if (is_file($path)) {
@unlink($path);
}
}
private function assertImageManager(): void
{
if ($this->manager !== null) {
return;
}
throw new RuntimeException('Image processing is not available on this environment.');
}
}

View File

@@ -32,23 +32,33 @@ final class ExploreController extends Controller
/** Meilisearch sort-field arrays per sort alias. */
private const SORT_MAP = [
'trending' => ['trending_score_24h:desc', 'trending_score_7d:desc', 'favorites_count:desc', 'created_at:desc'],
'fresh' => ['trending_score_7d:desc', 'favorites_count:desc', 'created_at:desc'],
'top-rated' => ['awards_received_count:desc', 'favorites_count:desc'],
'latest' => ['created_at:desc'],
// Legacy aliases kept for backward compatibility.
'new-hot' => ['trending_score_7d:desc', 'favorites_count:desc', 'created_at:desc'],
'best' => ['awards_received_count:desc', 'favorites_count:desc'],
'latest' => ['created_at:desc'],
];
private const SORT_TTL = [
'trending' => 300,
'fresh' => 120,
'top-rated'=> 600,
'latest' => 120,
'new-hot' => 120,
'best' => 600,
'latest' => 120,
];
private const SORT_OPTIONS = [
['value' => 'trending', 'label' => '🔥 Trending'],
['value' => 'new-hot', 'label' => '🚀 New & Hot'],
['value' => 'best', 'label' => '⭐ Best'],
['value' => 'latest', 'label' => '🕐 Latest'],
['value' => 'trending', 'label' => '🔥 Trending'],
['value' => 'fresh', 'label' => '🚀 New & Hot'],
['value' => 'top-rated', 'label' => '⭐ Best'],
['value' => 'latest', 'label' => '🕐 Latest'],
];
private const SORT_ALIASES = [
'new-hot' => 'fresh',
'best' => 'top-rated',
];
public function __construct(
@@ -81,23 +91,25 @@ final class ExploreController extends Controller
? $this->spotlight->getSpotlight(6)->map(fn ($a) => $this->presentArtwork($a))
: collect();
$contentTypes = $this->contentTypeLinks();
$mainCategories = $this->mainCategories();
$seo = $this->paginationSeo($request, url('/explore'), $artworks);
return view('web.explore.index', [
'artworks' => $artworks,
'spotlight' => $spotlightItems,
'contentTypes' => $contentTypes,
'activeType' => null,
'current_sort' => $sort,
'sort_options' => self::SORT_OPTIONS,
'hero_title' => 'Explore',
'hero_description' => 'Browse the full Skinbase catalog — wallpapers, skins, photography and more.',
'breadcrumbs' => collect([
(object) ['name' => 'Explore', 'url' => '/explore'],
]),
'page_title' => 'Explore Artworks — Skinbase',
return view('gallery.index', [
'gallery_type' => 'browse',
'mainCategories' => $mainCategories,
'subcategories' => $mainCategories,
'contentType' => null,
'category' => null,
'artworks' => $artworks,
'spotlight' => $spotlightItems,
'current_sort' => $sort,
'sort_options' => self::SORT_OPTIONS,
'hero_title' => 'Explore',
'hero_description' => 'Browse the full Skinbase catalog — wallpapers, skins, photography and more.',
'breadcrumbs' => collect([(object) ['name' => 'Explore', 'url' => '/explore']]),
'page_title' => 'Explore Artworks - Skinbase',
'page_meta_description' => 'Explore the full catalog of wallpapers, skins, photography and other artworks on Skinbase.',
'page_meta_keywords' => 'explore, wallpapers, skins, photography, artworks, skinbase',
'page_canonical' => $seo['canonical'],
'page_rel_prev' => $seo['prev'],
'page_rel_next' => $seo['next'],
@@ -117,6 +129,11 @@ final class ExploreController extends Controller
// "artworks" is the umbrella — search all types
$isAll = $type === 'artworks';
// Canonical URLs for content types are /skins, /wallpapers, /photography, /other.
if (! $isAll) {
return redirect()->to($this->canonicalTypeUrl($request, $type), 301);
}
$sort = $this->resolveSort($request);
$perPage = $this->resolvePerPage($request);
$page = max(1, (int) $request->query('page', 1));
@@ -142,26 +159,44 @@ final class ExploreController extends Controller
? $this->spotlight->getSpotlight(6)->map(fn ($a) => $this->presentArtwork($a))
: collect();
$contentTypes = $this->contentTypeLinks();
$mainCategories = $this->mainCategories();
$contentType = null;
$subcategories = $mainCategories;
if (! $isAll) {
$contentType = ContentType::where('slug', $type)->first();
$subcategories = $contentType
? $contentType->rootCategories()->orderBy('sort_order')->orderBy('name')->get()
: collect();
}
if ($isAll) {
$humanType = 'Artworks';
} else {
$humanType = $contentType?->name ?? ucfirst($type);
}
$baseUrl = url('/explore/' . $type);
$seo = $this->paginationSeo($request, $baseUrl, $artworks);
$humanType = ucfirst($type);
return view('web.explore.index', [
'artworks' => $artworks,
'spotlight' => $spotlightItems,
'contentTypes' => $contentTypes,
'activeType' => $type,
'current_sort' => $sort,
'sort_options' => self::SORT_OPTIONS,
'hero_title' => $humanType,
'hero_description' => "Browse {$humanType} on Skinbase.",
'breadcrumbs' => collect([
return view('gallery.index', [
'gallery_type' => $isAll ? 'browse' : 'content-type',
'mainCategories' => $mainCategories,
'subcategories' => $subcategories,
'contentType' => $contentType,
'category' => null,
'artworks' => $artworks,
'spotlight' => $spotlightItems,
'current_sort' => $sort,
'sort_options' => self::SORT_OPTIONS,
'hero_title' => $humanType,
'hero_description' => "Browse {$humanType} on Skinbase.",
'breadcrumbs' => collect([
(object) ['name' => 'Explore', 'url' => '/explore'],
(object) ['name' => $humanType, 'url' => "/explore/{$type}"],
]),
'page_title' => "{$humanType} Explore Skinbase",
'page_title' => "{$humanType} - Explore - Skinbase",
'page_meta_description' => "Discover the best {$humanType} artworks on Skinbase. Browse trending, new and top-rated.",
'page_meta_keywords' => strtolower($type) . ', explore, skinbase, artworks, wallpapers, skins, photography',
'page_canonical' => $seo['canonical'],
'page_rel_prev' => $seo['prev'],
'page_rel_next' => $seo['next'],
@@ -173,6 +208,14 @@ final class ExploreController extends Controller
public function byTypeMode(Request $request, string $type, string $mode)
{
$type = strtolower($type);
if ($type !== 'artworks') {
$query = $request->query();
$query['sort'] = $this->normalizeSort((string) $mode);
return redirect()->to($this->canonicalTypeUrl($request, $type, $query), 301);
}
// Rewrite the sort via the URL segment and delegate
$request->query->set('sort', $mode);
return $this->byType($request, $type);
@@ -180,24 +223,49 @@ final class ExploreController extends Controller
// ── Helpers ──────────────────────────────────────────────────────────
private function contentTypeLinks(): Collection
private function mainCategories(): Collection
{
return collect([
(object) ['name' => 'All Artworks', 'slug' => 'artworks', 'url' => '/explore/artworks'],
...ContentType::orderBy('id')->get(['name', 'slug'])->map(fn ($ct) => (object) [
$categories = ContentType::orderBy('id')
->get(['name', 'slug'])
->map(fn ($ct) => (object) [
'name' => $ct->name,
'slug' => $ct->slug,
'url' => '/explore/' . strtolower($ct->slug),
]),
'url' => '/' . strtolower($ct->slug),
]);
return $categories->push((object) [
'name' => 'Members',
'slug' => 'members',
'url' => '/members',
]);
}
private function resolveSort(Request $request): string
{
$s = (string) $request->query('sort', 'trending');
$s = $this->normalizeSort((string) $request->query('sort', 'trending'));
return array_key_exists($s, self::SORT_MAP) ? $s : 'trending';
}
private function normalizeSort(string $sort): string
{
$sort = strtolower($sort);
return self::SORT_ALIASES[$sort] ?? $sort;
}
private function canonicalTypeUrl(Request $request, string $type, ?array $query = null): string
{
$query = $query ?? $request->query();
if (isset($query['sort'])) {
$query['sort'] = $this->normalizeSort((string) $query['sort']);
if ($query['sort'] === 'trending') {
unset($query['sort']);
}
}
return url('/' . $type) . ($query ? ('?' . http_build_query($query)) : '');
}
private function resolvePerPage(Request $request): int
{
$v = (int) ($request->query('per_page') ?: $request->query('limit') ?: 24);

View File

@@ -25,22 +25,16 @@ final class SearchController extends Controller
'downloads' => 'downloads:desc',
];
$artworks = null;
$popular = collect();
if ($q !== '') {
$artworks = $this->search->search($q, [
$artworks = $q !== ''
? $this->search->search($q, [
'sort' => ($sortMap[$sort] ?? 'created_at:desc'),
]);
} else {
$popular = $this->search->popular(16)->getCollection();
}
])
: $this->search->popular(24);
return view('search.index', [
'q' => $q,
'sort' => $sort,
'artworks' => $artworks ?? collect()->paginate(0),
'popular' => $popular,
'artworks' => $artworks,
'page_title' => $q !== '' ? 'Search: ' . $q . ' — Skinbase' : 'Search — Skinbase',
'page_meta_description' => 'Search Skinbase for artworks, photography, wallpapers and skins.',
'page_robots' => 'noindex,follow',

View File

@@ -0,0 +1,34 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Middleware\HandleCors as BaseHandleCors;
use Illuminate\Http\Request;
class ConditionalCors
{
/**
* Handle an incoming request.
*/
public function handle(Request $request, Closure $next)
{
$paths = config('cors.paths', null);
// If paths are empty the CORS config intentionally disables CORS.
if (is_array($paths) && count($paths) === 0) {
return $next($request);
}
// Fallback to env if config wasn't populated for some reason.
$enabled = env('CP_ENABLE_CORS', true);
if (! $enabled) {
return $next($request);
}
$handler = app(BaseHandleCors::class);
return $handler->handle($request, $next);
}
}

View File

@@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class EnsureCreatorAccess
{
public function handle(Request $request, Closure $next): Response
{
$user = $request->user();
if ($user === null) {
abort(403, 'Authentication required.');
}
$role = strtolower((string) ($user->role ?? 'user'));
$isCreatorRole = in_array($role, ['creator', 'user', 'admin', 'moderator', 'mod'], true);
if (! $isCreatorRole || (property_exists($user, 'is_active') && $user->is_active === false)) {
abort(403, 'Creator access is required.');
}
return $next($request);
}
}

View File

@@ -22,6 +22,7 @@ class AvatarUploadRequest extends FormRequest
'mimes:jpg,jpeg,png,webp',
'mimetypes:image/jpeg,image/png,image/webp',
],
'avatar_position' => ['nullable', 'in:top-left,top,top-right,left,center,right,bottom-left,bottom,bottom-right'],
];
}
}

View File

@@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\Settings;
use App\Models\User;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class RequestEmailChangeRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
protected function prepareForValidation(): void
{
if ($this->has('new_email')) {
$this->merge([
'new_email' => strtolower(trim((string) $this->input('new_email'))),
]);
}
}
public function rules(): array
{
return [
'new_email' => [
'required',
'string',
'lowercase',
'email',
'max:255',
Rule::unique(User::class, 'email')->ignore((int) $this->user()->id),
function (string $attribute, mixed $value, \Closure $fail): void {
if (strtolower((string) $value) === strtolower((string) $this->user()->email)) {
$fail('Please enter a different email address.');
}
},
],
];
}
}

View File

@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\Settings;
use App\Http\Requests\UsernameRequest;
use Illuminate\Foundation\Http\FormRequest;
class UpdateAccountSectionRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
protected function prepareForValidation(): void
{
if ($this->has('username')) {
$this->merge([
'username' => \App\Support\UsernamePolicy::normalize((string) $this->input('username')),
]);
}
}
public function rules(): array
{
return [
'username' => ['required', ...UsernameRequest::rulesFor((int) $this->user()->id)],
];
}
}

View File

@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\Settings;
use Illuminate\Foundation\Http\FormRequest;
class UpdateNotificationsSectionRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'email_notifications' => ['required', 'boolean'],
'upload_notifications' => ['required', 'boolean'],
'follower_notifications' => ['required', 'boolean'],
'comment_notifications' => ['required', 'boolean'],
'newsletter' => ['required', 'boolean'],
];
}
}

View File

@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\Settings;
use Illuminate\Foundation\Http\FormRequest;
class UpdatePersonalSectionRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'birthday' => ['nullable', 'date', 'before:today'],
'gender' => ['nullable', 'in:m,f,x,M,F,X'],
'country' => ['nullable', 'string', 'max:10'],
];
}
}

View File

@@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\Settings;
use Illuminate\Foundation\Http\FormRequest;
class UpdateProfileSectionRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'display_name' => ['required', 'string', 'max:60'],
'website' => ['nullable', 'url', 'max:255'],
'bio' => ['nullable', 'string', 'max:200'],
'signature' => ['nullable', 'string', 'max:1000'],
'description' => ['nullable', 'string', 'max:1000'],
'avatar' => ['nullable', 'file', 'image', 'max:2048', 'mimes:jpg,jpeg,png,webp', 'mimetypes:image/jpeg,image/png,image/webp'],
'remove_avatar' => ['nullable', 'boolean'],
'avatar_position' => ['nullable', 'in:top-left,top,top-right,left,center,right,bottom-left,bottom,bottom-right'],
];
}
}

View File

@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\Settings;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rules\Password;
class UpdateSecurityPasswordRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'current_password' => ['required', 'current_password'],
'new_password' => ['required', 'confirmed', Password::min(8)],
];
}
}

View File

@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\Settings;
use Illuminate\Foundation\Http\FormRequest;
class VerifyEmailChangeRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
protected function prepareForValidation(): void
{
if ($this->has('code')) {
$this->merge([
'code' => preg_replace('/\D+/', '', (string) $this->input('code')),
]);
}
}
public function rules(): array
{
return [
'code' => ['required', 'digits:6'],
];
}
}

View File

@@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
namespace App\Mail;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
class EmailChangeVerificationCodeMail extends Mailable implements ShouldQueue
{
use Queueable, SerializesModels;
public int $tries = 3;
public function __construct(
public readonly string $code,
public readonly int $expiresInMinutes,
) {
$this->onQueue('mail');
}
public function envelope(): Envelope
{
return new Envelope(
subject: 'Skinbase email change verification code',
);
}
public function content(): Content
{
return new Content(
view: 'emails.email-change-verification-code',
with: [
'code' => $this->code,
'expiresInMinutes' => $this->expiresInMinutes,
],
);
}
public function attachments(): array
{
return [];
}
}

View File

@@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
namespace App\Mail;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
class EmailChangedSecurityAlertMail extends Mailable implements ShouldQueue
{
use Queueable, SerializesModels;
public int $tries = 3;
public function __construct(public readonly string $newEmail)
{
$this->onQueue('mail');
}
public function envelope(): Envelope
{
return new Envelope(
subject: 'Your Skinbase email address was changed',
);
}
public function content(): Content
{
return new Content(
view: 'emails.email-changed-security-alert',
with: [
'newEmail' => $this->newEmail,
'supportEmail' => (string) config('mail.from.address', 'support@skinbase.org'),
],
);
}
public function attachments(): array
{
return [];
}
}

View File

@@ -1,4 +1,7 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
@@ -14,11 +17,18 @@ class ArtworkDownload extends Model
{
protected $table = 'artwork_downloads';
public $timestamps = false;
const CREATED_AT = 'created_at';
const UPDATED_AT = null;
protected $fillable = [
'artwork_id',
'user_id',
'ip',
'ip_address',
'user_agent',
'referer',
];
public function artwork(): BelongsTo

View File

@@ -87,7 +87,7 @@ class ForumCategory extends Model
}
if ($slug !== '') {
return '/images/forum/defaults/' . $slug . '.jpg';
return '/images/forum/' . $slug . '.webp';
}
return $default;

View File

@@ -15,7 +15,18 @@ class ForumPost extends Model
protected $table = 'forum_posts';
protected $fillable = [
'id','thread_id','user_id','content','is_edited','edited_at'
'id',
'thread_id',
'topic_id',
'user_id',
'content',
'is_edited',
'edited_at',
'spam_score',
'quality_score',
'flagged',
'flagged_reason',
'moderation_checked',
];
public $incrementing = true;
@@ -23,6 +34,10 @@ class ForumPost extends Model
protected $casts = [
'is_edited' => 'boolean',
'edited_at' => 'datetime',
'spam_score' => 'integer',
'quality_score' => 'integer',
'flagged' => 'boolean',
'moderation_checked' => 'boolean',
];
public function thread(): BelongsTo

View File

@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class Notification extends Model
{
use HasFactory;
protected $table = 'notifications';
protected $fillable = [
'user_id',
'type',
'data',
'read_at',
];
protected $casts = [
'data' => 'array',
'read_at' => 'datetime',
'created_at' => 'datetime',
'updated_at' => 'datetime',
];
public function user(): BelongsTo
{
return $this->belongsTo(User::class, 'user_id');
}
public function markAsRead(): void
{
if ($this->read_at === null) {
$this->forceFill(['read_at' => now()])->save();
}
}
}

View File

@@ -4,11 +4,16 @@ declare(strict_types=1);
namespace App\Models;
use App\Models\StoryLike;
use App\Models\StoryView;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Model;
/**
* Story editorial content replacing the legacy Interviews module.
* Creator Story model.
*
* @property int $id
* @property string $slug
@@ -16,10 +21,10 @@ use Illuminate\Database\Eloquent\Model;
* @property string|null $excerpt
* @property string|null $content
* @property string|null $cover_image
* @property int|null $author_id
* @property int|null $creator_id
* @property int $views
* @property bool $featured
* @property string $status draft|published
* @property string $status draft|pending_review|published|scheduled|archived|rejected
* @property \Carbon\Carbon|null $published_at
* @property int|null $legacy_interview_id
*/
@@ -35,38 +40,81 @@ class Story extends Model
'excerpt',
'content',
'cover_image',
'author_id',
'creator_id',
'story_type',
'reading_time',
'views',
'likes_count',
'comments_count',
'featured',
'status',
'published_at',
'scheduled_for',
'meta_title',
'meta_description',
'canonical_url',
'og_image',
'submitted_for_review_at',
'reviewed_at',
'reviewed_by_id',
'rejected_reason',
'legacy_interview_id',
];
protected $casts = [
'featured' => 'boolean',
'published_at' => 'datetime',
'scheduled_for' => 'datetime',
'submitted_for_review_at' => 'datetime',
'reviewed_at' => 'datetime',
'views' => 'integer',
'likes_count' => 'integer',
'comments_count' => 'integer',
'reading_time' => 'integer',
];
// ── Relations ────────────────────────────────────────────────────────
public function author()
public function creator(): BelongsTo
{
return $this->belongsTo(StoryAuthor::class, 'author_id');
return $this->belongsTo(User::class, 'creator_id');
}
public function tags()
// Legacy alias used by older views/controllers.
public function author(): BelongsTo
{
return $this->belongsToMany(StoryTag::class, 'stories_tag_relation', 'story_id', 'tag_id');
return $this->creator();
}
public function tags(): BelongsToMany
{
return $this->belongsToMany(StoryTag::class, 'relation_story_tags', 'story_id', 'tag_id');
}
public function storyViews(): HasMany
{
return $this->hasMany(StoryView::class, 'story_id');
}
public function storyLikes(): HasMany
{
return $this->hasMany(StoryLike::class, 'story_id');
}
// ── Scopes ───────────────────────────────────────────────────────────
public function scopePublished($query)
{
return $query->where('status', 'published')
->where(fn ($q) => $q->whereNull('published_at')->orWhere('published_at', '<=', now()));
return $query
->where(function ($q): void {
$q->where('status', 'published')
->orWhere(function ($scheduled): void {
$scheduled->where('status', 'scheduled')
->whereNotNull('published_at')
->where('published_at', '<=', now());
});
})
->where(fn ($q) => $q->whereNull('published_at')->orWhere('published_at', '<=', now()));
}
public function scopeFeatured($query)
@@ -95,6 +143,10 @@ class Story extends Model
*/
public function getReadingTimeAttribute(): int
{
if (! empty($this->attributes['reading_time'])) {
return max(1, (int) $this->attributes['reading_time']);
}
$wordCount = str_word_count(strip_tags((string) $this->content));
return max(1, (int) ceil($wordCount / 200));

32
app/Models/StoryLike.php Normal file
View File

@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class StoryLike extends Model
{
use HasFactory;
public const UPDATED_AT = null;
protected $fillable = [
'story_id',
'user_id',
'created_at',
];
public function story(): BelongsTo
{
return $this->belongsTo(Story::class, 'story_id');
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class, 'user_id');
}
}

View File

@@ -5,10 +5,11 @@ declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Model;
/**
* Story Tag editorial tag for the Stories system.
* Story tag entity for creator stories.
*
* @property int $id
* @property string $slug
@@ -18,7 +19,7 @@ class StoryTag extends Model
{
use HasFactory;
protected $table = 'stories_tags';
protected $table = 'story_tags';
protected $fillable = [
'slug',
@@ -27,9 +28,9 @@ class StoryTag extends Model
// ── Relations ────────────────────────────────────────────────────────
public function stories()
public function stories(): BelongsToMany
{
return $this->belongsToMany(Story::class, 'stories_tag_relation', 'tag_id', 'story_id');
return $this->belongsToMany(Story::class, 'relation_story_tags', 'tag_id', 'story_id');
}
// ── Accessors ────────────────────────────────────────────────────────

33
app/Models/StoryView.php Normal file
View File

@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class StoryView extends Model
{
use HasFactory;
public const UPDATED_AT = null;
protected $fillable = [
'story_id',
'user_id',
'ip_address',
'created_at',
];
public function story(): BelongsTo
{
return $this->belongsTo(Story::class, 'story_id');
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class, 'user_id');
}
}

View File

@@ -11,6 +11,7 @@ use App\Models\SocialAccount;
use App\Models\Conversation;
use App\Models\ConversationParticipant;
use App\Models\Message;
use App\Models\Notification;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
@@ -30,6 +31,7 @@ class User extends Authenticatable
protected $fillable = [
'username',
'username_changed_at',
'last_username_change_at',
'onboarding_step',
'name',
'email',
@@ -38,6 +40,10 @@ class User extends Authenticatable
'verification_send_window_started_at',
'is_active',
'needs_password_reset',
'cover_hash',
'cover_ext',
'cover_position',
'trust_score',
'password',
'role',
'allow_messages_from',
@@ -66,7 +72,10 @@ class User extends Authenticatable
'verification_send_window_started_at' => 'datetime',
'verification_send_count_24h' => 'integer',
'username_changed_at' => 'datetime',
'last_username_change_at' => 'datetime',
'deleted_at' => 'datetime',
'cover_position' => 'integer',
'trust_score' => 'integer',
'password' => 'hashed',
'allow_messages_from' => 'string',
];
@@ -139,6 +148,19 @@ class User extends Authenticatable
return $this->hasMany(Message::class, 'sender_id');
}
/**
* Skinbase notifications are keyed by user_id (non-polymorphic table).
*/
public function notifications(): HasMany
{
return $this->hasMany(Notification::class, 'user_id')->latest();
}
public function unreadNotifications(): HasMany
{
return $this->notifications()->whereNull('read_at');
}
/**
* Check if this user allows receiving messages from the given user.
*/
@@ -221,6 +243,11 @@ class User extends Authenticatable
return $this->hasMany(Post::class)->orderByDesc('created_at');
}
public function stories(): HasMany
{
return $this->hasMany(Story::class, 'creator_id')->orderByDesc('published_at');
}
// ─── Meilisearch ──────────────────────────────────────────────────────────
/**

View File

@@ -30,12 +30,22 @@ class UserProfile extends Model
'gender',
'website',
'auto_post_upload',
'email_notifications',
'upload_notifications',
'follower_notifications',
'comment_notifications',
'newsletter',
];
protected $casts = [
'birthdate' => 'date',
'avatar_updated_at'=> 'datetime',
'auto_post_upload' => 'boolean',
'email_notifications' => 'boolean',
'upload_notifications' => 'boolean',
'follower_notifications' => 'boolean',
'comment_notifications' => 'boolean',
'newsletter' => 'boolean',
];
public $timestamps = true;

View File

@@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
namespace App\Notifications;
use App\Models\Story;
use Illuminate\Bus\Queueable;
use Illuminate\Notifications\Notification;
class StoryStatusNotification extends Notification
{
use Queueable;
public function __construct(
private readonly Story $story,
private readonly string $event,
private readonly ?string $reason = null,
) {
}
public function via(object $notifiable): array
{
return ['database'];
}
public function toDatabase(object $notifiable): array
{
$message = match ($this->event) {
'approved' => 'Your story "' . $this->story->title . '" was approved and published.',
'rejected' => 'Your story "' . $this->story->title . '" was rejected. Update it and resubmit for review.',
'published' => 'Your story "' . $this->story->title . '" is now published.',
default => 'Story update: "' . $this->story->title . '" status changed.',
};
return [
'type' => 'story.' . $this->event,
'story_id' => $this->story->id,
'title' => $this->story->title,
'slug' => $this->story->slug,
'status' => $this->story->status,
'reason' => $this->reason,
'message' => $message,
'url' => route('creator.stories.edit', ['story' => $this->story->id]),
];
}
}

View File

@@ -63,6 +63,8 @@ class AppServiceProvider extends ServiceProvider
$this->configureAuthRateLimiters();
$this->configureUploadRateLimiters();
$this->configureMessagingRateLimiters();
$this->configureDownloadRateLimiter();
$this->configureSettingsRateLimiters();
$this->configureMailFailureLogging();
ArtworkAward::observe(ArtworkAwardObserver::class);
@@ -143,6 +145,20 @@ class AppServiceProvider extends ServiceProvider
$view->with(compact('userId','uploadCount', 'favCount', 'msgCount', 'noticeCount', 'avatarHash', 'displayName'));
});
// Replace the framework HandleCors with our ConditionalCors so the
// CP_ENABLE_CORS / config('cors.paths') toggle takes effect.
try {
$middlewareConfig = $this->app->make(\Illuminate\Foundation\Configuration\Middleware::class);
$middlewareConfig->replace(
\Illuminate\Http\Middleware\HandleCors::class,
\App\Http\Middleware\ConditionalCors::class
);
} catch (\Throwable $_) {
// Fallback: push to kernel if replace isn't available in this app instance
$this->app->make(\Illuminate\Contracts\Http\Kernel::class)
->pushMiddleware(\App\Http\Middleware\ConditionalCors::class);
}
}
private function configureAuthRateLimiters(): void
@@ -244,4 +260,40 @@ class AppServiceProvider extends ServiceProvider
];
});
}
private function configureDownloadRateLimiter(): void
{
RateLimiter::for('downloads', function (Request $request): array {
$userId = $request->user()?->id;
// Higher user-based allowance prevents false positives for active users,
// while IP limit still protects guest endpoints from bursts.
return [
Limit::perMinute(60)->by('downloads:user:' . ($userId ?? 'guest')),
Limit::perMinute(120)->by('downloads:ip:' . $request->ip()),
];
});
}
private function configureSettingsRateLimiters(): void
{
RateLimiter::for('username-check', function (Request $request): Limit {
$key = 'username-check:ip:' . $request->ip();
if (method_exists(Limit::class, 'perSecond')) {
return Limit::perSecond(5)->by($key);
}
return Limit::perMinute(300)->by($key);
});
RateLimiter::for('email-change-request', function (Request $request): Limit {
$userId = $request->user()?->id;
$key = $userId !== null
? 'email-change-request:user:' . $userId
: 'email-change-request:ip:' . $request->ip();
return Limit::perHour(1)->by($key);
});
}
}

View File

@@ -30,5 +30,9 @@ class AuthServiceProvider extends ServiceProvider
public function boot(): void
{
$this->registerPolicies();
Gate::define('moderate-forum', static function ($user): bool {
return method_exists($user, 'isAdmin') && ($user->isAdmin() || $user->isModerator());
});
}
}

View File

@@ -18,6 +18,18 @@ class AvatarService
private const ALLOWED_MIME_TYPES = ['image/jpeg', 'image/png', 'image/webp'];
private const ALLOWED_POSITIONS = [
'top-left',
'top',
'top-right',
'left',
'center',
'right',
'bottom-left',
'bottom',
'bottom-right',
];
protected $sizes = [
'xs' => 32,
'sm' => 64,
@@ -50,13 +62,13 @@ class AvatarService
}
}
public function storeFromUploadedFile(int $userId, UploadedFile $file): string
public function storeFromUploadedFile(int $userId, UploadedFile $file, string $position = 'center'): string
{
$this->assertImageManagerAvailable();
$this->assertStorageIsAllowed();
$binary = $this->assertSecureImageUpload($file);
return $this->storeFromBinary($userId, $binary);
return $this->storeFromBinary($userId, $binary, $position);
}
public function storeFromLegacyFile(int $userId, string $path): ?string
@@ -76,10 +88,26 @@ class AvatarService
return $this->storeFromBinary($userId, $binary);
}
private function storeFromBinary(int $userId, string $binary): string
public function removeAvatar(int $userId): void
{
$diskName = (string) config('avatars.disk', 's3');
Storage::disk($diskName)->deleteDirectory("avatars/{$userId}");
UserProfile::query()->updateOrCreate(
['user_id' => $userId],
[
'avatar_hash' => null,
'avatar_mime' => null,
'avatar_updated_at' => Carbon::now(),
]
);
}
private function storeFromBinary(int $userId, string $binary, string $position = 'center'): string
{
$image = $this->readImageFromBinary($binary);
$image = $this->normalizeImage($image);
$cropPosition = $this->normalizePosition($position);
$diskName = (string) config('avatars.disk', 's3');
$disk = Storage::disk($diskName);
@@ -87,7 +115,7 @@ class AvatarService
$hashSeed = '';
foreach ($this->sizes as $size) {
$variant = $image->cover($size, $size);
$variant = $image->cover($size, $size, $cropPosition);
$encoded = (string) $variant->encode(new WebpEncoder($this->quality));
$disk->put("{$basePath}/{$size}.webp", $encoded, [
'visibility' => 'public',
@@ -110,6 +138,17 @@ class AvatarService
return $hash;
}
private function normalizePosition(string $position): string
{
$normalized = strtolower(trim($position));
if (in_array($normalized, self::ALLOWED_POSITIONS, true)) {
return $normalized;
}
return 'center';
}
private function normalizeImage($image)
{
try {

View File

@@ -18,21 +18,22 @@ class ThumbnailService
}
/**
* Canonical size keys (upload-agent spec §8): thumb · sq · md · lg · xl
* 'sm' is kept as a backwards-compatible alias for 'thumb'.
* Canonical size keys: xs · sm · md · lg · xl (+ legacy thumb/sq support).
*/
protected const VALID_SIZES = ['thumb', 'sq', 'sm', 'md', 'lg', 'xl'];
protected const VALID_SIZES = ['xs', 'sm', 'md', 'lg', 'xl', 'thumb', 'sq'];
/** Size aliases: legacy 'sm' maps to the 'thumb' CDN directory. */
protected const SIZE_ALIAS = ['sm' => 'thumb'];
/** Size aliases for backwards compatibility with old callers. */
protected const SIZE_ALIAS = [];
protected const THUMB_SIZES = [
'thumb' => ['height' => 320, 'quality' => 78, 'dir' => 'thumb'],
'xs' => ['height' => 160, 'quality' => 74, 'dir' => 'xs'],
'sm' => ['height' => 320, 'quality' => 78, 'dir' => 'sm'],
'sq' => ['height' => 512, 'quality' => 82, 'dir' => 'sq', 'square' => true],
'sm' => ['height' => 320, 'quality' => 78, 'dir' => 'thumb'], // alias for thumb
'md' => ['height' => 1024, 'quality' => 82, 'dir' => 'md'],
'lg' => ['height' => 1920, 'quality' => 85, 'dir' => 'lg'],
'xl' => ['height' => 2560, 'quality' => 90, 'dir' => 'xl'],
// Legacy compatibility for older paths still expecting /thumb/.
'thumb' => ['height' => 320, 'quality' => 78, 'dir' => 'thumb'],
];
/**

View File

@@ -129,8 +129,25 @@ final class UploadPipelineService
$height = is_array($dimensions) && isset($dimensions[1]) ? (int) $dimensions[1] : 1;
$origExt = strtolower(pathinfo($originalPath, PATHINFO_EXTENSION) ?: '');
$downloadFileName = $origFilename;
if (is_string($originalFileName) && trim($originalFileName) !== '') {
$candidate = basename(str_replace('\\', '/', $originalFileName));
$candidate = preg_replace('/[\x00-\x1F\x7F]/', '', (string) $candidate) ?? '';
$candidate = trim((string) $candidate);
if ($candidate !== '') {
$candidateExt = strtolower((string) pathinfo($candidate, PATHINFO_EXTENSION));
if ($candidateExt === '' && $origExt !== '') {
$candidate .= '.' . $origExt;
}
$downloadFileName = $candidate;
}
}
Artwork::query()->whereKey($artworkId)->update([
'file_name' => $origFilename,
'file_name' => $downloadFileName,
'file_path' => '',
'file_size' => (int) filesize($originalPath),
'mime_type' => $origMime,

62
app/Support/CoverUrl.php Normal file
View File

@@ -0,0 +1,62 @@
<?php
namespace App\Support;
class CoverUrl
{
private const DEFAULT_FILES_CDN = 'https://files.skinbase.org';
public static function forUser(?string $hash, ?string $ext, ?int $version = null): ?string
{
$coverHash = trim((string) $hash);
$coverExt = strtolower(trim((string) $ext));
if ($coverHash === '' || $coverExt === '') {
return null;
}
$base = self::resolveBaseUrl();
$p1 = substr($coverHash, 0, 2);
$p2 = substr($coverHash, 2, 2);
$v = $version ?? time();
return sprintf('%s/covers/%s/%s/%s.%s?v=%s', $base, $p1, $p2, $coverHash, $coverExt, $v);
}
private static function resolveBaseUrl(): string
{
$configured = trim((string) config('cdn.files_url', self::DEFAULT_FILES_CDN));
// If a non-default CDN/files host is configured, always respect it.
if ($configured !== '' && $configured !== self::DEFAULT_FILES_CDN) {
return rtrim($configured, '/');
}
// Local/dev fallback: derive a web path from uploads.storage_root when it lives under public/.
$local = self::deriveLocalBaseFromStorageRoot();
if ($local !== null) {
return $local;
}
return rtrim($configured !== '' ? $configured : self::DEFAULT_FILES_CDN, '/');
}
private static function deriveLocalBaseFromStorageRoot(): ?string
{
$storageRoot = str_replace('\\', '/', rtrim((string) config('uploads.storage_root'), DIRECTORY_SEPARATOR));
$publicRoot = str_replace('\\', '/', rtrim((string) public_path(), DIRECTORY_SEPARATOR));
$appUrl = rtrim((string) config('app.url'), '/');
if ($storageRoot === '' || $publicRoot === '' || $appUrl === '') {
return null;
}
if (! str_starts_with(strtolower($storageRoot), strtolower($publicRoot))) {
return null;
}
$suffix = trim((string) substr($storageRoot, strlen($publicRoot)), '/');
return $suffix === '' ? $appUrl : ($appUrl . '/' . $suffix);
}
}

View File

@@ -2,35 +2,12 @@
namespace App\Support;
use cPad\Plugins\Forum\Services\ForumContentRenderer;
class ForumPostContent
{
public static function render(?string $raw): string
{
$content = (string) ($raw ?? '');
if ($content === '') {
return '';
}
$allowedTags = '<p><br><strong><em><b><i><u><ul><ol><li><blockquote><code><pre><a><img>';
$sanitized = strip_tags($content, $allowedTags);
$sanitized = preg_replace('/\son\w+\s*=\s*"[^"]*"/i', '', $sanitized) ?? $sanitized;
$sanitized = preg_replace('/\son\w+\s*=\s*\'[^\']*\'/i', '', $sanitized) ?? $sanitized;
$sanitized = preg_replace('/\s(href|src)\s*=\s*"\s*javascript:[^"]*"/i', ' $1="#"', $sanitized) ?? $sanitized;
$sanitized = preg_replace('/\s(href|src)\s*=\s*\'\s*javascript:[^\']*\'/i', ' $1="#"', $sanitized) ?? $sanitized;
$linked = preg_replace_callback(
'/(?<!["\'>])(https?:\/\/[^\s<]+)/i',
static function (array $matches): string {
$url = $matches[1] ?? '';
$escapedUrl = e($url);
return '<a href="' . $escapedUrl . '" target="_blank" rel="noopener noreferrer" class="text-sky-300 hover:text-sky-200 underline">' . $escapedUrl . '</a>';
},
$sanitized,
);
return (string) ($linked ?? $sanitized);
return app(ForumContentRenderer::class)->render($raw);
}
}

View File

@@ -23,7 +23,7 @@ final class UsernamePolicy
public static function regex(): string
{
return (string) config('usernames.regex', '/^[a-zA-Z0-9_-]+$/');
return (string) config('usernames.regex', '/^[a-zA-Z0-9_]{3,20}$/');
}
/**
@@ -31,7 +31,12 @@ final class UsernamePolicy
*/
public static function reserved(): array
{
return array_values(array_unique(array_map(static fn (string $v): string => strtolower(trim($v)), (array) config('usernames.reserved', []))));
$pool = [
...(array) config('usernames.reserved', []),
...(array) config('skinbase.reserved_usernames', []),
];
return array_values(array_unique(array_map(static fn (string $v): string => strtolower(trim($v)), $pool)));
}
public static function normalize(string $value): string