# SkinBase – Legacy Users Migration to Laravel Auth (Authoritative Spec) This document is the **single source of truth** for migrating users from the legacy SkinBase database (`projekti_old_skinbase`) into the new Laravel (SkinBase 2026) application using modern authentication. It covers: - ✅ Target data model (Laravel-friendly) - ✅ What to keep / what to drop from legacy - ✅ Exact Laravel migrations - ✅ Import SQL + migration order - ✅ Old password compatibility (if possible) - ✅ First-login password reset flow (recommended) - ✅ Validation SQL & sanity checks - ✅ Role-based access control (RBAC) > **Copilot AI Agent instructions:** > Follow this document strictly. Do not invent additional fields/tables unless explicitly allowed here. --- ## 0) Context: Legacy tables (what we’re migrating) Legacy schema relevant for users: ### `users` (MyISAM, mixed responsibilities) - `user_id` (PK) - `uname` (username) - `password` (varchar 80) – legacy hash or plaintext (unknown) - `password2` (varchar 255) – sometimes present - `email` - `real_name` - `web` - `birth`, `gender`, `country`, `country_code`, `lang` - `picture`, `cover_art` - `signature`, `about_me`, `description` - `LastVisit`, `joinDate` - `user_type` (membership / level) - `active`, `authorized` - many legacy preferences and obsolete fields (ICQ etc.) ### `users_data` (mostly duplicate/overlap) This is redundant and will NOT be kept as-is. ### `users_statistics` Useful but not auth-related; will migrate to `user_statistics`. ### `users_types` Legacy user “levels”. We’ll map to modern roles. --- ## 1) Migration Goals ### Authentication goals - Use **Laravel default authentication** (Breeze/Fortify/Jetstream-compatible). - Allow login via: - username OR email - Preserve user accounts with minimal friction. - Handle legacy password format safely: - Prefer secure migration with password reset - Optionally support legacy hash verification if algorithm is known ### Data goals - Keep IDs stable where reasonable (`user_id` → `users.id`) to simplify future migrations. - Move non-auth profile data into a dedicated profile table. - Remove obsolete fields (ICQ etc.) and replace with modern social links. ### Security goals - Do not store weak hashes long-term. - If legacy password verification is implemented, rehash to bcrypt/argon immediately upon successful login. - Default to forcing password reset if legacy hash format is unknown. --- ## 2) Target Database Design (New System) ### 2.1 `users` (Auth + identity only) **Keep it clean.** This table should contain only identity/auth/security-critical fields. Fields: - `id` (BIGINT) - `username` (unique) - `name` - `email` (unique) - `password` (bcrypt/argon hash) - `email_verified_at` - `remember_token` - `is_active` (legacy `active`) - `needs_password_reset` (new) - `role` (simple RBAC) OR use roles table/spatie later - timestamps ### 2.2 `user_profiles` (Profile data) - bio/about, avatar, cover image - country + language + birthdate + gender - website - timestamps ### 2.3 `user_social_links` (modern social replacement) Instead of ICQ, store dynamic social platforms: - github, twitter/x, instagram, youtube, discord, website, etc. ### 2.4 `user_statistics` (optional but useful) Migrated from legacy `users_statistics`. --- ## 3) What to Remove / Replace ### Remove (obsolete / not used / legacy UI junk) - `icq` (obsolete) - `zone` - `numboard`, `NumStats`, `numskin`, `section_style` - `menu` - `eicon` - `mlist` - various “board/menu” preferences that no longer exist ### Keep / migrate - username, email, name - last visit (optional) - active/authorized → `is_active` + `email_verified_at` strategy - about/bio - avatar/cover - country/language/gender/birthdate - website - statistics (optional) ### Replace ICQ with social links Add `user_social_links` table. --- ## 4) Role Mapping (Legacy `users_types` → Modern RBAC) Legacy: - `users.user_type` references `users_types.id` New (simple approach): - store a string `role` directly in `users.role`: - `user` - `moderator` - `admin` Mapping recommendation (adjust if your legacy meaning differs): - `user_type` <= 0 → `user` - `user_type` in [1..X] with “moderator” meaning → `moderator` - special admin IDs → `admin` > If you later need granular permissions, adopt **spatie/laravel-permission**. > For now, keep it simple. --- ## 5) Exact Laravel Migrations (Copy/Paste) > These migrations are authoritative. Put them in `database/migrations/` in this order. ### 5.1 Create/extend `users` table If you already have Laravel’s default `users` migration, create a new migration to **modify** it. **Migration: `2026_02_01_000010_update_users_table_for_skinbase.php`** ```php string('username', 80)->nullable()->unique()->after('id'); } // If name exists, keep it. Ensure nullable for legacy. if (!Schema::hasColumn('users', 'name')) { $table->string('name')->nullable(); } else { $table->string('name')->nullable()->change(); } // Email is important; legacy might have duplicates/NULLs -> handle in import script carefully. if (Schema::hasColumn('users', 'email')) { $table->string('email')->nullable()->change(); } if (!Schema::hasColumn('users', 'is_active')) { $table->boolean('is_active')->default(true)->after('remember_token'); } if (!Schema::hasColumn('users', 'needs_password_reset')) { $table->boolean('needs_password_reset')->default(true)->after('is_active'); } if (!Schema::hasColumn('users', 'role')) { $table->string('role', 32)->default('user')->after('needs_password_reset'); } // Optional: store legacy hash algorithm marker (only if doing compat) if (!Schema::hasColumn('users', 'legacy_password_algo')) { $table->string('legacy_password_algo', 32)->nullable()->after('role'); } // Optional: store legacy last visit if (!Schema::hasColumn('users', 'last_visit_at')) { $table->timestamp('last_visit_at')->nullable()->after('legacy_password_algo'); } }); } public function down(): void { Schema::table('users', function (Blueprint $table) { if (Schema::hasColumn('users', 'username')) $table->dropColumn('username'); if (Schema::hasColumn('users', 'is_active')) $table->dropColumn('is_active'); if (Schema::hasColumn('users', 'needs_password_reset')) $table->dropColumn('needs_password_reset'); if (Schema::hasColumn('users', 'role')) $table->dropColumn('role'); if (Schema::hasColumn('users', 'legacy_password_algo')) $table->dropColumn('legacy_password_algo'); if (Schema::hasColumn('users', 'last_visit_at')) $table->dropColumn('last_visit_at'); }); } }; ```` --- ### 5.2 Create `user_profiles` **Migration: `2026_02_01_000020_create_user_profiles_table.php`** ```php unsignedBigInteger('user_id')->primary(); $table->text('bio')->nullable(); $table->string('avatar', 255)->nullable(); $table->string('cover_image', 255)->nullable(); $table->string('country', 80)->nullable(); $table->char('country_code', 2)->nullable(); // normalize to ISO-3166-1 alpha-2 $table->string('language', 10)->nullable(); $table->date('birthdate')->nullable(); $table->enum('gender', ['M','F','X'])->default('X'); $table->string('website', 255)->nullable(); $table->timestamps(); $table->foreign('user_id')->references('id')->on('users')->cascadeOnDelete(); }); } public function down(): void { Schema::dropIfExists('user_profiles'); } }; ``` --- ### 5.3 Create `user_social_links` **Migration: `2026_02_01_000030_create_user_social_links_table.php`** ```php id(); $table->unsignedBigInteger('user_id'); $table->string('platform', 32); // e.g. github, twitter, instagram, youtube, discord, website $table->string('url', 255); $table->timestamps(); $table->unique(['user_id', 'platform']); $table->foreign('user_id')->references('id')->on('users')->cascadeOnDelete(); }); } public function down(): void { Schema::dropIfExists('user_social_links'); } }; ``` --- ### 5.4 Create `user_statistics` (optional) **Migration: `2026_02_01_000040_create_user_statistics_table.php`** ```php unsignedBigInteger('user_id')->primary(); $table->unsignedInteger('uploads')->default(0); $table->unsignedInteger('downloads')->default(0); $table->unsignedInteger('pageviews')->default(0); $table->unsignedInteger('awards')->default(0); $table->timestamps(); $table->foreign('user_id')->references('id')->on('users')->cascadeOnDelete(); }); } public function down(): void { Schema::dropIfExists('user_statistics'); } }; ``` --- ## 6) Auth Logic: Old Password Compatibility (Two Approaches) ### Approach A (Recommended): Force Reset for Everyone **Safest**, because legacy hashing is unknown. * Import users with a random password hash (or keep legacy hash in `password` temporarily but do not allow it). * Set `needs_password_reset = 1` * On login attempt, require reset. This avoids accepting weak hashes forever. ### Approach B (Optional): Support legacy hash on first login, then rehash Only do this if you can identify the algorithm used in legacy `password` / `password2`. Common old hashes: * MD5: 32 hex chars * SHA1: 40 hex chars * bcrypt: starts with `$2y$` or `$2a$` * phpBB style or custom salts: unknown #### Detection hints (informational) * If legacy `password` length is 32 and is hex → probably MD5 * If length is 40 hex → probably SHA1 * If starts with `$2y$` → already bcrypt > **Action requirement:** Before implementing compat, confirm by checking a known user password against a sample hash. --- ## 7) Implementation: Login via Username OR Email ### 7.1 Breeze/Fortify login request logic In your login controller (or Fortify authentication callback), accept a single input field: * `login` (username or email) * `password` Example lookup: ```php $user = User::query() ->where('email', $login) ->orWhere('username', $login) ->first(); ``` --- ## 8) Implementation: Legacy Password Compatibility (If enabled) ### 8.1 Add a service: `app/Support/LegacyPassword.php` ```php md5($plain) === $legacyHash, 'sha1' => sha1($plain) === $legacyHash, 'bcrypt' => password_verify($plain, $legacyHash), default => false }; } } ``` ### 8.2 Modify authentication (pseudo-code) On login: 1. Try normal Laravel hash check (`Hash::check`) 2. If fails AND `legacy_password_algo` is present (or detected), try legacy verify 3. If legacy verify passes: * set new password using `Hash::make($plain)` * set `needs_password_reset = 0` * clear `legacy_password_algo` Example snippet: ```php use Illuminate\Support\Facades\Hash; use App\Support\LegacyPassword; if (Hash::check($password, $user->password)) { // ok } else { $algo = $user->legacy_password_algo ?: LegacyPassword::detectAlgo($user->password); if ($algo && LegacyPassword::verify($password, $user->password, $algo)) { $user->password = Hash::make($password); $user->needs_password_reset = false; $user->legacy_password_algo = null; $user->save(); } else { // invalid credentials } } ``` > If `detectAlgo()` returns null, do NOT allow login: require password reset via email. --- ## 9) First-login Password Reset Flow (Recommended & Secure) ### Requirements * If `needs_password_reset = 1`, user must reset password before accessing account. * This can be enforced via middleware. ### 9.1 Middleware: `EnsurePasswordResetCompleted` Create: `app/Http/Middleware/EnsurePasswordResetCompleted.php` ```php user(); if ($user && $user->needs_password_reset) { if (!$request->routeIs('password.reset.*')) { return redirect()->route('password.reset.notice'); } } return $next($request); } } ``` ### 9.2 Routes for reset notice & flow Add routes: * `password.reset.notice` → show "You must reset your password" * Use Laravel’s standard password reset email flow ### 9.3 UX recommendation * When migrating, send optional mass email campaign: * “SkinBase upgraded – set your new password” * But don’t require sending all at once; users can request reset when needed. --- ## 10) Migration / Import Process (Recommended Order) ### Step 1: Ensure old DB is accessible Options: * Import old DB into same MySQL server * Or create a read-only connection in Laravel (`config/database.php`) to `projekti_old_skinbase` ### Step 2: Run migrations for new schema ```bash php artisan migrate ``` ### Step 3: Import users We keep `user_id` as `users.id` to preserve identity mapping. --- ## 11) Import SQL (Base migration) > These SQL examples assume both databases are on the same MySQL server. > Adjust database names as needed. ### 11.1 Insert into `users` **Important rules:** * Some users may have NULL/duplicate emails → handle safely. * Username should be unique. If duplicates exist, add suffix. Recommended initial import (conservative): * Keep legacy hash in `password` temporarily * Mark `needs_password_reset = 1` * Set `legacy_password_algo` if detectable ```sql INSERT INTO users (id, username, name, email, password, is_active, needs_password_reset, role, legacy_password_algo, last_visit_at, created_at, updated_at) SELECT u.user_id AS id, NULLIF(u.uname, '') AS username, NULLIF(u.real_name, '') AS name, NULLIF(u.email, '') AS email, COALESCE(NULLIF(u.password2, ''), NULLIF(u.password, ''), '') AS password, CASE WHEN u.active = 1 THEN 1 ELSE 0 END AS is_active, 1 AS needs_password_reset, 'user' AS role, NULL AS legacy_password_algo, u.LastVisit AS last_visit_at, u.joinDate AS created_at, NOW() AS updated_at FROM projekti_old_skinbase.users u; ``` > After import, you can populate `legacy_password_algo` using detection rules if you want compat. Example: ```sql UPDATE users SET legacy_password_algo = CASE WHEN password LIKE '$2y$%' THEN 'bcrypt' WHEN password REGEXP '^[a-f0-9]{32}$' THEN 'md5' WHEN password REGEXP '^[a-f0-9]{40}$' THEN 'sha1' ELSE NULL END WHERE legacy_password_algo IS NULL; ``` --- ### 11.2 Insert into `user_profiles` ```sql INSERT INTO user_profiles (user_id, bio, avatar, cover_image, country, country_code, language, birthdate, gender, website, created_at, updated_at) SELECT u.user_id, NULLIF(u.about_me, '') AS bio, NULLIF(u.picture, '') AS avatar, NULLIF(u.cover_art, '') AS cover_image, NULLIF(u.country, '') AS country, NULLIF(LEFT(u.country_code, 2), '') AS country_code, NULLIF(u.lang, '') AS language, u.birth AS birthdate, COALESCE(u.gender, 'X') AS gender, NULLIF(u.web, '') AS website, NOW(), NOW() FROM projekti_old_skinbase.users u WHERE u.user_id IS NOT NULL; ``` --- ### 11.3 Social links (only website initially) Optionally insert website into social links (if you prefer everything in one place): ```sql INSERT INTO user_social_links (user_id, platform, url, created_at, updated_at) SELECT u.user_id, 'website', u.web, NOW(), NOW() FROM projekti_old_skinbase.users u WHERE u.web IS NOT NULL AND u.web <> ''; ``` --- ### 11.4 Statistics ```sql INSERT INTO user_statistics (user_id, uploads, downloads, pageviews, awards, created_at, updated_at) SELECT s.user_id, s.uploads, s.downloads, s.pageviews, s.awards, NOW(), NOW() FROM projekti_old_skinbase.users_statistics s; ``` --- ## 12) Migration Validation SQL (Sanity Checks) ### 12.1 Count parity ```sql SELECT (SELECT COUNT(*) FROM projekti_old_skinbase.users) AS old_users, (SELECT COUNT(*) FROM users) AS new_users; ``` ### 12.2 Missing usernames ```sql SELECT id, email FROM users WHERE username IS NULL OR username = ''; ``` ### 12.3 Duplicate usernames ```sql SELECT username, COUNT(*) c FROM users WHERE username IS NOT NULL AND username <> '' GROUP BY username HAVING c > 1; ``` ### 12.4 Duplicate emails ```sql SELECT email, COUNT(*) c FROM users WHERE email IS NOT NULL AND email <> '' GROUP BY email HAVING c > 1; ``` ### 12.5 Orphaned profiles ```sql SELECT p.user_id FROM user_profiles p LEFT JOIN users u ON u.id = p.user_id WHERE u.id IS NULL; ``` ### 12.6 Users inactive / unauthorized review Legacy had `authorized`. If you want to incorporate: ```sql SELECT user_id, active, authorized FROM projekti_old_skinbase.users WHERE active = 0 OR authorized = 0 LIMIT 200; ``` --- ## 13) Role-based Access Control (RBAC) ### Option 1 (Simple, recommended now): string role on users * `users.role` = `user|moderator|admin` * Add middleware checks: * admin-only panels * moderator actions (approve uploads, etc.) Example middleware: ```php public function handle($request, Closure $next, string $role) { $user = $request->user(); if (!$user || $user->role !== $role) abort(403); return $next($request); } ``` ### Option 2 (Advanced later): spatie/laravel-permission Adopt if you need granular permissions: * `approve_artwork` * `ban_user` * `edit_categories` * etc. Not required for v1 migration. --- ## 14) Implementation Notes (Important) ### Email issues Legacy allows NULL/duplicate emails. Laravel password reset requires unique emails. Strategy: * If email missing: user must login with username and request support or add email. * If duplicate emails: resolve manually or append `+id` style (not recommended) or enforce unique by cleanup. ### Username issues If duplicates exist, your import must resolve them. Recommended rule: * if username duplicate, append `-` ### MyISAM note Legacy tables are MyISAM; importing into InnoDB is fine. Do not try to preserve MyISAM. --- ## 15) Recommended Laravel Auth Starter Kit Use **Laravel Breeze** for simplest modern auth: * login/register * password resets * email verification (optional) Then customize: * login field: username OR email * middleware to enforce password reset --- ## 16) Deliverables Checklist (What Copilot must implement) 1. ✅ Migrations (sections 5.1–5.4) 2. ✅ Import strategy + SQL (section 11) 3. ✅ Validation SQL queries (section 12) 4. ✅ Login supports username/email (section 7) 5. ✅ Password reset enforcement (section 9) 6. ✅ Optional legacy password compatibility (section 8) 7. ✅ RBAC (section 13 option 1) --- ## 17) Final Security Policy * Default `needs_password_reset = 1` for all migrated users. * If legacy hash compatibility is used: * accept legacy hash **only once** * rehash immediately to Laravel hash * clear legacy markers * Do not keep MD5/SHA1 hashes long-term. --- END OF DOCUMENT