login update

This commit is contained in:
2026-03-05 11:24:37 +01:00
parent 5a33ca55a1
commit f6772f673b
67 changed files with 10640 additions and 116 deletions

View File

@@ -0,0 +1,89 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
class AvatarsBulkUpdate extends Command
{
protected $signature = 'avatars:bulk-update
{path=./user_profiles_avatar.csv : CSV file path (user_id,avatar_hash)}
{--dry-run : Do not write to database}
';
protected $description = 'Bulk update user_profiles.avatar_hash from CSV (user_id,avatar_hash)';
public function handle(): int
{
$path = $this->argument('path');
$dry = $this->option('dry-run');
if (!file_exists($path)) {
$this->error("CSV file not found: {$path}");
return 1;
}
$this->info('Reading CSV: ' . $path);
if (($handle = fopen($path, 'r')) === false) {
$this->error('Unable to open CSV file');
return 1;
}
$row = 0;
$updates = 0;
while (($data = fgetcsv($handle)) !== false) {
$row++;
// Skip empty rows
if (count($data) === 0) {
continue;
}
// Expect at least two columns: user_id, avatar_hash
$userId = isset($data[0]) ? trim($data[0]) : null;
$hash = isset($data[1]) ? trim($data[1]) : null;
// If first row looks like a header, skip it
if ($row === 1 && (!is_numeric($userId) || $userId === 'user_id')) {
continue;
}
if ($userId === '' || $hash === '') {
$this->line("[skip] row={$row} invalid data");
continue;
}
$userId = (int) $userId;
if ($dry) {
$this->line("[dry] user={$userId} would set avatar_hash={$hash}");
$updates++;
continue;
}
try {
$affected = DB::table('user_profiles')
->where('user_id', $userId)
->update([ 'avatar_hash' => $hash, 'avatar_updated_at' => now() ]);
if ($affected) {
$this->line("[ok] user={$userId} avatar_hash updated");
$updates++;
} else {
$this->line("[noop] user={$userId} no row updated (missing profile?)");
}
} catch (\Throwable $e) {
$this->error("[error] user={$userId} {$e->getMessage()}");
continue;
}
}
fclose($handle);
$this->info("Done. Processed rows={$row} updates={$updates}");
return 0;
}
}

View File

@@ -4,6 +4,7 @@ namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Facades\DB;
use App\Models\User;
use App\Models\UserProfile;
use Intervention\Image\ImageManagerStatic as Image;
@@ -39,6 +40,7 @@ class AvatarsMigrate extends Command
protected $allowed = [
'image/jpeg',
'image/png',
'image/gif',
'image/webp',
];
@@ -47,7 +49,7 @@ class AvatarsMigrate extends Command
*
* @var int[]
*/
protected $sizes = [32, 64, 128, 256, 512];
protected $sizes = [32, 40, 64, 128, 256, 512];
public function handle(): int
{
@@ -56,6 +58,7 @@ class AvatarsMigrate extends Command
$removeLegacy = $this->option('remove-legacy');
$legacyPath = base_path($this->option('path'));
$userId = $this->option('user-id') ? (int) $this->option('user-id') : null;
$verbose = $this->output->isVerbose();
$this->info('Starting avatar migration' . ($dry ? ' (dry-run)' : '') . ($userId ? " for user={$userId}" : ''));
@@ -72,7 +75,7 @@ class AvatarsMigrate extends Command
$query->where('id', $userId);
}
$query->chunk(100, function ($users) use ($dry, $force, $removeLegacy, $legacyPath, &$bar, $useIntervention) {
$query->chunk(100, function ($users) use ($dry, $force, $removeLegacy, $legacyPath, &$bar, $useIntervention, $verbose) {
foreach ($users as $user) {
/** @var UserProfile|null $profile */
$profile = $user->profile;
@@ -87,10 +90,13 @@ class AvatarsMigrate extends Command
continue;
}
$source = $this->findLegacyFile($profile, $user->id, $legacyPath);
$source = $this->findLegacyFile($profile, $user->id, $legacyPath, 'legacy');
//dd($source);
if (!$source) {
$this->line("[noop] user={$user->id} no legacy file found");
if ($verbose) {
$this->line("[noop] user={$user->id} no legacy file found");
}
continue;
}
@@ -123,14 +129,19 @@ class AvatarsMigrate extends Command
$contentPart = substr(sha1($originalBlob), 0, 12);
$hash = sprintf('%s_%s', $idPart, $contentPart);
// Precompute storage dir for dry-run and real run
$hashPrefix1 = substr($hash, 0, 2);
$hashPrefix2 = substr($hash, 2, 2);
$dir = "avatars/{$hashPrefix1}/{$hashPrefix2}/{$hash}";
// CDN base for public URLs
$cdnBase = rtrim((string) config('cdn.avatar_url', 'https://files.skinbase.org'), '/');
if ($dry) {
$this->line("[dry] user={$user->id} would write avatars for hash={$hash}");
$absPathDry = Storage::disk('public')->path("{$dir}/original.webp");
$publicUrlDry = sprintf('%s/%s/original.webp?v=%s', $cdnBase, $dir, $hash);
$this->line("[dry] user={$user->id} would write avatars for hash={$hash} path={$absPathDry} url={$publicUrlDry}");
} else {
// Use hash-based directory structure: avatars/ab/cd/{hash}/
$hashPrefix1 = substr($hash, 0, 2);
$hashPrefix2 = substr($hash, 2, 2);
$dir = "avatars/{$hashPrefix1}/{$hashPrefix2}/{$hash}";
Storage::disk('public')->makeDirectory($dir);
// Save original.webp
Storage::disk('public')->put("{$dir}/original.webp", $originalBlob);
@@ -155,7 +166,9 @@ class AvatarsMigrate extends Command
$profile->avatar_updated_at = Carbon::now();
$profile->save();
$this->line("[ok] user={$user->id} migrated hash={$hash}");
$absPath = Storage::disk('public')->path("{$dir}/original.webp");
$publicUrl = sprintf('%s/%s/original.webp?v=%s', $cdnBase, $dir, $hash);
$this->line("[ok] user={$user->id} migrated hash={$hash} path={$absPath} url={$publicUrl}");
if ($removeLegacy && !empty($profile->avatar_legacy)) {
$legacyFile = base_path("public/files/usericons/{$profile->avatar_legacy}");
@@ -185,8 +198,19 @@ class AvatarsMigrate extends Command
* @param string $legacyBase
* @return string|null
*/
protected function findLegacyFile(UserProfile $profile, int $userId, string $legacyBase): ?string
protected function findLegacyFile(UserProfile $profile, int $userId, string $legacyBase, ?string $legacyConnection = null): ?string
{
$avatar = DB::connection('legacy')->table('users')->where('user_id', $userId)->value('icon');
if (!empty($profile->avatar_legacy)) {
$p = $legacyBase . DIRECTORY_SEPARATOR . $avatar;
if (file_exists($p)) {
return $p;
}
}
// 1) If profile->avatar_legacy looks like a filename, try it
if (!empty($profile->avatar_legacy)) {
$p = $legacyBase . DIRECTORY_SEPARATOR . $profile->avatar_legacy;
@@ -212,6 +236,34 @@ class AvatarsMigrate extends Command
}
}
// 4) Fallback: try legacy database connection (connection name 'legacy')
// If a legacy DB connection is configured, query `users.icon` for avatar filename.
try {
$conn = $legacyConnection ?: (config('database.connections.legacy') ? 'legacy' : null);
if ($conn) {
$icon = DB::connection($conn)->table('users')->where('id', $userId)->value('icon');
if (!empty($icon)) {
// If icon looks like an absolute path, use it directly; otherwise resolve under legacy base path
$p = $icon;
if (!file_exists($p)) {
$p = $legacyBase . DIRECTORY_SEPARATOR . ltrim($icon, '\/');
}
if (file_exists($p)) {
if ($this->output->isVerbose()) {
$this->line("[legacy-db] user={$userId} icon={$icon} resolved={$p}");
}
return $p;
}
if ($this->output->isVerbose()) {
$this->line("[legacy-db] user={$userId} icon={$icon} not found at resolved path {$p}");
}
}
}
} catch (\Throwable $e) {
// Non-fatal: just skip legacy DB if query fails or connection missing
}
return null;
}
@@ -308,6 +360,53 @@ class AvatarsMigrate extends Command
return imagecreatefromwebp($path);
}
return false;
case 'image/gif':
if (function_exists('imagecreatefromgif')) {
$res = imagecreatefromgif($path);
if (!$res) {
return false;
}
// Ensure returned resource is truecolor (WebP requires truecolor)
if (!imageistruecolor($res)) {
$w = imagesx($res);
$h = imagesy($res);
$true = imagecreatetruecolor($w, $h);
// Preserve transparency where possible
imagealphablending($true, false);
imagesavealpha($true, true);
// Fill with fully transparent color
$transparent = imagecolorallocatealpha($true, 0, 0, 0, 127);
imagefilledrectangle($true, 0, 0, $w, $h, $transparent);
// If the source has an indexed transparent color, try to preserve it
$transIndex = imagecolortransparent($res);
if ($transIndex >= 0) {
try {
$colorTotal = imagecolorstotal($res);
if ($transIndex >= 0 && $transIndex < $colorTotal) {
$colors = imagecolorsforindex($res, $transIndex);
if (is_array($colors)) {
$alphaColor = imagecolorallocatealpha($true, $colors['red'], $colors['green'], $colors['blue'], 127);
imagefilledrectangle($true, 0, 0, $w, $h, $alphaColor);
}
}
} catch (\Throwable $e) {
// Non-fatal: skip preserving indexed transparent color
}
}
// Copy pixels
imagecopy($true, $res, 0, 0, 0, 0, $w, $h);
imagedestroy($res);
return $true;
}
return $res;
}
return false;
default:
return false;
}

View File

@@ -0,0 +1,197 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Models\Story;
use App\Models\StoryAuthor;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
use Throwable;
/**
* Migrate legacy interview records into the new Stories system.
*
* Usage:
* php artisan stories:migrate-legacy
* php artisan stories:migrate-legacy --dry-run
* php artisan stories:migrate-legacy --legacy-connection=legacy --chunk=100
*
* Idempotent: running multiple times will not duplicate records.
* Legacy records are identified via `legacy_interview_id` column on stories table.
*/
final class MigrateStoriesCommand extends Command
{
protected $signature = 'stories:migrate-legacy
{--chunk=50 : number of records to process per batch}
{--dry-run : preview migration without persisting changes}
{--legacy-connection= : DB connection name for legacy database (default: uses default connection)}
{--legacy-table=interviews : legacy interviews table name}
';
protected $description = 'Migrate legacy interview records into the new nova Stories system (idempotent)';
public function handle(): int
{
$chunk = max(1, (int) $this->option('chunk'));
$dryRun = (bool) $this->option('dry-run');
$legacyConn = $this->option('legacy-connection') ?: null;
$table = (string) $this->option('legacy-table');
$this->info('Nova Stories — legacy interview migration');
$this->info("Table: {$table} | Chunk: {$chunk} | Dry-run: " . ($dryRun ? 'YES' : 'NO'));
$this->newLine();
try {
$db = $legacyConn ? DB::connection($legacyConn) : DB::connection();
// Quick existence check
$db->table($table)->limit(1)->get();
} catch (Throwable $e) {
$this->error("Cannot access table `{$table}`: " . $e->getMessage());
return self::FAILURE;
}
$inserted = 0;
$skipped = 0;
$failed = 0;
$db->table($table)->orderBy('id')->chunkById($chunk, function ($rows) use (
$dryRun, &$inserted, &$skipped, &$failed
) {
foreach ($rows as $row) {
$legacyId = (int) ($row->id ?? 0);
if (! $legacyId) {
$skipped++;
continue;
}
// Idempotency: skip if already migrated
if (Story::where('legacy_interview_id', $legacyId)->exists()) {
$skipped++;
continue;
}
try {
// ── Resolve / create author ──────────────────────────────
$authorName = $this->coerceString($row->username ?? $row->author ?? $row->uname ?? '');
$authorAvatar = $this->coerceString($row->icon ?? $row->avatar ?? '');
$author = null;
if ($authorName) {
$author = StoryAuthor::firstOrCreate(
['name' => $authorName],
['avatar' => $authorAvatar ?: null]
);
}
// ── Build slug ───────────────────────────────────────────
$rawTitle = $this->coerceString(
$row->headline ?? $row->title ?? $row->subject ?? ''
) ?: 'interview-' . $legacyId;
$slugBase = Str::slug(Str::limit($rawTitle, 180));
$slug = $slugBase ?: 'interview-' . $legacyId;
// Ensure uniqueness
$slug = $this->uniqueSlug($slug);
// ── Excerpt ──────────────────────────────────────────────
$fullContent = $this->coerceString(
$row->content ?? $row->tekst ?? $row->body ?? $row->text ?? ''
);
$excerpt = $this->coerceString($row->excerpt ?? $row->intro ?? $row->lead ?? '');
if (! $excerpt && $fullContent) {
$excerpt = Str::limit(strip_tags($fullContent), 200);
}
// ── Cover image ──────────────────────────────────────────
$coverRaw = $this->coerceString($row->pic ?? $row->image ?? $row->cover ?? $row->photo ?? '');
$coverImage = $coverRaw ? 'legacy/interviews/' . ltrim($coverRaw, '/') : null;
// ── Published date ───────────────────────────────────────
$publishedAt = null;
foreach (['datum', 'published_at', 'date', 'created_at'] as $field) {
$val = $row->{$field} ?? null;
if ($val) {
$ts = strtotime((string) $val);
if ($ts) {
$publishedAt = date('Y-m-d H:i:s', $ts);
break;
}
}
}
if ($dryRun) {
$this->line(" [DRY-RUN] Would import: #{$legacyId}{$slug}");
$inserted++;
continue;
}
Story::create([
'slug' => $slug,
'title' => Str::limit($rawTitle, 255),
'excerpt' => $excerpt ?: null,
'content' => $fullContent ?: null,
'cover_image' => $coverImage,
'author_id' => $author?->id,
'views' => max(0, (int) ($row->views ?? $row->hits ?? 0)),
'featured' => false,
'status' => 'published',
'published_at' => $publishedAt,
'legacy_interview_id' => $legacyId,
]);
$this->line(" Imported: #{$legacyId}{$slug}");
$inserted++;
} catch (Throwable $e) {
$failed++;
$this->warn(" FAILED #{$legacyId}: " . $e->getMessage());
Log::warning("stories:migrate-legacy failed for id={$legacyId}", ['error' => $e->getMessage()]);
}
}
});
$this->newLine();
$this->info("Migration complete.");
$this->table(
['Inserted', 'Skipped (existing)', 'Failed'],
[[$inserted, $skipped, $failed]]
);
return $failed > 0 ? self::FAILURE : self::SUCCESS;
}
// ── Helpers ───────────────────────────────────────────────────────────────
private function coerceString(mixed $value, string $default = ''): string
{
if ($value === null) {
return $default;
}
$str = trim((string) $value);
return $str !== '' ? $str : $default;
}
/**
* Ensure the slug is unique, appending a numeric suffix if needed.
*/
private function uniqueSlug(string $slug): string
{
if (! Story::where('slug', $slug)->exists()) {
return $slug;
}
$i = 2;
do {
$candidate = $slug . '-' . $i++;
} while (Story::where('slug', $candidate)->exists());
return $candidate;
}
}

View File

@@ -34,6 +34,7 @@ class Kernel extends ConsoleKernel
ImportCategories::class,
MigrateFeaturedWorks::class,
\App\Console\Commands\AvatarsMigrate::class,
\App\Console\Commands\AvatarsBulkUpdate::class,
\App\Console\Commands\ResetAllUserPasswords::class,
CleanupUploadsCommand::class,
PublishScheduledArtworksCommand::class,

View File

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

View File

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

View File

@@ -34,8 +34,9 @@ class FollowingController extends Controller
->through(fn ($row) => (object) [
'id' => $row->id,
'username' => $row->username,
'name' => $row->name,
'uname' => $row->username ?? $row->name,
'avatar_url' => AvatarUrl::forUser((int) $row->id, $row->avatar_hash, 50),
'avatar_url' => AvatarUrl::forUser((int) $row->id, $row->avatar_hash, 64),
'profile_url' => '/@' . strtolower((string) ($row->username ?? $row->id)),
'uploads' => $row->uploads_count ?? 0,
'followers_count'=> $row->followers_count ?? 0,

View File

@@ -36,7 +36,8 @@ class TopAuthorsController extends Controller
$query = DB::table(DB::raw('(' . $sub->toSql() . ') as t'))
->mergeBindings($sub->getQuery())
->join('users as u', 'u.id', '=', 't.user_id')
->select('u.id as user_id', 'u.name as uname', 'u.username', 't.total_metric', 't.latest_published')
->leftJoin('user_profiles as up', 'up.user_id', '=', 'u.id')
->select('u.id as user_id', 'u.name as uname', 'u.username', 'up.avatar_hash', 't.total_metric', 't.latest_published')
->orderByDesc('t.total_metric')
->orderByDesc('t.latest_published');
@@ -48,6 +49,7 @@ class TopAuthorsController extends Controller
'user_id' => $row->user_id,
'uname' => $row->uname,
'username' => $row->username,
'avatar_hash' => $row->avatar_hash,
'total' => (int) $row->total_metric,
'metric' => $metric,
];

View File

@@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\RSS;
use App\Http\Controllers\Controller;
use App\Models\BlogPost;
use App\Services\RSS\RSSFeedBuilder;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\Cache;
/**
* BlogFeedController
*
* GET /rss/blog latest blog posts feed (spec §3.6)
*/
final class BlogFeedController extends Controller
{
public function __construct(private readonly RSSFeedBuilder $builder) {}
public function __invoke(): Response
{
$feedUrl = url('/rss/blog');
$posts = Cache::remember('rss:blog', 600, fn () =>
BlogPost::published()
->with('author:id,username')
->latest('published_at')
->limit(RSSFeedBuilder::FEED_LIMIT)
->get()
);
return $this->builder->buildFromBlogPosts(
'Blog',
'Latest posts from the Skinbase blog.',
$feedUrl,
$posts,
);
}
}

View File

@@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\RSS;
use App\Http\Controllers\Controller;
use App\Models\Artwork;
use App\Models\User;
use App\Services\RSS\RSSFeedBuilder;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\Cache;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
/**
* CreatorFeedController
*
* GET /rss/creator/{username} latest artworks by a given creator (spec §3.5)
*/
final class CreatorFeedController extends Controller
{
public function __construct(private readonly RSSFeedBuilder $builder) {}
public function __invoke(string $username): Response
{
$user = User::where('username', $username)->first();
if (! $user) {
throw new NotFoundHttpException("Creator [{$username}] not found.");
}
$feedUrl = url('/rss/creator/' . $username);
$artworks = Cache::remember('rss:creator:' . strtolower($username), 300, fn () =>
Artwork::public()->published()
->with(['user:id,username', 'categories:id,name,slug,content_type_id'])
->where('artworks.user_id', $user->id)
->latest('artworks.published_at')
->limit(RSSFeedBuilder::FEED_LIMIT)
->get()
);
return $this->builder->buildFromArtworks(
$user->username . '\'s Artworks',
'Latest artworks by ' . $user->username . ' on Skinbase.',
$feedUrl,
$artworks,
);
}
}

View File

@@ -0,0 +1,98 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\RSS;
use App\Http\Controllers\Controller;
use App\Models\Artwork;
use App\Services\RSS\RSSFeedBuilder;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\Cache;
/**
* DiscoverFeedController
*
* Powers the /rss/discover/* feeds (spec §3.2).
*
* GET /rss/discover fresh/latest (default)
* GET /rss/discover/trending trending by trending_score_7d
* GET /rss/discover/fresh latest published
* GET /rss/discover/rising rising by heat_score
*/
final class DiscoverFeedController extends Controller
{
public function __construct(private readonly RSSFeedBuilder $builder) {}
/** /rss/discover → redirect to fresh */
public function index(): Response
{
return $this->fresh();
}
/** /rss/discover/trending */
public function trending(): Response
{
$feedUrl = url('/rss/discover/trending');
$artworks = Cache::remember('rss:discover:trending', 600, fn () =>
Artwork::public()->published()
->with(['user:id,username', 'categories:id,name,slug,content_type_id'])
->leftJoin('artwork_stats', 'artwork_stats.artwork_id', '=', 'artworks.id')
->orderByDesc('artwork_stats.trending_score_7d')
->orderByDesc('artworks.published_at')
->select('artworks.*')
->limit(RSSFeedBuilder::FEED_LIMIT)
->get()
);
return $this->builder->buildFromArtworks(
'Trending Artworks',
'The most-viewed and trending artworks on Skinbase over the past 7 days.',
$feedUrl,
$artworks,
);
}
/** /rss/discover/fresh */
public function fresh(): Response
{
$feedUrl = url('/rss/discover/fresh');
$artworks = Cache::remember('rss:discover:fresh', 300, fn () =>
Artwork::public()->published()
->with(['user:id,username', 'categories:id,name,slug,content_type_id'])
->latest('published_at')
->limit(RSSFeedBuilder::FEED_LIMIT)
->get()
);
return $this->builder->buildFromArtworks(
'Fresh Uploads',
'The latest artworks just published on Skinbase.',
$feedUrl,
$artworks,
);
}
/** /rss/discover/rising */
public function rising(): Response
{
$feedUrl = url('/rss/discover/rising');
$artworks = Cache::remember('rss:discover:rising', 600, fn () =>
Artwork::public()->published()
->with(['user:id,username', 'categories:id,name,slug,content_type_id'])
->leftJoin('artwork_stats', 'artwork_stats.artwork_id', '=', 'artworks.id')
->orderByDesc('artwork_stats.heat_score')
->orderByDesc('artworks.published_at')
->select('artworks.*')
->limit(RSSFeedBuilder::FEED_LIMIT)
->get()
);
return $this->builder->buildFromArtworks(
'Rising Artworks',
'Fastest-growing artworks gaining momentum on Skinbase right now.',
$feedUrl,
$artworks,
);
}
}

View File

@@ -0,0 +1,105 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\RSS;
use App\Http\Controllers\Controller;
use App\Models\Artwork;
use App\Models\ContentType;
use App\Services\RSS\RSSFeedBuilder;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\Cache;
/**
* ExploreFeedController
*
* Powers the /rss/explore/* feeds (spec §3.3).
*
* GET /rss/explore/{type} latest by content type
* GET /rss/explore/{type}/{mode} sorted by mode (trending|latest|best)
*
* Valid types: artworks | wallpapers | skins | photography | other
* Valid modes: trending | latest | best
*/
final class ExploreFeedController extends Controller
{
private const SORT_TTL = [
'trending' => 600,
'best' => 600,
'latest' => 300,
];
public function __construct(private readonly RSSFeedBuilder $builder) {}
/** /rss/explore/{type} — defaults to latest */
public function byType(string $type): Response
{
return $this->feed($type, 'latest');
}
/** /rss/explore/{type}/{mode} */
public function byTypeMode(string $type, string $mode): Response
{
return $this->feed($type, $mode);
}
// ─────────────────────────────────────────────────────────────────────────
private function feed(string $type, string $mode): Response
{
$mode = in_array($mode, ['trending', 'latest', 'best'], true) ? $mode : 'latest';
$ttl = self::SORT_TTL[$mode] ?? 300;
$feedUrl = url('/rss/explore/' . $type . ($mode !== 'latest' ? '/' . $mode : ''));
$label = ucfirst(str_replace('-', ' ', $type));
$artworks = Cache::remember("rss:explore:{$type}:{$mode}", $ttl, function () use ($type, $mode) {
$contentType = ContentType::where('slug', $type)->first();
$query = Artwork::public()->published()
->with(['user:id,username', 'categories:id,name,slug,content_type_id']);
if ($contentType) {
$query->whereHas('categories', fn ($q) =>
$q->where('content_type_id', $contentType->id)
);
}
return match ($mode) {
'trending' => $query
->leftJoin('artwork_stats', 'artwork_stats.artwork_id', '=', 'artworks.id')
->orderByDesc('artwork_stats.trending_score_7d')
->orderByDesc('artworks.published_at')
->select('artworks.*')
->limit(RSSFeedBuilder::FEED_LIMIT)
->get(),
'best' => $query
->leftJoin('artwork_stats', 'artwork_stats.artwork_id', '=', 'artworks.id')
->orderByDesc('artwork_stats.favorites')
->orderByDesc('artwork_stats.downloads')
->select('artworks.*')
->limit(RSSFeedBuilder::FEED_LIMIT)
->get(),
default => $query
->latest('artworks.published_at')
->limit(RSSFeedBuilder::FEED_LIMIT)
->get(),
};
});
$modeLabel = match ($mode) {
'trending' => 'Trending',
'best' => 'Best',
default => 'Latest',
};
return $this->builder->buildFromArtworks(
"{$modeLabel} {$label}",
"{$modeLabel} {$label} artworks on Skinbase.",
$feedUrl,
$artworks,
);
}
}

View File

@@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\RSS;
use App\Http\Controllers\Controller;
use App\Models\Artwork;
use App\Services\RSS\RSSFeedBuilder;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\Cache;
/**
* GlobalFeedController
*
* GET /rss global latest-artworks feed (spec §3.1)
*/
final class GlobalFeedController extends Controller
{
public function __construct(private readonly RSSFeedBuilder $builder) {}
public function __invoke(): Response
{
$feedUrl = url('/rss');
$artworks = Cache::remember('rss:global', 300, fn () =>
Artwork::public()->published()
->with(['user:id,username', 'categories:id,name,slug,content_type_id'])
->latest('published_at')
->limit(RSSFeedBuilder::FEED_LIMIT)
->get()
);
return $this->builder->buildFromArtworks(
'Latest Artworks',
'The newest artworks published on Skinbase.',
$feedUrl,
$artworks,
);
}
}

View File

@@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\RSS;
use App\Http\Controllers\Controller;
use App\Models\Artwork;
use App\Models\Tag;
use App\Services\RSS\RSSFeedBuilder;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\Cache;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
/**
* TagFeedController
*
* GET /rss/tag/{slug} artworks tagged with given slug (spec §3.4)
*/
final class TagFeedController extends Controller
{
public function __construct(private readonly RSSFeedBuilder $builder) {}
public function __invoke(string $slug): Response
{
$tag = Tag::where('slug', $slug)->first();
if (! $tag) {
throw new NotFoundHttpException("Tag [{$slug}] not found.");
}
$feedUrl = url('/rss/tag/' . $slug);
$artworks = Cache::remember('rss:tag:' . $slug, 600, fn () =>
Artwork::public()->published()
->with(['user:id,username', 'categories:id,name,slug,content_type_id'])
->whereHas('tags', fn ($q) => $q->where('tags.id', $tag->id))
->latest('artworks.published_at')
->limit(RSSFeedBuilder::FEED_LIMIT)
->get()
);
return $this->builder->buildFromArtworks(
ucwords(str_replace('-', ' ', $slug)) . ' Artworks',
'Latest Skinbase artworks tagged "' . $tag->name . '".',
$feedUrl,
$artworks,
);
}
}

View File

@@ -33,7 +33,8 @@ class TopAuthorsController extends Controller
$query = DB::table(DB::raw('(' . $sub->toSql() . ') as t'))
->mergeBindings($sub->getQuery())
->join('users as u', 'u.id', '=', 't.user_id')
->select('u.id as user_id', 'u.name as uname', 'u.username', 't.total_metric', 't.latest_published')
->leftJoin('user_profiles as up', 'up.user_id', '=', 'u.id')
->select('u.id as user_id', 'u.name as uname', 'u.username', 'up.avatar_hash', 't.total_metric', 't.latest_published')
->orderByDesc('t.total_metric')
->orderByDesc('t.latest_published');
@@ -44,6 +45,7 @@ class TopAuthorsController extends Controller
'user_id' => $row->user_id,
'uname' => $row->uname,
'username' => $row->username,
'avatar_hash' => $row->avatar_hash,
'total' => (int) $row->total_metric,
'metric' => $metric,
];

View File

@@ -13,18 +13,66 @@ use Illuminate\View\View;
/**
* RssFeedController
*
* GET /rss-feeds info page listing available feeds
* GET /rss/latest-uploads.xml all published artworks
* GET /rss/latest-skins.xml skins only
* GET /rss/latest-wallpapers.xml wallpapers only
* GET /rss/latest-photos.xml photography only
* GET /rss-feeds info page listing all available feeds
* GET /rss/latest-uploads.xml all published artworks (legacy)
* GET /rss/latest-skins.xml skins only (legacy)
* GET /rss/latest-wallpapers.xml wallpapers only (legacy)
* GET /rss/latest-photos.xml photography only (legacy)
*
* Nova feeds live in App\Http\Controllers\RSS\*.
*/
final class RssFeedController extends Controller
{
/** Number of items per feed. */
/** Number of items per legacy feed. */
private const FEED_LIMIT = 25;
/** Feed definitions shown on the info page. */
/**
* Grouped feed definitions shown on the /rss-feeds info page.
* Each group has a 'label' and an array of 'feeds' with title + url.
*/
public const FEED_GROUPS = [
'global' => [
'label' => 'Global',
'feeds' => [
['title' => 'Latest Artworks', 'url' => '/rss', 'description' => 'All new artworks across the platform.'],
],
],
'discover' => [
'label' => 'Discover',
'feeds' => [
['title' => 'Fresh Uploads', 'url' => '/rss/discover/fresh', 'description' => 'The newest artworks just published.'],
['title' => 'Trending', 'url' => '/rss/discover/trending', 'description' => 'Most-viewed artworks over the past 7 days.'],
['title' => 'Rising', 'url' => '/rss/discover/rising', 'description' => 'Artworks gaining momentum right now.'],
],
],
'explore' => [
'label' => 'Explore',
'feeds' => [
['title' => 'All Artworks', 'url' => '/rss/explore/artworks', 'description' => 'Latest artworks of all types.'],
['title' => 'Wallpapers', 'url' => '/rss/explore/wallpapers', 'description' => 'Latest wallpapers.'],
['title' => 'Skins', 'url' => '/rss/explore/skins', 'description' => 'Latest skins.'],
['title' => 'Photography', 'url' => '/rss/explore/photography', 'description' => 'Latest photography.'],
['title' => 'Trending Wallpapers', 'url' => '/rss/explore/wallpapers/trending', 'description' => 'Trending wallpapers this week.'],
],
],
'blog' => [
'label' => 'Blog',
'feeds' => [
['title' => 'Blog Posts', 'url' => '/rss/blog', 'description' => 'Latest posts from the Skinbase blog.'],
],
],
'legacy' => [
'label' => 'Legacy Feeds',
'feeds' => [
['title' => 'Latest Uploads (XML)', 'url' => '/rss/latest-uploads.xml', 'description' => 'Legacy XML feed.'],
['title' => 'Latest Skins (XML)', 'url' => '/rss/latest-skins.xml', 'description' => 'Legacy XML feed.'],
['title' => 'Latest Wallpapers (XML)', 'url' => '/rss/latest-wallpapers.xml', 'description' => 'Legacy XML feed.'],
['title' => 'Latest Photos (XML)', 'url' => '/rss/latest-photos.xml', 'description' => 'Legacy XML feed.'],
],
],
];
/** Flat feed list kept for backward-compatibility (old view logic). */
public const FEEDS = [
'uploads' => ['title' => 'Latest Uploads', 'url' => '/rss/latest-uploads.xml'],
'skins' => ['title' => 'Latest Skins', 'url' => '/rss/latest-skins.xml'],
@@ -45,7 +93,8 @@ final class RssFeedController extends Controller
(object) ['name' => 'Home', 'url' => '/'],
(object) ['name' => 'RSS Feeds', 'url' => '/rss-feeds'],
]),
'feeds' => self::FEEDS,
'feeds' => self::FEEDS,
'feed_groups' => self::FEED_GROUPS,
'center_content' => true,
'center_max' => '3xl',
]);

View File

@@ -0,0 +1,59 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Web;
use App\Http\Controllers\Controller;
use App\Models\Story;
use App\Models\StoryAuthor;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
use Illuminate\View\View;
/**
* Stories filtered by author /stories/author/{username}
*/
final class StoriesAuthorController extends Controller
{
public function show(Request $request, string $username): View
{
// Resolve by linked user username first, then by author name slug
$author = StoryAuthor::whereHas('user', fn ($q) => $q->where('username', $username))
->with('user')
->first();
if (! $author) {
// Fallback: author name matches slug-style
$author = StoryAuthor::where('name', $username)->first();
}
if (! $author) {
abort(404);
}
$stories = Cache::remember('stories:author:' . $author->id . ':page:' . ($request->get('page', 1)), 300, fn () =>
Story::published()
->with('author', 'tags')
->where('author_id', $author->id)
->orderByDesc('published_at')
->paginate(12)
->withQueryString()
);
$authorName = $author->user?->username ?? $author->name;
return view('web.stories.author', [
'author' => $author,
'stories' => $stories,
'page_title' => 'Stories by ' . $authorName . ' — Skinbase',
'page_meta_description' => 'All stories and interviews by ' . $authorName . ' on Skinbase.',
'page_canonical' => url('/stories/author/' . $username),
'page_robots' => 'index,follow',
'breadcrumbs' => collect([
(object) ['name' => 'Stories', 'url' => '/stories'],
(object) ['name' => $authorName, 'url' => '/stories/author/' . $username],
]),
]);
}
}

View File

@@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Web;
use App\Http\Controllers\Controller;
use App\Models\Story;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
use Illuminate\View\View;
/**
* Stories listing page /stories
*/
final class StoriesController extends Controller
{
public function index(Request $request): View
{
$featured = Cache::remember('stories:featured', 300, fn () =>
Story::published()->featured()
->with('author', 'tags')
->orderByDesc('published_at')
->first()
);
$stories = Cache::remember('stories:list:page:' . ($request->get('page', 1)), 300, fn () =>
Story::published()
->with('author', 'tags')
->orderByDesc('published_at')
->paginate(12)
->withQueryString()
);
return view('web.stories.index', [
'featured' => $featured,
'stories' => $stories,
'page_title' => 'Stories — Skinbase',
'page_meta_description' => 'Artist interviews, community spotlights, tutorials and announcements from Skinbase.',
'page_canonical' => url('/stories'),
'page_robots' => 'index,follow',
'breadcrumbs' => collect([
(object) ['name' => 'Stories', 'url' => '/stories'],
]),
]);
}
}

View File

@@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Web;
use App\Http\Controllers\Controller;
use App\Models\Story;
use App\Models\StoryTag;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
use Illuminate\View\View;
/**
* Stories filtered by tag /stories/tag/{tag}
*/
final class StoriesTagController extends Controller
{
public function show(Request $request, string $tag): View
{
$storyTag = StoryTag::where('slug', $tag)->firstOrFail();
$stories = Cache::remember('stories:tag:' . $tag . ':page:' . ($request->get('page', 1)), 300, fn () =>
Story::published()
->with('author', 'tags')
->whereHas('tags', fn ($q) => $q->where('stories_tags.id', $storyTag->id))
->orderByDesc('published_at')
->paginate(12)
->withQueryString()
);
return view('web.stories.tag', [
'storyTag' => $storyTag,
'stories' => $stories,
'page_title' => '#' . $storyTag->name . ' Stories — Skinbase',
'page_meta_description' => 'Stories tagged with "' . $storyTag->name . '" on Skinbase.',
'page_canonical' => url('/stories/tag/' . $storyTag->slug),
'page_robots' => 'index,follow',
'breadcrumbs' => collect([
(object) ['name' => 'Stories', 'url' => '/stories'],
(object) ['name' => '#' . $storyTag->name, 'url' => '/stories/tag/' . $storyTag->slug],
]),
]);
}
}

View File

@@ -0,0 +1,86 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Web;
use App\Http\Controllers\Controller;
use App\Models\Story;
use Illuminate\Support\Facades\Cache;
use Illuminate\View\View;
/**
* Single story page /stories/{slug}
*/
final class StoryController extends Controller
{
public function show(string $slug): View
{
$story = Cache::remember('stories:' . $slug, 600, fn () =>
Story::published()
->with('author', 'tags')
->where('slug', $slug)
->firstOrFail()
);
// Increment view counter (fire-and-forget, no cache invalidation needed)
Story::where('id', $story->id)->increment('views');
// Related stories: shared tags → same author → newest
$related = Cache::remember('stories:related:' . $story->id, 600, function () use ($story) {
$tagIds = $story->tags->pluck('id');
$related = collect();
if ($tagIds->isNotEmpty()) {
$related = Story::published()
->with('author', 'tags')
->whereHas('tags', fn ($q) => $q->whereIn('stories_tags.id', $tagIds))
->where('id', '!=', $story->id)
->orderByDesc('published_at')
->limit(6)
->get();
}
if ($related->count() < 3 && $story->author_id) {
$byAuthor = Story::published()
->with('author', 'tags')
->where('author_id', $story->author_id)
->where('id', '!=', $story->id)
->whereNotIn('id', $related->pluck('id'))
->orderByDesc('published_at')
->limit(6 - $related->count())
->get();
$related = $related->merge($byAuthor);
}
if ($related->count() < 3) {
$newest = Story::published()
->with('author', 'tags')
->where('id', '!=', $story->id)
->whereNotIn('id', $related->pluck('id'))
->orderByDesc('published_at')
->limit(6 - $related->count())
->get();
$related = $related->merge($newest);
}
return $related->take(6);
});
return view('web.stories.show', [
'story' => $story,
'related' => $related,
'page_title' => $story->title . ' — Skinbase Stories',
'page_meta_description' => $story->meta_excerpt,
'page_canonical' => $story->url,
'page_robots' => 'index,follow',
'breadcrumbs' => collect([
(object) ['name' => 'Stories', 'url' => '/stories'],
(object) ['name' => $story->title, 'url' => $story->url],
]),
]);
}
}

View File

@@ -9,6 +9,7 @@ use App\Models\ContentType;
use App\Models\Tag;
use App\Services\ArtworkSearchService;
use App\Services\EarlyGrowth\GridFiller;
use App\Services\ThumbnailPresenter;
use Illuminate\Http\Request;
use Illuminate\View\View;
@@ -60,11 +61,10 @@ final class TagController extends Controller
$page = max(1, (int) $request->query('page', 1));
$artworks = $this->gridFiller->fill($artworks, 0, $page);
// Eager-load relations needed by the artwork-card component.
// Scout returns bare Eloquent models; without this, each card triggers N+1 queries.
$artworks->getCollection()->loadMissing(['user.profile']);
// Eager-load relations used by the gallery presenter and thumbnails.
$artworks->getCollection()->each(fn($m) => $m->loadMissing(['user.profile', 'categories']));
// Sidebar: content type links (same as browse gallery)
// Sidebar: main content type links (same as browse gallery)
$mainCategories = ContentType::orderBy('id')->get(['name', 'slug'])
->map(fn ($type) => (object) [
'id' => $type->id,
@@ -73,15 +73,76 @@ final class TagController extends Controller
'url' => '/' . strtolower($type->slug),
]);
return view('tags.show', [
'tag' => $tag,
'artworks' => $artworks,
'sort' => $sort,
'ogImage' => null,
'page_title' => 'Artworks tagged "' . $tag->name . '" — Skinbase',
'page_meta_description' => 'Browse all Skinbase artworks tagged "' . $tag->name . '". Discover photography, wallpapers and skins.',
'page_canonical' => route('tags.show', $tag->slug),
'page_robots' => 'index,follow',
// Map artworks into the lightweight shape expected by the gallery React component.
$galleryCollection = $artworks->getCollection()->map(function ($a) {
$primaryCategory = $a->categories->sortBy('sort_order')->first();
$present = ThumbnailPresenter::present($a, 'md');
$avatarUrl = \App\Support\AvatarUrl::forUser((int) ($a->user_id ?? 0), $a->user?->profile?->avatar_hash ?? null, 64);
return (object) [
'id' => $a->id,
'name' => $a->title ?? ($a->name ?? null),
'category_name' => $primaryCategory->name ?? '',
'category_slug' => $primaryCategory->slug ?? '',
'thumb_url' => $present['url'] ?? ($a->thumbUrl('md') ?? null),
'thumb_srcset' => $present['srcset'] ?? null,
'uname' => $a->user?->name ?? '',
'username' => $a->user?->username ?? '',
'avatar_url' => $avatarUrl,
'published_at' => $a->published_at ?? null,
'width' => $a->width ?? null,
'height' => $a->height ?? null,
'slug' => $a->slug ?? null,
];
})->values();
// Replace paginator collection with the gallery-shaped collection so
// the gallery.index blade will generate the expected JSON payload.
if (method_exists($artworks, 'setCollection')) {
$artworks->setCollection($galleryCollection);
}
// Determine gallery sort mapping so the gallery UI highlights the right tab.
$sortMapToGallery = [
'popular' => 'trending',
'latest' => 'latest',
'likes' => 'top-rated',
'downloads' => 'downloaded',
];
$gallerySort = $sortMapToGallery[$sort] ?? 'trending';
// Build simple pagination SEO links
$prev = method_exists($artworks, 'previousPageUrl') ? $artworks->previousPageUrl() : null;
$next = method_exists($artworks, 'nextPageUrl') ? $artworks->nextPageUrl() : null;
return view('gallery.index', [
'gallery_type' => 'tag',
'mainCategories' => $mainCategories,
'subcategories' => collect(),
'contentType' => null,
'category' => null,
'artworks' => $artworks,
'current_sort' => $gallerySort,
'sort_options' => [
['value' => 'trending', 'label' => '🔥 Trending'],
['value' => 'fresh', 'label' => '🆕 New & Hot'],
['value' => 'top-rated', 'label' => '⭐ Top Rated'],
['value' => 'latest', 'label' => '🕐 Latest'],
],
'hero_title' => $tag->name,
'hero_description' => 'Artworks tagged "' . $tag->name . '"',
'breadcrumbs' => collect([
(object) ['name' => 'Home', 'url' => '/'],
(object) ['name' => 'Tags', 'url' => route('tags.index')],
(object) ['name' => $tag->name, 'url' => route('tags.show', $tag->slug)],
]),
'page_title' => 'Artworks tagged "' . $tag->name . '" — Skinbase',
'page_meta_description' => 'Browse all Skinbase artworks tagged "' . $tag->name . '".',
'page_meta_keywords' => $tag->slug . ', skinbase, artworks, tag',
'page_canonical' => route('tags.show', $tag->slug),
'page_rel_prev' => $prev,
'page_rel_next' => $next,
'page_robots' => 'index,follow',
]);
}
}

View File

@@ -8,6 +8,18 @@ use Symfony\Component\HttpFoundation\Response;
class EnsureOnboardingComplete
{
/**
* Paths that must always be reachable regardless of onboarding state,
* so authenticated users can log out, complete OAuth flows, etc.
*/
private const ALWAYS_ALLOW = [
'logout',
'auth/*', // OAuth redirects & callbacks
'verify/*', // email verification links
'setup/*', // all /setup/* pages (password, username)
'up', // health check
];
public function handle(Request $request, Closure $next): Response
{
$user = $request->user();
@@ -20,17 +32,18 @@ class EnsureOnboardingComplete
return $next($request);
}
$target = match ($step) {
'email' => '/login',
'verified' => '/setup/password',
'password', 'username' => '/setup/username',
default => '/setup/password',
};
if ($request->is(ltrim($target, '/'))) {
// Always allow critical auth / setup paths through.
if ($request->is(self::ALWAYS_ALLOW)) {
return $next($request);
}
$target = match ($step) {
'email' => '/login',
'verified' => '/setup/password',
'password', 'username' => '/setup/username',
default => '/setup/password',
};
return redirect($target);
}
}

View File

@@ -14,5 +14,6 @@ class VerifyCsrfToken extends Middleware
protected $except = [
'chat_post',
'chat_post/*',
// Apple Sign In removed — no special CSRF exception required
];
}

View File

@@ -0,0 +1,24 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class SocialAccount extends Model
{
protected $table = 'social_accounts';
protected $fillable = [
'user_id',
'provider',
'provider_id',
'provider_email',
'avatar',
];
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
}

113
app/Models/Story.php Normal file
View File

@@ -0,0 +1,113 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
/**
* Story editorial content replacing the legacy Interviews module.
*
* @property int $id
* @property string $slug
* @property string $title
* @property string|null $excerpt
* @property string|null $content
* @property string|null $cover_image
* @property int|null $author_id
* @property int $views
* @property bool $featured
* @property string $status draft|published
* @property \Carbon\Carbon|null $published_at
* @property int|null $legacy_interview_id
*/
class Story extends Model
{
use HasFactory;
protected $table = 'stories';
protected $fillable = [
'slug',
'title',
'excerpt',
'content',
'cover_image',
'author_id',
'views',
'featured',
'status',
'published_at',
'legacy_interview_id',
];
protected $casts = [
'featured' => 'boolean',
'published_at' => 'datetime',
'views' => 'integer',
];
// ── Relations ────────────────────────────────────────────────────────
public function author()
{
return $this->belongsTo(StoryAuthor::class, 'author_id');
}
public function tags()
{
return $this->belongsToMany(StoryTag::class, 'stories_tag_relation', 'story_id', 'tag_id');
}
// ── Scopes ───────────────────────────────────────────────────────────
public function scopePublished($query)
{
return $query->where('status', 'published')
->where(fn ($q) => $q->whereNull('published_at')->orWhere('published_at', '<=', now()));
}
public function scopeFeatured($query)
{
return $query->where('featured', true);
}
// ── Accessors ────────────────────────────────────────────────────────
public function getUrlAttribute(): string
{
return url('/stories/' . $this->slug);
}
public function getCoverUrlAttribute(): ?string
{
if (! $this->cover_image) {
return null;
}
return str_starts_with($this->cover_image, 'http') ? $this->cover_image : asset($this->cover_image);
}
/**
* Estimated reading time in minutes based on word count.
*/
public function getReadingTimeAttribute(): int
{
$wordCount = str_word_count(strip_tags((string) $this->content));
return max(1, (int) ceil($wordCount / 200));
}
/**
* Short excerpt for meta descriptions / cards.
* Strips HTML, truncates to ~160 characters.
*/
public function getMetaExcerptAttribute(): string
{
$text = $this->excerpt ?: strip_tags((string) $this->content);
return \Illuminate\Support\Str::limit($text, 160);
}
}

View File

@@ -0,0 +1,63 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
/**
* Story Author - flexible author entity for the Stories system.
*
* @property int $id
* @property int|null $user_id
* @property string $name
* @property string|null $avatar
* @property string|null $bio
*/
class StoryAuthor extends Model
{
use HasFactory;
protected $table = 'stories_authors';
protected $fillable = [
'user_id',
'name',
'avatar',
'bio',
];
// ── Relations ────────────────────────────────────────────────────────
public function user()
{
return $this->belongsTo(User::class);
}
public function stories()
{
return $this->hasMany(Story::class, 'author_id');
}
// ── Accessors ────────────────────────────────────────────────────────
public function getAvatarUrlAttribute(): string
{
if ($this->avatar) {
return str_starts_with($this->avatar, 'http') ? $this->avatar : asset($this->avatar);
}
return asset('gfx/default-avatar.png');
}
public function getProfileUrlAttribute(): string
{
if ($this->user) {
return url('/@' . $this->user->username);
}
return url('/stories');
}
}

41
app/Models/StoryTag.php Normal file
View File

@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
/**
* Story Tag editorial tag for the Stories system.
*
* @property int $id
* @property string $slug
* @property string $name
*/
class StoryTag extends Model
{
use HasFactory;
protected $table = 'stories_tags';
protected $fillable = [
'slug',
'name',
];
// ── Relations ────────────────────────────────────────────────────────
public function stories()
{
return $this->belongsToMany(Story::class, 'stories_tag_relation', 'tag_id', 'story_id');
}
// ── Accessors ────────────────────────────────────────────────────────
public function getUrlAttribute(): string
{
return url('/stories/tag/' . $this->slug);
}
}

View File

@@ -7,6 +7,7 @@ use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasOne;
use Illuminate\Database\Eloquent\Relations\HasMany;
use App\Models\SocialAccount;
use App\Models\Conversation;
use App\Models\ConversationParticipant;
use App\Models\Message;
@@ -76,6 +77,11 @@ class User extends Authenticatable
return $this->hasMany(Artwork::class);
}
public function socialAccounts(): HasMany
{
return $this->hasMany(SocialAccount::class);
}
public function profile(): HasOne
{
return $this->hasOne(UserProfile::class, 'user_id');

View File

@@ -71,6 +71,13 @@ class AppServiceProvider extends ServiceProvider
ArtworkComment::observe(ArtworkCommentObserver::class);
ArtworkReaction::observe(ArtworkReactionObserver::class);
// ── OAuth / SocialiteProviders ──────────────────────────────────────
Event::listen(
\SocialiteProviders\Manager\SocialiteWasCalled::class,
\SocialiteProviders\Discord\DiscordExtendSocialite::class,
);
// Apple provider removed — no listener registered
// ── Posts / Feed System Events ──────────────────────────────────────
Event::listen(
\App\Events\Posts\ArtworkShared::class,

View File

@@ -594,7 +594,7 @@ final class HomepageService
$authorName = $artwork->user?->name ?? 'Artist';
$authorUsername = $artwork->user?->username ?? '';
$avatarHash = $artwork->user?->profile?->avatar_hash ?? null;
$authorAvatar = AvatarUrl::forUser((int) $authorId, $avatarHash, 40);
$authorAvatar = AvatarUrl::forUser((int) $authorId, $avatarHash, 64);
return [
'id' => $artwork->id,

View File

@@ -0,0 +1,138 @@
<?php
declare(strict_types=1);
namespace App\Services\RSS;
use Illuminate\Http\Response;
use Illuminate\Support\Collection;
/**
* RSSFeedBuilder
*
* Responsible for:
* - normalising feed items from Eloquent collections
* - enforcing feed limits (max 20 items)
* - rendering RSS 2.0 XML via the rss.channel Blade template
* - returning a properly typed HTTP Response
*/
final class RSSFeedBuilder
{
/** Hard item limit per feed (spec §7). */
public const FEED_LIMIT = 20;
// ── Public builders ───────────────────────────────────────────────────────
/**
* Build an RSS 2.0 Response from an Artwork Eloquent collection.
* Artworks must have 'user' and 'categories' relations preloaded.
*/
public function buildFromArtworks(
string $channelTitle,
string $channelDescription,
string $feedUrl,
Collection $artworks,
): Response {
$items = $artworks->take(self::FEED_LIMIT)->map(fn ($a) => $this->artworkToItem($a));
return $this->buildResponse($channelTitle, $channelDescription, url('/'), $feedUrl, $items);
}
/**
* Build an RSS 2.0 Response from a BlogPost Eloquent collection.
* Posts must have 'author' relation preloaded.
*/
public function buildFromBlogPosts(
string $channelTitle,
string $channelDescription,
string $feedUrl,
Collection $posts,
): Response {
$items = $posts->take(self::FEED_LIMIT)->map(fn ($p) => $this->blogPostToItem($p));
return $this->buildResponse($channelTitle, $channelDescription, url('/blog'), $feedUrl, $items);
}
// ── Private helpers ───────────────────────────────────────────────────────
private function buildResponse(
string $channelTitle,
string $channelDescription,
string $channelLink,
string $feedUrl,
Collection $items,
): Response {
$xml = view('rss.channel', [
'channelTitle' => trim($channelTitle) . ' — Skinbase',
'channelDescription' => $channelDescription,
'channelLink' => $channelLink,
'feedUrl' => $feedUrl,
'items' => $items,
'buildDate' => now()->toRfc2822String(),
])->render();
return response($xml, 200, [
'Content-Type' => 'application/rss+xml; charset=utf-8',
'Cache-Control' => 'public, max-age=300',
]);
}
/** Convert an Artwork model to an RSS item array. */
private function artworkToItem(object $artwork): array
{
$link = url('/art/' . $artwork->id . '/' . ($artwork->slug ?? ''));
$thumb = method_exists($artwork, 'thumbUrl') ? $artwork->thumbUrl('sm') : null;
// Primary category from eagerly loaded relation (avoid N+1)
$primaryCategory = ($artwork->relationLoaded('categories'))
? $artwork->categories->first()
: null;
// Build HTML description embedded in CDATA
$descParts = [];
if ($thumb) {
$descParts[] = '<img src="' . htmlspecialchars($thumb, ENT_XML1) . '" '
. 'alt="' . htmlspecialchars((string) $artwork->title, ENT_XML1) . '" />';
}
if (!empty($artwork->description)) {
$descParts[] = '<p>' . htmlspecialchars(strip_tags((string) $artwork->description), ENT_XML1) . '</p>';
}
return [
'title' => (string) $artwork->title,
'link' => $link,
'guid' => $link,
'description' => implode('', $descParts),
'pubDate' => $artwork->published_at?->toRfc2822String(),
'author' => $artwork->user?->username ?? 'Unknown',
'category' => $primaryCategory?->name,
'enclosure' => $thumb ? [
'url' => $thumb,
'length' => 0,
'type' => 'image/jpeg',
] : null,
];
}
/** Convert a BlogPost model to an RSS item array. */
private function blogPostToItem(object $post): array
{
$link = url('/blog/' . $post->slug);
$excerpt = $post->excerpt ?? strip_tags((string) ($post->body ?? ''));
return [
'title' => (string) $post->title,
'link' => $link,
'guid' => $link,
'description' => $excerpt,
'pubDate' => $post->published_at?->toRfc2822String(),
'author' => $post->author?->username ?? 'Skinbase',
'category' => null,
'enclosure' => !empty($post->featured_image) ? [
'url' => $post->featured_image,
'length' => 0,
'type' => 'image/jpeg',
] : null,
];
}
}

View File

@@ -3,6 +3,7 @@
namespace App\Support;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
class AvatarUrl
{
@@ -26,6 +27,9 @@ class AvatarUrl
$p1 = substr($avatarHash, 0, 2);
$p2 = substr($avatarHash, 2, 2);
$diskPath = sprintf('avatars/%s/%s/%s/%d.webp', $p1, $p2, $avatarHash, $size);
// Always use CDN-hosted avatar files.
return sprintf('%s/avatars/%s/%s/%s/%d.webp?v=%s', $base, $p1, $p2, $avatarHash, $size, $avatarHash);
}