diff --git a/.gitea/workflows/deploy-staging.yml b/.gitea/workflows/deploy-staging.yml new file mode 100644 index 0000000..c56c2e7 --- /dev/null +++ b/.gitea/workflows/deploy-staging.yml @@ -0,0 +1,52 @@ +name: Deploy Laravel Staging + +on: + push: + branches: [staging] + +jobs: + deploy: + runs-on: self-hosted + + steps: + - name: Configure Git credentials + run: | + git config --global credential.helper store + echo "https://staging-runner:${{ env.GITEA_TOKEN }}@code.romhackplaza.org" \ + > /root/.git-credentials + + - name: Mark Git Folder as safe + run: git config --global --add safe.directory /deploy/laravel + + - name: Code pull + run: | + git -C /deploy/laravel fetch origin staging + git -C /deploy/laravel reset --hard origin/staging + + - name: Storage Link + run: | + docker exec staging_rhpz_web \ + php /sites/laravel/artisan storage:link --force + + - name: Composer Dependencies + run: | + docker exec staging_rhpz_web composer install \ + --no-dev \ + --optimize-autoloader \ + --working-dir=/sites/laravel + + - name: JS Dependencies + run: cd /deploy/laravel && npm ci + + - name: Build JS/CSS + run: cd /deploy/laravel && npm run build + + - name: Migrations + run: | + docker exec staging_rhpz_web \ + php /sites/laravel/artisan migrate --force + + - name: Opti Laravel + run: | + docker exec staging_rhpz_web \ + php /sites/laravel/artisan optimize diff --git a/_rhpz_ide_helper.php b/_rhpz_ide_helper.php new file mode 100644 index 0000000..e457369 --- /dev/null +++ b/_rhpz_ide_helper.php @@ -0,0 +1,9 @@ +user() !== null; } + /** + * Check if user is a guest. + * @return bool + */ public function guest(): bool { return ! $this->check(); } + /** + * Get user ID. + * @return mixed + */ public function id(): mixed { return $this->user()?->getAuthIdentifier(); } + /** + * If user is defined. + * @return bool + */ public function hasUser(): bool { return $this->user !== null; } + /** + * Login user. + * @return XenForoUser|null + */ public function user(): ?XenForoUser { if ($this->hasUser()) @@ -64,6 +92,13 @@ class XenForoGuard implements Guard return $this->user = new XenForoUser($xfUser); } + /** + * Unused. + * + * @param array $credentials + * + * @return bool + */ public function validate(array $credentials = []): bool { return false; @@ -74,6 +109,10 @@ class XenForoGuard implements Guard $this->user = $user; } + /** + * Unused. + * @return void + */ public function logout(): void { redirect('/'); diff --git a/app/Auth/XenForoUser.php b/app/Auth/XenForoUser.php index def6d9e..8cf5fcb 100644 --- a/app/Auth/XenForoUser.php +++ b/app/Auth/XenForoUser.php @@ -10,11 +10,67 @@ use Filament\Panel; use Illuminate\Contracts\Auth\Access\Authorizable; use Illuminate\Contracts\Auth\Authenticatable; +/** + * Xenforo user custom model used for authentification. + * + * @property-read int $user_id + * @property-read string $username + * @property-read int $username_date + * @property-read int $username_date_visible + * @property-read string $email + * @property-read string $custom_title + * @property-read int $language_id + * @property-read int $style_id + * @property-read string $style_variation + * @property-read string $timezone + * @property-read int $visible + * @property-read int $activity_visible + * @property-read int $user_group_id + * @property-read array $secondary_group_ids + * @property-read int $display_style_group_id + * @property-read int $permission_combination_id + * @property-read int $message_count + * @property-read int $question_solution_count + * @property-read int $conversations_unread + * @property-read int $register_date + * @property-read int $last_activity + * @property-read int $last_summary_email_date + * @property-read int $trophy_points + * @property-read int $alerts_unviewed + * @property-read int $alerts_unread + * @property-read int $avatar_date + * @property-read int $avatar_width + * @property-read int $avatar_height + * @property-read int $avatar_highdpi + * @property-read int $avatar_optimized + * @property-read string $gravatar + * @property-read string $user_state + * @property-read string $security_lock + * @property-read int $is_moderator + * @property-read int $is_admin + * @property-read int $is_banned + * @property-read int $reaction_score + * @property-read int $vote_score + * @property-read int $warning_points + * @property-read int $is_staff + * @property-read string $secret_key + * @property-read int $privacy_policy_accepted + * @property-read int $terms_accepted + * + * Custom properties. + * + * @property-read int $rhpz_entry_count + */ class XenForoUser extends XenForoData implements Authenticatable, Authorizable, FilamentUser, HasName { use \Illuminate\Foundation\Auth\Access\Authorizable; + /** + * Permissions identifier array. + * @var array|null + */ public ?array $permissions = null; + public function getAuthIdentifierName(): string { return 'user_id'; @@ -51,9 +107,9 @@ class XenForoUser extends XenForoData implements Authenticatable, Authorizable, } /** - * Get XenForo avatar if it exist. + * Get XenForo avatar if it exists. * - * @param string $xfSize + * @param string $xfSize s/m/... * * @return string|null */ @@ -70,6 +126,14 @@ class XenForoUser extends XenForoData implements Authenticatable, Authorizable, return null; } + /** + * Custom can function. Check XF user permissions. + * + * @param string $permissionGroup + * @param string $permissionName + * + * @return bool + */ public function _can(string $permissionGroup, string $permissionName): bool { if( !$this->permissions ){ @@ -105,4 +169,9 @@ class XenForoUser extends XenForoData implements Authenticatable, Authorizable, { return 'user_id'; } + + public function validState(): bool + { + return $this->user_state === 'valid'; + } } diff --git a/app/Console/Commands/DeleteRejectedEntries.php b/app/Console/Commands/DeleteRejectedEntries.php index c65582f..395742b 100644 --- a/app/Console/Commands/DeleteRejectedEntries.php +++ b/app/Console/Commands/DeleteRejectedEntries.php @@ -3,6 +3,7 @@ namespace App\Console\Commands; use App\Models\Entry; +use App\Models\News; use Illuminate\Console\Attributes\Description; use Illuminate\Console\Attributes\Signature; use Illuminate\Console\Command; @@ -20,7 +21,11 @@ class DeleteRejectedEntries extends Command $count = Entry::where('state', 'rejected') ->where('rejected_at', '<', now()->subDays($days)) ->delete(); + $count += News::where('state', 'rejected') + ->where('rejected_at', '<', now()->subDays($days)) + ->delete(); - $this->info("Deleted {$count} entries"); + $this->info("Deleted {$count} entries/news"); + return self::SUCCESS; } } diff --git a/app/Console/Commands/FixEncodedSlugs.php b/app/Console/Commands/FixEncodedSlugs.php new file mode 100644 index 0000000..e2600df --- /dev/null +++ b/app/Console/Commands/FixEncodedSlugs.php @@ -0,0 +1,40 @@ +where('slug', 'like', '%\\%%')->get(['id', 'slug']); + $fixed = 0; + + foreach ($rows as $row) { + $decoded = rawurldecode($row->slug); + if ($decoded === $row->slug) continue; + + try { + DB::table($table)->where('id', $row->id)->update(['slug' => $decoded]); + $fixed++; + } catch (\Throwable $e) { + $this->warn("{$table}#{$row->id} : collision '{$decoded}', ignored ({$e->getMessage()})."); + } + } + + if ($fixed > 0) { + $this->info("{$table}: {$fixed} fixed slugs."); + } + } + } +} diff --git a/app/Console/Commands/FixEntriesDescription.php b/app/Console/Commands/FixEntriesDescription.php new file mode 100644 index 0000000..e0765be --- /dev/null +++ b/app/Console/Commands/FixEntriesDescription.php @@ -0,0 +1,47 @@ + true]); + + $rows = DB::table('migrations_logs') + ->where('source_system', 'wp') + ->where('source_table', 'wp_posts') + ->where('target_table', 'entries') + ->get(['source_id', 'target_id']); + + $this->info("{$rows->count()} entries touched."); + + $this->withProgressBar($rows, function ($row) use ($converter) { + $rawHtml = DB::connection('old_wp')->table('posts') + ->where('ID', $row->source_id) + ->value('post_content'); + + if ($rawHtml === null) return; + + $markdown = trim($rawHtml) === '' ? $rawHtml : $converter->convert(MigrationHelpers::wpAutoP($rawHtml)); + + DB::table('entries')->where('id', $row->target_id)->update([ + 'description' => $markdown, + 'updated_at' => now(), + ]); + }); + + $this->newLine(); + $this->info('Process finished.'); + return self::SUCCESS; + } +} diff --git a/app/Console/Commands/MigrateCategoriesConfigure.php b/app/Console/Commands/MigrateCategoriesConfigure.php new file mode 100644 index 0000000..6c9589e --- /dev/null +++ b/app/Console/Commands/MigrateCategoriesConfigure.php @@ -0,0 +1,44 @@ +ask("Write WP taxonomy name. Write nothing if you want to save changes.", "" ); + if( $taxonomy == "" ) + break; + + $section = $this->ask("Write entry section equivalent."); + + $taxonomyMap[$taxonomy] = $section; + } + + DB::table('migration_settings') + ->updateOrInsert([ + 'key' => 'wp_categories_to_entry_sections' + ],[ + 'value' => json_encode($taxonomyMap), 'updated_at' => now() + ]); + + $this->info('WP Categories to entry sections has been configured.'); + } +} diff --git a/app/Console/Commands/MigrateCategoriesExecute.php b/app/Console/Commands/MigrateCategoriesExecute.php new file mode 100644 index 0000000..5959285 --- /dev/null +++ b/app/Console/Commands/MigrateCategoriesExecute.php @@ -0,0 +1,92 @@ +where('key', 'wp_categories_to_entry_sections')->value('value'), true); + + if( !$taxMap ){ + $this->error("No WP taxonomies need to be transferred."); + return self::FAILURE; + } + + if( $this->ask("Are you sure you want launch that migration? Write ok if you want to launch it.", "") !== 'ok' ){ + return self::SUCCESS; + } + + $table = self::TABLE; + + foreach ( $taxMap as $wpTax => $restrictedTo ) + { + $this->info("Migrate: {$wpTax} restricted to {$table}"); + + $terms = DB::connection('old_wp') + ->table('term_taxonomy') + ->join('terms', 'term_taxonomy.term_id', '=', 'terms.term_id') + ->where('term_taxonomy.taxonomy', $wpTax) + ->select('term_taxonomy.term_taxonomy_id', 'terms.name', 'terms.slug') + ->get(); + + $this->withProgressBar($terms, function ($term) use ($table, $restrictedTo) { + + $exists = DB::table('migrations_logs') + ->where('source_system', 'wp') + ->where('source_table', 'wp_term_taxonomy') + ->where('source_id', $term->term_taxonomy_id ) + ->exists(); + + if( $exists ) + return; + + $existing = DB::table( $table )->where('slug', $term->slug)->first(); + + if( $existing === null) { + $Id = DB::table($table) + ->insertGetId([ + 'name' => $term->name, 'slug' => $term->slug, + 'restricted_to' => json_encode([$restrictedTo]), + 'created_at' => now(), 'updated_at' => now() + ]); + } else { + $Id = $existing->id; + $restrictedToField = json_decode($existing->restricted_to, true) ?? []; + if( !in_array( $restrictedTo, $restrictedToField, true ) ){ + $restrictedToField[] = $restrictedTo; + DB::table($table) + ->where('id', $Id) + ->update(['restricted_to' => json_encode($restrictedToField), 'updated_at' => now()]); + } + } + + DB::table('migrations_logs')->insert([ + 'source_system' => 'wp', + 'source_table' => 'wp_term_taxonomy', + 'source_id' => $term->term_taxonomy_id, + 'target_table' => $table, + 'target_id' => $Id, + 'status' => 'done', + 'migrated_at' => now(), + 'created_at' => now(), + 'updated_at' => now() + ]); + }); + $this->newLine(); + } + + $this->info("Migration done"); + return self::SUCCESS; + } +} diff --git a/app/Console/Commands/MigrateEntriesComments.php b/app/Console/Commands/MigrateEntriesComments.php new file mode 100644 index 0000000..7b75704 --- /dev/null +++ b/app/Console/Commands/MigrateEntriesComments.php @@ -0,0 +1,80 @@ +option('import-log-table'); + if( !$logTable ){ + $this->error( 'XenForo import log table required' ); + return self::FAILURE; + } + + $this->commentsThread( 'entries', 'wp_posts', $logTable ); + $this->commentsThread( 'news', 'wp_posts__news', $logTable ); + + $this->info( "Done" ); + return self::SUCCESS; + } + + private function commentsThread( string $table, string $sourceTable, string $logTable ): void + { + $query = DB::table('migrations_logs') + ->where('source_system', 'wp' ) + ->where('source_table', $sourceTable ) + ->where('target_table', $table ); + + if( $limit = $this->option('limit') ){ + $query->limit((int)$limit); + } + + $rows = $query->get(['source_id', 'target_id']); + $this->info( "{$rows->count()} need migration logs" ); + + $stats = [ 'updated' => 0, 'no_meta' => 0, 'no_new_thread' => 0 ]; + + $this->withProgressBar( $rows, function ( $row ) use( $table, $logTable, &$stats ) { + + $oldThreadId = DB::connection('old_wp') + ->table('postmeta') + ->where('post_id', $row->source_id ) + ->where('meta_key', 'xf_thread_id') + ->value('meta_value'); + + if( !$oldThreadId ){ + $stats['no_meta']++; + return; + } + + $newThreadId = DB::connection('xenforo') + ->withoutTablePrefix( function( Connection $db ) use( $logTable, $oldThreadId ){ + return $db->table( $logTable ) + ->where('content_type', 'thread') + ->where('old_id', (string) $oldThreadId ) + ->value('new_id'); + }); + + if( !$newThreadId ){ + $stats['no_new_thread']++; + return; + } + + DB::table( $table )->where('id', $row->target_id )->update([ + 'comments_thread_id' => (int) $newThreadId + ]); + + $stats['updated']++; + }); + + $this->newLine(); + $this->info( "Updated: {$stats['updated']}, No new thread: {$stats['no_new_thread']}, No meta: {$stats['no_meta']}" ); + } +} diff --git a/app/Console/Commands/MigrateEntriesExecute.php b/app/Console/Commands/MigrateEntriesExecute.php new file mode 100644 index 0000000..cda0727 --- /dev/null +++ b/app/Console/Commands/MigrateEntriesExecute.php @@ -0,0 +1,295 @@ + 'draft', + 'pending' => 'pending', + 'publish' => 'published', + 'private' => 'hidden', + 'locked' => 'locked', + ]; + + private const array ACF_FIELDS = [ + 'entry_title', 'version_number', 'release_date', 'release_site', + 'youtube_video', 'staff', 'hashes', + ]; + + private const array MULTI_TAXONOMIES = [ + 'language' => 'languages', + 'modifications' => 'modifications', + 'author-name' => 'authors', + 'document-category' => 'categories', + 'utility-category' => 'categories', + 'utility-os' => 'systems', + ]; + + private array $stats = []; + + private function getSingle( ?int $term_id, string $targetTable ): ?int + { + if (!$term_id) { + return null; + } + + return DB::table('migrations_logs') + ->where('source_system', 'wp') + ->where('target_table', $targetTable ) + ->where('source_id', $term_id) + ->value('target_id'); + } + + private function uniqueSlug(string $baseSlug, string $table, ?int $ignoreId = null): string + { + $slug = $baseSlug; + $i = 1; + while ( + DB::table($table)->where('slug', $slug) + ->when($ignoreId, fn ($q) => $q->where('id', '!=', $ignoreId)) + ->exists() && $i < 100 + ) { + $slug = $baseSlug . '-' . $i++; + } + if ($i >= 100) { + $slug = (string) \Str::uuid(); + } + return $slug; + } + + private function parseStaffCredits(?string $raw): array + { + if (!$raw || trim($raw) === '') { + return []; + } + + $credits = []; + foreach (preg_split('/\r\n|\r|\n/', $raw) as $line) { + $line = trim($line); + if ($line === '') continue; + + if (preg_match('/^(.+?):\s*(.*)$/', $line, $m)) { + $credits[] = ['name' => trim($m[1]), 'description' => trim($m[2])]; + } elseif (!empty($credits)) { + $last = array_key_last($credits); + $credits[$last]['description'] = trim($credits[$last]['description'] . ' ' . $line); + } else { + $credits[] = ['name' => $line, 'description' => '']; + } + } + + return $credits; + } + + private function parseHashes(?string $raw): array + { + if (!$raw || trim($raw) === '') return []; + + $results = []; + foreach (preg_split('/\n\s*\n/', trim($raw)) as $block) { + $fields = []; + foreach (preg_split('/\r\n|\r|\n/', trim($block)) as $line) { + $line = trim($line); + if ($line === '' || !str_contains($line, ':')) continue; + [$key, $value] = array_map('trim', explode(':', $line, 2)); + $fields[strtolower(preg_replace('/[^a-z0-9]/i', '', $key))] = $value; + } + if (empty($fields)) continue; + + $results[] = [ + 'filename' => $fields['filename'] ?? '', + 'hash_crc32' => $fields['crc32'] ?? '', + 'hash_sha1' => $fields['sha1'] ?? '', + ]; + } + return $results; + } + + private function attachMany(int $entryId, string $pivotTable, string $foreignKey, array $ttids, string $targetTable): void + { + if (empty($ttids)) return; + + $ids = DB::table('migrations_logs') + ->where('source_system', 'wp')->where('target_table', $targetTable) + ->whereIn('source_id', $ttids)->pluck('target_id'); + + foreach ($ids as $id) { + DB::table($pivotTable)->insertOrIgnore(['entry_id' => $entryId, $foreignKey => $id]); + } + } + + /** + * @param \StdClass $post + * @param string $cpt + * + * @return void + */ + private function migratePost( $post, string $cpt ): void + { + $exists = DB::table('migrations_logs') + ->where('source_system', 'wp') + ->where('source_table', 'wp_posts') + ->where('source_id', $post->ID) + ->exists(); + + if ($exists) + return; + + $meta = DB::connection('old_wp') + ->table('postmeta') + ->where('post_id', $post->ID) + ->whereIn('meta_key', self::ACF_FIELDS) + ->pluck('meta_value', 'meta_key'); + + $terms = DB::connection('old_wp') + ->table('term_relationships as tr') + ->join('term_taxonomy as tt', 'tr.term_taxonomy_id', '=', 'tt.term_taxonomy_id') + ->where('tr.object_id', $post->ID) + ->whereIn('tt.taxonomy', array_merge(['game', 'platform', 'hack-status', 'experience-level'], array_keys(self::MULTI_TAXONOMIES)) ) + ->select('tt.taxonomy', 'tt.term_taxonomy_id') + ->get(); + + $byTax = []; + foreach ($terms as $term) { + $byTax[$term->taxonomy][] = $term->term_taxonomy_id; + } + + $gameId = null; + if( !empty( $byTax['game'][0] ) && !empty( $byTax['platform'][0] ) ) + { + $gameId = DB::table('migration_game_plan') + ->where('wp_game_id', $byTax['game'][0]) + ->where('wp_platform_id', $byTax['platform'][0]) + ->value('game_id'); + + if( !$gameId ) + $this->stats['missing_game_plan']++; + } + + $statusId = $this->getSingle( $byTax['hack-status'][0] ?? null, 'statuses' ); + $levelId = $this->getSingle( $byTax['experience-level'][0] ?? null, 'levels' ); + + $userId = DB::table('migration_user_plan') + ->where('wp_user_id', $post->post_author ) + ->value('user_id'); + + if( !$userId ) { + $this->stats['missing_author']++; + return; + } + + $title = $meta['entry_title'] ?? null; + if( $cpt === 'translations' && !$title && $gameId ) + { + $title = DB::table('games')->where('id', $gameId)->value('name'); + } + + $slug = $this->uniqueSlug( rawurldecode($post->post_name), 'entries' ); + + $entryId = DB::table('entries')->insertGetId([ + 'type' => $cpt, + 'title' => $title, + 'complete_title' => $post->post_title ?? null, + 'slug' => $slug, + 'description' => MigrationHelpers::htmlToMarkdown($post->post_content), + 'state' => self::STATE_MAP[$post->post_status], + 'game_id' => $gameId, + 'platform_id' => null, + 'status_id' => $statusId, + 'level_id' => $levelId, + 'version' => $meta['version_number'] ?? null, + 'release_date' => $meta['release_date'] ?? null, + 'staff_credits' => json_encode( $this->parseStaffCredits($meta['staff'] ?? null)), + 'relevant_link' => $meta['release_site'] ?? null, + 'youtube_link' => $meta['youtube_video'] ?? null, + 'user_id' => $userId, + 'created_at' => $post->post_date, + 'updated_at' => $post->post_modified, + ]); + + $this->attachMany($entryId, 'entry_authors', 'author_id', $byTax['author-name'] ?? [], 'authors'); + $this->attachMany($entryId, 'entry_languages', 'language_id', $byTax['language'] ?? [], 'languages'); + + if( $cpt === 'romhacks') + $this->attachMany($entryId, 'entry_modifications', 'modification_id', $byTax['modifications'] ?? [], 'modifications'); + + if ($cpt === 'utilities') { + $this->attachMany($entryId, 'entry_categories', 'category_id', $byTax['utility-category'] ?? [], 'categories'); + $this->attachMany($entryId, 'entry_systems', 'system_id', $byTax['utility-os'] ?? [], 'systems'); + } + if ($cpt === 'documents') { + $this->attachMany($entryId, 'entry_categories', 'category_id', $byTax['document-category'] ?? [], 'categories'); + } + + if( $cpt === 'translations' || $cpt === 'romhacks' ){ + foreach ( $this->parseHashes( $meta['hashes'] ?? null ) as $hash ) { + DB::table('entry_hashes')->insert([ + 'entry_id' => $entryId, + 'filename' => $hash['filename'], + 'hash_crc32' => $hash['hash_crc32'], + 'hash_sha1' => $hash['hash_sha1'], + 'verified' => 'TBD', + 'created_at' => now(), + 'updated_at' => now(), + ]); + } + } + + DB::table('migrations_logs')->insert([ + 'source_system' => 'wp', + 'source_table' => 'wp_posts', + 'source_id' => $post->ID, + 'target_table' => 'entries', + 'target_id' => $entryId, + 'status' => 'done', + 'migrated_at' => now(), + 'updated_at' => now(), + 'created_at' => now(), + ]); + + $this->stats['created']++; + + } + + private function migrateCpt( string $cpt ): void + { + $this->info( "Current migration of : $cpt "); + $this->stats = [ 'missing_author' => 0, 'missing_game_plan' => 0, 'created' => 0 ]; + + $query = DB::connection('old_wp') + ->table('posts') + ->where('post_type', $cpt) + ->whereIn('post_status', array_keys(self::STATE_MAP)) + ; + + if( $limit = $this->option('limit') ) { + $query->limit((int) $limit); + } + + $posts = $query->select('ID', 'post_title', 'post_name', 'post_content', 'post_status', 'post_author', 'post_date', 'post_modified') + ->get(); + + $this->withProgressBar($posts, fn( $post ) => $this->migratePost( $post, $cpt ) ); + + $this->newLine(); + $this->info( "Created {$this->stats['created']} entries'. No authors {$this->stats['missing_author']}. Missing game {$this->stats['missing_game_plan']} entries" ); + } + + public function handle() + { + foreach ( self::WP_CPTS as $cpt ) { + $this->migrateCpt( $cpt ); + } + } +} diff --git a/app/Console/Commands/MigrateEntriesImages.php b/app/Console/Commands/MigrateEntriesImages.php new file mode 100644 index 0000000..944c61b --- /dev/null +++ b/app/Console/Commands/MigrateEntriesImages.php @@ -0,0 +1,146 @@ +table('postmeta') + ->where('post_id', $attachmentId) + ->where('meta_key', '_wp_attached_file') + ->value('meta_value'); + + if( !$relativePath ) + return null; + + $sourcePath = rtrim($wpUploadsPath, '/') . '/' . $relativePath; + if( !is_file($sourcePath) ) + return null; + + $extension = pathinfo($sourcePath, PATHINFO_EXTENSION); + $filename = Str::random(40) . ($extension ? '.' . $extension : ''); + $destinationRelative = $destinationPath . '/' . $filename; + + $stream = fopen($sourcePath, 'r'); + Storage::disk('public')->put($destinationRelative, $stream); + if( is_resource($stream) ) + fclose($stream); + + return $destinationRelative; + } + + private function processEntry( int $wpPostId, int $entryId, string $wpUploadsPath, array &$stats ): void + { + $exists = DB::table('migrations_logs') + ->where('source_system', 'wp') + ->where('source_table', 'wp_posts__attachments') + ->where('source_id', $wpPostId) + ->exists(); + + if( $exists ) + return; + + $thumbnailId = DB::connection('old_wp') + ->table('postmeta') + ->where('post_id', $wpPostId) + ->where('meta_key', '_thumbnail_id') + ->value('meta_value'); + + if( $thumbnailId ){ + $newPath = $this->copyAttachment( (int) $thumbnailId, $wpUploadsPath, 'entries/main-images' ); + if( $newPath ){ + DB::table('entries') + ->where('id', $entryId) + ->update(['main_image' => $newPath]); + $stats['main_image']++; + } else { + $stats['missing_files']++; + } + } + + $galleryRaw = DB::connection('old_wp') + ->table('postmeta') + ->where('post_id', $wpPostId) + ->where('meta_key', 'my_gallery') + ->value('meta_value'); + + $attachmentIds = $galleryRaw ? (@unserialize($galleryRaw) ?: []) : []; + + foreach ( array_values( $attachmentIds ) as $order => $attachmentId ) { + $newPath = $this->copyAttachment( (int) $attachmentId, $wpUploadsPath, "entries/gallery-images/{$entryId}" ); + if( !$newPath ){ + $stats['missing_files']++; + continue; + } + + DB::table('galleries') + ->insert([ + 'galleryable_type' => self::GALLERYABLE_TYPE, + 'galleryable_id' => $entryId, + 'image' => $newPath, + 'order' => $order, + 'created_at' => now(), + 'updated_at' => now(), + ]); + $stats['gallery_images']++; + } + + DB::table('migrations_logs') + ->insert([ + 'source_system' => 'wp', + 'source_table' => 'wp_posts__attachments', + 'source_id' => $wpPostId, + 'target_table' => 'entries', + 'target_id' => $entryId, + 'status' => 'done', + 'migrated_at' => now(), + 'created_at' => now(), + 'updated_at' => now(), + ]); + } + + public function handle() + { + $wpUploadsPath = $this->option('wp-uploads-path'); + if( !$wpUploadsPath || !is_dir($wpUploadsPath) ){ + $this->error('Missing WP Uploads Path'); + return self::FAILURE; + } + + $query = DB::table('migrations_logs') + ->where('source_system', 'wp') + ->where('source_table', 'wp_posts') + ->where('target_table', 'entries') + ; + + if( $limit = $this->option('limit') ){ + $query->limit((int)$limit); + } + + $rows = $query->get(['source_id', 'target_id']); + $this->info("{$rows->count()} entries need to be migrated"); + + $stats = ['main_image' => 0, 'gallery_images' => 0, 'missing_files' => 0 ]; + + $this->withProgressBar($rows, function($row) use($wpUploadsPath, &$stats) { + $this->processEntry( $row->source_id, $row->target_id, $wpUploadsPath, $stats ); + }); + + $this->newLine(); + $this->info("Migrated attachments. Main images: {$stats['main_image']}, Galleries: {$stats['gallery_images']}, Missing files: {$stats['missing_files']}"); + return self::SUCCESS; + } +} diff --git a/app/Console/Commands/MigrateGamesExecute.php b/app/Console/Commands/MigrateGamesExecute.php new file mode 100644 index 0000000..fc37c23 --- /dev/null +++ b/app/Console/Commands/MigrateGamesExecute.php @@ -0,0 +1,169 @@ +table('term_relationships as tr') + ->join('term_taxonomy as tt', 'tr.term_taxonomy_id', '=', 'tt.term_taxonomy_id') + ->join('posts as p', 'tr.object_id', '=', 'p.ID') + ->whereIn('tt.taxonomy', self::RELATED_TAXS ) + ->whereIn('p.post_type', self::WP_CPTS ) + ->where('p.post_status', '!=', 'trash') + ->select('p.ID as post_id', 'tt.taxonomy', 'tt.term_taxonomy_id' ) + ->get(); + + $byPost = []; + $ignored = 0; + foreach( $rows as $row ){ + if( isset( $byPost[ $row->post_id ][$row->taxonomy] ) ){ + $ignored++; + continue; + } + $byPost[ $row->post_id ][$row->taxonomy] = $row->term_taxonomy_id; + } + if( $ignored ){ + $this->warn("$ignored posts with multiple taxs will be ignored."); + } + + $structure = []; + $ignoredNoPlatform = 0; + + foreach ( $byPost as $data ) + { + if( empty( $data['game'] ) ) + continue; + + if( empty( $data['platform'] ) ){ + $ignoredNoPlatform++; + continue; + } + + $gameId = $data['game']; + $platformId = $data['platform']; + $genreId = $data['genre'] ?? self::NO_GENRE_SENTINEL; + + $structure[$gameId][$platformId]['count'] = ( $structure[$gameId][$platformId]['count'] ?? 0 ) + 1; + $structure[$gameId][$platformId]['genres'][$genreId] = ( $structure[$gameId][$platformId]['genres'][$genreId] ?? 0 ) + 1; + } + if( $ignoredNoPlatform ){ + $this->warn("$ignoredNoPlatform posts with no platforms will be ignored."); + } + + $games = DB::connection('old_wp') + ->table('term_taxonomy') + ->join('terms', 'term_taxonomy.term_id', '=', 'terms.term_id') + ->where('term_taxonomy.taxonomy', 'game' ) + ->select('term_taxonomy.term_taxonomy_id', 'terms.name', 'terms.slug' ) + ->get() + ->keyBy('term_taxonomy_id'); + + $platformMap = DB::table('migrations_logs') + ->where('source_system', 'wp' ) + ->where('target_table', 'platforms') + ->pluck('target_id', 'source_id'); + + $genreMap = DB::table('migrations_logs') + ->where('source_system', 'wp' ) + ->where('target_table', 'genres') + ->pluck('target_id', 'source_id'); + + if( $this->ask("Are you sure you want launch that migration? Write ok if you want to launch it.", "") !== 'ok' ){ + return self::SUCCESS; + } + + $created = 0; + $genreConflicts = 0; + + foreach( $structure as $gameId => $platforms ) + { + $game = $games->get( $gameId ); + if( !$game ) + continue; + + foreach( $platforms as $platformId => $info ) + { + $alreadyExists = DB::table('migration_game_plan') + ->where('wp_game_id', $gameId ) + ->where('wp_platform_id', $platformId ) + ->exists(); + if( $alreadyExists ) + continue; + + $newPlatformId = $platformMap[$platformId] ?? null; + if( !$newPlatformId ){ + $this->warn("$gameId ignored because platform $platformId does not exist in Laravel."); + continue; + } + + $genres = $info['genres']; + $newGenreId = null; + $topId = null; + $genreConflict = false; + + arsort( $genres ); + $topId = array_key_first( $genres ); + $topCount = $genres[$topId]; + $tied = count(array_filter( + $genres, fn( $c ) => $c === $topCount + )); + if( $tied > 1 ){ + $genreConflict = true; + $genreConflicts++; + } + $newGenreId = $topId === self::NO_GENRE_SENTINEL + ? self::DEFAULT_GENRE_ID + : ($genreMap[$topId] ?? self::DEFAULT_GENRE_ID); + + $gameSlug = $game->slug; + if (count($platforms) > 1) { + $platformSlug = DB::table('platforms')->where('id', $newPlatformId)->value('slug'); + $gameSlug = $game->slug . '-' . $platformSlug; + } + + $newGameId = DB::table('games') + ->insertGetId([ + 'name' => $game->name, + 'slug' => $gameSlug, + 'platform_id' => $newPlatformId, + 'genre_id' => $newGenreId, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + DB::table('migration_game_plan')->insert([ + 'wp_game_id' => $gameId, + 'wp_platform_id' => $platformId, + 'game_id' => $newGameId, + 'wp_genre_id' => $topId === self::NO_GENRE_SENTINEL ? null : $topId, + 'post_count' => $info['count'], + 'genre_conflict' => $genreConflict, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $created++; + + } + } + + $this->newLine(); + $this->info("$created games created. $genreConflicts genre conflicts."); + } +} diff --git a/app/Console/Commands/MigrateNewsExecute.php b/app/Console/Commands/MigrateNewsExecute.php new file mode 100644 index 0000000..0141f10 --- /dev/null +++ b/app/Console/Commands/MigrateNewsExecute.php @@ -0,0 +1,149 @@ + 'draft', + 'pending' => 'pending', + 'publish' => 'published', + 'private' => 'hidden', + 'locked' => 'locked', + ]; + + private array $stats = []; + + private function uniqueSlug(string $baseSlug, string $table, ?int $ignoreId = null): string + { + $slug = $baseSlug; + $i = 1; + while ( + DB::table($table)->where('slug', $slug) + ->when($ignoreId, fn ($q) => $q->where('id', '!=', $ignoreId)) + ->exists() && $i < 100 + ) { + $slug = $baseSlug . '-' . $i++; + } + if ($i >= 100) { + $slug = (string) Str::uuid(); + } + return $slug; + } + + private function migrateNews($post): void + { + $exists = DB::table('migrations_logs') + ->where('source_system', 'wp') + ->where('source_table', 'wp_posts__news') + ->where('source_id', $post->ID) + ->exists(); + if ($exists) + return; + + $meta = DB::connection('old_wp') + ->table('postmeta') + ->where('post_id', $post->ID) + ->whereIn('meta_key', ['release_site', 'romhacks_page', 'youtube_video' ]) + ->pluck('meta_value', 'meta_key'); + + $categoryTtId = DB::connection('old_wp') + ->table('term_relationships as tr') + ->join('term_taxonomy as tt', 'tr.term_taxonomy_id', '=', 'tt.term_taxonomy_id') + ->where('tr.object_id', $post->ID) + ->where('tt.taxonomy', 'news-category') + ->value('tt.term_taxonomy_id'); + + $categoryId = null; + if( $categoryTtId ){ + $categoryId = DB::table('migrations_logs') + ->where('source_system', 'wp') + ->where('target_table', 'categories' ) + ->where('source_id', $categoryTtId) + ->value('target_id'); + if( !$categoryId ){ + $this->stats['missing_category']++; + } + } + + $userId = DB::table('migration_user_plan') + ->where('wp_user_id', $post->post_author ) + ->value('user_id'); + + if( !$userId ){ + $this->stats['missing_author']++; + return; + } + + $slug = $this->uniqueSlug(rawurldecode($post->post_name), 'news'); + $description = trim($post->post_content) === '' ? '' : MigrationHelpers::htmlToMarkdown($post->post_content); + + if( isset( $meta['romhacks_page'] ) && $meta['romhacks_page'] !== null && $meta['romhacks_page'] !== '' ){ + $description .= "\n\nLink to: " . $meta['romhacks_page']; + } + + $newsId = DB::table('news') + ->insertGetId([ + 'title' => $post->post_title, + 'slug' => $slug, + 'category_id' => $categoryId, + 'description' => $description, + 'state' => self::STATE_MAP[$post->post_status], + 'entry_id' => null, + 'relevant_link' => $meta['release_site'] ?? null, + 'youtube_link' => $meta['youtube_video'] ?? null, + 'user_id' => $userId, + 'created_at' => $post->post_date, + 'updated_at' => $post->post_modified, + ]); + + DB::table('migrations_logs')->insert([ + 'source_system' => 'wp', + 'source_table' => 'wp_posts__news', + 'source_id' => $post->ID, + 'target_table' => 'news', + 'target_id' => $newsId, + 'status' => 'done', + 'migrated_at' => now(), + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $this->stats['created']++; + } + + public function handle() + { + $this->stats = [ 'created' => 0, 'missing_author' => 0, 'missing_category' => 0 ]; + + $query = DB::connection('old_wp') + ->table('posts') + ->where('post_type', 'news') + ->whereIn('post_status', array_keys(self::STATE_MAP)) + ; + + if( $limit = $this->option('limit') ) { + $query->limit((int)$limit); + } + + $posts = $query->select('ID', 'post_title', 'post_name', 'post_content', 'post_author', 'post_status', 'post_date', 'post_modified')->get(); + + $this->info( "{$posts->count()} posts found" ); + + $this->withProgressBar($posts, fn($post) => $this->migrateNews($post) ); + + $this->newLine(); + $this->info( "Created {$this->stats['created']} posts. Missing authors: {$this->stats['missing_author']}. Missing category: {$this->stats['missing_category']}" ); + + return self::SUCCESS; + } +} diff --git a/app/Console/Commands/MigrateNewsImages.php b/app/Console/Commands/MigrateNewsImages.php new file mode 100644 index 0000000..b2a637e --- /dev/null +++ b/app/Console/Commands/MigrateNewsImages.php @@ -0,0 +1,128 @@ +table('postmeta') + ->where('post_id', $attachmentId) + ->where('meta_key', '_wp_attached_file') + ->value('meta_value'); + + if( !$relativePath ) + return null; + + $sourcePath = rtrim($wpUploadsPath, '/') . '/' . $relativePath; + if( !is_file($sourcePath) ) + return null; + + $extension = pathinfo($sourcePath, PATHINFO_EXTENSION); + $filename = Str::random(40) . ($extension ? '.' . $extension : ''); + $destinationRelative = $destinationPath . '/' . $filename; + + $stream = fopen($sourcePath, 'r'); + Storage::disk('public')->put($destinationRelative, $stream); + if( is_resource($stream) ) + fclose($stream); + + return $destinationRelative; + } + + private function processEntry( int $wpPostId, int $entryId, string $wpUploadsPath, array &$stats ): void + { + $exists = DB::table('migrations_logs') + ->where('source_system', 'wp') + ->where('source_table', 'wp_posts__attachments') + ->where('source_id', $wpPostId) + ->exists(); + + if( $exists ) + return; + + $galleryRaw = DB::connection('old_wp') + ->table('postmeta') + ->where('post_id', $wpPostId) + ->where('meta_key', 'my_gallery') + ->value('meta_value'); + + $attachmentIds = $galleryRaw ? (@unserialize($galleryRaw) ?: []) : []; + + foreach ( array_values( $attachmentIds ) as $order => $attachmentId ) { + $newPath = $this->copyAttachment( (int) $attachmentId, $wpUploadsPath, "news/gallery-images/{$entryId}" ); + if( !$newPath ){ + $stats['missing_files']++; + continue; + } + + DB::table('galleries') + ->insert([ + 'galleryable_type' => self::GALLERYABLE_TYPE, + 'galleryable_id' => $entryId, + 'image' => $newPath, + 'order' => $order, + 'created_at' => now(), + 'updated_at' => now(), + ]); + $stats['gallery_images']++; + } + + DB::table('migrations_logs') + ->insert([ + 'source_system' => 'wp', + 'source_table' => 'wp_posts__attachments', + 'source_id' => $wpPostId, + 'target_table' => 'news', + 'target_id' => $entryId, + 'status' => 'done', + 'migrated_at' => now(), + 'created_at' => now(), + 'updated_at' => now(), + ]); + } + + public function handle() + { + $wpUploadsPath = $this->option('wp-uploads-path'); + if( !$wpUploadsPath || !is_dir($wpUploadsPath) ){ + $this->error('Missing WP Uploads Path'); + return self::FAILURE; + } + + $query = DB::table('migrations_logs') + ->where('source_system', 'wp') + ->where('source_table', 'wp_posts__news') + ->where('target_table', 'news') + ; + + if( $limit = $this->option('limit') ){ + $query->limit((int)$limit); + } + + $rows = $query->get(['source_id', 'target_id']); + $this->info("{$rows->count()} news need to be migrated"); + + $stats = ['gallery_images' => 0, 'missing_files' => 0 ]; + + $this->withProgressBar($rows, function($row) use($wpUploadsPath, &$stats) { + $this->processEntry( $row->source_id, $row->target_id, $wpUploadsPath, $stats ); + }); + + $this->newLine(); + $this->info("Migrated attachments Galleries: {$stats['gallery_images']}, Missing files: {$stats['missing_files']}"); + return self::SUCCESS; + } +} diff --git a/app/Console/Commands/MigrateReviewsExecute.php b/app/Console/Commands/MigrateReviewsExecute.php new file mode 100644 index 0000000..97bf8a1 --- /dev/null +++ b/app/Console/Commands/MigrateReviewsExecute.php @@ -0,0 +1,117 @@ +where('source_system', 'wp') + ->where('source_table', 'wp_posts__reviews') + ->where('source_id', $post->ID ) + ->exists(); + + if( $exists ) + return; + + $meta = DB::connection('old_wp') + ->table('postmeta') + ->where('post_id', $post->ID) + ->whereIn('meta_key', ['reviews_post_link', 'review_rating']) + ->pluck('meta_value', 'meta_key') + ; + + $linkedWpPostId = $meta['reviews_post_link'] ?? null; + if( !$linkedWpPostId ){ + $stats['missing_entry']++; + return; + } + + $entryId = DB::table('migrations_logs') + ->where('source_system', 'wp') + ->where('source_table', 'wp_posts') + ->where('source_id', (int) $linkedWpPostId ) + ->where('target_table', 'entries') + ->value('target_id'); + + if( !$entryId ){ + $stats['missing_entry']++; + return; + } + + $userId = DB::table('migration_user_plan') + ->where('wp_user_id', $post->post_author ) + ->value('user_id'); + + if( !$userId ){ + $stats['missing_author']++; + return; + } + + $rating = $meta['review_rating'] ?? null; + if( $rating === null || $rating === '' ){ + $stats['missing_rating']++; + return; + } + + $description = trim($post->post_content) === '' ? '' : MigrationHelpers::htmlToMarkdown($post->post_content); + + $reviewId = DB::table('entry_reviews')->insertGetId([ + 'entry_id' => $entryId, + 'title' => $post->post_title, + 'rating' => (int) $rating, + 'description' => $description, + 'user_id' => $userId, + 'created_at' => $post->post_date, + 'updated_at' => $post->post_modified, + ]); + + DB::table('migrations_logs')->insert([ + 'source_system' => 'wp', + 'source_table' => 'wp_posts__reviews', + 'source_id' => $post->ID, + 'target_table' => 'entry_reviews', + 'target_id' => $reviewId, + 'status' => 'done', + 'migrated_at' => now(), + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $stats['created']++; + } + + public function handle(): int + { + $query = DB::connection('old_wp') + ->table('posts') + ->where('post_type', 'reviews') + ->where('post_status', 'publish') + ; + + if( $limit = $this->option('limit') ) { + $query->limit((int) $limit); + } + + $posts = $query->select('ID', 'post_title', 'post_content', 'post_author', 'post_date', 'post_modified' )->get(); + $this->info("{$posts->count()} reviews found."); + + $stats = ['created' => 0, 'missing_entry' => 0, 'missing_author' => 0, 'missing_rating' => 0 ]; + + $this->withProgressBar($posts, function($post) use (&$stats){ + $this->migrateReview($post, $stats); + }); + + $this->newLine(); + $this->info("{$stats['created']} reviews created. {$stats['missing_entry']} missing entry. {$stats['missing_author']} missing author. {$stats['missing_rating']} missing rating."); + return self::SUCCESS; + } +} diff --git a/app/Console/Commands/MigrateTaxonomiesConfigure.php b/app/Console/Commands/MigrateTaxonomiesConfigure.php new file mode 100644 index 0000000..50e302f --- /dev/null +++ b/app/Console/Commands/MigrateTaxonomiesConfigure.php @@ -0,0 +1,44 @@ +ask("Write WP taxonomy name. Write nothing if you want to save changes.", "" ); + if( $taxonomy == "" ) + break; + + $tableName = $this->ask("Write equivalent Laravel table name."); + + $taxonomyMap[$taxonomy] = $tableName; + } + + DB::table('migration_settings') + ->updateOrInsert([ + 'key' => 'wp_taxonomies_to_laravel_tables' + ],[ + 'value' => json_encode($taxonomyMap), 'updated_at' => now() + ]); + + $this->info('WP Taxonomies to Laravel tables have been configured.'); + } +} diff --git a/app/Console/Commands/MigrateTaxonomiesExecute.php b/app/Console/Commands/MigrateTaxonomiesExecute.php new file mode 100644 index 0000000..d32cc6e --- /dev/null +++ b/app/Console/Commands/MigrateTaxonomiesExecute.php @@ -0,0 +1,77 @@ +where('key', 'wp_taxonomies_to_laravel_tables')->value('value'), true); + + if( !$taxMap ){ + $this->error("No WP taxonomies need to be transferred."); + return self::FAILURE; + } + + if( $this->ask("Are you sure you want launch that migration? Write ok if you want to launch it.", "") !== 'ok' ){ + return self::SUCCESS; + } + + foreach ( $taxMap as $wpTax => $table ) + { + $this->info("Migrate: {$wpTax} => {$table}"); + + $terms = DB::connection('old_wp') + ->table('term_taxonomy') + ->join('terms', 'term_taxonomy.term_id', '=', 'terms.term_id') + ->where('term_taxonomy.taxonomy', $wpTax) + ->select('term_taxonomy.term_taxonomy_id', 'terms.name', 'terms.slug') + ->get(); + + $this->withProgressBar($terms, function ($term) use ($table) { + + $exists = DB::table('migrations_logs') + ->where('source_system', 'wp') + ->where('source_table', 'wp_term_taxonomy') + ->where('source_id', $term->term_taxonomy_id ) + ->exists(); + + if( $exists ) + return; + + $Id = DB::table( $table )->where('slug', $term->slug)->value('id'); + + if( $Id === null) { + $Id = DB::table($table) + ->insertGetId([ + 'name' => $term->name, 'slug' => $term->slug, + 'created_at' => now(), 'updated_at' => now() + ]); + } + + DB::table('migrations_logs')->insert([ + 'source_system' => 'wp', + 'source_table' => 'wp_term_taxonomy', + 'source_id' => $term->term_taxonomy_id, + 'target_table' => $table, + 'target_id' => $Id, + 'status' => 'done', + 'migrated_at' => now(), + 'created_at' => now(), + 'updated_at' => now() + ]); + }); + $this->newLine(); + } + + $this->info("Migration done"); + return self::SUCCESS; + } +} diff --git a/app/Console/Commands/MigrateUsersConfigure.php b/app/Console/Commands/MigrateUsersConfigure.php new file mode 100644 index 0000000..98d25d0 --- /dev/null +++ b/app/Console/Commands/MigrateUsersConfigure.php @@ -0,0 +1,57 @@ +table('user_group')->pluck('title', 'user_group_id'); + } + + public function handle() + { + $wpRoles = $this->getWpRoles(); + $roleMap = []; + foreach ($wpRoles as $role) { + $roleMap[$role] = (int) $this->ask("XenForo group ID linked to role {$role}"); + } + DB::table('migration_settings') + ->updateOrInsert([ + 'key' => 'wp_role_to_xf_group' + ], [ + 'value' => json_encode($roleMap), 'updated_at' => now() + ]); + + $oldXfGroups = $this->getOldXfGroups(); + $groupMap = []; + foreach ($oldXfGroups as $id => $title) { + $groupMap[$id] = (int) $this->ask("Xenforo group ID linked to old XF group {$title}|{$id}"); + } + DB::table('migration_settings') + ->updateOrInsert([ + 'key' => 'old_xf_group_to_xf_group' + ],[ + 'value' => json_encode($groupMap), 'updated_at' => now() + ]); + + $this->info('XF groups updated.'); + } +} diff --git a/app/Console/Commands/MigrateUsersExecute.php b/app/Console/Commands/MigrateUsersExecute.php new file mode 100644 index 0000000..6160e1d --- /dev/null +++ b/app/Console/Commands/MigrateUsersExecute.php @@ -0,0 +1,229 @@ +table('postmeta') + ->where('post_id', $mediaId) + ->where('meta_key', '_wp_attached_file') + ->value('meta_value'); + + if( !$relative ) + return null; + + $path = rtrim($wpUploadsPath, '/') . '/' . $relative; + return is_file($path) ? $path : null; + } + + private function extractXfAvatarPath(int $userId, string $xfDataPath, string $size = 'o' ): ?string + { + $path = sprintf('%s/avatars/%s/%d/%d.jpg', + rtrim($xfDataPath, '/'), $size, (int) floor($userId / 1000), $userId + ); + return is_file($path) ? $path : null; + } + + private function extractXfBannerPath(int $userId, string $xfDataPath, string $size = 'l' ): ?string + { + $path = sprintf('%s/profile_banners/%s/%d/%d.jpg', + rtrim($xfDataPath, '/'), $size, (int) floor($userId / 1000), $userId + ); + return is_file($path) ? $path : null; + } + + private function buildUserInfos( MigrationUserPlan $plan, array $roleMap, array $groupMap, string $wpUploadsPath, string $xfDataPath ): array + { + $infos = [ + 'username' => $plan->xf_username ?: $plan->wp_username, + 'email' => $plan->email, + 'user_group_id' => null, + 'password' => Str::uuid(), // Used for API verifications. + 'xf_user_id' => 0, + 'avatar_path' => null, + 'banner_path' => null, + 'register_date' => null, + 'profile' => [ + 'about' => null, + 'website' => null, + ], + 'source_real_password' => null, + 'wp_password' => null, + 'xf_scheme_class' => null, + 'xf_password_data' => null, + ]; + + if( $plan->wp_user_id ) + { + $wp = DB::connection('old_wp') + ->table('users') + ->leftJoin('usermeta as m1', fn( $j ) => $j->on('users.ID', '=', 'm1.user_id')->where('m1.meta_key', '=', 'description') ) + ->leftJoin('usermeta as m2', fn( $j ) => $j->on('users.ID', '=', 'm2.user_id')->where('m2.meta_key', '=', 'wp_capabilities') ) + ->leftJoin('usermeta as m3', fn( $j ) => $j->on('users.ID', '=', 'm3.user_id')->where('m3.meta_key', '=', 'simple_local_avatar') ) + ->where('users.ID', '=', $plan->wp_user_id ) + ->select('m1.meta_value as description', 'm2.meta_value as capabilities', 'm3.meta_value as avatar_meta', 'users.user_url as website', 'users.user_pass as password', 'users.user_registred' ) + ->first(); + + $infos['register_date'] = $wp->user_registred ? strtotime($wp->user_registred) : null; + $infos['profile']['about'] = $wp->description; + $infos['profile']['website'] = $wp->website; + $role = $this->extractWpRole($wp->capabilities); + $infos['user_group_id'] = $roleMap[$role] ?? $roleMap['contributor']; + + if( $url = $this->extractWpAvatarPath($wp->avatar_meta, $wpUploadsPath)){ + $infos['avatar_path'] = $url; + } + + $infos['source_real_password'] = 'wp'; + $infos['wp_password'] = $wp->password; + } + + if( $plan->xf_user_id ) + { + $xf = DB::connection('old_xf') + ->table('user') + ->leftJoin('user_profile', 'user.user_id', '=', 'user_profile.user_id') + ->leftJoin('user_authenticate', 'user.user_id', '=', 'user_authenticate.user_id') + ->where('user.user_id', '=', $plan->xf_user_id) + ->select('user.avatar_date', 'user.user_group_id', 'user.register_date', 'user_profile.about', 'user_profile.website', 'user_profile.banner_date', 'user_authenticate.scheme_class', 'user_authenticate.data') + ->first(); + + if( !$infos['register_date'] && $xf ) + $infos['register_date'] = $xf->register_date ?: null; + if( !$infos['profile']['about'] && $xf ) + $infos['profile']['about'] = $xf->about ?: null; + if( !$infos['profile']['website'] && $xf ) + $infos['profile']['website'] = $xf->website ?: null; + + if( !$plan->wp_user_id ){ + $infos['user_group_id'] = $groupMap[$xf->user_group_id] ?? reset($groupMap); + } + $infos['xf_user_id'] = $plan->xf_user_id; + + if( $infos['avatar_path'] === null && (int) $xf->avatar_date > 0){ + if( $path = $this->extractXfAvatarPath($plan->xf_user_id, $xfDataPath)){ + $infos['avatar_path'] = $path; + } + } + + if( $infos['banner_path'] === null && (int) $xf->banner_date > 0 ){ + if( $path = $this->extractXfBannerPath($plan->xf_user_id, $xfDataPath)){ + $infos['banner_path'] = $path; + } + } + + if( $infos['source_real_password'] === null && $xf->scheme_class && $xf->data ){ + $infos['source_real_password'] = 'xf'; + $infos['xf_scheme_class'] = $xf->scheme_class; + $infos['xf_password_data'] = $xf->data; + } + } + + return $infos; + } + + private function logMap( string $sourceSystem, string $sourceTable, int $sourceId, int $targetId ) + { + DB::table('migrations_logs')->insert([ + 'source_system' => $sourceSystem, + 'source_table' => $sourceTable, + 'source_id' => $sourceId, + 'target_table' => 'xf_user', + 'target_id' => $targetId, + 'status' => 'done', + 'migrated_at' => now(), + 'created_at' => now(), + 'updated_at' => now() + ]); + } + + public function handle() + { + $wpUploadsPath = $this->option('wp-uploads-path'); + $xfDataPath = $this->option('xf-data-path'); + + if( !$wpUploadsPath || !is_dir($wpUploadsPath) ){ + $this->error('Missing WP Uploads Path'); + return self::FAILURE; + } + + if( !$xfDataPath || !is_dir($xfDataPath) ){ + $this->error('Missing XF Data Path'); + return self::FAILURE; + } + + $roleMap = json_decode(DB::table('migration_settings')->where('key', 'wp_role_to_xf_group')->value('value'), true); + $groupMap = json_decode(DB::table('migration_settings')->where('key', 'old_xf_group_to_xf_group')->value('value'), true); + + if( !$roleMap || !$groupMap ) { + $this->error('Role map and group map are required.'); + return self::FAILURE; + } + + $query = MigrationUserPlan::where('status', 'approved')->whereNull('user_id'); + if( $limit = $this->option('limit') ) { + $query->limit((int) $limit); + } + + $rows = $query->get(); + $this->info("{$rows->count()} accounts will be created on XenForo database !!!."); + $ok = $this->ask("Write 'ok' if you want to start the migration. Everything else to quit the migration."); + if( $ok !== 'ok' ) { + return self::SUCCESS; + } + + $service = app(XenforoApiService::class); + + $this->withProgressBar($rows, function($plan) use( $roleMap, $groupMap, $wpUploadsPath, $xfDataPath, $service ) { + try { + $infos = $this->buildUserInfos( $plan, $roleMap, $groupMap, $wpUploadsPath, $xfDataPath ); + + [ $userId, $passwordSet ] = $service->_migrateUser( $infos ); + if( !$userId ){ + throw new \RuntimeException("Error when user creation."); + } + + MigrationUserPlan::where('id', $plan->id )->update([ 'user_id' => $userId ]); + + $this->logMap( $plan->wp_user_id ? 'wp' : 'xf', $plan->wp_user_id ? 'wp_users' : 'xf_user', $plan->wp_user_id ?? $plan->xf_user_id, $userId ); + if( $plan->wp_user_id && $plan->xf_user_id ){ + $this->logMap('xf', 'xf_user', $plan->xf_user_id, $userId ); + } + + + } catch ( \Throwable $e ) { + Log::error("Unable to create Plan#{$plan->id} user : {$e->getMessage()}"); + } + }); + + $this->newLine(2); + $this->info("Process finished."); + return self::SUCCESS; + } +} diff --git a/app/Console/Commands/MigrateUsersPlan.php b/app/Console/Commands/MigrateUsersPlan.php new file mode 100644 index 0000000..28b9336 --- /dev/null +++ b/app/Console/Commands/MigrateUsersPlan.php @@ -0,0 +1,143 @@ +option('fresh') ) { + MigrationUserPlan::truncate(); + } + + $this->info("Loading old XF accounts..."); + + $xfUsers = DB::connection('old_xf')->table('user') + ->select('user_id','username','email') + ->get() + ->keyBy('user_id') + ; + + $xfUsersByEmail = $xfUsers->groupBy(fn($u) => strtolower($u->email)); + + $this->info("Loading old WP accounts..."); + + $wpUsers = DB::connection('old_wp')->table('users') + ->leftJoin('usermeta', function ($join) { + $join->on('users.ID', '=', 'usermeta.user_id')->where('usermeta.meta_key', '=', 'xf_user_id'); + }) + ->select('users.ID as wp_id', 'users.user_email as email', 'users.user_login as username', 'usermeta.meta_value as linked_xf_id') + ->get(); + + $linkedXfIds = []; + + $this->withProgressBar( $wpUsers, function ($wp) use ($xfUsers, $xfUsersByEmail, &$linkedXfIds) { + $email = strtolower($wp->email); + $linkedId = $wp->linked_xf_id ? (int) $wp->linked_xf_id : null; + + if( $linkedId && $xfUsers->has($linkedId) ) { + + $xf = $xfUsers->get($linkedId); + $matchType = strtolower($xf->email) === $email ? 'explicit' : 'conflict'; + + MigrationUserPlan::updateOrCreate( + ['wp_user_id' => $wp->wp_id], + [ + 'xf_user_id' => $linkedId, + 'match_type' => $matchType, + 'email' => $email, + 'wp_username' => $wp->username, + 'xf_username' => $xf->username, + 'note' => $matchType === 'conflict' ? "E-Mail différent: {$xf->email}" : null, + 'status' => $matchType === 'explicit' ? 'approved' : 'pending', + ] + ); + $linkedXfIds[$linkedId] = true; + return; + } + + if( $linkedId && !$xfUsers->has($linkedId) ) { + MigrationUserPlan::updateOrCreate( + ['wp_user_id' => $wp->wp_id ], + [ + 'match_type' => 'conflict', + 'email' => $email, + 'wp_username' => $wp->username, + 'note' => "xf_user_id={$linkedId} introuvable", + 'status' => 'approved' + ]); + return; + } + + $candidates = $xfUsersByEmail->get($email,collect()); + + if( $candidates->count() === 1 ){ + $xf = $candidates->first(); + MigrationUserPlan::updateOrCreate( + ['wp_user_id' => $wp->wp_id], + [ + 'xf_user_id' => $xf->user_id, + 'match_type' => 'email', + 'email' => $email, + 'wp_username' => $wp->username, + 'xf_username' => $xf->username, + 'status' => 'pending' + ] + ); + $linkedXfIds[$xf->user_id] = true; + return; + } + + if( $candidates->count() > 1 ){ + MigrationUserPlan::updateOrCreate( + ['wp_user_id' => $wp->wp_id ], + [ + 'match_type' => 'conflict', + 'email' => $email, + 'wp_username' => $wp->username, + 'note' => "E-mail identique sur plusieurs comptes XF.", + ] + ); + return; + } + + MigrationUserPlan::updateOrCreate( + ['wp_user_id' => $wp->wp_id ], + [ + 'match_type' => 'wp_only', + 'email' => $email, + 'wp_username' => $wp->username, + 'status' => 'approved' + ] + ); + }); + + $this->info("Listing old XF accounts..."); + foreach( $xfUsers as $id => $xf ) { + if( isset($linkedXfIds[$id]) ) { + continue; + } + MigrationUserPlan::updateOrCreate( + ['xf_user_id' => $xf->user_id, 'wp_user_id' => null], + [ + 'match_type' => 'xf_only', + 'email' => strtolower($xf->email), + 'xf_username' => $xf->username, + 'status' => 'approved' + ] + ); + } + + $pending = MigrationUserPlan::where('status', 'pending')->count(); + $this->newLine(2); + $this->info("Plan generated. {$pending} pending cases."); + } +} diff --git a/app/Console/Commands/MigrateXFConfigure.php b/app/Console/Commands/MigrateXFConfigure.php new file mode 100644 index 0000000..bac1581 --- /dev/null +++ b/app/Console/Commands/MigrateXFConfigure.php @@ -0,0 +1,41 @@ +table('node') + ->where('node_type_id', 'Forum') + ->select('node_id', 'title') + ->get() + ; + + $this->info("{$forums->count()} forums found."); + + $forumMap = []; + foreach ($forums as $forum) { + $newId = $this->ask("New ID for {$forum->title}"); + if( $newId !== null && $newId !== '' ){ + $map[$forum->node_id] = (int) $newId; + } + } + + DB::table('migration_settings') + ->updateOrInsert( + [ 'key' => 'old_xf_node_to_new_xf_node'], + [ 'value' => json_encode($map), 'updated_at' => now() ], + ); + + $this->info("Config saved."); + } +} diff --git a/app/Console/Commands/PurgeFeaturedEntries.php b/app/Console/Commands/PurgeFeaturedEntries.php new file mode 100644 index 0000000..e487ce2 --- /dev/null +++ b/app/Console/Commands/PurgeFeaturedEntries.php @@ -0,0 +1,31 @@ +option('days'); + + $cutoff = now()->subDays($days); + + $count = Entry::query() + ->where('featured', true) + ->where('featured_at', '<=', $cutoff) + ->update([ + 'featured' => false, + 'featured_at' => null, + ]); + + $this->info("$count entr" . ($count > 1 ? 'ies' : 'y') . " unfeatured."); + return self::SUCCESS; + } +} diff --git a/app/Filament/Resources/Categories/CategoryResource.php b/app/Filament/Resources/Categories/CategoryResource.php new file mode 100644 index 0000000..2b6fa11 --- /dev/null +++ b/app/Filament/Resources/Categories/CategoryResource.php @@ -0,0 +1,48 @@ + ListCategories::route('/'), + 'create' => CreateCategory::route('/create'), + 'edit' => EditCategory::route('/{record}/edit'), + ]; + } +} diff --git a/app/Filament/Resources/Categories/Pages/CreateCategory.php b/app/Filament/Resources/Categories/Pages/CreateCategory.php new file mode 100644 index 0000000..a842af9 --- /dev/null +++ b/app/Filament/Resources/Categories/Pages/CreateCategory.php @@ -0,0 +1,11 @@ +components([ + TextInput::make('name') + ->required(), + TextInput::make('slug') + ->required(), + Textarea::make('restricted_to') + ->default(null) + ->columnSpanFull(), + ]); + } +} diff --git a/app/Filament/Resources/Categories/Tables/CategoriesTable.php b/app/Filament/Resources/Categories/Tables/CategoriesTable.php new file mode 100644 index 0000000..346d3a9 --- /dev/null +++ b/app/Filament/Resources/Categories/Tables/CategoriesTable.php @@ -0,0 +1,42 @@ +columns([ + TextColumn::make('name') + ->searchable(), + TextColumn::make('slug') + ->searchable(), + TextColumn::make('created_at') + ->dateTime() + ->sortable() + ->toggleable(isToggledHiddenByDefault: true), + TextColumn::make('updated_at') + ->dateTime() + ->sortable() + ->toggleable(isToggledHiddenByDefault: true), + ]) + ->filters([ + // + ]) + ->recordActions([ + EditAction::make(), + ]) + ->toolbarActions([ + BulkActionGroup::make([ + DeleteBulkAction::make(), + ]), + ]); + } +} diff --git a/app/Filament/Resources/Levels/LevelResource.php b/app/Filament/Resources/Levels/LevelResource.php new file mode 100644 index 0000000..c2bb67d --- /dev/null +++ b/app/Filament/Resources/Levels/LevelResource.php @@ -0,0 +1,48 @@ + ListLevels::route('/'), + 'create' => CreateLevel::route('/create'), + 'edit' => EditLevel::route('/{record}/edit'), + ]; + } +} diff --git a/app/Filament/Resources/Levels/Pages/CreateLevel.php b/app/Filament/Resources/Levels/Pages/CreateLevel.php new file mode 100644 index 0000000..e8cfbde --- /dev/null +++ b/app/Filament/Resources/Levels/Pages/CreateLevel.php @@ -0,0 +1,11 @@ +components([ + TextInput::make('name') + ->required(), + TextInput::make('slug') + ->required(), + ]); + } +} diff --git a/app/Filament/Resources/Levels/Tables/LevelsTable.php b/app/Filament/Resources/Levels/Tables/LevelsTable.php new file mode 100644 index 0000000..d64fd20 --- /dev/null +++ b/app/Filament/Resources/Levels/Tables/LevelsTable.php @@ -0,0 +1,42 @@ +columns([ + TextColumn::make('name') + ->searchable(), + TextColumn::make('slug') + ->searchable(), + TextColumn::make('created_at') + ->dateTime() + ->sortable() + ->toggleable(isToggledHiddenByDefault: true), + TextColumn::make('updated_at') + ->dateTime() + ->sortable() + ->toggleable(isToggledHiddenByDefault: true), + ]) + ->filters([ + // + ]) + ->recordActions([ + EditAction::make(), + ]) + ->toolbarActions([ + BulkActionGroup::make([ + DeleteBulkAction::make(), + ]), + ]); + } +} diff --git a/app/Filament/Resources/Systems/Pages/CreateSystem.php b/app/Filament/Resources/Systems/Pages/CreateSystem.php new file mode 100644 index 0000000..c03c9fd --- /dev/null +++ b/app/Filament/Resources/Systems/Pages/CreateSystem.php @@ -0,0 +1,11 @@ +components([ + TextInput::make('name') + ->required(), + TextInput::make('slug') + ->required(), + ]); + } +} diff --git a/app/Filament/Resources/Systems/SystemResource.php b/app/Filament/Resources/Systems/SystemResource.php new file mode 100644 index 0000000..14aead4 --- /dev/null +++ b/app/Filament/Resources/Systems/SystemResource.php @@ -0,0 +1,48 @@ + ListSystems::route('/'), + 'create' => CreateSystem::route('/create'), + 'edit' => EditSystem::route('/{record}/edit'), + ]; + } +} diff --git a/app/Filament/Resources/Systems/Tables/SystemsTable.php b/app/Filament/Resources/Systems/Tables/SystemsTable.php new file mode 100644 index 0000000..822e32e --- /dev/null +++ b/app/Filament/Resources/Systems/Tables/SystemsTable.php @@ -0,0 +1,42 @@ +columns([ + TextColumn::make('name') + ->searchable(), + TextColumn::make('slug') + ->searchable(), + TextColumn::make('created_at') + ->dateTime() + ->sortable() + ->toggleable(isToggledHiddenByDefault: true), + TextColumn::make('updated_at') + ->dateTime() + ->sortable() + ->toggleable(isToggledHiddenByDefault: true), + ]) + ->filters([ + // + ]) + ->recordActions([ + EditAction::make(), + ]) + ->toolbarActions([ + BulkActionGroup::make([ + DeleteBulkAction::make(), + ]), + ]); + } +} diff --git a/app/Helpers/EntryHelpers.php b/app/Helpers/EntryHelpers.php index 0d50789..25228a3 100644 --- a/app/Helpers/EntryHelpers.php +++ b/app/Helpers/EntryHelpers.php @@ -3,6 +3,8 @@ namespace App\Helpers; use App\Models\Entry; +use App\Models\EntryFile; +use App\Models\News; use App\Services\XenforoApiService; use Illuminate\Support\Facades\Cache; use Illuminate\Support\Str; @@ -62,13 +64,16 @@ class EntryHelpers { }; } - public static function getLatestComments(Entry $entry, int $limit = 20): array { + public static function getLatestComments(Entry|News $entry, int $limit = 20): array { if( !$entry->comments_thread_id ){ return []; } - $cacheKey = "entry_comments_{$entry->id}"; + if( is_a( $entry, News::class ) ) + $cacheKey = "news_comments_{$entry->id}"; + else + $cacheKey = "entry_comments_{$entry->id}"; return Cache::remember($cacheKey, now()->addDays(1), function () use ($entry, $limit) { $service = app(XenforoApiService::class); @@ -90,4 +95,28 @@ class EntryHelpers { }); } + + public static function enableOnlinePatcherBasedOnExtension(string $filename): bool + { + return Str::endsWith(Str::lower($filename), ['.ips', '.bps', '.xdelta', '.ups', '.aps', '.ppf', '.zip' ]); + } + + public static function getYoutubeVideoId(string $url): ?string + { + $pattern = '%(?:https?://)?(?:www\.|m\.)?(?:youtu\.be/|youtube(?:-nocookie)?\.com/(?:watch\?.*v=|embed/|v/|shorts/|live/))([\w-]{11})%i'; + + preg_match($pattern, $url, $matches); + return $matches[1] ?? null; + } + + public static function fileAlreadyDownloaded(EntryFile $entryFile): bool + { + return session("downloaded_file_{$entryFile->file_uuid}", null ) !== null; + } + + public static function markFileAsDownloaded(EntryFile $entryFile): bool + { + session(["downloaded_file_{$entryFile->file_uuid}" => 1]); + return true; + } } diff --git a/app/Helpers/FormHelpers.php b/app/Helpers/FormHelpers.php index 9227063..efba8c8 100644 --- a/app/Helpers/FormHelpers.php +++ b/app/Helpers/FormHelpers.php @@ -41,6 +41,79 @@ class FormHelpers { 'release_site_helper' => "Project entry on site/blog/forum/Github.", 'youtube_video' => "YouTube video", ], + 'homebrew' => [ + 'page_title' => "Submit an homebrew", + 'about_the' => "About the homebrew", + 'version' => "Version", + 'status' => "Status", + 'release_date' => "Release date", + 'release_date_helper' => "If only initial release exist, the release date.", + 'description' => "Description", + 'about_game' => "Game Information", + 'attachments' => "Attachments", + 'authors' => "Team members", + 'related_links' => "Related links", + 'release_site' => "Release site", + 'release_site_helper' => "Project entry on site/blog/forum/Github.", + 'youtube_video' => "YouTube video", + ], + 'utilities' => [ + 'page_title' => "Submit a utility", + 'entry_title' => "Title", + 'about_the' => "About the utility", + 'version' => "Version", + 'status' => "Status", + 'system' => "OS", + 'categories' => "Categories", + 'level' => "Experience Level", + 'release_date' => "Release date", + 'release_date_helper' => "If only initial release exist, the release date.", + 'description' => "Description", + 'about_game' => "Game Information", + 'attachments' => "Attachments", + 'authors' => "Team members", + 'related_links' => "Related links", + 'release_site' => "Release site", + 'release_site_helper' => "Project entry on site/blog/forum/Github.", + 'youtube_video' => "YouTube video", + ], + 'documents' => [ + 'page_title' => "Submit a document", + 'entry_title' => "Title", + 'about_the' => "About the document", + 'version' => "Version", + 'status' => "Status", + 'categories' => "Categories", + 'level' => "Experience Level", + 'release_date' => "Release date", + 'release_date_helper' => "If only initial release exist, the release date.", + 'description' => "Description", + 'about_game' => "Game Information", + 'attachments' => "Attachments", + 'authors' => "Team members", + 'related_links' => "Related links", + 'release_site' => "Release site", + 'release_site_helper' => "Project entry on site/blog/forum/Github.", + 'youtube_video' => "YouTube video", + ], + 'lua-scripts' => [ + 'page_title' => "Submit a LUA Script", + 'about_the' => "About the script", + 'entry_title' => "Title", + 'type_of_hack' => "Modifications", + 'version' => "Version", + 'status' => "Status", + 'release_date' => "Release date", + 'release_date_helper' => "If only initial release exist, the release date.", + 'description' => "Description", + 'about_game' => "Game Information", + 'attachments' => "Attachments", + 'authors' => "Team members", + 'related_links' => "Related links", + 'release_site' => "Release site", + 'release_site_helper' => "Project entry on site/blog/forum/Github.", + 'youtube_video' => "YouTube video", + ], ]; public static function getEntryFormWords( string $section ){ diff --git a/app/Helpers/MigrationHelpers.php b/app/Helpers/MigrationHelpers.php new file mode 100644 index 0000000..74a24d4 --- /dev/null +++ b/app/Helpers/MigrationHelpers.php @@ -0,0 +1,47 @@ +]/i', $block)) { + return $block; + } + return '
' . nl2br($block, false) . '
'; + }, $blocks); + + return implode("\n\n", array_filter($blocks, fn ($b) => $b !== '')); + } + + public static function htmlToMarkdown( ?string $html ): string + { + if (!$html || trim($html) === '') { + return $html; + } + + static $converter = null; + if ($converter === null) { + $converter = new HtmlConverter(['hard_break' => true]); + } + + return $converter->convert(self::wpAutoP($html)); + } +} diff --git a/app/Helpers/PlayOnlineHelpers.php b/app/Helpers/PlayOnlineHelpers.php new file mode 100644 index 0000000..f7377b8 --- /dev/null +++ b/app/Helpers/PlayOnlineHelpers.php @@ -0,0 +1,57 @@ +updateEntriesCount( $count, $userId ); } - public static function entryApproved( Entry $entry ): void + public static function entryApproved( Entry|News $entry ): void { // 1. Update XF Entry count. self::updateEntriesCount( $entry->user_id ); @@ -58,6 +59,29 @@ class XenForoHelpers { $title = "Entry approved : {$entry->title}"; $message = "Your entry {$entry->title} has been approved by {$moderator}."; + $service = app(XenForoApiService::class); + + $service->createConversation([ $entry->user_id ], $title, $message, false, false); + } + + public static function entryRejected( Entry|News $entry ): void + { + // 1. Update XF Entry count. + self::updateEntriesCount( $entry->user_id ); + + // 2. Send a private message + /* + if( \Auth::user()->user_id === $entry->user_id ) { + return; + } + */ + + $moderator = \Auth::user()->username; + $title = "Entry rejected : {$entry->title}"; + $message = "Your entry {$entry->title} has been rejected by {$moderator}.\nReason: {$entry->staff_comment}\n\nYou have 7 days to edit your entry before it is permanently deleted."; + + $service = app(XenForoApiService::class); + $service->createConversation([ $entry->user_id ], $title, $message, false, false); } } diff --git a/app/Http/Controllers/DynamicLoadController.php b/app/Http/Controllers/DynamicLoadController.php index bbf847a..5cde67a 100644 --- a/app/Http/Controllers/DynamicLoadController.php +++ b/app/Http/Controllers/DynamicLoadController.php @@ -3,8 +3,10 @@ namespace App\Http\Controllers; use App\Helpers\XenForoHelpers; +use App\Services\ActivityService; use App\Services\XenforoApiService; use App\Services\XenforoService; +use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\Cache; @@ -63,4 +65,24 @@ class DynamicLoadController extends Controller return response()->json( $data ); } + + public function activityFeed(Request $request): JsonResponse + { + $availableFilters = ['entries', 'news', 'messages', 'threads', 'clubs', 'reviews']; + + $requested = $request->query('filters') + ? explode(',', $request->query('filters')) + : []; + + $activeFilters = !empty($requested) + ? array_intersect($requested, $availableFilters) + : $availableFilters; + + $service = app(ActivityService::class); + $items = $service->getActivities(array_values($activeFilters)); + + return response()->json([ + 'html' => view('activity.timeline', compact('items'))->render(), + ]); + } } diff --git a/app/Http/Controllers/EntryController.php b/app/Http/Controllers/EntryController.php index 07b9b71..dcb5c84 100644 --- a/app/Http/Controllers/EntryController.php +++ b/app/Http/Controllers/EntryController.php @@ -4,6 +4,7 @@ namespace App\Http\Controllers; use App\Helpers\EntryHelpers; use App\Models\Entry; +use App\Models\News; use Illuminate\Support\Facades\Gate; use Illuminate\Http\Request; use Illuminate\View\View; @@ -11,7 +12,7 @@ use Illuminate\View\View; class EntryController extends Controller { - private const SECTION_TYPES = ['translations', 'romhacks', 'homebrew', 'utilities', 'documents', 'lua-scripts', 'tutorials']; + private const SECTION_TYPES = ['translations', 'romhacks', 'homebrew', 'utilities', 'documents', 'lua-scripts']; public function index(): View { @@ -31,7 +32,8 @@ class EntryController extends Controller if ($entry->type !== $section) abort(404); - Gate::authorize('viewAny', $entry); + if( !\Auth::guest() ) + Gate::authorize('viewAny', $entry); // Permissions. $entryPolicy = match ($entry->state) { @@ -48,8 +50,9 @@ class EntryController extends Controller Gate::authorize($entryPolicy, $entry); $comments = EntryHelpers::getLatestComments($entry); + $reviews = $entry->reviews()->orderBy('created_at', 'desc')->limit(10)->get(); - return view('entries.show', compact('entry', 'section', 'comments')); + return view('entries.show', compact('entry', 'section', 'comments', 'reviews')); } @@ -61,7 +64,12 @@ class EntryController extends Controller ->orderBy('updated_at', 'desc') ->paginate(20); - return view('entries.drafts', compact('drafts')); + $newsDrafts = News::where('user_id', \Auth::user()->user_id ) + ->where('state', 'draft') + ->orderBy('updated_at', 'desc') + ->paginate(20); + + return view('entries.drafts', compact('drafts', 'newsDrafts')); } } diff --git a/app/Http/Controllers/EntryFeaturedRequestController.php b/app/Http/Controllers/EntryFeaturedRequestController.php new file mode 100644 index 0000000..6a158e2 --- /dev/null +++ b/app/Http/Controllers/EntryFeaturedRequestController.php @@ -0,0 +1,23 @@ +featuredRequest($entry); + return response()->json([ + 'success' => $response, + ]); + } + +} diff --git a/app/Http/Controllers/FileServerController.php b/app/Http/Controllers/FileServerController.php index f8c71f3..2054018 100644 --- a/app/Http/Controllers/FileServerController.php +++ b/app/Http/Controllers/FileServerController.php @@ -2,7 +2,9 @@ namespace App\Http\Controllers; +use App\Helpers\EntryHelpers; use App\Models\EntryFile; +use App\Models\LogXfUser; use App\Services\FileServersService; use Illuminate\Http\Client\ConnectionException; use Illuminate\Http\JsonResponse; @@ -44,7 +46,7 @@ class FileServerController extends Controller { return response()->json($data); } - \Cache::put("uploaded_file_{$fileUuid}", [ + $fileData = [ 'uuid' => $fileUuid, 'type' => $type, 'filename' => $filename, @@ -52,8 +54,17 @@ class FileServerController extends Controller { 'filesize' => $data['file']['size'], 'favorite_server' => $data['favorite_server'], 'favorite_at' => time(), - 'state' => 'public' - ], now()->addHours(2) ); + 'state' => 'public', + ]; + + activity('entry-file') + ->causedBy(LogXfUser::find(\Auth::user()->getAuthIdentifier())) + ->withProperties($fileData) + ->event('file_upload') + ->log("File uploaded") + ; + + \Cache::put("uploaded_file_{$fileUuid}", $fileData, now()->addHours(2) ); $data['finished'] = true; return response()->json($data); @@ -66,7 +77,13 @@ class FileServerController extends Controller { abort(404); } - // TODO: DL Count. + if($request->input('count_download', true)) { + if (!EntryHelpers::fileAlreadyDownloaded($file)) { + EntryHelpers::markFileAsDownloaded($file); + $file->increaseDownloadCount(); + } + } + return redirect( $this->fs->getDownloadFileUrl( $file) ); } } diff --git a/app/Http/Controllers/HomeController.php b/app/Http/Controllers/HomeController.php index e6054db..eafa41e 100644 --- a/app/Http/Controllers/HomeController.php +++ b/app/Http/Controllers/HomeController.php @@ -2,14 +2,43 @@ namespace App\Http\Controllers; +use App\Models\Entry; +use App\Services\ActivityService; use Illuminate\Http\Request; use Illuminate\View\View; +use App\Models\News; class HomeController extends Controller { - public function index(): View { - return view('home'); + public function __construct( private ActivityService $service ) {} + + public function index( Request $request ): View { + + $filters = [ 'entries', 'news', 'messages', 'threads', 'clubs', 'reviews' ]; + + $cookie = $request->cookie('activity_filters'); + $activeFilters = $cookie ? array_intersect( json_decode( $cookie, true ) ?? [], $filters ) : $filters; + + if( empty( $activeFilters ) ) { + $activeFilters = $filters; + } + + $items = $this->service->getActivities( array_values( $activeFilters ) ); + + $viewFilters = [ + 'entries' => ['label' => 'Entries', 'icon' => 'database'], + 'news' => ['label' => 'News', 'icon' => 'newspaper'], + 'messages' => ['label' => 'Posts', 'icon' => 'message-square'], + 'threads' => ['label' => 'Threads', 'icon' => 'messages-square'], + 'clubs' => ['label' => 'Clubs', 'icon' => 'balloon'], + 'reviews' => ['label' => 'Reviews', 'icon' => 'star'], + ]; + + $latestNews = News::published()->latest('created_at')->limit(5)->get(); + $featuredEntries = Entry::published()->where('featured', true)->latest('featured_at')->get(); + + return view('home', compact('items', 'activeFilters', 'viewFilters', 'latestNews', 'featuredEntries')); } } diff --git a/app/Http/Controllers/ModCPController.php b/app/Http/Controllers/ModCPController.php index b0d0d52..bf75df0 100644 --- a/app/Http/Controllers/ModCPController.php +++ b/app/Http/Controllers/ModCPController.php @@ -75,4 +75,9 @@ class ModCPController extends Controller $entry->forceDelete(); return back()->with('success', "Entry permanently deleted"); } + + public function logs() + { + return view('modcp.logs'); + } } diff --git a/app/Http/Controllers/NewsController.php b/app/Http/Controllers/NewsController.php new file mode 100644 index 0000000..23bb429 --- /dev/null +++ b/app/Http/Controllers/NewsController.php @@ -0,0 +1,124 @@ +state) { + 'pending' => 'viewPending', + 'draft' => 'viewDraft', + 'rejected' => 'viewRejected', + 'hidden' => 'viewHidden', + 'locked' => 'viewLocked', + 'published' => null, + 'default' => null + }; + + if ($entryPolicy) + Gate::authorize($entryPolicy, $news); + + $comments = EntryHelpers::getLatestComments($news); + + return view('news.show', compact('news', 'comments')); + } + + public function create(Request $request) + { + $data = [ + 'news' => new News(), + 'isEdit' => false, + 'oldCategory' => old('category') ? [ old('category') ] : [] + ]; + + return view ('news.create', $data); + } + + public function edit(Request $request, News $news) + { + $data = [ + 'news' => $news, + 'isEdit' => true, + 'oldCategory' => old('category', $news->category_id) ? [ old('category', $news->category_id) ] : [] + ]; + + return view ('news.edit', $data); + } + + public function store(Request $request) + { + $request = $request->input('submit-state') === 'draft' ? app(StoreNewsDraftRequest::class) : app(StoreNewsRequest::class); + $request->validateResolved(); + + $service = app(NewsService::class); + + try { + $entry = $service->storeNews($request); + + return match ($entry->state) { + 'published' => redirect()->route('news.show', ['news' => $entry->slug])->with('success', "Your entry has been published."), + 'pending' => redirect()->route('home')->with('success', "Your entry has been submitted and is pending review."), + default => redirect()->route('home')->with('success', "Your entry has been saved as a draft.") + }; + } catch ( SubmissionException $e ) { + return back()->withInput()->withErrors(['error' => $e->getMessage()]); + } catch ( \Exception $e ) { + return back()->withInput()->withErrors(['error' => 'Unknown error: '.$e->getMessage()]); + } + } + + public function update(Request $request, News $news) + { + $request = $request->input('submit-state') === 'draft' ? app(StoreNewsDraftRequest::class) : app(StoreNewsRequest::class); + $request->validateResolved(); + + $service = app(NewsService::class); + + try { + $news = $service->editNews($request, $news); + + return match ($news->state) { + 'published' => redirect()->route('news.show', ['news' => $news->slug])->with('success', "Your entry has been published."), + 'pending' => redirect()->route('home')->with('success', "Your entry has been submitted and is pending review."), + default => redirect()->route('home')->with('success', "Your entry has been saved as a draft.") + }; + } catch ( SubmissionException $e ) { + return back()->withInput()->withErrors(['error' => $e->getMessage()]); + } catch ( \Exception $e ) { + return back()->withInput()->withErrors(['error' => 'Unknown error: '.$e->getMessage()]); + } + + } + + public function destroy(Request $request, News $news) + { + if( $news->comments_thread_id ) + DeleteXenForoCommentsThread::dispatch( $news->comments_thread_id ); + + $news->delete(); + return redirect( route('news.index') )->with('success', "Entry successfully deleted."); + } +} diff --git a/app/Http/Controllers/QueueController.php b/app/Http/Controllers/QueueController.php index c9f4e91..7b5d037 100644 --- a/app/Http/Controllers/QueueController.php +++ b/app/Http/Controllers/QueueController.php @@ -4,6 +4,7 @@ namespace App\Http\Controllers; use App\Helpers\XenForoHelpers; use App\Models\Entry; +use App\Models\News; use App\Services\XenforoService; use Illuminate\Http\Request; class QueueController extends Controller @@ -14,7 +15,25 @@ class QueueController extends Controller ->with(['authors', 'game.platform']) ->orderByRaw("CASE WHEN state = 'pending' THEN 1 ELSE 0 END") ->orderBy('created_at', 'asc') - ->get(); + ->get() + ->map(fn($item) => $item->setAttribute('queue_type', 'entry')); + + $news = News::inQueue() + ->orderByRaw("CASE WHEN state = 'pending' THEN 1 ELSE 0 END") + ->orderBy('created_at', 'asc') + ->get() + ->map(fn($item) => $item->setAttribute('queue_type', 'news')); + + $entries = $entries->concat($news)->sort(function($a, $b) { + $aPending = $a->state === 'pending' ? 0 : 1; + $bPending = $b->state === 'pending' ? 0 : 1; + + if($aPending !== $bPending) { + return $aPending <=> $bPending; + } + + return $a->created_at <=> $b->created_at; + })->values(); return view('queue.index', compact('entries')); } @@ -25,24 +44,53 @@ class QueueController extends Controller $entry->update(['staff_comment' => $request->input('comment')]); + return back()->with('success', 'Comment supdated'); + } + + public function updateComment_news(Request $request, News $news) + { + $request->validate(['comment' => 'nullable|string|max:2000']); + + $news->update(['staff_comment' => $request->input('comment')]); + return back()->with('success', 'Comment updated'); } public function approve(Request $request, Entry $entry) { - // $entry->update(['state' => 'published']); + $entry->update(['state' => 'published', 'created_at' => now()]); XenForoHelpers::entryApproved($entry); return back()->with('success', 'Entry approved'); } + public function approve_news(Request $request, News $news) + { + $news->update(['state' => 'published', 'created_at' => now()]); + + XenForoHelpers::entryApproved($news); + + return back()->with('success', 'Entry approved'); + } + public function reject(Request $request, Entry $entry) { $request->validate(['reason' => 'nullable|string|max:2000']); $entry->update(['state' => 'rejected', 'staff_comment' => $request->input('reason'), 'rejected_at' => now() ]); + XenForoHelpers::entryRejected($entry); + return back()->with('success', 'Entry rejected'); + } + + public function reject_news(Request $request, News $news) + { + $request->validate(['reason' => 'nullable|string|max:2000']); + + $news->update(['state' => 'rejected', 'staff_comment' => $request->input('reason'), 'rejected_at' => now() ]); + + XenForoHelpers::entryRejected($news); return back()->with('success', 'Entry rejected'); } diff --git a/app/Http/Controllers/RedirectController.php b/app/Http/Controllers/RedirectController.php index 10d9753..61254e2 100644 --- a/app/Http/Controllers/RedirectController.php +++ b/app/Http/Controllers/RedirectController.php @@ -14,4 +14,13 @@ class RedirectController extends Controller return redirect()->route('entries.show', ['section' => $entry->type, 'entry' => $entry])->with('success', "Your report has been sent."); } + + public function newsReportRedirect( Request $request ) + { + $id = $request->input('id'); + $entry = News::findOrFail($id); + + return redirect()->route('news.show', ['news' => $entry])->with('success', "Your report has been sent."); + } + } diff --git a/app/Http/Controllers/ReviewController.php b/app/Http/Controllers/ReviewController.php new file mode 100644 index 0000000..7ee1898 --- /dev/null +++ b/app/Http/Controllers/ReviewController.php @@ -0,0 +1,33 @@ +services->storeReview( $request, $entry ); + return redirect()->route('entries.show', [ 'section' => $entry->type, 'entry' => $entry ])->with('success', "Your review has been published."); + } catch ( SubmissionException $e ) { + return back()->withInput()->withErrors(['error' => $e->getMessage()]); + } catch ( \Exception $e ) { + return back()->withInput()->withErrors(['error' => 'Unknown error: '.$e->getMessage()]); + } + } +} diff --git a/app/Http/Controllers/SubmissionController.php b/app/Http/Controllers/SubmissionController.php index a8ee83d..ebc7c4b 100644 --- a/app/Http/Controllers/SubmissionController.php +++ b/app/Http/Controllers/SubmissionController.php @@ -10,14 +10,16 @@ use App\Jobs\DeleteXenForoCommentsThread; use App\Models\Author; use App\Models\Entry; use App\Models\EntryFile; -use App\Models\EntryGallery; +use App\Models\Gallery; use App\Models\EntryHash; use App\Models\Game; use App\Models\Genre; use App\Models\Language; +use App\Models\Level; use App\Models\Modification; use App\Models\Platform; use App\Models\Status; +use App\Models\System; use App\Services\SubmissionsService; use App\Services\XenforoApiService; use Illuminate\Http\Request; @@ -31,6 +33,62 @@ class SubmissionController extends Controller public function __construct(private SubmissionsService $services){} + public function index(Request $request) + { + + $entryTypes = [ + 'romhack' => [ + 'slug' => 'romhacks', + 'label' => 'Romhack', + 'icon' => 'gamepad-2', + 'color' => '#4a6fc2', + 'bg' => '#d5e1fc1a', + 'border' => '#d5e1fc40', + ], + 'translation' => [ + 'slug' => 'translations', + 'label' => 'Translation', + 'icon' => 'languages', + 'color' => '#4a8a2a', + 'bg' => '#e7f4d91a', + 'border' => '#e7f4d940', + ], + 'homebrew' => [ + 'slug' => 'homebrew', + 'label' => 'Homebrew', + 'icon' => 'cpu', + 'color' => '#c23060', + 'bg' => '#ffeaf01a', + 'border' => '#ffeaf040', + ], + 'utility' => [ + 'slug' => 'utilities', + 'label' => 'Utility', + 'icon' => 'wrench', + 'color' => '#8a6600', + 'bg' => '#fff8d51a', + 'border' => '#fff8d540', + ], + 'document' => [ + 'slug' => 'documents', + 'label' => 'Document', + 'icon' => 'file-text', + 'color' => '#7a35c2', + 'bg' => '#f3eaff1a', + 'border' => '#f3eaff40', + ], + 'lua-script' => [ + 'slug' => 'lua-script', + 'label' => 'Lua script', + 'icon' => 'terminal', + 'color' => '#a04515', + 'bg' => '#eed6c51a', + 'border' => '#eed6c540', + ] ]; + + return view('submissions.index', compact('entryTypes')); + } + public function create(Request $request, string $section) { $data = [ @@ -39,19 +97,27 @@ class SubmissionController extends Controller 'words' => FormHelpers::getEntryFormWords($section), 'isEdit' => false, 'oldModifications' => old( 'modifications', [] ), + 'oldSystems' => old( 'systems', [] ), 'oldLanguages' => old( 'languages', [] ), + 'oldCategories' => old( 'categories', [] ), 'oldFilesArray' => $this->services->prepareOldFiles( null ) ]; if( $data['words'] === [] ) abort(500); - if( section_must_be( 'romhacks', $section ) ){ + if( section_must_be( ['romhacks', 'lua-scripts'], $section ) ){ $data['modifications'] = Modification::orderBy('name')->get(); } - if( section_must_be( [ 'romhacks', 'translations' ], $section ) ){ + if( section_must_be( [ 'romhacks', 'translations', 'homebrew', 'lua-scripts' ], $section ) ){ $data['statuses'] = Status::orderBy('id')->get(); } + if( section_must_be( 'utilities' , $section ) ){ + $data['systems'] = System::orderBy('name')->get(); + } + if( section_must_be( [ 'utilities', 'documents' ], $section ) ) { + $data['levels'] = Level::orderBy('id')->get(); + } return view('submissions.create', $data); } @@ -68,19 +134,27 @@ class SubmissionController extends Controller 'words' => FormHelpers::getEntryFormWords($section), 'isEdit' => true, 'oldModifications' => old('modifications', $entry->modifications->pluck('id')->toArray() ?? [] ), + 'oldSystems' => old( 'systems', $entry->systems->pluck('id')->toArray() ?? [] ), 'oldLanguages' => old('languages', $entry->languages->pluck('id')->toArray() ?? [] ), + 'oldCategories' => old('categories', $entry->categories->pluck('id')->toArray() ?? [] ), 'oldFilesArray' => $this->services->prepareOldFiles( $entry ) ]; if( $data['words'] === [] ) abort(500); - if( section_must_be( 'romhacks', $section ) ){ + if( section_must_be( [ 'romhacks', 'lua-scripts' ], $section ) ){ $data['modifications'] = Modification::orderBy('name')->get(); } - if( section_must_be( [ 'romhacks', 'translations' ], $section ) ){ + if( section_must_be( [ 'romhacks', 'translations', 'homebrew', 'lua-scripts' ], $section ) ){ $data['statuses'] = Status::orderBy('id')->get(); } + if( section_must_be( 'utilities' , $section ) ){ + $data['systems'] = System::orderBy('name')->get(); + } + if( section_must_be( [ 'utilities', 'documents' ], $section ) ) { + $data['levels'] = Level::orderBy('id')->get(); + } return view('submissions.edit', $data); } @@ -90,7 +164,7 @@ class SubmissionController extends Controller if( $entry->type !== $section ) abort(404); - if( $entry->comments_thread_id) + if( $entry->comments_thread_id ) DeleteXenForoCommentsThread::dispatch( $entry->comments_thread_id ); $entry->delete(); diff --git a/app/Http/Controllers/ToolsController.php b/app/Http/Controllers/ToolsController.php index 4502744..f1b5e9b 100644 --- a/app/Http/Controllers/ToolsController.php +++ b/app/Http/Controllers/ToolsController.php @@ -2,6 +2,9 @@ namespace App\Http\Controllers; +use App\Models\Entry; +use App\Models\EntryFile; +use App\Services\FileServersService; use Illuminate\Http\Request; class ToolsController extends Controller @@ -9,14 +12,59 @@ class ToolsController extends Controller public function patcher() { + return view('tools.patcher'); + } + + public function directPatch( Request $request, int $entryId, EntryFile $file ) + { + if( $file->entry_id != $entryId || $file->state === 'private' ) { + abort(404); + } + + $service = app(FileServersService::class); + $patches = [ - 'file' => 'ZELDA.ips', - 'name' => "Meltin", - 'description' => 'Blablabla', - 'outputName' => 'Game...' + 'file' => $service->getDownloadFileUrl( $file ), + 'name' => $file->entry->title, + 'outputName' => $file->filename ]; + + return view('tools.patcher', compact('patches')); } + + public function play( Request $request, int $entryId, EntryFile $file ) + { + if( $file->entry_id != $entryId || $file->state === 'private' ) { + abort(404); + } + + $service = app(FileServersService::class); + + $emuConfig = [ + 'core' => $file->playOnlineSetting?->core, + 'threads' => $file->playOnlineSetting?->threads, + ]; + + if( $file->entry->type === 'homebrew' ){ + $filePath = $service->getDownloadFileUrl( $file ); + + return view('tools.play-homebrew', compact('filePath', 'emuConfig')); + } + $patches = [ + 'file' => $service->getDownloadFileUrl( $file ), + 'name' => $file->entry->title, + 'outputName' => $file->filename + ]; + + return view('tools.play', compact('patches', 'emuConfig')); + } + + public function hasher( Request $request ) + { + return view('tools.hasher'); + } + } diff --git a/app/Http/Middleware/CheckXenForoUserState.php b/app/Http/Middleware/CheckXenForoUserState.php new file mode 100644 index 0000000..3d36c34 --- /dev/null +++ b/app/Http/Middleware/CheckXenForoUserState.php @@ -0,0 +1,50 @@ +security_lock === 'change' ) + return $this->deny( $request, "Password must be changed." ); + else if( \Auth::user()->security_lock === 'reset' ) + return $this->deny( $request, "Password must be reset."); + + if( \Auth::user()->user_state === 'valid' ) + return $next($request); + + else if( \Auth::user()->user_state === 'email_confirm' || \Auth::user()->user_state === 'email_confirm_edit' ) + return $this->deny( $request, "You must verify your email address." ); + else if( \Auth::user()->user_state === 'email_bounce' ) + return $this->deny( $request, "Invalid email address." ); + else if( \Auth::user()->user_state === 'rejected' ) + return $this->deny( $request, "Your account is currently rejected." ); + else if( \Auth::user()->user_state === 'disabled' ) + return $this->deny( $request, "Your account is currently disabled." ); + + return $this->deny($request, "Invalid user state."); + } + + private function deny(Request $request, string $reason): Response + { + if($request->expectsJson()) + return \response()->json(['error' => 'forbidden', 'reason' => $reason], 403); + + return response()->view('pages.user_state', [ + 'reason' => $reason, + ], 403 ); + } +} diff --git a/app/Http/Requests/StoreEntryRequest.php b/app/Http/Requests/StoreEntryRequest.php index 2652b6f..276f472 100644 --- a/app/Http/Requests/StoreEntryRequest.php +++ b/app/Http/Requests/StoreEntryRequest.php @@ -64,34 +64,55 @@ class StoreEntryRequest extends FormRequest $rules['files_state.*'] = 'string|in:public,private,archived'; } - if( section_must_not_be( 'translations', $section ) ){ + if( section_must_not_be( ['translations','homebrew'], $section ) ){ $rules['entry_title'] = "required|string|max:255"; } else { $rules['entry_title'] = "nullable|string|max:255"; } - if( section_must_be( 'romhacks', $section ) ){ + if( section_must_be( ['romhacks', 'lua-scripts'], $section ) ){ $rules['modifications'] = 'array|required|min:1'; $rules['modifications.*'] = 'integer|exists:modifications,id'; + } else if( section_must_be( ['utilities','documents'], $section ) ){ + $rules['categories'] = 'array|required|min:1'; + $rules['categories.*'] = 'integer|exists:categories,id'; + } + + if( section_must_be( 'utilities', $section ) ){ + $rules['systems'] = 'array|required|min:1'; + $rules['systems.*'] = 'integer|exists:systems,id'; } $rules['version'] = 'required|string|max:50'; $rules['release-date'] = 'required|date'; - $rules['status'] = 'required|integer|exists:statuses,id'; + if( section_must_not_be( ['utilities', 'documents'], $section ) ){ + $rules['status'] = 'required|integer|exists:statuses,id'; + } else { + $rules['level'] = 'required|integer|exists:levels,id'; + } $rules['description'] = 'required|string'; - if( section_must_be( ['romhacks', 'translations' ], $section ) ){ + $rules['game_selection_mode'] = 'required|string|in:game,platform,none'; + $gameSelectionMode = $this->input('game_selection_mode') !== '' ? $this->input('game_selection_mode') : 'game'; + + if( $gameSelectionMode === 'none' ){ + // ... + } else if( $gameSelectionMode === 'platform' ){ + $rules['platform_only_id'] = 'required|integer|exists:platforms,id'; + } else { $rules['game_id'] = 'required_without:new-game-title|nullable|integer|exists:games,id'; $rules['new-game-title'] = 'required_without:game_id|nullable|string|max:255'; $rules['new-game-platform'] = 'required_with:new-game-title|nullable|integer|exists:platforms,id'; $rules['new-game-genre'] = 'required_with:new-game-title|integer|nullable|exists:genres,id'; } - $rules['hashes'] = 'array|required|min:1'; - $rules['hashes.*.filename'] = 'required|string|max:512'; - $rules['hashes.*.hash_crc32'] = 'required|string|max:512'; - $rules['hashes.*.hash_sha1'] = 'required|string|max:512'; - $rules['hashes.*.verified'] = 'required|string|max:512'; + if( section_must_be( ['translations', 'romhacks'], $section ) ){ + $rules['hashes'] = 'array|required|min:1'; + $rules['hashes.*.filename'] = 'required|string|max:512'; + $rules['hashes.*.hash_crc32'] = 'required|string|max:512'; + $rules['hashes.*.hash_sha1'] = 'required|string|max:512'; + $rules['hashes.*.verified'] = 'required|string|max:512'; + } $rules['languages'] = 'array|required|min:1'; $rules['languages.*'] = 'integer|exists:languages,id'; @@ -124,11 +145,21 @@ class StoreEntryRequest extends FormRequest } } + if( $isEdit ){ + $rules['files_metadata'] = 'array|nullable'; + $rules['files_metadata.*.online_patcher'] = 'nullable|boolean'; + $rules['files_metadata.*.secondary_online_patcher'] = 'nullable|boolean|required_with:files_metadata.*.online_patcher'; + $rules['files_metadata.*.play_online'] = 'nullable|boolean'; + $rules['files_metadata.*.play_online_core'] = 'nullable|string'; + $rules['files_metadata.*.play_online_threads'] = 'nullable|boolean'; + } + if( $isEdit && $this->user()->can('moderate', $this->route('entry') ) ){ $rules['staff_comment'] = 'nullable|string'; $rules['owner_user_id'] = [ 'required', 'integer', new XfUserExists ]; $rules['comments_thread_id'] = 'nullable|integer'; $rules['featured'] = 'nullable|boolean'; + $rules['refresh_created_at'] = 'nullable|boolean'; } return $rules; @@ -137,16 +168,31 @@ class StoreEntryRequest extends FormRequest public function messages(): array { return [ - 'entry_title.required' => 'Please provide an entry title.', - 'slug.unique' => 'An entry with this title already exists. Please choose another title.', - 'file_ids.required' => 'Please upload at least one file.', - 'file_ids.min' => 'Please upload at least one file.', - 'hashes.required' => 'Please add at least one hash.', - 'hashes.min' => 'Please add at least one hash.', - 'gallery.required' => 'Please add at least one screenshot.', - 'gallery.min' => 'Please add at least one screenshot.', - 'new-game-platform.required_without' => 'Please choose a platform for the new game.', - 'new-game-genre.required_without' => 'Please choose a genre for the new game.', + 'files_uuid.required' => 'Please upload at least a file.', + 'files_state.required' => 'A file may be corrupted, please reupload it.', + 'files_state.*.in' => 'A file state doesn\'t have a standard value.', + 'entry_title.required' => 'Please enter a title.', + 'modifications.required' => 'Please select at least one modification.', + 'categories.required' => 'Please select at least one category.', + 'system.required' => 'Please select at least one system.', + 'version.required' => 'Please enter a version number.', + 'release-date.required' => 'Please enter a valid release date.', + 'status.required' => 'Please select a status.', + 'level.required' => 'Please select an experience level.', + 'description.required' => 'Please enter a description.', + 'game_selection_mode.required' => 'Please select a game selection mode.', + 'platform_only_id.required' => 'Please select a platform.', + 'game_id.required' => 'Please select a game.', + 'new-game-title.required' => 'Please enter a game title.', + 'new-game-platform.required' => 'Please select a game platform.', + 'new-game-genre.required' => 'Please select a game genre.', + 'hashes.required' => 'Please send at least one hash.', + 'languages.required' => 'Please select at least one language.', + 'main-image.required' => 'Please upload a main image.', + 'gallery.required' => 'Please upload at least one image for the gallery.', + 'authors.required' => 'Please select at least one author.', + 'new-authors.required' => 'Please select at least one author.', + 'submit-state.required' => 'Please select a submit state.', ]; } } diff --git a/app/Http/Requests/StoreNewsDraftRequest.php b/app/Http/Requests/StoreNewsDraftRequest.php new file mode 100644 index 0000000..87d806c --- /dev/null +++ b/app/Http/Requests/StoreNewsDraftRequest.php @@ -0,0 +1,26 @@ + $r === 'required' ? 'nullable' : $r, $rule); + } + + return preg_replace( + ['/\brequired_without\S*/', '/required_with\S*/', '/\brequired\b/'], + ['nullable', 'nullable', 'nullable'], + $rule + ); + }, $rules ); + + return $rules; + } +} diff --git a/app/Http/Requests/StoreNewsRequest.php b/app/Http/Requests/StoreNewsRequest.php new file mode 100644 index 0000000..822abc3 --- /dev/null +++ b/app/Http/Requests/StoreNewsRequest.php @@ -0,0 +1,78 @@ +route('news'); + if( $news ) + return $this->user()->can('update', $news); + + return $this->user()->can('create', News::class); + } + + public function prepareForValidation(): void + { + $this->merge([ + 'gallery' => $this->input('gallery') !== '' ? $this->input('gallery') : null, + ]); + } + + /** + * Get the validation rules that apply to the request. + * + * @return array