more fixes
This commit is contained in:
206
app/Http/Controllers/Admin/StoryAdminController.php
Normal file
206
app/Http/Controllers/Admin/StoryAdminController.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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 !== '') {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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.']);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
133
app/Http/Controllers/ArtworkDownloadController.php
Normal file
133
app/Http/Controllers/ArtworkDownloadController.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -28,7 +28,7 @@ class AuthenticatedSessionController extends Controller
|
||||
|
||||
$request->session()->regenerate();
|
||||
|
||||
return redirect()->intended(route('dashboard', absolute: false));
|
||||
return redirect()->intended('/');
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -105,6 +105,7 @@ class RegisteredUserController extends Controller
|
||||
'is_active' => false,
|
||||
'onboarding_step' => 'email',
|
||||
'username_changed_at' => now(),
|
||||
'last_username_change_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
284
app/Http/Controllers/DashboardController.php
Normal file
284
app/Http/Controllers/DashboardController.php
Normal 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';
|
||||
}
|
||||
}
|
||||
157
app/Http/Controllers/News/NewsController.php
Normal file
157
app/Http/Controllers/News/NewsController.php
Normal 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);
|
||||
}
|
||||
}
|
||||
74
app/Http/Controllers/News/NewsRssController.php
Normal file
74
app/Http/Controllers/News/NewsRssController.php
Normal 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;
|
||||
}
|
||||
}
|
||||
1350
app/Http/Controllers/StoryController.php
Normal file
1350
app/Http/Controllers/StoryController.php
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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,
|
||||
|
||||
@@ -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', [
|
||||
|
||||
@@ -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,
|
||||
|
||||
252
app/Http/Controllers/User/ProfileCoverController.php
Normal file
252
app/Http/Controllers/User/ProfileCoverController.php
Normal 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.');
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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',
|
||||
|
||||
34
app/Http/Middleware/ConditionalCors.php
Normal file
34
app/Http/Middleware/ConditionalCors.php
Normal 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);
|
||||
}
|
||||
}
|
||||
29
app/Http/Middleware/EnsureCreatorAccess.php
Normal file
29
app/Http/Middleware/EnsureCreatorAccess.php
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
45
app/Http/Requests/Settings/RequestEmailChangeRequest.php
Normal file
45
app/Http/Requests/Settings/RequestEmailChangeRequest.php
Normal 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.');
|
||||
}
|
||||
},
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
32
app/Http/Requests/Settings/UpdateAccountSectionRequest.php
Normal file
32
app/Http/Requests/Settings/UpdateAccountSectionRequest.php
Normal 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)],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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'],
|
||||
];
|
||||
}
|
||||
}
|
||||
24
app/Http/Requests/Settings/UpdatePersonalSectionRequest.php
Normal file
24
app/Http/Requests/Settings/UpdatePersonalSectionRequest.php
Normal 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'],
|
||||
];
|
||||
}
|
||||
}
|
||||
29
app/Http/Requests/Settings/UpdateProfileSectionRequest.php
Normal file
29
app/Http/Requests/Settings/UpdateProfileSectionRequest.php
Normal 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'],
|
||||
];
|
||||
}
|
||||
}
|
||||
24
app/Http/Requests/Settings/UpdateSecurityPasswordRequest.php
Normal file
24
app/Http/Requests/Settings/UpdateSecurityPasswordRequest.php
Normal 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)],
|
||||
];
|
||||
}
|
||||
}
|
||||
31
app/Http/Requests/Settings/VerifyEmailChangeRequest.php
Normal file
31
app/Http/Requests/Settings/VerifyEmailChangeRequest.php
Normal 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'],
|
||||
];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user