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,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',