Auth: convert auth views and verification email to Nova layout
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
96
app/Console/Commands/EnforceUsernamePolicy.php
Normal file
96
app/Console/Commands/EnforceUsernamePolicy.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
149
app/Http/Controllers/Api/Admin/UsernameApprovalController.php
Normal file
149
app/Http/Controllers/Api/Admin/UsernameApprovalController.php
Normal 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(),
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
44
app/Http/Controllers/Api/UsernameAvailabilityController.php
Normal file
44
app/Http/Controllers/Api/UsernameAvailabilityController.php
Normal 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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.');
|
||||
}
|
||||
}
|
||||
45
app/Http/Controllers/Auth/SetupPasswordController.php
Normal file
45
app/Http/Controllers/Auth/SetupPasswordController.php
Normal 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.');
|
||||
}
|
||||
}
|
||||
94
app/Http/Controllers/Auth/SetupUsernameController.php
Normal file
94
app/Http/Controllers/Auth/SetupUsernameController.php
Normal 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));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
348
app/Http/Controllers/Forum/ForumController.php
Normal file
348
app/Http/Controllers/Forum/ForumController.php
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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 ?? ''))),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ class UserController extends Controller
|
||||
$profile = null;
|
||||
}
|
||||
|
||||
return view('user.user', [
|
||||
return view('legacy.user', [
|
||||
'profile' => $profile,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
36
app/Http/Middleware/EnsureOnboardingComplete.php
Normal file
36
app/Http/Middleware/EnsureOnboardingComplete.php
Normal 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);
|
||||
}
|
||||
}
|
||||
28
app/Http/Middleware/NormalizeUsername.php
Normal file
28
app/Http/Middleware/NormalizeUsername.php
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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')),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
73
app/Http/Requests/UsernameRequest.php
Normal file
73
app/Http/Requests/UsernameRequest.php
Normal 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;
|
||||
}
|
||||
}
|
||||
62
app/Mail/RegistrationVerificationMail.php
Normal file
62
app/Mail/RegistrationVerificationMail.php
Normal 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),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
40
app/Models/ForumPostReport.php
Normal file
40
app/Models/ForumPostReport.php
Normal 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');
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
];
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
51
app/Services/Security/RecaptchaVerifier.php
Normal file
51
app/Services/Security/RecaptchaVerifier.php
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
48
app/Services/UsernameApprovalService.php
Normal file
48
app/Services/UsernameApprovalService.php
Normal 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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
36
app/Support/ForumPostContent.php
Normal file
36
app/Support/ForumPostContent.php
Normal 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);
|
||||
}
|
||||
}
|
||||
128
app/Support/UsernamePolicy.php
Normal file
128
app/Support/UsernamePolicy.php
Normal 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user