login update
This commit is contained in:
89
app/Console/Commands/AvatarsBulkUpdate.php
Normal file
89
app/Console/Commands/AvatarsBulkUpdate.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
197
app/Console/Commands/MigrateStoriesCommand.php
Normal file
197
app/Console/Commands/MigrateStoriesCommand.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
188
app/Http/Controllers/Api/StoriesApiController.php
Normal file
188
app/Http/Controllers/Api/StoriesApiController.php
Normal 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,
|
||||
];
|
||||
}
|
||||
}
|
||||
252
app/Http/Controllers/Auth/OAuthController.php
Normal file
252
app/Http/Controllers/Auth/OAuthController.php
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
|
||||
40
app/Http/Controllers/RSS/BlogFeedController.php
Normal file
40
app/Http/Controllers/RSS/BlogFeedController.php
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
49
app/Http/Controllers/RSS/CreatorFeedController.php
Normal file
49
app/Http/Controllers/RSS/CreatorFeedController.php
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
98
app/Http/Controllers/RSS/DiscoverFeedController.php
Normal file
98
app/Http/Controllers/RSS/DiscoverFeedController.php
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
105
app/Http/Controllers/RSS/ExploreFeedController.php
Normal file
105
app/Http/Controllers/RSS/ExploreFeedController.php
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
40
app/Http/Controllers/RSS/GlobalFeedController.php
Normal file
40
app/Http/Controllers/RSS/GlobalFeedController.php
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
49
app/Http/Controllers/RSS/TagFeedController.php
Normal file
49
app/Http/Controllers/RSS/TagFeedController.php
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
];
|
||||
|
||||
@@ -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',
|
||||
]);
|
||||
|
||||
59
app/Http/Controllers/Web/StoriesAuthorController.php
Normal file
59
app/Http/Controllers/Web/StoriesAuthorController.php
Normal 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],
|
||||
]),
|
||||
]);
|
||||
}
|
||||
}
|
||||
47
app/Http/Controllers/Web/StoriesController.php
Normal file
47
app/Http/Controllers/Web/StoriesController.php
Normal 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'],
|
||||
]),
|
||||
]);
|
||||
}
|
||||
}
|
||||
45
app/Http/Controllers/Web/StoriesTagController.php
Normal file
45
app/Http/Controllers/Web/StoriesTagController.php
Normal 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],
|
||||
]),
|
||||
]);
|
||||
}
|
||||
}
|
||||
86
app/Http/Controllers/Web/StoryController.php
Normal file
86
app/Http/Controllers/Web/StoryController.php
Normal 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],
|
||||
]),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,5 +14,6 @@ class VerifyCsrfToken extends Middleware
|
||||
protected $except = [
|
||||
'chat_post',
|
||||
'chat_post/*',
|
||||
// Apple Sign In removed — no special CSRF exception required
|
||||
];
|
||||
}
|
||||
|
||||
24
app/Models/SocialAccount.php
Normal file
24
app/Models/SocialAccount.php
Normal 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
113
app/Models/Story.php
Normal 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);
|
||||
}
|
||||
}
|
||||
63
app/Models/StoryAuthor.php
Normal file
63
app/Models/StoryAuthor.php
Normal 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
41
app/Models/StoryTag.php
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
138
app/Services/RSS/RSSFeedBuilder.php
Normal file
138
app/Services/RSS/RSSFeedBuilder.php
Normal 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,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user