Auth: convert auth views and verification email to Nova layout

This commit is contained in:
2026-02-21 07:37:08 +01:00
parent 93b009d42a
commit 795c7a835f
117 changed files with 5385 additions and 1291 deletions

View File

@@ -2,124 +2,308 @@
namespace App\Console\Commands;
use App\Services\AvatarService;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
use App\Models\User;
use App\Models\UserProfile;
use Intervention\Image\ImageManagerStatic as Image;
use Carbon\Carbon;
class AvatarsMigrate extends Command
{
protected $signature = 'avatars:migrate {--force : Reprocess avatars even when avatar_hash is already present} {--limit=0 : Limit number of users processed}';
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'avatars:migrate
{--dry-run : Do not write files or update database}
{--force : Overwrite existing migrated avatars}
{--remove-legacy : Remove legacy files after successful migration}
{--path=public/files/usericons : Legacy path to scan}
';
protected $description = 'Migrate legacy avatars into CDN-ready WebP variants and avatar_hash metadata';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Migrate legacy avatars from public/files/usericons to storage/app/public/avatars and generate sizes (WebP)';
public function __construct(private readonly AvatarService $service)
{
parent::__construct();
}
/**
* Allowed MIME types for source images.
*
* @var array
*/
protected $allowed = [
'image/jpeg',
'image/png',
'image/webp',
];
/**
* Target sizes to generate.
*
* @var int[]
*/
protected $sizes = [32, 64, 128, 256, 512];
public function handle(): int
{
$force = (bool) $this->option('force');
$limit = max(0, (int) $this->option('limit'));
$dry = $this->option('dry-run');
$force = $this->option('force');
$removeLegacy = $this->option('remove-legacy');
$legacyPath = base_path($this->option('path'));
$this->info('Starting avatar migration...');
$this->info('Starting avatar migration' . ($dry ? ' (dry-run)' : ''));
$rows = DB::table('user_profiles as p')
->leftJoin('users as u', 'u.id', '=', 'p.user_id')
->select([
'p.user_id',
'p.avatar_hash',
'p.avatar_legacy',
'u.icon as user_icon',
])
->when(!$force, fn ($query) => $query->whereNull('p.avatar_hash'))
->where(function ($query) {
$query->whereNotNull('p.avatar_legacy')
->orWhereNotNull('u.icon');
})
->orderBy('p.user_id')
->when($limit > 0, fn ($query) => $query->limit($limit))
->get();
if ($rows->isEmpty()) {
$this->info('No avatars require migration.');
return self::SUCCESS;
// Detect processing backend: Intervention preferred, GD fallback
$useIntervention = class_exists('Intervention\\Image\\ImageManagerStatic');
if ($useIntervention) {
Image::configure(['driver' => extension_loaded('imagick') ? 'imagick' : 'gd']);
}
$migrated = 0;
$skipped = 0;
$failed = 0;
$bar = null;
foreach ($rows as $row) {
$userId = (int) $row->user_id;
$legacyName = $this->normalizeLegacyName($row->avatar_legacy ?: $row->user_icon);
User::with('profile')->chunk(100, function ($users) use ($dry, $force, $removeLegacy, $legacyPath, &$bar, $useIntervention) {
foreach ($users as $user) {
/** @var UserProfile|null $profile */
$profile = $user->profile;
if ($legacyName === null) {
$skipped++;
continue;
}
$path = $this->locateLegacyAvatarPath($userId, $legacyName);
if ($path === null) {
$failed++;
$this->warn("User {$userId}: legacy avatar not found ({$legacyName})");
continue;
}
try {
$hash = $this->service->storeFromLegacyFile($userId, $path);
if (!$hash) {
$failed++;
$this->warn("User {$userId}: unable to process legacy avatar ({$legacyName})");
if (!$profile) {
continue;
}
$migrated++;
$this->line("User {$userId}: migrated ({$hash})");
} catch (\Throwable $e) {
$failed++;
$this->warn("User {$userId}: migration failed ({$e->getMessage()})");
// Skip if already migrated unless --force
if (!$force && !empty($profile->avatar_hash)) {
$this->line("[skip] user={$user->id} already migrated");
continue;
}
$source = $this->findLegacyFile($profile, $user->id, $legacyPath);
if (!$source) {
$this->line("[noop] user={$user->id} no legacy file found");
continue;
}
try {
$this->line("[proc] user={$user->id} file={$source}");
if ($useIntervention) {
$img = Image::make($source);
$mime = $img->mime();
} else {
$info = @getimagesize($source);
$mime = $info['mime'] ?? null;
}
if (!in_array($mime, $this->allowed, true)) {
$this->line("[reject] user={$user->id} unsupported mime={$mime}");
continue;
}
// Re-encode full original to webp (strip metadata)
if ($useIntervention) {
$originalBlob = (string) $img->encode('webp', 82);
} else {
$originalBlob = $this->gdEncodeWebp($source, 82);
}
// Hybrid hash: deterministic user-id fingerprint + short content fingerprint
// idPart = sha1(zero-padded user id), contentPart = first 12 chars of sha1(original webp blob)
$idPart = sha1(sprintf('%08d', $user->id));
$contentPart = substr(sha1($originalBlob), 0, 12);
$hash = sprintf('%s_%s', $idPart, $contentPart);
if ($dry) {
$this->line("[dry] user={$user->id} would write avatars for hash={$hash}");
} 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);
// Generate sizes
foreach ($this->sizes as $size) {
if ($useIntervention) {
$thumb = Image::make($source)->fit($size, $size, function ($constraint) {
$constraint->upsize();
});
$thumbBlob = (string) $thumb->encode('webp', 82);
} else {
$thumbBlob = $this->gdCreateThumbnailWebp($source, $size, 82);
}
Storage::disk('public')->put("{$dir}/{$size}.webp", $thumbBlob);
}
// Update DB
$profile->avatar_hash = $hash;
$profile->avatar_mime = 'image/webp';
$profile->avatar_updated_at = Carbon::now();
$profile->save();
$this->line("[ok] user={$user->id} migrated hash={$hash}");
if ($removeLegacy && !empty($profile->avatar_legacy)) {
$legacyFile = base_path("public/files/usericons/{$profile->avatar_legacy}");
if (file_exists($legacyFile)) {
@unlink($legacyFile);
$this->line("[rm] removed legacy file {$legacyFile}");
}
}
}
} catch (\Exception $e) {
$this->error("[error] user={$user->id} {$e->getMessage()}");
continue;
}
}
});
$this->info('Avatar migration complete');
return 0;
}
/**
* Try to find a legacy avatar file for a user/profile.
*
* @param UserProfile $profile
* @param int $userId
* @param string $legacyBase
* @return string|null
*/
protected function findLegacyFile(UserProfile $profile, int $userId, string $legacyBase): ?string
{
// 1) If profile->avatar_legacy looks like a filename, try it
if (!empty($profile->avatar_legacy)) {
$p = $legacyBase . DIRECTORY_SEPARATOR . $profile->avatar_legacy;
if (file_exists($p)) {
return $p;
}
}
$this->info("Avatar migration complete. Migrated={$migrated}, Skipped={$skipped}, Failed={$failed}");
return $failed > 0 ? self::FAILURE : self::SUCCESS;
}
private function normalizeLegacyName(?string $value): ?string
{
if (!$value) {
return null;
// 2) Try files named by user id with common extensions
$exts = ['png','jpg','jpeg','webp','gif'];
foreach ($exts as $ext) {
$p = $legacyBase . DIRECTORY_SEPARATOR . "{$userId}.{$ext}";
if (file_exists($p)) {
return $p;
}
}
$trimmed = trim($value);
if ($trimmed === '') {
return null;
}
return basename(urldecode($trimmed));
}
private function locateLegacyAvatarPath(int $userId, string $legacyName): ?string
{
$candidates = [
public_path('avatar/' . $legacyName),
public_path('avatar/' . $userId . '/' . $legacyName),
public_path('user-picture/' . $legacyName),
storage_path('app/public/avatar/' . $legacyName),
storage_path('app/public/avatar/' . $userId . '/' . $legacyName),
storage_path('app/public/user-picture/' . $legacyName),
base_path('oldSite/www/files/usericons/' . $legacyName),
];
foreach ($candidates as $candidate) {
if (is_string($candidate) && is_file($candidate) && is_readable($candidate)) {
return $candidate;
// 3) Try any file under legacy dir that contains the user id in name
if (is_dir($legacyBase)) {
$files = glob($legacyBase . DIRECTORY_SEPARATOR . "*{$userId}*.*");
if (!empty($files)) {
return $files[0];
}
}
return null;
}
/**
* GD-based encode to WebP binary blob.
*
* @param string $path
* @param int $quality
* @return string
*/
protected function gdEncodeWebp(string $path, int $quality = 82): string
{
if (!function_exists('imagewebp')) {
throw new \RuntimeException('GD imagewebp function is not available. Install Intervention Image or enable GD WebP support.');
}
$src = $this->gdCreateResource($path);
if (!$src) {
throw new \RuntimeException('Unable to read image for GD processing: ' . $path);
}
ob_start();
imagewebp($src, null, $quality);
$data = ob_get_clean();
imagedestroy($src);
return $data;
}
/**
* Create a center-cropped square thumbnail and return WebP binary.
*
* @param string $path
* @param int $size
* @param int $quality
* @return string
*/
protected function gdCreateThumbnailWebp(string $path, int $size, int $quality = 82): string
{
if (!function_exists('imagewebp')) {
throw new \RuntimeException('GD imagewebp function is not available. Install Intervention Image or enable GD WebP support.');
}
$src = $this->gdCreateResource($path);
if (!$src) {
throw new \RuntimeException('Unable to read image for GD processing: ' . $path);
}
$w = imagesx($src);
$h = imagesy($src);
$min = min($w, $h);
$srcX = (int) floor(($w - $min) / 2);
$srcY = (int) floor(($h - $min) / 2);
$dst = imagecreatetruecolor($size, $size);
// preserve transparency
imagealphablending($dst, false);
imagesavealpha($dst, true);
imagecopyresampled($dst, $src, 0, 0, $srcX, $srcY, $size, $size, $min, $min);
ob_start();
imagewebp($dst, null, $quality);
$data = ob_get_clean();
imagedestroy($src);
imagedestroy($dst);
return $data;
}
/**
* Create GD image resource from file path.
*
* @param string $path
* @return resource|false
*/
protected function gdCreateResource(string $path)
{
$info = @getimagesize($path);
if (!$info) {
return false;
}
$mime = $info['mime'] ?? '';
switch ($mime) {
case 'image/jpeg':
return imagecreatefromjpeg($path);
case 'image/png':
return imagecreatefrompng($path);
case 'image/webp':
if (function_exists('imagecreatefromwebp')) {
return imagecreatefromwebp($path);
}
return false;
default:
return false;
}
}
}

View File

@@ -0,0 +1,96 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use App\Models\User;
use App\Support\UsernamePolicy;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
class EnforceUsernamePolicy extends Command
{
protected $signature = 'skinbase:enforce-usernames {--dry-run : Report only, no writes}';
protected $description = 'Normalize and enforce username policy on existing users, with collision resolution and redirect logging.';
public function handle(): int
{
$dryRun = (bool) $this->option('dry-run');
$logPath = storage_path('logs/username_migration.log');
@file_put_contents($logPath, '['.now()."] enforce-usernames dry_run=".($dryRun ? '1' : '0')."\n", FILE_APPEND);
$used = User::query()->whereNotNull('username')->pluck('id', 'username')->mapWithKeys(fn ($id, $username) => [strtolower((string) $username) => (int) $id])->all();
$updated = 0;
User::query()->orderBy('id')->chunkById(500, function ($users) use (&$used, &$updated, $dryRun, $logPath): void {
foreach ($users as $user) {
$current = strtolower(trim((string) ($user->username ?? '')));
$base = UsernamePolicy::sanitizeLegacy($current !== '' ? $current : ('user'.$user->id));
if (UsernamePolicy::isReserved($base) || UsernamePolicy::similarReserved($base) !== null) {
$base = 'user'.$user->id;
}
$candidate = substr($base, 0, UsernamePolicy::max());
$suffix = 1;
while ((isset($used[$candidate]) && (int) $used[$candidate] !== (int) $user->id) || UsernamePolicy::isReserved($candidate) || UsernamePolicy::similarReserved($candidate) !== null) {
$suffixStr = (string) $suffix;
$prefixLen = max(1, UsernamePolicy::max() - strlen($suffixStr));
$candidate = substr($base, 0, $prefixLen) . $suffixStr;
$suffix++;
}
$needsUpdate = $candidate !== $current;
if (! $needsUpdate) {
$used[$candidate] = (int) $user->id;
continue;
}
@file_put_contents($logPath, sprintf("[%s] user_id=%d old=%s new=%s\n", now()->toDateTimeString(), (int) $user->id, $current, $candidate), FILE_APPEND);
if (! $dryRun) {
DB::transaction(function () use ($user, $current, $candidate): void {
if ($current !== '' && Schema::hasTable('username_history')) {
DB::table('username_history')->insert([
'user_id' => (int) $user->id,
'old_username' => $current,
'changed_at' => now(),
'created_at' => now(),
'updated_at' => now(),
]);
}
if ($current !== '' && Schema::hasTable('username_redirects')) {
DB::table('username_redirects')->updateOrInsert(
['old_username' => $current],
[
'new_username' => $candidate,
'user_id' => (int) $user->id,
'created_at' => now(),
'updated_at' => now(),
]
);
}
DB::table('users')->where('id', (int) $user->id)->update([
'username' => $candidate,
'username_changed_at' => now(),
'updated_at' => now(),
]);
});
}
$used[$candidate] = (int) $user->id;
$updated++;
}
});
$this->info("Username policy enforcement complete. Updated: {$updated}" . ($dryRun ? ' (dry run)' : ''));
return self::SUCCESS;
}
}

View File

@@ -4,8 +4,10 @@ namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str;
use App\Models\ForumCategory;
use App\Models\User;
use App\Models\ForumThread;
use App\Models\ForumPost;
use Exception;
@@ -13,12 +15,19 @@ use App\Services\BbcodeConverter;
class ForumMigrateOld extends Command
{
protected $signature = 'forum:migrate-old {--dry-run} {--only=} {--limit=} {--chunk=500} {--report}';
protected $signature = 'forum:migrate-old {--dry-run} {--only=} {--limit=} {--chunk=500} {--report} {--repair-orphans}';
protected $description = 'Migrate legacy forum data from legacy DB into new forum tables';
protected string $logPath;
protected ?int $limit = null;
protected ?int $deletedUserId = null;
/** @var array<int,int> */
protected array $missingUserIds = [];
public function __construct()
{
parent::__construct();
@@ -33,6 +42,17 @@ class ForumMigrateOld extends Command
$dry = $this->option('dry-run');
$only = $this->option('only');
$chunk = (int)$this->option('chunk');
$this->limit = $this->option('limit') !== null ? max(0, (int) $this->option('limit')) : null;
$only = $only === 'attachments' ? 'gallery' : $only;
if ($only && !in_array($only, ['categories', 'threads', 'posts', 'gallery', 'repair-orphans'], true)) {
$this->error('Invalid --only value. Allowed: categories, threads, posts, gallery (or attachments), repair-orphans.');
return 1;
}
if ($chunk < 1) {
$chunk = 500;
}
try {
if (!$only || $only === 'categories') {
@@ -51,6 +71,10 @@ class ForumMigrateOld extends Command
$this->migrateGallery($dry, $chunk);
}
if ($this->option('repair-orphans') || $only === 'repair-orphans') {
$this->repairOrphanPosts($dry);
}
if ($this->option('report')) {
$this->generateReport();
}
@@ -74,8 +98,13 @@ class ForumMigrateOld extends Command
->select('root_id')
->distinct()
->where('root_id', '>', 0)
->orderBy('root_id')
->pluck('root_id');
if ($this->limit !== null && $this->limit > 0) {
$roots = $roots->take($this->limit);
}
$this->info('Found ' . $roots->count() . ' legacy root ids');
foreach ($roots as $rootId) {
@@ -90,10 +119,12 @@ class ForumMigrateOld extends Command
continue;
}
ForumCategory::updateOrCreate(
['id' => $rootId],
['name' => $name, 'slug' => $slug]
);
DB::transaction(function () use ($rootId, $name, $slug) {
ForumCategory::updateOrCreate(
['id' => $rootId],
['name' => $name, 'slug' => $slug]
);
}, 3);
}
$this->info('Categories migrated');
@@ -107,15 +138,26 @@ class ForumMigrateOld extends Command
$query = $legacy->table('forum_topics')->orderBy('topic_id');
$total = $query->count();
if ($this->limit !== null && $this->limit > 0) {
$total = min($total, $this->limit);
}
$this->info("Total threads to process: {$total}");
$bar = $this->output->createProgressBar($total);
$bar->start();
$processed = 0;
$limit = $this->limit;
// chunk by legacy primary key `topic_id`
$query->chunkById($chunk, function ($rows) use ($dry, $bar) {
$query->chunkById($chunk, function ($rows) use ($dry, $bar, &$processed, $limit) {
foreach ($rows as $r) {
if ($limit !== null && $limit > 0 && $processed >= $limit) {
return false;
}
$bar->advance();
$processed++;
$data = [
'id' => $r->topic_id,
@@ -137,7 +179,9 @@ class ForumMigrateOld extends Command
continue;
}
ForumThread::updateOrCreate(['id' => $data['id']], $data);
DB::transaction(function () use ($data) {
ForumThread::updateOrCreate(['id' => $data['id']], $data);
}, 3);
}
}, 'topic_id');
@@ -153,15 +197,26 @@ class ForumMigrateOld extends Command
$query = $legacy->table('forum_posts')->orderBy('post_id');
$total = $query->count();
if ($this->limit !== null && $this->limit > 0) {
$total = min($total, $this->limit);
}
$this->info("Total posts to process: {$total}");
$bar = $this->output->createProgressBar($total);
$bar->start();
$processed = 0;
$limit = $this->limit;
// legacy forum_posts uses `post_id` as primary key
$query->chunkById($chunk, function ($rows) use ($dry, $bar) {
$query->chunkById($chunk, function ($rows) use ($dry, $bar, &$processed, $limit) {
foreach ($rows as $r) {
if ($limit !== null && $limit > 0 && $processed >= $limit) {
return false;
}
$bar->advance();
$processed++;
$data = [
'id' => $r->post_id,
@@ -177,7 +232,9 @@ class ForumMigrateOld extends Command
continue;
}
ForumPost::updateOrCreate(['id' => $data['id']], $data);
DB::transaction(function () use ($data) {
ForumPost::updateOrCreate(['id' => $data['id']], $data);
}, 3);
}
}, 'post_id');
@@ -243,15 +300,50 @@ class ForumMigrateOld extends Command
protected function resolveUserId($userId)
{
if (empty($userId)) {
return 1;
return $this->resolveDeletedUserId();
}
// check users table in default connection
if (\DB::table('users')->where('id', $userId)->exists()) {
if (DB::table('users')->where('id', $userId)->exists()) {
return $userId;
}
return 1; // fallback system user
$uid = (int) $userId;
if ($uid > 0 && !in_array($uid, $this->missingUserIds, true)) {
$this->missingUserIds[] = $uid;
}
return $this->resolveDeletedUserId();
}
protected function resolveDeletedUserId(): int
{
if ($this->deletedUserId !== null) {
return $this->deletedUserId;
}
$userOne = User::query()->find(1);
if ($userOne) {
$this->deletedUserId = 1;
return $this->deletedUserId;
}
$fallback = User::query()->orderBy('id')->first();
if ($fallback) {
$this->deletedUserId = (int) $fallback->id;
return $this->deletedUserId;
}
$created = User::query()->create([
'name' => 'Deleted User',
'email' => 'deleted-user+forum@skinbase.local',
'password' => Hash::make(Str::random(64)),
'role' => 'user',
]);
$this->deletedUserId = (int) $created->id;
return $this->deletedUserId;
}
protected function convertLegacyMessage($msg)
@@ -260,6 +352,114 @@ class ForumMigrateOld extends Command
return $converter->convert($msg);
}
protected function repairOrphanPosts(bool $dry): void
{
$this->info('Repairing orphan posts');
$orphansQuery = ForumPost::query()->whereDoesntHave('thread')->orderBy('id');
$orphanCount = (clone $orphansQuery)->count();
if ($orphanCount === 0) {
$this->info('No orphan posts found.');
return;
}
$this->warn("Found {$orphanCount} orphan posts.");
$repairThread = $this->resolveOrCreateOrphanRepairThread($dry);
if ($repairThread === null) {
$this->warn('Unable to resolve/create repair thread in dry-run mode. Reporting only.');
(clone $orphansQuery)->limit(20)->get(['id', 'thread_id', 'user_id', 'created_at'])->each(function (ForumPost $post): void {
$this->line("- orphan post id={$post->id} thread_id={$post->thread_id} user_id={$post->user_id}");
});
return;
}
$this->line("Repair target thread: {$repairThread->id} ({$repairThread->slug})");
if ($dry) {
$this->info("[dry] Would reassign {$orphanCount} orphan posts to thread {$repairThread->id}");
(clone $orphansQuery)->limit(20)->get(['id', 'thread_id', 'user_id', 'created_at'])->each(function (ForumPost $post) use ($repairThread): void {
$this->log("[dry] orphan post {$post->id}: {$post->thread_id} -> {$repairThread->id}");
});
return;
}
$updated = 0;
(clone $orphansQuery)->chunkById(500, function ($posts) use ($repairThread, &$updated): void {
DB::transaction(function () use ($posts, $repairThread, &$updated): void {
/** @var ForumPost $post */
foreach ($posts as $post) {
$post->thread_id = $repairThread->id;
$post->is_edited = true;
$post->edited_at = $post->edited_at ?: now();
$post->save();
$updated++;
}
}, 3);
}, 'id');
$latestPostAt = ForumPost::query()
->where('thread_id', $repairThread->id)
->max('created_at');
if ($latestPostAt) {
$repairThread->last_post_at = $latestPostAt;
$repairThread->save();
}
$this->info("Repaired orphan posts: {$updated}");
$this->log("Repaired orphan posts: {$updated} -> thread {$repairThread->id}");
}
protected function resolveOrCreateOrphanRepairThread(bool $dry): ?ForumThread
{
$slug = 'migration-orphaned-posts';
$existing = ForumThread::query()->where('slug', $slug)->first();
if ($existing) {
return $existing;
}
$category = ForumCategory::query()->ordered()->first();
if (!$category && !$dry) {
$category = ForumCategory::query()->create([
'name' => 'Migration Repairs',
'slug' => 'migration-repairs',
'parent_id' => null,
'position' => 9999,
]);
}
if (!$category) {
return null;
}
if ($dry) {
return new ForumThread([
'id' => 0,
'slug' => $slug,
'category_id' => $category->id,
]);
}
return ForumThread::query()->create([
'category_id' => $category->id,
'user_id' => $this->resolveDeletedUserId(),
'title' => 'Migration: Orphaned Posts Recovery',
'slug' => $slug,
'content' => 'Automatic recovery thread for legacy posts whose source thread no longer exists after migration.',
'views' => 0,
'is_locked' => false,
'is_pinned' => false,
'visibility' => 'staff',
'last_post_at' => now(),
]);
}
protected function generateReport()
{
$this->info('Generating migration report');
@@ -275,12 +475,32 @@ class ForumMigrateOld extends Command
'categories' => ForumCategory::count(),
'threads' => ForumThread::count(),
'posts' => ForumPost::count(),
'attachments' => \DB::table('forum_attachments')->count(),
'attachments' => DB::table('forum_attachments')->count(),
];
$orphans = ForumPost::query()
->whereDoesntHave('thread')
->count();
$legacyThreadsWithLastUpdate = $legacy->table('forum_topics')->whereNotNull('last_update')->count();
$newThreadsWithLastPost = ForumThread::query()->whereNotNull('last_post_at')->count();
$legacyPostsWithPostDate = $legacy->table('forum_posts')->whereNotNull('post_date')->count();
$newPostsWithCreatedAt = ForumPost::query()->whereNotNull('created_at')->count();
$report = [
'missing_users_count' => count($this->missingUserIds),
'missing_users' => $this->missingUserIds,
'orphan_posts' => $orphans,
'timestamp_mismatches' => [
'threads_last_post_gap' => max(0, $legacyThreadsWithLastUpdate - $newThreadsWithLastPost),
'posts_created_at_gap' => max(0, $legacyPostsWithPostDate - $newPostsWithCreatedAt),
],
];
$this->info('Legacy counts: ' . json_encode($legacyCounts));
$this->info('New counts: ' . json_encode($newCounts));
$this->log('Report: legacy=' . json_encode($legacyCounts) . ' new=' . json_encode($newCounts));
$this->info('Report: ' . json_encode($report));
$this->log('Report: legacy=' . json_encode($legacyCounts) . ' new=' . json_encode($newCounts) . ' extra=' . json_encode($report));
}
protected function log(string $msg)
@@ -301,14 +521,25 @@ class ForumMigrateOld extends Command
$query = $legacy->table('forum_topics_gallery')->orderBy('id');
$total = $query->count();
if ($this->limit !== null && $this->limit > 0) {
$total = min($total, $this->limit);
}
$this->info("Total gallery items to process: {$total}");
$bar = $this->output->createProgressBar($total);
$bar->start();
$query->chunkById($chunk, function ($rows) use ($dry, $bar) {
$processed = 0;
$limit = $this->limit;
$query->chunkById($chunk, function ($rows) use ($dry, $bar, &$processed, $limit) {
foreach ($rows as $r) {
if ($limit !== null && $limit > 0 && $processed >= $limit) {
return false;
}
$bar->advance();
$processed++;
// expected legacy fields: id, name, category (topic id), folder, datum, description
$topicId = $r->category ?? ($r->topic_id ?? null);
@@ -368,16 +599,21 @@ class ForumMigrateOld extends Command
continue;
}
\App\Models\ForumAttachment::create([
'post_id' => $postId,
'file_path' => $relativePath,
'file_size' => $fileSize ?? 0,
'mime_type' => $mimeType,
'width' => $width,
'height' => $height,
'created_at' => now(),
'updated_at' => now(),
]);
DB::transaction(function () use ($postId, $relativePath, $fileSize, $mimeType, $width, $height) {
\App\Models\ForumAttachment::query()->updateOrCreate(
[
'post_id' => $postId,
'file_path' => $relativePath,
],
[
'file_size' => $fileSize ?? 0,
'mime_type' => $mimeType,
'width' => $width,
'height' => $height,
'updated_at' => now(),
]
);
}, 3);
}
}, 'id');

View File

@@ -3,9 +3,11 @@
namespace App\Console\Commands;
use App\Models\User;
use App\Support\UsernamePolicy;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Str;
class ImportLegacyUsers extends Command
@@ -15,9 +17,13 @@ class ImportLegacyUsers extends Command
protected array $usedUsernames = [];
protected array $usedEmails = [];
protected string $migrationLogPath;
public function handle(): int
{
$this->migrationLogPath = storage_path('logs/username_migration.log');
@file_put_contents($this->migrationLogPath, '['.now()."] Starting legacy username policy migration\n", FILE_APPEND);
$this->usedUsernames = User::pluck('username', 'username')->filter()->all();
$this->usedEmails = User::pluck('email', 'email')->filter()->all();
@@ -56,9 +62,19 @@ class ImportLegacyUsers extends Command
protected function importRow($row, $statRow = null): void
{
$legacyId = (int) $row->user_id;
$baseUsername = $this->sanitizeUsername($row->uname ?: ('user'.$legacyId));
$rawLegacyUsername = (string) ($row->uname ?: ('user'.$legacyId));
$baseUsername = $this->sanitizeUsername($rawLegacyUsername);
$username = $this->uniqueUsername($baseUsername);
$normalizedLegacy = UsernamePolicy::normalize($rawLegacyUsername);
if ($normalizedLegacy !== $username) {
@file_put_contents(
$this->migrationLogPath,
sprintf("[%s] user_id=%d old=%s new=%s\n", now()->toDateTimeString(), $legacyId, $normalizedLegacy, $username),
FILE_APPEND
);
}
$email = $this->prepareEmail($row->email ?? null, $username);
$legacyPassword = $row->password2 ?: $row->password ?: null;
@@ -88,6 +104,7 @@ class ImportLegacyUsers extends Command
DB::table('users')->insert([
'id' => $legacyId,
'username' => $username,
'username_changed_at' => now(),
'name' => $row->real_name ?: $username,
'email' => $email,
'password' => $passwordHash,
@@ -126,6 +143,21 @@ class ImportLegacyUsers extends Command
'created_at' => $now,
'updated_at' => $now,
]);
if (Schema::hasTable('username_redirects')) {
$old = UsernamePolicy::normalize((string) ($row->uname ?? ''));
if ($old !== '' && $old !== $username) {
DB::table('username_redirects')->updateOrInsert(
['old_username' => $old],
[
'new_username' => $username,
'user_id' => $legacyId,
'created_at' => $now,
'updated_at' => $now,
]
);
}
}
});
}
@@ -143,19 +175,12 @@ class ImportLegacyUsers extends Command
protected function sanitizeUsername(string $username): string
{
$username = strtolower(trim($username));
$username = preg_replace('/[^a-z0-9._-]/', '-', $username) ?: 'user';
return trim($username, '.-') ?: 'user';
return UsernamePolicy::sanitizeLegacy($username);
}
protected function uniqueUsername(string $base): string
{
$name = $base;
$i = 1;
while (isset($this->usedUsernames[$name]) || DB::table('users')->where('username', $name)->exists()) {
$name = $base . '-' . $i;
$i++;
}
$name = UsernamePolicy::uniqueCandidate($base);
$this->usedUsernames[$name] = $name;
return $name;
}

View File

@@ -22,6 +22,7 @@ class Kernel extends ConsoleKernel
*/
protected $commands = [
ImportLegacyUsers::class,
\App\Console\Commands\EnforceUsernamePolicy::class,
ImportCategories::class,
MigrateFeaturedWorks::class,
\App\Console\Commands\AvatarsMigrate::class,

View File

@@ -0,0 +1,149 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api\Admin;
use App\Http\Controllers\Controller;
use App\Models\User;
use App\Support\UsernamePolicy;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Symfony\Component\HttpFoundation\Response;
final class UsernameApprovalController extends Controller
{
public function pending(): JsonResponse
{
$rows = DB::table('username_approval_requests')
->where('status', 'pending')
->orderBy('created_at')
->get([
'id',
'user_id',
'requested_username',
'context',
'similar_to',
'payload',
'created_at',
]);
return response()->json(['data' => $rows], Response::HTTP_OK);
}
public function approve(int $id, Request $request): JsonResponse
{
$row = DB::table('username_approval_requests')->where('id', $id)->first();
if (! $row) {
return response()->json(['message' => 'Request not found.'], Response::HTTP_NOT_FOUND);
}
if ((string) $row->status !== 'pending') {
return response()->json(['message' => 'Request is not pending.'], Response::HTTP_UNPROCESSABLE_ENTITY);
}
DB::beginTransaction();
try {
DB::table('username_approval_requests')
->where('id', $id)
->update([
'status' => 'approved',
'reviewed_by' => (int) $request->user()->id,
'reviewed_at' => now(),
'review_note' => (string) $request->input('note', ''),
'updated_at' => now(),
]);
if ((string) $row->context === 'profile_update' && ! empty($row->user_id)) {
$this->applyProfileRename((int) $row->user_id, (string) $row->requested_username);
}
DB::commit();
} catch (\Throwable $e) {
DB::rollBack();
return response()->json(['message' => $e->getMessage()], Response::HTTP_UNPROCESSABLE_ENTITY);
}
return response()->json([
'success' => true,
'id' => $id,
'status' => 'approved',
], Response::HTTP_OK);
}
public function reject(int $id, Request $request): JsonResponse
{
$affected = DB::table('username_approval_requests')
->where('id', $id)
->where('status', 'pending')
->update([
'status' => 'rejected',
'reviewed_by' => (int) $request->user()->id,
'reviewed_at' => now(),
'review_note' => (string) $request->input('note', ''),
'updated_at' => now(),
]);
if ($affected === 0) {
return response()->json(['message' => 'Request not found or not pending.'], Response::HTTP_NOT_FOUND);
}
return response()->json([
'success' => true,
'id' => $id,
'status' => 'rejected',
], Response::HTTP_OK);
}
private function applyProfileRename(int $userId, string $requestedUsername): void
{
$user = User::query()->find($userId);
if (! $user) {
return;
}
$requested = UsernamePolicy::normalize($requestedUsername);
if ($requested === '') {
throw new \RuntimeException('Requested username is invalid.');
}
$exists = User::query()
->whereRaw('LOWER(username) = ?', [$requested])
->where('id', '!=', $userId)
->exists();
if ($exists) {
throw new \RuntimeException('Requested username is already taken.');
}
$old = UsernamePolicy::normalize((string) ($user->username ?? ''));
if ($old === $requested) {
return;
}
$user->username = $requested;
$user->username_changed_at = now();
$user->save();
if ($old !== '') {
DB::table('username_history')->insert([
'user_id' => $userId,
'old_username' => $old,
'changed_at' => now(),
'created_at' => now(),
'updated_at' => now(),
]);
DB::table('username_redirects')->updateOrInsert(
['old_username' => $old],
[
'new_username' => $requested,
'user_id' => $userId,
'created_at' => now(),
'updated_at' => now(),
]
);
}
}
}

View File

@@ -535,6 +535,7 @@ final class UploadController extends Controller
'upload_id' => (string) $upload->id,
'status' => (string) $upload->status,
'published_at' => optional($upload->published_at)->toISOString(),
'final_path' => (string) ($upload->final_path ?? ''),
], Response::HTTP_OK);
} catch (UploadOwnershipException $e) {
return response()->json(['message' => $e->getMessage()], Response::HTTP_FORBIDDEN);

View File

@@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Http\Requests\UsernameRequest;
use App\Models\User;
use App\Support\UsernamePolicy;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
final class UsernameAvailabilityController extends Controller
{
public function __invoke(Request $request): JsonResponse
{
$candidate = UsernamePolicy::normalize((string) $request->query('username', ''));
$validator = validator(
['username' => $candidate],
['username' => UsernameRequest::formatRules()]
);
if ($validator->fails()) {
return response()->json([
'available' => false,
'normalized' => $candidate,
'errors' => $validator->errors()->toArray(),
], 422);
}
$ignoreUserId = $request->user()?->id;
$exists = User::query()
->whereRaw('LOWER(username) = ?', [$candidate])
->when($ignoreUserId !== null, fn ($q) => $q->where('id', '!=', (int) $ignoreUserId))
->exists();
return response()->json([
'available' => ! $exists,
'normalized' => $candidate,
]);
}
}

View File

@@ -3,23 +3,46 @@
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use App\Mail\RegistrationVerificationMail;
use App\Models\User;
use Illuminate\Auth\Events\Registered;
use App\Services\Security\RecaptchaVerifier;
use Carbon\CarbonImmutable;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use Illuminate\Validation\Rules;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Str;
use Illuminate\View\View;
class RegisteredUserController extends Controller
{
public function __construct(
private readonly RecaptchaVerifier $recaptchaVerifier
)
{
}
/**
* Display the registration view.
*/
public function create(): View
public function create(Request $request): View
{
return view('auth.register');
return view('auth.register', [
'prefillEmail' => (string) $request->query('email', ''),
]);
}
public function notice(Request $request): View
{
$email = (string) session('registration_email', '');
$remaining = $email === '' ? 0 : $this->resendRemainingSeconds($email);
return view('auth.register-notice', [
'email' => $email,
'resendSeconds' => $remaining,
]);
}
/**
@@ -29,22 +52,127 @@ class RegisteredUserController extends Controller
*/
public function store(Request $request): RedirectResponse
{
$request->validate([
'name' => ['required', 'string', 'max:255'],
$validated = $request->validate([
'email' => ['required', 'string', 'lowercase', 'email', 'max:255', 'unique:'.User::class],
'password' => ['required', 'confirmed', Rules\Password::defaults()],
'website' => ['nullable', 'max:0'],
]);
if ($this->recaptchaVerifier->isEnabled()) {
$request->validate([
'g-recaptcha-response' => ['required', 'string'],
]);
$verified = $this->recaptchaVerifier->verify(
(string) $request->input('g-recaptcha-response', ''),
$request->ip()
);
if (! $verified) {
return back()
->withInput($request->except('website'))
->withErrors(['captcha' => 'reCAPTCHA verification failed. Please try again.']);
}
}
$user = User::create([
'name' => $request->name,
'email' => $request->email,
'password' => Hash::make($request->password),
'username' => null,
'name' => Str::before((string) $validated['email'], '@'),
'email' => $validated['email'],
'password' => Hash::make(Str::random(64)),
'is_active' => false,
'onboarding_step' => 'email',
'username_changed_at' => now(),
]);
event(new Registered($user));
$token = Str::random(64);
DB::table('user_verification_tokens')->insert([
'user_id' => $user->id,
'token' => $token,
'expires_at' => now()->addDay(),
'created_at' => now(),
'updated_at' => now(),
]);
Auth::login($user);
Mail::to($user->email)->queue(new RegistrationVerificationMail($token));
return redirect(route('dashboard', absolute: false));
$cooldown = $this->resendCooldownSeconds();
$this->setResendCooldown((string) $validated['email'], $cooldown);
return redirect(route('register.notice', absolute: false))
->with('status', 'Verification email sent. Please check your inbox.')
->with('registration_email', (string) $validated['email']);
}
public function resendVerification(Request $request): RedirectResponse
{
$validated = $request->validate([
'email' => ['required', 'string', 'lowercase', 'email', 'max:255'],
]);
$email = (string) $validated['email'];
$remaining = $this->resendRemainingSeconds($email);
if ($remaining > 0) {
return back()
->with('registration_email', $email)
->withErrors(['email' => "Please wait {$remaining} seconds before resending."]);
}
$user = User::query()
->where('email', $email)
->whereNull('email_verified_at')
->where('onboarding_step', 'email')
->first();
if (! $user) {
return back()
->with('registration_email', $email)
->withErrors(['email' => 'No pending verification found for this email.']);
}
DB::table('user_verification_tokens')->where('user_id', $user->id)->delete();
$token = Str::random(64);
DB::table('user_verification_tokens')->insert([
'user_id' => $user->id,
'token' => $token,
'expires_at' => now()->addDay(),
'created_at' => now(),
'updated_at' => now(),
]);
Mail::to($user->email)->queue(new RegistrationVerificationMail($token));
$cooldown = $this->resendCooldownSeconds();
$this->setResendCooldown($email, $cooldown);
return redirect(route('register.notice', absolute: false))
->with('registration_email', $email)
->with('status', 'Verification email resent. Please check your inbox.');
}
private function resendCooldownSeconds(): int
{
return max(5, (int) config('antispam.register.resend_cooldown_seconds', 60));
}
private function resendCooldownCacheKey(string $email): string
{
return 'register:resend:cooldown:' . sha1(strtolower(trim($email)));
}
private function setResendCooldown(string $email, int $seconds): void
{
$until = CarbonImmutable::now()->addSeconds($seconds)->timestamp;
Cache::put($this->resendCooldownCacheKey($email), $until, $seconds + 5);
}
private function resendRemainingSeconds(string $email): int
{
$until = (int) Cache::get($this->resendCooldownCacheKey($email), 0);
if ($until <= 0) {
return 0;
}
return max(0, $until - time());
}
}

View File

@@ -0,0 +1,53 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use App\Models\User;
use Illuminate\Http\RedirectResponse;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
class RegistrationVerificationController extends Controller
{
public function __invoke(string $token): RedirectResponse
{
$record = DB::table('user_verification_tokens')
->where('token', $token)
->first();
if (! $record) {
return redirect(route('login', absolute: false))
->withErrors(['email' => 'Verification link is invalid.']);
}
if (now()->greaterThan($record->expires_at)) {
DB::table('user_verification_tokens')->where('id', $record->id)->delete();
return redirect(route('login', absolute: false))
->withErrors(['email' => 'Verification link has expired.']);
}
$user = User::query()->find((int) $record->user_id);
if (! $user) {
DB::table('user_verification_tokens')->where('id', $record->id)->delete();
return redirect(route('login', absolute: false))
->withErrors(['email' => 'Verification link is invalid.']);
}
$user->forceFill([
'email_verified_at' => $user->email_verified_at ?? now(),
'onboarding_step' => 'verified',
'is_active' => true,
])->save();
DB::table('user_verification_tokens')
->where('id', $record->id)
->delete();
Auth::login($user);
return redirect('/setup/password')->with('status', 'Email verified. Continue with password setup.');
}
}

View File

@@ -0,0 +1,45 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Illuminate\View\View;
class SetupPasswordController extends Controller
{
public function create(Request $request): View
{
return view('auth.setup-password', [
'email' => (string) ($request->user()?->email ?? ''),
]);
}
public function store(Request $request): RedirectResponse
{
$validated = $request->validate([
'password' => [
'required',
'string',
'min:10',
'regex:/\d/',
'regex:/[^\w\s]/',
'confirmed',
],
], [
'password.min' => 'Your password must be at least 10 characters.',
'password.regex' => 'Your password must include at least one number and one symbol.',
'password.confirmed' => 'Password confirmation does not match.',
]);
$request->user()->forceFill([
'password' => Hash::make((string) $validated['password']),
'onboarding_step' => 'password',
'needs_password_reset' => false,
])->save();
return redirect('/setup/username')->with('status', 'Password saved. Choose your public username to finish setup.');
}
}

View File

@@ -0,0 +1,94 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use App\Http\Requests\UsernameRequest;
use App\Services\UsernameApprovalService;
use App\Support\UsernamePolicy;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\View\View;
class SetupUsernameController extends Controller
{
public function __construct(private readonly UsernameApprovalService $usernameApprovalService)
{
}
public function create(Request $request): View
{
return view('auth.setup-username', [
'username' => (string) ($request->user()?->username ?? ''),
]);
}
public function store(Request $request): RedirectResponse
{
$normalized = UsernamePolicy::normalize((string) $request->input('username', ''));
$request->merge(['username' => $normalized]);
$validated = $request->validate([
'username' => UsernameRequest::rulesFor((int) $request->user()->id),
], [
'username.required' => 'Please choose a username to continue.',
'username.unique' => 'This username is already taken.',
'username.regex' => 'Use only letters, numbers, underscores, or hyphens.',
'username.min' => 'Username must be at least 3 characters.',
'username.max' => 'Username must be at most 20 characters.',
]);
$candidate = (string) $validated['username'];
$user = $request->user();
$similar = UsernamePolicy::similarReserved($candidate);
if ($similar !== null && ! UsernamePolicy::hasApprovedOverride($candidate, (int) $user->id)) {
$this->usernameApprovalService->submit($user, $candidate, 'onboarding_username', [
'current_username' => (string) ($user->username ?? ''),
]);
return back()
->withInput()
->with('status', 'Your request has been submitted for manual username review.')
->withErrors([
'username' => 'This username is too similar to a reserved name and requires manual approval.',
]);
}
DB::transaction(function () use ($user, $candidate): void {
$oldUsername = (string) ($user->username ?? '');
if ($oldUsername !== '' && strtolower($oldUsername) !== strtolower($candidate) && Schema::hasTable('username_history')) {
DB::table('username_history')->insert([
'user_id' => (int) $user->id,
'old_username' => strtolower($oldUsername),
'changed_at' => now(),
'created_at' => now(),
'updated_at' => now(),
]);
}
if ($oldUsername !== '' && strtolower($oldUsername) !== strtolower($candidate) && Schema::hasTable('username_redirects')) {
DB::table('username_redirects')->updateOrInsert(
['old_username' => strtolower($oldUsername)],
[
'new_username' => strtolower($candidate),
'user_id' => (int) $user->id,
'created_at' => now(),
'updated_at' => now(),
]
);
}
$user->forceFill([
'username' => strtolower($candidate),
'onboarding_step' => 'complete',
'username_changed_at' => now(),
])->save();
});
return redirect('/@' . strtolower($candidate));
}
}

View File

@@ -1,101 +0,0 @@
<?php
namespace App\Http\Controllers\Community;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use App\Services\LegacyService;
class ForumController extends Controller
{
protected LegacyService $legacy;
public function __construct(LegacyService $legacy)
{
$this->legacy = $legacy;
}
public function index()
{
$data = $this->legacy->forumIndex();
if (empty($data['topics']) || count($data['topics']) === 0) {
try {
$categories = \App\Models\ForumCategory::query()
->withCount(['threads as num_subtopics'])
->orderBy('position')
->orderBy('id')
->get();
$topics = $categories->map(function ($category) {
$threadIds = \App\Models\ForumThread::where('category_id', $category->id)->pluck('id');
return (object) [
'topic_id' => $category->id,
'topic' => $category->name,
'discuss' => null,
'last_update' => \App\Models\ForumThread::where('category_id', $category->id)->max('last_post_at'),
'num_posts' => $threadIds->isEmpty() ? 0 : \App\Models\ForumPost::whereIn('thread_id', $threadIds)->count(),
'num_subtopics' => (int) ($category->num_subtopics ?? 0),
];
});
$data['topics'] = $topics;
} catch (\Throwable $e) {
// keep legacy response
}
}
return view('community.forum.index', $data);
}
public function topic(Request $request, $topic_id, $slug = null)
{
// Redirect to canonical slug when possible
try {
$thread = \App\Models\ForumThread::find((int) $topic_id);
if ($thread && !empty($thread->slug)) {
$correct = $thread->slug;
if ($slug !== $correct) {
$qs = $request->getQueryString();
$url = route('legacy.forum.topic', ['topic_id' => $topic_id, 'slug' => $correct]);
if ($qs) $url .= '?' . $qs;
return redirect($url, 301);
}
}
} catch (\Throwable $e) {
// ignore
}
$data = $this->legacy->forumTopic((int) $topic_id, (int) $request->query('page', 1));
if (! $data) {
// fallback to new forum tables if migration already ran
try {
$thread = \App\Models\ForumThread::with(['posts.user'])->find((int) $topic_id);
if ($thread) {
$posts = \App\Models\ForumPost::where('thread_id', $thread->id)->orderBy('created_at')->get();
$data = [
'type' => 'posts',
'thread' => $thread,
'posts' => $posts,
'page_title' => $thread->title ?? 'Forum',
];
}
} catch (\Throwable $e) {
// ignore and fall through to placeholder
}
}
if (! $data) {
return view('shared.placeholder');
}
if (isset($data['type']) && $data['type'] === 'subtopics') {
return view('community.forum.topic', $data);
}
return view('community.forum.posts', $data);
}
}

View File

@@ -0,0 +1,348 @@
<?php
namespace App\Http\Controllers\Forum;
use App\Http\Controllers\Controller;
use App\Models\ForumCategory;
use App\Models\ForumPost;
use App\Models\ForumPostReport;
use App\Models\ForumThread;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Str;
class ForumController extends Controller
{
public function index()
{
$categories = Cache::remember('forum:index:categories:v1', now()->addMinutes(5), function () {
return ForumCategory::query()
->select(['id', 'name', 'slug', 'parent_id', 'position'])
->roots()
->ordered()
->withForumStats()
->get()
->map(function (ForumCategory $category) {
return [
'id' => $category->id,
'name' => $category->name,
'slug' => $category->slug,
'thread_count' => (int) ($category->thread_count ?? 0),
'post_count' => (int) ($category->post_count ?? 0),
'last_activity_at' => $category->lastThread?->last_post_at ?? $category->lastThread?->updated_at,
'preview_image' => $category->preview_image,
];
});
});
$data = [
'categories' => $categories,
'page_title' => 'Forum',
'page_meta_description' => 'Skinbase forum discussions.',
'page_meta_keywords' => 'forum, discussions, topics, skinbase',
];
return view('forum.index', $data);
}
public function showCategory(Request $request, ForumCategory $category)
{
$subtopics = ForumThread::query()
->where('category_id', $category->id)
->withCount('posts')
->with('user:id,name')
->orderByDesc('is_pinned')
->orderByDesc('last_post_at')
->orderByDesc('id')
->paginate(50)
->withQueryString();
$subtopics->getCollection()->transform(function (ForumThread $item) {
return (object) [
'topic_id' => $item->id,
'topic' => $item->title,
'discuss' => $item->content,
'post_date' => $item->created_at,
'last_update' => $item->last_post_at ?? $item->created_at,
'uname' => $item->user?->name,
'num_posts' => (int) ($item->posts_count ?? 0),
];
});
$topic = (object) [
'topic_id' => $category->id,
'topic' => $category->name,
'discuss' => null,
];
return view('forum.community.topic', [
'type' => 'subtopics',
'topic' => $topic,
'subtopics' => $subtopics,
'category' => $category,
'page_title' => $category->name,
'page_meta_description' => 'Forum section: ' . $category->name,
'page_meta_keywords' => 'forum, section, skinbase',
]);
}
public function showThread(Request $request, ForumThread $thread, ?string $slug = null)
{
if (! empty($thread->slug) && $slug !== $thread->slug) {
return redirect()->route('forum.thread.show', ['thread' => $thread->id, 'slug' => $thread->slug], 301);
}
$thread->loadMissing([
'category:id,name,slug',
'user:id,name',
'user.profile:user_id,avatar_hash',
]);
$threadMeta = Cache::remember(
'forum:thread:meta:v1:' . $thread->id . ':' . ($thread->updated_at?->timestamp ?? 0),
now()->addMinutes(5),
fn () => [
'category' => $thread->category,
'author' => $thread->user,
]
);
$sort = strtolower((string) $request->query('sort', 'asc')) === 'desc' ? 'desc' : 'asc';
$opPost = ForumPost::query()
->where('thread_id', $thread->id)
->with([
'user:id,name',
'user.profile:user_id,avatar_hash',
'attachments:id,post_id,file_path,file_size,mime_type,width,height',
])
->orderBy('created_at', 'asc')
->orderBy('id', 'asc')
->first();
$posts = ForumPost::query()
->where('thread_id', $thread->id)
->when($opPost, fn ($query) => $query->where('id', '!=', $opPost->id))
->with([
'user:id,name',
'user.profile:user_id,avatar_hash',
'attachments:id,post_id,file_path,file_size,mime_type,width,height',
])
->orderBy('created_at', $sort)
->paginate(50)
->withQueryString();
$replyCount = max((int) ForumPost::query()->where('thread_id', $thread->id)->count() - 1, 0);
$attachments = collect($opPost?->attachments ?? [])
->merge($posts->getCollection()->flatMap(fn (ForumPost $post) => $post->attachments ?? []))
->values();
$quotedPost = null;
$quotePostId = (int) $request->query('quote', 0);
if ($quotePostId > 0) {
$quotedPost = ForumPost::query()
->where('thread_id', $thread->id)
->with('user:id,name')
->find($quotePostId);
}
$replyPrefill = old('content');
if ($replyPrefill === null && $quotedPost) {
$quotedAuthor = (string) ($quotedPost->user?->name ?? 'Anonymous');
$quoteText = trim(strip_tags((string) $quotedPost->content));
$quoteText = preg_replace('/\s+/', ' ', $quoteText) ?? $quoteText;
$quoteSnippet = Str::limit($quoteText, 300);
$replyPrefill = '[quote=' . $quotedAuthor . ']'
. $quoteSnippet
. '[/quote]'
. "\n\n";
}
return view('forum.thread.show', [
'thread' => $thread,
'category' => $threadMeta['category'] ?? $thread->category,
'author' => $threadMeta['author'] ?? $thread->user,
'opPost' => $opPost,
'posts' => $posts,
'attachments' => $attachments,
'reply_count' => $replyCount,
'quoted_post' => $quotedPost,
'reply_prefill' => $replyPrefill,
'sort' => $sort,
'page_title' => $thread->title,
'page_meta_description' => 'Forum thread: ' . $thread->title,
'page_meta_keywords' => 'forum, thread, skinbase',
]);
}
public function createThreadForm(ForumCategory $category)
{
return view('forum.community.new-thread', [
'category' => $category,
'page_title' => 'New thread',
]);
}
public function storeThread(Request $request, ForumCategory $category)
{
$user = Auth::user();
abort_unless($user, 403);
$validated = $request->validate([
'title' => ['required', 'string', 'max:255'],
'content' => ['required', 'string', 'min:2'],
]);
$baseSlug = Str::slug((string) $validated['title']);
$slug = $baseSlug ?: ('thread-' . time());
$counter = 2;
while (ForumThread::where('slug', $slug)->exists()) {
$slug = ($baseSlug ?: 'thread') . '-' . $counter;
$counter++;
}
$thread = ForumThread::create([
'category_id' => $category->id,
'user_id' => (int) $user->id,
'title' => $validated['title'],
'slug' => $slug,
'content' => $validated['content'],
'views' => 0,
'is_locked' => false,
'is_pinned' => false,
'visibility' => 'public',
'last_post_at' => now(),
]);
ForumPost::create([
'thread_id' => $thread->id,
'user_id' => (int) $user->id,
'content' => $validated['content'],
'is_edited' => false,
'edited_at' => null,
]);
return redirect()->route('forum.thread.show', ['thread' => $thread->id, 'slug' => $thread->slug]);
}
public function reply(Request $request, ForumThread $thread)
{
$user = Auth::user();
abort_unless($user, 403);
abort_if($thread->is_locked, 423, 'Thread is locked.');
$validated = $request->validate([
'content' => ['required', 'string', 'min:2'],
]);
ForumPost::create([
'thread_id' => $thread->id,
'user_id' => (int) $user->id,
'content' => $validated['content'],
'is_edited' => false,
'edited_at' => null,
]);
$thread->last_post_at = now();
$thread->save();
return redirect()->route('forum.thread.show', ['thread' => $thread->id, 'slug' => $thread->slug]);
}
public function editPostForm(ForumPost $post)
{
$user = Auth::user();
abort_unless($user, 403);
abort_unless(((int) $post->user_id === (int) $user->id) || Gate::allows('moderate-forum'), 403);
return view('forum.community.edit-post', [
'post' => $post,
'thread' => $post->thread,
'page_title' => 'Edit post',
]);
}
public function updatePost(Request $request, ForumPost $post)
{
$user = Auth::user();
abort_unless($user, 403);
abort_unless(((int) $post->user_id === (int) $user->id) || Gate::allows('moderate-forum'), 403);
$validated = $request->validate([
'content' => ['required', 'string', 'min:2'],
]);
$post->content = $validated['content'];
$post->is_edited = true;
$post->edited_at = now();
$post->save();
return redirect()->route('forum.thread.show', ['thread' => $post->thread_id, 'slug' => $post->thread?->slug]);
}
public function reportPost(Request $request, ForumPost $post)
{
$user = Auth::user();
abort_unless($user, 403);
abort_if((int) $post->user_id === (int) $user->id, 422, 'You cannot report your own post.');
$validated = $request->validate([
'reason' => ['nullable', 'string', 'max:500'],
]);
ForumPostReport::query()->updateOrCreate(
[
'post_id' => (int) $post->id,
'reporter_user_id' => (int) $user->id,
],
[
'thread_id' => (int) $post->thread_id,
'reason' => $validated['reason'] ?? null,
'status' => 'open',
'source_url' => (string) $request->headers->get('referer', ''),
'reported_at' => now(),
]
);
return back()->with('status', 'Post reported. Thank you for helping moderate the forum.');
}
public function lockThread(ForumThread $thread)
{
$thread->is_locked = true;
$thread->save();
return back();
}
public function unlockThread(ForumThread $thread)
{
$thread->is_locked = false;
$thread->save();
return back();
}
public function pinThread(ForumThread $thread)
{
$thread->is_pinned = true;
$thread->save();
return back();
}
public function unpinThread(ForumThread $thread)
{
$thread->is_pinned = false;
$thread->save();
return back();
}
}

View File

@@ -1,38 +0,0 @@
<?php
namespace App\Http\Controllers\Legacy;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use App\Services\LegacyService;
class ForumController extends Controller
{
protected LegacyService $legacy;
public function __construct(LegacyService $legacy)
{
$this->legacy = $legacy;
}
public function index()
{
$data = $this->legacy->forumIndex();
return view('legacy.forum.index', $data);
}
public function topic(Request $request, $topic_id)
{
$data = $this->legacy->forumTopic((int) $topic_id, (int) $request->query('page', 1));
if (! $data) {
return view('legacy.placeholder');
}
if (isset($data['type']) && $data['type'] === 'subtopics') {
return view('legacy.forum.topic', $data);
}
return view('legacy.forum.posts', $data);
}
}

View File

@@ -255,133 +255,6 @@ class LegacyController extends Controller
));
}
public function forumIndex()
{
$page_title = 'Forum';
$page_meta_description = 'Skinbase forum threads.';
$page_meta_keywords = 'forum, discussions, topics, skinbase';
try {
$topics = DB::table('forum_topics as t')
->select(
't.topic_id',
't.topic',
't.discuss',
't.last_update',
't.privilege',
DB::raw('(SELECT COUNT(*) FROM forum_posts p WHERE p.topic_id IN (SELECT topic_id FROM forum_topics st WHERE st.root_id = t.topic_id)) AS num_posts'),
DB::raw('(SELECT COUNT(*) FROM forum_topics st WHERE st.root_id = t.topic_id) AS num_subtopics')
)
->where('t.root_id', 0)
->where('t.privilege', '<', 4)
->orderByDesc('t.last_update')
->limit(100)
->get();
} catch (\Throwable $e) {
$topics = collect();
}
return view('legacy.forum.index', compact(
'topics',
'page_title',
'page_meta_description',
'page_meta_keywords'
));
}
public function forumTopic(Request $request, int $topic_id)
{
try {
$topic = DB::table('forum_topics')->where('topic_id', $topic_id)->first();
} catch (\Throwable $e) {
$topic = null;
}
if (!$topic) {
return redirect('/forum');
}
$page_title = $topic->topic;
$page_meta_description = Str::limit(strip_tags($topic->discuss ?? 'Forum topic'), 160);
$page_meta_keywords = 'forum, topic, skinbase';
// Fetch subtopics; if none exist, fall back to posts (matches legacy behavior where some topics hold posts directly)
try {
$subtopics = DB::table('forum_topics as t')
->leftJoin('users as u', 't.user_id', '=', 'u.user_id')
->select(
't.topic_id',
't.topic',
't.discuss',
't.post_date',
't.last_update',
'u.uname',
DB::raw('(SELECT COUNT(*) FROM forum_posts p WHERE p.topic_id = t.topic_id) AS num_posts')
)
->where('t.root_id', $topic->topic_id)
->orderByDesc('t.last_update')
->paginate(50)
->withQueryString();
} catch (\Throwable $e) {
$subtopics = new LengthAwarePaginator([], 0, 50, 1, [
'path' => $request->url(),
'query' => $request->query(),
]);
}
if ($subtopics->total() > 0) {
return view('legacy.forum.topic', compact(
'topic',
'subtopics',
'page_title',
'page_meta_description',
'page_meta_keywords'
));
}
$sort = strtolower($request->query('sort', 'desc')) === 'asc' ? 'asc' : 'desc';
// First try topic_id; if empty, retry using legacy tid column
$posts = new LengthAwarePaginator([], 0, 50, 1, [
'path' => $request->url(),
'query' => $request->query(),
]);
try {
$posts = DB::table('forum_posts as p')
->leftJoin('users as u', 'p.user_id', '=', 'u.user_id')
->select('p.id', 'p.message', 'p.post_date', 'u.uname', 'u.user_id', 'u.icon', 'u.eicon')
->where('p.topic_id', $topic->topic_id)
->orderBy('p.post_date', $sort)
->paginate(50)
->withQueryString();
} catch (\Throwable $e) {
// will retry with tid
}
if ($posts->total() === 0) {
try {
$posts = DB::table('forum_posts as p')
->leftJoin('users as u', 'p.user_id', '=', 'u.user_id')
->select('p.id', 'p.message', 'p.post_date', 'u.uname', 'u.user_id', 'u.icon', 'u.eicon')
->where('p.tid', $topic->topic_id)
->orderBy('p.post_date', $sort)
->paginate(50)
->withQueryString();
} catch (\Throwable $e) {
// keep empty paginator
}
}
return view('legacy.forum.posts', compact(
'topic',
'posts',
'page_title',
'page_meta_description',
'page_meta_keywords'
));
}
/**
* Fetch featured artworks with graceful fallbacks.
*/
@@ -437,19 +310,16 @@ class LegacyController extends Controller
private function forumNews(): array
{
try {
return DB::table('forum_topics as t1')
->leftJoin('users as t2', 't1.user_id', '=', 't2.user_id')
->select(
't1.topic_id',
't1.topic',
't1.views',
't1.post_date',
't1.preview',
't2.uname'
)
->where('t1.root_id', 2876)
->where('t1.privilege', '<', 4)
->orderByDesc('t1.post_date')
return DB::table('forum_threads as t1')
->leftJoin('users as t2', 't1.user_id', '=', 't2.id')
->leftJoin('forum_categories as c', 't1.category_id', '=', 'c.id')
->selectRaw('t1.id as topic_id, t1.title as topic, t1.views, t1.created_at as post_date, t1.content as preview, COALESCE(t2.name, ?) as uname', ['Unknown'])
->whereNull('t1.deleted_at')
->where(function ($query) {
$query->where('t1.category_id', 2876)
->orWhereIn('c.slug', ['news', 'forum-news']);
})
->orderByDesc('t1.created_at')
->limit(8)
->get()
->toArray();
@@ -487,17 +357,25 @@ class LegacyController extends Controller
private function latestForumActivity(): array
{
try {
return DB::table('forum_topics as t1')
->select(
't1.topic_id',
't1.topic',
DB::raw('(SELECT COUNT(*) FROM forum_posts WHERE topic_id = t1.topic_id) AS numPosts')
)
->where('t1.root_id', '<>', 0)
->where('t1.root_id', '<>', 2876)
->where('t1.privilege', '<', 4)
->orderByDesc('t1.last_update')
->orderByDesc('t1.post_date')
return DB::table('forum_threads as t1')
->leftJoin('forum_categories as c', 't1.category_id', '=', 'c.id')
->leftJoin('forum_posts as p', function ($join) {
$join->on('p.thread_id', '=', 't1.id')
->whereNull('p.deleted_at');
})
->selectRaw('t1.id as topic_id, t1.title as topic, COUNT(p.id) AS numPosts')
->whereNull('t1.deleted_at')
->where(function ($query) {
$query->where('t1.category_id', '<>', 2876)
->orWhereNull('t1.category_id');
})
->where(function ($query) {
$query->whereNull('c.slug')
->orWhereNotIn('c.slug', ['news', 'forum-news']);
})
->groupBy('t1.id', 't1.title')
->orderByDesc('t1.last_post_at')
->orderByDesc('t1.created_at')
->limit(10)
->get()
->toArray();

View File

@@ -4,9 +4,15 @@ namespace App\Http\Controllers\User;
use App\Http\Controllers\Controller;
use App\Http\Requests\ProfileUpdateRequest;
use App\Models\Artwork;
use App\Models\User;
use App\Services\ArtworkService;
use App\Services\UsernameApprovalService;
use App\Support\UsernamePolicy;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Redirect;
use Illuminate\View\View;
use Illuminate\Support\Facades\Hash;
@@ -14,6 +20,49 @@ use Illuminate\Validation\Rules\Password as PasswordRule;
class ProfileController extends Controller
{
public function __construct(
private readonly ArtworkService $artworkService,
private readonly UsernameApprovalService $usernameApprovalService,
)
{
}
public function showByUsername(Request $request, string $username)
{
$normalized = UsernamePolicy::normalize($username);
$user = User::query()->whereRaw('LOWER(username) = ?', [$normalized])->first();
if (! $user) {
$redirect = DB::table('username_redirects')
->whereRaw('LOWER(old_username) = ?', [$normalized])
->value('new_username');
if ($redirect) {
return redirect()->route('profile.show', ['username' => strtolower((string) $redirect)], 301);
}
abort(404);
}
if ($username !== strtolower((string) $user->username)) {
return redirect()->route('profile.show', ['username' => strtolower((string) $user->username)], 301);
}
return $this->renderUserProfile($request, $user);
}
public function legacyById(Request $request, int $id, ?string $username = null)
{
$user = User::query()->findOrFail($id);
return redirect()->route('profile.show', ['username' => strtolower((string) $user->username)], 301);
}
public function legacyByUsername(Request $request, string $username)
{
return redirect()->route('profile.show', ['username' => UsernamePolicy::normalize($username)], 301);
}
public function edit(Request $request): View
{
return view('profile.edit', [
@@ -33,6 +82,56 @@ class ProfileController extends Controller
$user->name = $validated['name'];
}
if (array_key_exists('username', $validated)) {
$incomingUsername = UsernamePolicy::normalize((string) $validated['username']);
$currentUsername = UsernamePolicy::normalize((string) ($user->username ?? ''));
if ($incomingUsername !== '' && $incomingUsername !== $currentUsername) {
$similar = UsernamePolicy::similarReserved($incomingUsername);
if ($similar !== null && ! UsernamePolicy::hasApprovedOverride($incomingUsername, (int) $user->id)) {
$this->usernameApprovalService->submit($user, $incomingUsername, 'profile_update', [
'current_username' => $currentUsername,
]);
return Redirect::back()->withErrors([
'username' => 'This username is too similar to a reserved name and requires manual approval.',
]);
}
$cooldownDays = (int) config('usernames.rename_cooldown_days', 90);
$isAdmin = method_exists($user, 'isAdmin') ? $user->isAdmin() : false;
if (! $isAdmin && $user->username_changed_at !== null && $user->username_changed_at->gt(now()->subDays($cooldownDays))) {
return Redirect::back()->withErrors([
'username' => "Username can only be changed once every {$cooldownDays} days.",
]);
}
$user->username = $incomingUsername;
$user->username_changed_at = now();
DB::table('username_history')->insert([
'user_id' => (int) $user->id,
'old_username' => $currentUsername,
'changed_at' => now(),
'created_at' => now(),
'updated_at' => now(),
]);
if ($currentUsername !== '') {
DB::table('username_redirects')->updateOrInsert(
['old_username' => $currentUsername],
[
'new_username' => $incomingUsername,
'user_id' => (int) $user->id,
'updated_at' => now(),
'created_at' => now(),
]
);
}
}
}
if (!empty($validated['email']) && empty($user->email)) {
$user->email = $validated['email'];
$user->email_verified_at = null;
@@ -154,4 +253,41 @@ class ProfileController extends Controller
return Redirect::to('/user')->with('status', 'password-updated');
}
private function renderUserProfile(Request $request, User $user)
{
$isOwner = Auth::check() && Auth::id() === $user->id;
$perPage = 24;
$artworks = $this->artworkService->getArtworksByUser($user->id, $isOwner, $perPage)
->through(function (Artwork $art) {
$present = \App\Services\ThumbnailPresenter::present($art, 'md');
return (object) [
'id' => $art->id,
'name' => $art->title,
'picture' => $art->file_name,
'datum' => $art->published_at,
'thumb' => $present['url'],
'thumb_srcset' => $present['srcset'] ?? $present['url'],
'uname' => $art->user->name ?? 'Skinbase',
];
});
$legacyUser = (object) [
'user_id' => $user->id,
'uname' => $user->username ?? $user->name,
'name' => $user->name,
'real_name' => $user->name,
'icon' => DB::table('user_profiles')->where('user_id', $user->id)->value('avatar_hash'),
'about_me' => $user->bio ?? null,
];
return response()->view('legacy.profile', [
'user' => $legacyUser,
'artworks' => $artworks,
'page_title' => 'Profile: ' . ($legacyUser->uname ?? ''),
'page_canonical' => url('/@' . strtolower((string) ($user->username ?? ''))),
]);
}
}

View File

@@ -20,7 +20,7 @@ class UserController extends Controller
$profile = null;
}
return view('user.user', [
return view('legacy.user', [
'profile' => $profile,
]);
}

View File

@@ -5,6 +5,7 @@ namespace App\Http\Controllers\Web;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use App\Services\ArtworkService;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Database\QueryException;
use Illuminate\Support\Facades\Log;
@@ -29,26 +30,32 @@ class HomeController extends Controller
$featured = $featuredResult->getCollection()->first();
} elseif (is_array($featuredResult)) {
$featured = $featuredResult[0] ?? null;
} elseif ($featuredResult instanceof Collection) {
$featured = $featuredResult->first();
} else {
$featured = method_exists($featuredResult, 'first') ? $featuredResult->first() : $featuredResult;
$featured = $featuredResult;
}
$memberFeatured = $featured;
$latestUploads = $this->artworks->getLatestArtworks(20);
// Forum news (root forum section id 2876)
// Forum news (prefer migrated legacy news category id 2876, fallback to slug)
try {
$forumNews = DB::table('forum_topics as t1')
->leftJoin('users as u', 't1.user_id', '=', 'u.user_id')
->select('t1.topic_id', 't1.topic', 'u.uname', 't1.post_date', 't1.preview')
->where('t1.root_id', 2876)
->where('t1.privilege', '<', 4)
->orderBy('t1.post_date', 'desc')
$forumNews = DB::table('forum_threads as t1')
->leftJoin('users as u', 't1.user_id', '=', 'u.id')
->leftJoin('forum_categories as c', 't1.category_id', '=', 'c.id')
->selectRaw('t1.id as topic_id, t1.title as topic, COALESCE(u.name, ?) as uname, t1.created_at as post_date, t1.content as preview', ['Unknown'])
->whereNull('t1.deleted_at')
->where(function ($query) {
$query->where('t1.category_id', 2876)
->orWhereIn('c.slug', ['news', 'forum-news']);
})
->orderByDesc('t1.created_at')
->limit(8)
->get();
} catch (QueryException $e) {
Log::warning('Forum topics table missing or DB error when loading forum news', ['exception' => $e->getMessage()]);
Log::warning('Forum threads table missing or DB error when loading forum news', ['exception' => $e->getMessage()]);
$forumNews = collect();
}
@@ -66,19 +73,31 @@ class HomeController extends Controller
$ourNews = collect();
}
// Latest forum activity (exclude rootless and news root)
// Latest forum activity (exclude forum news category)
try {
$latestForumActivity = DB::table('forum_topics as t1')
->selectRaw('t1.topic_id, t1.topic, (SELECT COUNT(*) FROM forum_posts WHERE topic_id = t1.topic_id) AS numPosts')
->where('t1.root_id', '<>', 0)
->where('t1.root_id', '<>', 2876)
->where('t1.privilege', '<', 4)
->orderBy('t1.last_update', 'desc')
->orderBy('t1.post_date', 'desc')
$latestForumActivity = DB::table('forum_threads as t1')
->leftJoin('forum_categories as c', 't1.category_id', '=', 'c.id')
->leftJoin('forum_posts as p', function ($join) {
$join->on('p.thread_id', '=', 't1.id')
->whereNull('p.deleted_at');
})
->selectRaw('t1.id as topic_id, t1.title as topic, COUNT(p.id) as numPosts')
->whereNull('t1.deleted_at')
->where(function ($query) {
$query->where('t1.category_id', '<>', 2876)
->orWhereNull('t1.category_id');
})
->where(function ($query) {
$query->whereNull('c.slug')
->orWhereNotIn('c.slug', ['news', 'forum-news']);
})
->groupBy('t1.id', 't1.title')
->orderByDesc('t1.last_post_at')
->orderByDesc('t1.created_at')
->limit(10)
->get();
} catch (QueryException $e) {
Log::warning('Forum topics table missing or DB error when loading latest forum activity', ['exception' => $e->getMessage()]);
Log::warning('Forum threads table missing or DB error when loading latest forum activity', ['exception' => $e->getMessage()]);
$latestForumActivity = collect();
}

View File

@@ -0,0 +1,36 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class EnsureOnboardingComplete
{
public function handle(Request $request, Closure $next): Response
{
$user = $request->user();
if (! $user) {
return $next($request);
}
$step = strtolower((string) ($user->onboarding_step ?? ''));
if ($step === 'complete') {
return $next($request);
}
$target = match ($step) {
'email' => '/login',
'verified' => '/setup/password',
'password', 'username' => '/setup/username',
default => '/setup/password',
};
if ($request->is(ltrim($target, '/'))) {
return $next($request);
}
return redirect($target);
}
}

View File

@@ -0,0 +1,28 @@
<?php
namespace App\Http\Middleware;
use App\Support\UsernamePolicy;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class NormalizeUsername
{
public function handle(Request $request, Closure $next): Response
{
$payload = $request->all();
if (array_key_exists('username', $payload)) {
$payload['username'] = UsernamePolicy::normalize((string) $payload['username']);
}
if (array_key_exists('old_username', $payload)) {
$payload['old_username'] = UsernamePolicy::normalize((string) $payload['old_username']);
}
$request->merge($payload);
return $next($request);
}
}

View File

@@ -3,6 +3,7 @@
namespace App\Http\Requests;
use App\Models\User;
use App\Support\UsernamePolicy;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
@@ -16,7 +17,7 @@ class ProfileUpdateRequest extends FormRequest
public function rules(): array
{
return [
'username' => ['sometimes', 'string', 'max:255'],
'username' => ['sometimes', ...UsernameRequest::rulesFor((int) $this->user()->id)],
'email' => [
'required',
'string',
@@ -42,4 +43,13 @@ class ProfileUpdateRequest extends FormRequest
'photo' => ['nullable', 'image', 'max:2048', 'mimes:jpg,jpeg,png,webp'],
];
}
protected function prepareForValidation(): void
{
if ($this->has('username')) {
$this->merge([
'username' => UsernamePolicy::normalize((string) $this->input('username')),
]);
}
}
}

View File

@@ -0,0 +1,73 @@
<?php
namespace App\Http\Requests;
use App\Models\User;
use App\Support\UsernamePolicy;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class UsernameRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
protected function prepareForValidation(): void
{
if ($this->has('username')) {
$this->merge([
'username' => UsernamePolicy::normalize((string) $this->input('username')),
]);
}
}
public function rules(): array
{
return [
'username' => self::rulesFor($this->resolveIgnoreUserId()),
];
}
/**
* @return array<int, mixed>
*/
public static function rulesFor(?int $ignoreUserId = null): array
{
return [
...self::formatRules(),
Rule::unique(User::class, 'username')->ignore($ignoreUserId),
];
}
/**
* @return array<int, mixed>
*/
public static function formatRules(): array
{
return [
'required',
'string',
'min:' . UsernamePolicy::min(),
'max:' . UsernamePolicy::max(),
'regex:' . UsernamePolicy::regex(),
Rule::notIn(UsernamePolicy::reserved()),
];
}
private function resolveIgnoreUserId(): ?int
{
$user = $this->user();
if ($user) {
return (int) $user->id;
}
$routeUserId = $this->route('id') ?? $this->route('user');
if (is_numeric($routeUserId)) {
return (int) $routeUserId;
}
return null;
}
}

View File

@@ -0,0 +1,62 @@
<?php
namespace App\Mail;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Log;
class RegistrationVerificationMail extends Mailable implements ShouldQueue
{
use Queueable, SerializesModels;
public int $tries = 3;
public int $timeout = 30;
public array $backoff = [60, 300, 900];
public function __construct(public readonly string $token)
{
$this->onQueue('mail');
}
public function envelope(): Envelope
{
return new Envelope(
subject: 'Verify your Skinbase email',
);
}
public function content(): Content
{
$appUrl = rtrim((string) config('app.url', 'http://localhost'), '/');
return new Content(
view: 'emails.registration-verification',
with: [
'verificationUrl' => url('/verify/'.$this->token),
'expiresInHours' => 24,
'supportUrl' => $appUrl . '/support',
],
);
}
public function attachments(): array
{
return [];
}
public function failed(\Throwable $exception): void
{
Log::warning('registration verification mail job failed', [
'token_prefix' => substr($this->token, 0, 12),
'message' => $exception->getMessage(),
'class' => get_class($exception),
]);
}
}

View File

@@ -2,7 +2,12 @@
namespace App\Models;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasManyThrough;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasOne;
class ForumCategory extends Model
{
@@ -14,13 +19,77 @@ class ForumCategory extends Model
public $incrementing = true;
public function parent()
public function parent(): BelongsTo
{
return $this->belongsTo(ForumCategory::class, 'parent_id');
}
public function threads()
public function children(): HasMany
{
return $this->hasMany(ForumCategory::class, 'parent_id');
}
public function threads(): HasMany
{
return $this->hasMany(ForumThread::class, 'category_id');
}
public function postsThroughThreads(): HasManyThrough
{
return $this->hasManyThrough(
ForumPost::class,
ForumThread::class,
'category_id',
'thread_id',
'id',
'id'
);
}
public function lastThread(): HasOne
{
return $this->hasOne(ForumThread::class, 'category_id')->latestOfMany('last_post_at');
}
public function scopeOrdered(Builder $query): Builder
{
return $query->orderBy('position')->orderBy('id');
}
public function scopeRoots(Builder $query): Builder
{
return $query->whereNull('parent_id');
}
public function scopeWithForumStats(Builder $query): Builder
{
return $query
->withCount(['threads as thread_count'])
->withCount(['postsThroughThreads as post_count'])
->with(['lastThread' => function ($relationQuery) {
$relationQuery->select([
'forum_threads.id',
'forum_threads.category_id',
'forum_threads.last_post_at',
'forum_threads.updated_at',
]);
}]);
}
public function getPreviewImageAttribute(): string
{
$slug = (string) ($this->slug ?? '');
$map = (array) config('forum.preview_images.map', []);
$default = (string) config('forum.preview_images.default', '/images/forum/default.jpg');
if ($slug !== '' && !empty($map[$slug])) {
return (string) $map[$slug];
}
if ($slug !== '') {
return '/images/forum/defaults/' . $slug . '.jpg';
}
return $default;
}
}

View File

@@ -2,7 +2,10 @@
namespace App\Models;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
class ForumPost extends Model
@@ -18,16 +21,42 @@ class ForumPost extends Model
public $incrementing = true;
protected $casts = [
'is_edited' => 'boolean',
'edited_at' => 'datetime',
];
public function thread()
public function thread(): BelongsTo
{
return $this->belongsTo(ForumThread::class, 'thread_id');
}
public function attachments()
public function user(): BelongsTo
{
return $this->belongsTo(User::class, 'user_id');
}
public function attachments(): HasMany
{
return $this->hasMany(ForumAttachment::class, 'post_id');
}
public function scopeInThread(Builder $query, int $threadId): Builder
{
return $query->where('thread_id', $threadId);
}
public function scopeVisible(Builder $query): Builder
{
return $query;
}
public function scopePinned(Builder $query): Builder
{
return $query->whereHas('thread', fn (Builder $threadQuery) => $threadQuery->where('is_pinned', true));
}
public function scopeRecent(Builder $query): Builder
{
return $query->orderByDesc('created_at')->orderByDesc('id');
}
}

View File

@@ -0,0 +1,40 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class ForumPostReport extends Model
{
protected $table = 'forum_post_reports';
protected $fillable = [
'post_id',
'thread_id',
'reporter_user_id',
'reason',
'status',
'source_url',
'reported_at',
];
protected $casts = [
'reported_at' => 'datetime',
];
public function post(): BelongsTo
{
return $this->belongsTo(ForumPost::class, 'post_id');
}
public function thread(): BelongsTo
{
return $this->belongsTo(ForumThread::class, 'thread_id');
}
public function reporter(): BelongsTo
{
return $this->belongsTo(User::class, 'reporter_user_id');
}
}

View File

@@ -2,7 +2,10 @@
namespace App\Models;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
class ForumThread extends Model
@@ -18,16 +21,43 @@ class ForumThread extends Model
public $incrementing = true;
protected $casts = [
'is_locked' => 'boolean',
'is_pinned' => 'boolean',
'last_post_at' => 'datetime',
];
public function category()
public function category(): BelongsTo
{
return $this->belongsTo(ForumCategory::class, 'category_id');
}
public function posts()
public function user(): BelongsTo
{
return $this->belongsTo(User::class, 'user_id');
}
public function posts(): HasMany
{
return $this->hasMany(ForumPost::class, 'thread_id');
}
public function scopeVisible(Builder $query): Builder
{
return $query->where('visibility', 'public');
}
public function scopePinned(Builder $query): Builder
{
return $query->where('is_pinned', true);
}
public function scopeRecent(Builder $query): Builder
{
return $query->orderByDesc('last_post_at')->orderByDesc('id');
}
public function scopeInCategory(Builder $query, int $categoryId): Builder
{
return $query->where('category_id', $categoryId);
}
}

View File

@@ -21,8 +21,13 @@ class User extends Authenticatable
* @var list<string>
*/
protected $fillable = [
'username',
'username_changed_at',
'onboarding_step',
'name',
'email',
'is_active',
'needs_password_reset',
'password',
'role',
];
@@ -46,6 +51,7 @@ class User extends Authenticatable
{
return [
'email_verified_at' => 'datetime',
'username_changed_at' => 'datetime',
'deleted_at' => 'datetime',
'password' => 'hashed',
];

View File

@@ -11,6 +11,9 @@ use App\Services\Upload\UploadDraftService;
use Illuminate\Support\Facades\View;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Log;
use Illuminate\Queue\Events\JobFailed;
class AppServiceProvider extends ServiceProvider
{
@@ -30,7 +33,9 @@ class AppServiceProvider extends ServiceProvider
*/
public function boot(): void
{
$this->configureAuthRateLimiters();
$this->configureUploadRateLimiters();
$this->configureMailFailureLogging();
// Provide toolbar counts and user info to layout views (port of legacy toolbar logic)
View::composer(['layouts.nova', 'layouts.nova.*'], function ($view) {
@@ -84,6 +89,37 @@ class AppServiceProvider extends ServiceProvider
});
}
private function configureAuthRateLimiters(): void
{
RateLimiter::for('register', function (Request $request): array {
$emailKey = strtolower((string) $request->input('email', 'unknown'));
$ipLimit = (int) config('antispam.register.ip_per_minute', 20);
$emailLimit = (int) config('antispam.register.email_per_minute', 6);
return [
Limit::perMinute($ipLimit)->by('register:ip:' . $request->ip()),
Limit::perMinute($emailLimit)->by('register:email:' . $emailKey),
];
});
}
private function configureMailFailureLogging(): void
{
Event::listen(JobFailed::class, function (JobFailed $event): void {
if (! str_contains(strtolower($event->job->resolveName()), 'sendqueuedmailable')) {
return;
}
Log::warning('mail delivery failed', [
'transport' => config('mail.default'),
'job_name' => $event->job->resolveName(),
'queue' => $event->job->getQueue(),
'connection' => $event->connectionName,
'exception' => $event->exception->getMessage(),
]);
});
}
private function configureUploadRateLimiters(): void
{
RateLimiter::for('uploads-init', function (Request $request): array {

View File

@@ -4,7 +4,6 @@ namespace App\Services;
use App\Models\Artwork;
use App\Models\Category;
use App\Models\ContentType;
use App\Models\ArtworkFeature;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Contracts\Pagination\CursorPaginator;
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
@@ -220,14 +219,48 @@ class ArtworkService
}
}
$categoryIds = $this->categoryAndDescendantIds($current);
$query = $this->browseQuery($sort)
->whereHas('categories', function ($q) use ($current) {
$q->where('categories.id', $current->id);
->whereHas('categories', function ($q) use ($categoryIds) {
$q->whereIn('categories.id', $categoryIds);
});
return $query->cursorPaginate($perPage);
}
/**
* Collect category id plus all descendant category ids.
*
* @return array<int, int>
*/
private function categoryAndDescendantIds(Category $category): array
{
$allIds = [(int) $category->id];
$frontier = [(int) $category->id];
while (! empty($frontier)) {
$children = Category::whereIn('parent_id', $frontier)
->pluck('id')
->map(static fn ($id): int => (int) $id)
->all();
if (empty($children)) {
break;
}
$newIds = array_values(array_diff($children, $allIds));
if (empty($newIds)) {
break;
}
$allIds = array_values(array_unique(array_merge($allIds, $newIds)));
$frontier = $newIds;
}
return $allIds;
}
/**
* Get featured artworks ordered by featured_at DESC, optionally filtered by type.
* Uses artwork_features table and applies public/approved/published filters.

View File

@@ -2,10 +2,9 @@
namespace App\Services;
use App\Models\Artwork;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\File;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Pagination\Paginator;
use Illuminate\Support\Facades\Log;
/**
@@ -120,19 +119,16 @@ class LegacyService
public function forumNews(): array
{
try {
return DB::table('forum_topics as t1')
->leftJoin('users as t2', 't1.user_id', '=', 't2.user_id')
->select(
't1.topic_id',
't1.topic',
't1.views',
't1.post_date',
't1.preview',
't2.uname'
)
->where('t1.root_id', 2876)
->where('t1.privilege', '<', 4)
->orderByDesc('t1.post_date')
return DB::table('forum_threads as t1')
->leftJoin('users as t2', 't1.user_id', '=', 't2.id')
->leftJoin('forum_categories as c', 't1.category_id', '=', 'c.id')
->selectRaw('t1.id as topic_id, t1.title as topic, t1.views, t1.created_at as post_date, t1.content as preview, COALESCE(t2.name, ?) as uname', ['Unknown'])
->whereNull('t1.deleted_at')
->where(function ($query) {
$query->where('t1.category_id', 2876)
->orWhereIn('c.slug', ['news', 'forum-news']);
})
->orderByDesc('t1.created_at')
->limit(8)
->get()
->toArray();
@@ -170,17 +166,25 @@ class LegacyService
public function latestForumActivity(): array
{
try {
return DB::table('forum_topics as t1')
->select(
't1.topic_id',
't1.topic',
DB::raw('(SELECT COUNT(*) FROM forum_posts WHERE topic_id = t1.topic_id) AS numPosts')
)
->where('t1.root_id', '<>', 0)
->where('t1.root_id', '<>', 2876)
->where('t1.privilege', '<', 4)
->orderByDesc('t1.last_update')
->orderByDesc('t1.post_date')
return DB::table('forum_threads as t1')
->leftJoin('forum_categories as c', 't1.category_id', '=', 'c.id')
->leftJoin('forum_posts as p', function ($join) {
$join->on('p.thread_id', '=', 't1.id')
->whereNull('p.deleted_at');
})
->selectRaw('t1.id as topic_id, t1.title as topic, COUNT(p.id) AS numPosts')
->whereNull('t1.deleted_at')
->where(function ($query) {
$query->where('t1.category_id', '<>', 2876)
->orWhereNull('t1.category_id');
})
->where(function ($query) {
$query->whereNull('c.slug')
->orWhereNotIn('c.slug', ['news', 'forum-news']);
})
->groupBy('t1.id', 't1.title')
->orderByDesc('t1.last_post_at')
->orderByDesc('t1.created_at')
->limit(10)
->get()
->toArray();
@@ -266,7 +270,7 @@ class LegacyService
$row->encoded = $encoded;
// Prefer new files.skinbase.org when possible
try {
$art = \App\Models\Artwork::find($row->id);
$art = Artwork::find($row->id);
$present = \App\Services\ThumbnailPresenter::present($art ?: (array) $row, 'md');
$row->thumb_url = $present['url'];
$row->thumb_srcset = $present['srcset'];
@@ -402,126 +406,6 @@ class LegacyService
];
}
public function forumIndex()
{
try {
$topics = DB::table('forum_topics as t')
->select(
't.topic_id',
't.topic',
't.discuss',
't.last_update',
't.privilege',
DB::raw('(SELECT COUNT(*) FROM forum_posts p WHERE p.topic_id IN (SELECT topic_id FROM forum_topics st WHERE st.root_id = t.topic_id)) AS num_posts'),
DB::raw('(SELECT COUNT(*) FROM forum_topics st WHERE st.root_id = t.topic_id) AS num_subtopics')
)
->where('t.root_id', 0)
->where('t.privilege', '<', 4)
->orderByDesc('t.last_update')
->limit(100)
->get();
} catch (\Throwable $e) {
$topics = collect();
}
return [
'topics' => $topics,
'page_title' => 'Forum',
'page_meta_description' => 'Skinbase forum threads.',
'page_meta_keywords' => 'forum, discussions, topics, skinbase',
];
}
public function forumTopic(int $topic_id, int $page = 1)
{
try {
$topic = DB::table('forum_topics')->where('topic_id', $topic_id)->first();
} catch (\Throwable $e) {
$topic = null;
}
if (! $topic) {
return null;
}
try {
$subtopics = DB::table('forum_topics as t')
->leftJoin('users as u', 't.user_id', '=', 'u.user_id')
->select(
't.topic_id',
't.topic',
't.discuss',
't.post_date',
't.last_update',
'u.uname',
DB::raw('(SELECT COUNT(*) FROM forum_posts p WHERE p.topic_id = t.topic_id) AS num_posts')
)
->where('t.root_id', $topic->topic_id)
->orderByDesc('t.last_update')
->paginate(50)
->withQueryString();
} catch (\Throwable $e) {
$subtopics = null;
}
if ($subtopics && $subtopics->total() > 0) {
return [
'type' => 'subtopics',
'topic' => $topic,
'subtopics' => $subtopics,
'page_title' => $topic->topic,
'page_meta_description' => \Illuminate\Support\Str::limit(strip_tags($topic->discuss ?? 'Forum topic'), 160),
'page_meta_keywords' => 'forum, topic, skinbase',
];
}
$sort = strtolower(request()->query('sort', 'desc')) === 'asc' ? 'asc' : 'desc';
try {
$posts = DB::table('forum_posts as p')
->leftJoin('users as u', 'p.user_id', '=', 'u.user_id')
->select('p.id', 'p.message', 'p.post_date', 'u.uname', 'u.user_id', 'u.icon', 'u.eicon')
->where('p.topic_id', $topic->topic_id)
->orderBy('p.post_date', $sort)
->paginate(50)
->withQueryString();
} catch (\Throwable $e) {
$posts = null;
}
if (! $posts || $posts->total() === 0) {
try {
$posts = DB::table('forum_posts as p')
->leftJoin('users as u', 'p.user_id', '=', 'u.user_id')
->select('p.id', 'p.message', 'p.post_date', 'u.uname', 'u.user_id', 'u.icon', 'u.eicon')
->where('p.tid', $topic->topic_id)
->orderBy('p.post_date', $sort)
->paginate(50)
->withQueryString();
} catch (\Throwable $e) {
$posts = null;
}
}
// Ensure $posts is always a LengthAwarePaginator so views can iterate and render pagination safely
if (! $posts) {
$currentPage = max(1, (int) request()->query('page', $page));
$items = collect();
$posts = new LengthAwarePaginator($items, 0, 50, $currentPage, [
'path' => Paginator::resolveCurrentPath(),
]);
}
return [
'type' => 'posts',
'topic' => $topic,
'posts' => $posts,
'page_title' => $topic->topic,
'page_meta_description' => \Illuminate\Support\Str::limit(strip_tags($topic->discuss ?? 'Forum topic'), 160),
'page_meta_keywords' => 'forum, topic, skinbase',
];
}
/**
* Fetch a single artwork by id with author and category
* Returns null on failure.
@@ -555,7 +439,7 @@ class LegacyService
$thumb_600 = $appUrl . '/files/thumb/' . $nid_new . '/600_' . $row->id . '.jpg';
// Prefer new CDN when possible
try {
$art = \App\Models\Artwork::find($row->id);
$art = Artwork::find($row->id);
$present = \App\Services\ThumbnailPresenter::present($art ?: (array) $row, 'md');
$thumb_file = $present['url'];
$thumb_file_300 = $present['srcset'] ? explode(' ', explode(',', $present['srcset'])[0])[0] : ($present['url'] ?? null);

View File

@@ -0,0 +1,51 @@
<?php
namespace App\Services\Security;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class RecaptchaVerifier
{
public function isEnabled(): bool
{
return (bool) config('services.recaptcha.enabled', false);
}
public function verify(string $token, ?string $ip = null): bool
{
if (! $this->isEnabled()) {
return true;
}
$secret = (string) config('services.recaptcha.secret', '');
if ($secret === '' || $token === '') {
return false;
}
try {
/** @var \Illuminate\Http\Client\Response $response */
$response = Http::asForm()
->timeout((int) config('services.recaptcha.timeout', 5))
->post((string) config('services.recaptcha.verify_url'), [
'secret' => $secret,
'response' => $token,
'remoteip' => $ip,
]);
if ($response->status() < 200 || $response->status() >= 300) {
return false;
}
$payload = json_decode((string) $response->body(), true);
return (bool) data_get(is_array($payload) ? $payload : [], 'success', false);
} catch (\Throwable $e) {
Log::warning('recaptcha verification request failed', [
'message' => $e->getMessage(),
]);
return false;
}
}
}

View File

@@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
namespace App\Services;
use App\Models\User;
use App\Support\UsernamePolicy;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
final class UsernameApprovalService
{
public function submit(?User $user, string $username, string $context, array $payload = []): ?int
{
if (! Schema::hasTable('username_approval_requests')) {
return null;
}
$normalized = UsernamePolicy::normalize($username);
$similar = UsernamePolicy::similarReserved($normalized);
if ($similar === null) {
return null;
}
$existingId = DB::table('username_approval_requests')
->where('requested_username', $normalized)
->where('context', $context)
->where('status', 'pending')
->when($user !== null, fn ($q) => $q->where('user_id', (int) $user->id), fn ($q) => $q->whereNull('user_id'))
->value('id');
if ($existingId) {
return (int) $existingId;
}
return (int) DB::table('username_approval_requests')->insertGetId([
'user_id' => $user?->id,
'requested_username' => $normalized,
'context' => $context,
'similar_to' => $similar,
'status' => 'pending',
'payload' => $payload === [] ? null : json_encode($payload),
'created_at' => now(),
'updated_at' => now(),
]);
}
}

View File

@@ -19,9 +19,13 @@ class AvatarUrl
return self::default();
}
$base = rtrim((string) config('cdn.avatar_url', 'https://file.skinbase.org'), '/');
$base = rtrim((string) config('cdn.avatar_url', 'https://files.skinbase.org'), '/');
return sprintf('%s/avatars/%d/%d.webp?v=%s', $base, $userId, $size, $avatarHash);
// Use hash-based path: avatars/ab/cd/{hash}/{size}.webp?v={hash}
$p1 = substr($avatarHash, 0, 2);
$p2 = substr($avatarHash, 2, 2);
return sprintf('%s/avatars/%s/%s/%s/%d.webp?v=%s', $base, $p1, $p2, $avatarHash, $size, $avatarHash);
}
public static function default(): string

View File

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

View File

@@ -0,0 +1,128 @@
<?php
declare(strict_types=1);
namespace App\Support;
use App\Models\User;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Str;
final class UsernamePolicy
{
public static function min(): int
{
return (int) config('usernames.min', 3);
}
public static function max(): int
{
return (int) config('usernames.max', 20);
}
public static function regex(): string
{
return (string) config('usernames.regex', '/^[a-zA-Z0-9_-]+$/');
}
/**
* @return array<int, string>
*/
public static function reserved(): array
{
return array_values(array_unique(array_map(static fn (string $v): string => strtolower(trim($v)), (array) config('usernames.reserved', []))));
}
public static function normalize(string $value): string
{
return strtolower(trim($value));
}
public static function sanitizeLegacy(string $value): string
{
$value = Str::ascii($value);
$value = strtolower(trim($value));
$value = preg_replace('/[^a-z0-9_-]+/', '_', $value) ?? '';
$value = trim($value, '_-');
if ($value === '') {
return 'user';
}
return substr($value, 0, self::max());
}
public static function isReserved(string $username): bool
{
return in_array(self::normalize($username), self::reserved(), true);
}
public static function similarReserved(string $username): ?string
{
$normalized = self::normalize($username);
$reduced = self::reduceForSimilarity($normalized);
$threshold = (int) config('usernames.similarity_threshold', 2);
foreach (self::reserved() as $reserved) {
if (levenshtein($reduced, self::reduceForSimilarity($reserved)) <= $threshold) {
return $reserved;
}
}
return null;
}
public static function hasApprovedOverride(string $username, ?int $userId = null): bool
{
if (! Schema::hasTable('username_approval_requests')) {
return false;
}
$normalized = self::normalize($username);
return DB::table('username_approval_requests')
->where('requested_username', $normalized)
->where('status', 'approved')
->when($userId !== null, fn ($q) => $q->where(function ($sub) use ($userId) {
$sub->where('user_id', $userId)->orWhereNull('user_id');
}))
->exists();
}
public static function uniqueCandidate(string $base, ?int $ignoreUserId = null): string
{
$base = self::sanitizeLegacy($base);
if ($base === '' || self::isReserved($base) || self::similarReserved($base) !== null) {
$base = 'user';
}
$max = self::max();
$candidate = substr($base, 0, $max);
$suffix = 1;
while (self::exists($candidate, $ignoreUserId) || self::isReserved($candidate) || self::similarReserved($candidate) !== null) {
$suffixStr = (string) $suffix;
$prefixLen = max(1, $max - strlen($suffixStr));
$candidate = substr($base, 0, $prefixLen) . $suffixStr;
$suffix++;
}
return $candidate;
}
private static function exists(string $username, ?int $ignoreUserId = null): bool
{
$query = User::query()->whereRaw('LOWER(username) = ?', [strtolower($username)]);
if ($ignoreUserId !== null) {
$query->where('id', '!=', $ignoreUserId);
}
return $query->exists();
}
private static function reduceForSimilarity(string $value): string
{
return preg_replace('/[0-9_-]+/', '', strtolower($value)) ?? strtolower($value);
}
}