'$refresh', 'updateSelectedTranslationIds' => 'updateSelectedTranslationIds', ]; public function mount(bool $showBackupSelected = false) { // Verify admin access $this->authorizeAdminAccess(); $this->showBackupSelected = $showBackupSelected; } /** * Update selected translation IDs from parent component. */ public function updateSelectedTranslationIds(array $ids) { $this->selectedTranslationIds = $ids; } /** * Toggle select/deselect all posts for restore. */ public function toggleSelectAll() { if ($this->selectAllPosts) { $this->selectedPostIndices = array_column($this->restorePostList, 'index'); } else { $this->selectedPostIndices = []; } } /** * Update selectAllPosts state when individual checkboxes change. */ public function updatedSelectedPostIndices() { $this->selectAllPosts = count($this->selectedPostIndices) === count($this->restorePostList); } /** * Generate and download backup for all posts. */ public function backup() { $this->authorizeAdminAccess(); // Pass query builder (not ->get()) so generateBackup can chunk it $posts = Post::query(); return $this->generateBackup($posts, 'posts_backup_'); } /** * Generate and download backup for selected posts only. */ public function backupSelected() { $this->authorizeAdminAccess(); if (empty($this->selectedTranslationIds)) { $this->notification()->error( __('Error'), __('No posts selected') ); return; } // Get unique post IDs from selected translation IDs $postIds = PostTranslation::whereIn('id', $this->selectedTranslationIds) ->pluck('post_id') ->unique() ->toArray(); // Pass query builder (not ->get()) so generateBackup can chunk it $posts = Post::whereIn('id', $postIds); return $this->generateBackup($posts, 'posts_selected_backup_'); } /** * Generate backup data and return as download response (ZIP archive with media). * * @param \Illuminate\Database\Eloquent\Builder|\Illuminate\Database\Eloquent\Collection $posts Query builder or collection */ private function generateBackup($posts, string $filenamePrefix) { // Build category type lookup (id => type) $categoryTypes = Category::pluck('type', 'id')->toArray(); $filename = $filenamePrefix . now()->format('Ymd_His') . '.zip'; // Create temporary files $tempDir = storage_path('app/temp'); if (!File::isDirectory($tempDir)) { File::makeDirectory($tempDir, 0755, true); } $tempPath = $tempDir . '/' . uniqid('backup_') . '.zip'; $tempJsonPath = $tempDir . '/' . uniqid('backup_json_') . '.json'; // Track counts for meta $counts = ['posts' => 0, 'post_translations' => 0, 'meetings' => 0, 'media_files' => 0]; // Track media files to include in ZIP $mediaFiles = []; // Write posts as JSON incrementally to a temp file to avoid holding everything in memory $jsonHandle = fopen($tempJsonPath, 'w'); // Write a placeholder for meta - will be replaced via a separate meta file in the ZIP fwrite($jsonHandle, '{"meta":"__PLACEHOLDER__","posts":['); $isFirst = true; $chunkSize = 100; // Process posts in chunks to limit memory usage $processPost = function ($post) use ($categoryTypes, $jsonHandle, &$isFirst, &$counts, &$mediaFiles) { $categoryType = $categoryTypes[$post->category_id] ?? null; $postData = [ 'category_type' => $categoryType, 'love_reactant_id' => $post->love_reactant_id, 'author_id' => $post->author_id, 'author_model' => $post->author_model, 'created_at' => $this->formatDate($post->created_at), 'updated_at' => $this->formatDate($post->updated_at), 'translations' => [], 'meeting' => null, 'media' => null, ]; foreach ($post->translations as $translation) { $postData['translations'][] = [ 'locale' => $translation->locale, 'slug' => $translation->slug, 'title' => $translation->title, 'excerpt' => $translation->excerpt, 'content' => $translation->content, 'status' => $translation->status, 'updated_by_user_id' => $translation->updated_by_user_id, 'from' => $this->formatDate($translation->from), 'till' => $this->formatDate($translation->till), 'created_at' => $this->formatDate($translation->created_at), 'updated_at' => $this->formatDate($translation->updated_at), ]; $counts['post_translations']++; } if ($post->meeting) { $meeting = $post->meeting; $postData['meeting'] = [ 'meetingable_type' => $meeting->meetingable_type, 'meetingable_name' => $meeting->meetingable?->name, 'venue' => $meeting->venue, 'address' => $meeting->address, 'price' => $meeting->price, 'based_on_quantity' => $meeting->based_on_quantity, 'transaction_type_id' => $meeting->transaction_type_id, 'status' => $meeting->status, 'from' => $this->formatDate($meeting->from), 'till' => $this->formatDate($meeting->till), 'created_at' => $this->formatDate($meeting->created_at), 'updated_at' => $this->formatDate($meeting->updated_at), 'location' => $this->getLocationNames($meeting->location), ]; $counts['meetings']++; } $media = $post->getFirstMedia('posts'); if ($media) { $originalPath = $media->getPath(); if (File::exists($originalPath)) { $archivePath = "media/{$post->id}/{$media->file_name}"; $postData['media'] = [ 'name' => $media->name, 'file_name' => $media->file_name, 'mime_type' => $media->mime_type, 'size' => $media->size, 'collection_name' => $media->collection_name, 'custom_properties' => $media->custom_properties, 'archive_path' => $archivePath, ]; $mediaFiles[] = [ 'source' => $originalPath, 'archive_path' => $archivePath, ]; $counts['media_files']++; } } if (!$isFirst) { fwrite($jsonHandle, ','); } fwrite($jsonHandle, json_encode($postData, JSON_UNESCAPED_UNICODE)); $isFirst = false; $counts['posts']++; }; // Use chunking for query builders, iterate for collections if ($posts instanceof \Illuminate\Database\Eloquent\Builder) { $posts->with(['translations', 'meeting.location', 'media']) ->chunk($chunkSize, function ($chunk) use ($processPost) { foreach ($chunk as $post) { $processPost($post); } }); } else { foreach ($posts as $post) { $processPost($post); } } // Close JSON array fwrite($jsonHandle, ']}'); fclose($jsonHandle); // Build meta JSON $meta = json_encode([ 'version' => '2.0', 'created_at' => now()->toIso8601String(), 'source_database' => config('database.connections.mysql.database'), 'includes_media' => true, 'counts' => $counts, ], JSON_UNESCAPED_UNICODE); // Replace the placeholder in the temp JSON file without reading it all into memory // Use a second temp file and stream-copy with the replacement $finalJsonPath = $tempJsonPath . '.final'; $inHandle = fopen($tempJsonPath, 'r'); $outHandle = fopen($finalJsonPath, 'w'); // Read the placeholder prefix, replace it, then stream the rest $prefix = fread($inHandle, strlen('{"meta":"__PLACEHOLDER__"')); fwrite($outHandle, '{"meta":' . $meta); // Stream the rest of the file in small chunks while (!feof($inHandle)) { fwrite($outHandle, fread($inHandle, 8192)); } fclose($inHandle); fclose($outHandle); @unlink($tempJsonPath); rename($finalJsonPath, $tempJsonPath); // Create ZIP archive $zip = new ZipArchive(); if ($zip->open($tempPath, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) { @unlink($tempJsonPath); $this->notification()->error( __('Error'), __('Failed to create ZIP archive') ); return; } $zip->addFile($tempJsonPath, 'backup.json'); foreach ($mediaFiles as $mediaFile) { if (File::exists($mediaFile['source'])) { $zip->addFile($mediaFile['source'], $mediaFile['archive_path']); } } $zip->close(); @unlink($tempJsonPath); // Move ZIP to storage for download via dedicated route (bypasses Livewire response buffering) $backupsDir = storage_path('app/backups'); if (!File::isDirectory($backupsDir)) { File::makeDirectory($backupsDir, 0755, true); } File::move($tempPath, $backupsDir . '/' . $filename); $this->notification()->success( __('Backup created'), __('Downloaded :count posts with :media media files', [ 'count' => $counts['posts'], 'media' => $counts['media_files'], ]) ); // Dispatch browser event to trigger download via dedicated HTTP route $this->dispatch('backup-ready', filename: $filename); } /** * Open the restore modal. */ public function openRestoreModal() { $this->authorizeAdminAccess(); $this->cleanupTempFile(); $this->reset(['restorePreview', 'restoreStats', 'duplicateAction', 'restorePostList', 'selectedPostIndices', 'selectAllPosts', 'uploadId', 'uploadedFilePath', 'selectedFileName']); $this->showRestoreModal = true; } /** * Receive lightweight preview data parsed client-side by JavaScript. * JS extracts meta + post summaries from the backup JSON so we don't * need to send the entire multi-MB JSON string over the wire. * * @param array $meta The backup meta object * @param array $postSummaries Array of {index, title, slug, locales, slugs, has_meeting, has_media, category_type} * @param string $fileName Original file name * @param bool $isZip Whether the file is a ZIP archive */ public function parseBackupPreview(array $meta, array $postSummaries, string $fileName, bool $isZip) { $this->selectedFileName = $fileName; try { // Collect all slugs for duplicate checking $allSlugs = []; foreach ($postSummaries as $summary) { foreach ($summary['slugs'] ?? [] as $slug) { $allSlugs[] = $slug; } } $existingSlugs = PostTranslation::withTrashed() ->whereIn('slug', $allSlugs) ->pluck('slug') ->toArray(); $this->restorePreview = [ 'version' => $meta['version'] ?? 'unknown', 'created_at' => $meta['created_at'] ?? 'unknown', 'source_database' => $meta['source_database'] ?? 'unknown', 'posts' => $meta['counts']['posts'] ?? count($postSummaries), 'translations' => $meta['counts']['post_translations'] ?? 0, 'meetings' => $meta['counts']['meetings'] ?? 0, 'media_files' => $meta['counts']['media_files'] ?? 0, 'includes_media' => $meta['includes_media'] ?? false, 'duplicates' => count($existingSlugs), 'duplicate_slugs' => array_slice($existingSlugs, 0, 10), 'is_zip' => $isZip, ]; // Store the post summaries for selection UI (already lightweight) $this->restorePostList = $postSummaries; $this->selectedPostIndices = array_column($postSummaries, 'index'); $this->selectAllPosts = true; } catch (\Exception $e) { $this->addError('restoreFile', __('Error reading file: ') . $e->getMessage()); } } /** * Set the uploaded file path from a completed chunked upload. * Called by JS after chunk upload finalization succeeds. */ public function setUploadedFilePath(string $uploadId) { $this->authorizeAdminAccess(); $sessionKey = "backup_restore_file_{$uploadId}"; $path = session($sessionKey); if (!$path || !File::exists($path)) { $this->notification()->error(__('Error'), __('Uploaded file not found')); return; } $this->uploadId = $uploadId; $this->uploadedFilePath = $path; // Clean up session key session()->forget($sessionKey); } /** * Execute the restore operation. */ public function restore() { $this->authorizeAdminAccess(); if (!$this->uploadedFilePath || !File::exists($this->uploadedFilePath)) { $this->notification()->error(__('Error'), __('No file uploaded. Please upload the file first.')); return; } $this->isRestoring = true; $extractDir = null; try { $filePath = $this->uploadedFilePath; $extension = strtolower(pathinfo($filePath, PATHINFO_EXTENSION)); $isZip = $extension === 'zip'; $data = null; if ($isZip) { // Extract ZIP archive $extractDir = storage_path('app/temp/restore_' . uniqid()); File::makeDirectory($extractDir, 0755, true); $zip = new ZipArchive(); if ($zip->open($filePath) !== true) { $this->notification()->error(__('Error'), __('Failed to open ZIP archive')); $this->isRestoring = false; return; } // Validate ZIP entries to prevent zip-slip path traversal attacks for ($i = 0; $i < $zip->numFiles; $i++) { $entryName = $zip->getNameIndex($i); $fullPath = realpath($extractDir) . '/' . $entryName; if (strpos($fullPath, realpath($extractDir)) !== 0) { $zip->close(); File::deleteDirectory($extractDir); $this->notification()->error(__('Error'), __('ZIP archive contains unsafe file paths')); $this->isRestoring = false; return; } } $zip->extractTo($extractDir); $zip->close(); $jsonPath = "{$extractDir}/backup.json"; if (!File::exists($jsonPath)) { File::deleteDirectory($extractDir); $this->notification()->error(__('Error'), __('Invalid ZIP archive: missing backup.json')); $this->isRestoring = false; return; } $data = json_decode(File::get($jsonPath), true); } else { $data = json_decode(file_get_contents($filePath), true); } // Get active profile from session $profileId = session('activeProfileId'); $profileType = session('activeProfileType'); if (!$profileId || !$profileType) { if ($extractDir) { File::deleteDirectory($extractDir); } $this->notification()->error(__('Error'), __('No active profile in session')); $this->isRestoring = false; return; } // Build category type => id lookup $categoryLookup = Category::pluck('id', 'type')->toArray(); $stats = [ 'posts_created' => 0, 'posts_skipped' => 0, 'posts_overwritten' => 0, 'translations_created' => 0, 'meetings_created' => 0, 'media_restored' => 0, 'media_skipped' => 0, ]; $createdPostIds = []; DB::beginTransaction(); // Filter to only selected posts $selectedIndices = array_flip($this->selectedPostIndices); // Disable Scout indexing during bulk restore to prevent timeout Post::withoutSyncingToSearch(function () use ($data, $profileId, $profileType, $categoryLookup, &$stats, &$createdPostIds, $extractDir, $isZip, $selectedIndices) { foreach ($data['posts'] as $index => $postData) { if (!isset($selectedIndices[$index])) { continue; } // Look up category_id by category_type $categoryId = null; if (!empty($postData['category_type'])) { $categoryId = $categoryLookup[$postData['category_type']] ?? null; } // Check for existing slugs if (!empty($postData['translations'])) { $existingSlugs = PostTranslation::withTrashed() ->whereIn('slug', array_column($postData['translations'], 'slug')) ->pluck('slug') ->toArray(); if (!empty($existingSlugs)) { if ($this->duplicateAction === 'skip') { $stats['posts_skipped']++; continue; } elseif ($this->duplicateAction === 'overwrite') { // Delete existing translations and their posts $existingTranslations = PostTranslation::withTrashed() ->whereIn('slug', $existingSlugs) ->get(); foreach ($existingTranslations as $existingTranslation) { $postId = $existingTranslation->post_id; $existingTranslation->forceDelete(); $remainingTranslations = PostTranslation::withTrashed() ->where('post_id', $postId) ->count(); if ($remainingTranslations === 0) { $existingPost = Post::withTrashed()->find($postId); if ($existingPost) { // Clear media before deleting post $existingPost->clearMediaCollection('posts'); Meeting::withTrashed()->where('post_id', $postId)->forceDelete(); $existingPost->forceDelete(); } } } $stats['posts_overwritten']++; } } } // Create post $post = new Post(); $post->postable_id = $profileId; $post->postable_type = $profileType; $post->category_id = $categoryId; // Don't set love_reactant_id - let PostObserver register it as reactant $post->author_id = null; // Author IDs are not portable between databases $post->author_model = null; $post->created_at = $postData['created_at'] ? new \DateTime($postData['created_at']) : now(); $post->updated_at = $postData['updated_at'] ? new \DateTime($postData['updated_at']) : now(); $post->save(); // Ensure post is registered as reactant (in case observer didn't fire) if (!$post->isRegisteredAsLoveReactant()) { $post->registerAsLoveReactant(); } // Create translations foreach ($postData['translations'] as $translationData) { $translation = new PostTranslation(); $translation->post_id = $post->id; $translation->locale = $translationData['locale']; $translation->slug = $translationData['slug']; $translation->title = $translationData['title']; $translation->excerpt = $translationData['excerpt']; $translation->content = $translationData['content']; $translation->status = $translationData['status']; $translation->updated_by_user_id = $translationData['updated_by_user_id']; $translation->from = $translationData['from'] ? new \DateTime($translationData['from']) : null; $translation->till = $translationData['till'] ? new \DateTime($translationData['till']) : null; $translation->created_at = $translationData['created_at'] ? new \DateTime($translationData['created_at']) : now(); $translation->updated_at = $translationData['updated_at'] ? new \DateTime($translationData['updated_at']) : now(); $translation->save(); $stats['translations_created']++; } // Create meeting (hasOne relationship) if (!empty($postData['meeting'])) { $meetingData = $postData['meeting']; // Look up meetingable by name and type $meetingableId = null; $meetingableType = null; // Whitelist of allowed meetingable types to prevent arbitrary class instantiation $allowedMeetingableTypes = [ \App\Models\User::class, \App\Models\Organization::class, \App\Models\Bank::class, ]; if (!empty($meetingData['meetingable_type']) && !empty($meetingData['meetingable_name'])) { $meetingableType = $meetingData['meetingable_type']; if (in_array($meetingableType, $allowedMeetingableTypes, true)) { $meetingable = $meetingableType::where('name', $meetingData['meetingable_name'])->first(); if ($meetingable) { $meetingableId = $meetingable->id; } } } $meeting = new Meeting(); $meeting->post_id = $post->id; $meeting->meetingable_id = $meetingableId; $meeting->meetingable_type = $meetingableId ? $meetingableType : null; $meeting->venue = $meetingData['venue']; $meeting->address = $meetingData['address']; $meeting->price = $meetingData['price']; $meeting->based_on_quantity = $meetingData['based_on_quantity']; $meeting->transaction_type_id = $meetingData['transaction_type_id']; $meeting->status = $meetingData['status']; $meeting->from = $meetingData['from'] ? new \DateTime($meetingData['from']) : null; $meeting->till = $meetingData['till'] ? new \DateTime($meetingData['till']) : null; $meeting->created_at = $meetingData['created_at'] ? new \DateTime($meetingData['created_at']) : now(); $meeting->updated_at = $meetingData['updated_at'] ? new \DateTime($meetingData['updated_at']) : now(); $meeting->save(); // Create location if location data exists if (!empty($meetingData['location'])) { $locationIds = $this->lookupLocationIds($meetingData['location']); if ($locationIds['country_id'] || $locationIds['division_id'] || $locationIds['city_id'] || $locationIds['district_id']) { $location = new Location(); $location->locatable_id = $meeting->id; $location->locatable_type = Meeting::class; $location->country_id = $locationIds['country_id']; $location->division_id = $locationIds['division_id']; $location->city_id = $locationIds['city_id']; $location->district_id = $locationIds['district_id']; $location->save(); } } $stats['meetings_created']++; } // Restore media if available if ($isZip && $extractDir && !empty($postData['media'])) { $mediaData = $postData['media']; $mediaPath = "{$extractDir}/{$mediaData['archive_path']}"; // Prevent path traversal via crafted archive_path in JSON $realMediaPath = realpath($mediaPath); $realExtractDir = realpath($extractDir); if ($realMediaPath && $realExtractDir && strpos($realMediaPath, $realExtractDir) === 0 && File::exists($mediaPath)) { try { $media = $post->addMedia($mediaPath) ->preservingOriginal() // Don't delete from extract dir yet ->usingName($mediaData['name']) ->usingFileName($mediaData['file_name']) ->withCustomProperties($mediaData['custom_properties'] ?? []) ->toMediaCollection('posts'); // Dispatch conversion job to queue $conversionCollection = \Spatie\MediaLibrary\Conversions\ConversionCollection::createForMedia($media); if ($conversionCollection->isNotEmpty()) { dispatch(new \Spatie\MediaLibrary\Conversions\Jobs\PerformConversionsJob($conversionCollection, $media, false)) ->onQueue('low'); } $stats['media_restored']++; } catch (\Exception $e) { $stats['media_skipped']++; } } else { $stats['media_skipped']++; } } $createdPostIds[] = $post->id; $stats['posts_created']++; } }); // End withoutSyncingToSearch DB::commit(); // Clean up extracted files if ($extractDir) { File::deleteDirectory($extractDir); } // Queue posts for search indexing on 'low' queue in chunks if (!empty($createdPostIds)) { foreach (array_chunk($createdPostIds, 50) as $chunk) { $posts = Post::whereIn('id', $chunk)->get(); dispatch(new MakeSearchable($posts))->onQueue('low'); } } $this->restoreStats = $stats; // Clean up uploaded temp file and free memory if ($this->uploadedFilePath && File::exists($this->uploadedFilePath)) { @unlink($this->uploadedFilePath); } $this->uploadedFilePath = null; $this->uploadId = null; $this->restorePreview = []; $this->restorePostList = []; $this->selectedPostIndices = []; $mediaMsg = $stats['media_restored'] > 0 ? " with {$stats['media_restored']} media files" : ''; $this->notification()->success( __('Restore completed'), __('Created :count posts', ['count' => $stats['posts_created']]) . $mediaMsg ); // Refresh parent posts table $this->dispatch('refreshPostsTable')->to('posts.manage'); } catch (\Exception $e) { DB::rollBack(); if ($extractDir && File::isDirectory($extractDir)) { File::deleteDirectory($extractDir); } $this->notification()->error(__('Error'), $e->getMessage()); } $this->isRestoring = false; } /** * Close modal and reset state. */ public function closeRestoreModal() { $this->showRestoreModal = false; $this->cleanupTempFile(); $this->reset(['restorePreview', 'restoreStats', 'duplicateAction', 'restorePostList', 'selectedPostIndices', 'selectAllPosts', 'uploadId', 'uploadedFilePath', 'selectedFileName']); } /** * Clean up any uploaded temp file. */ private function cleanupTempFile(): void { if ($this->uploadedFilePath && File::exists($this->uploadedFilePath)) { @unlink($this->uploadedFilePath); } } /** * Safely format a date value to ISO8601 string. */ private function formatDate($value): ?string { if ($value === null) { return null; } if ($value instanceof \Carbon\Carbon || $value instanceof \DateTime) { return $value->format('c'); } if (is_string($value)) { return $value; } return null; } /** * Get location names in the app's base locale for backup. * Names are used for lookup on restore instead of IDs. */ private function getLocationNames($location): ?array { if (!$location) { return null; } $baseLocale = config('app.locale'); // Get country name $countryName = null; if ($location->country_id) { $countryLocale = CountryLocale::withoutGlobalScopes() ->where('country_id', $location->country_id) ->where('locale', $baseLocale) ->first(); $countryName = $countryLocale?->name; } // Get division name $divisionName = null; if ($location->division_id) { $divisionLocale = DivisionLocale::withoutGlobalScopes() ->where('division_id', $location->division_id) ->where('locale', $baseLocale) ->first(); $divisionName = $divisionLocale?->name; } // Get city name $cityName = null; if ($location->city_id) { $cityLocale = CityLocale::withoutGlobalScopes() ->where('city_id', $location->city_id) ->where('locale', $baseLocale) ->first(); $cityName = $cityLocale?->name; } // Get district name $districtName = null; if ($location->district_id) { $districtLocale = DistrictLocale::withoutGlobalScopes() ->where('district_id', $location->district_id) ->where('locale', $baseLocale) ->first(); $districtName = $districtLocale?->name; } return [ 'country_name' => $countryName, 'division_name' => $divisionName, 'city_name' => $cityName, 'district_name' => $districtName, ]; } /** * Look up location IDs by names in the app's base locale. * Returns null for any location component that cannot be found. */ private function lookupLocationIds(array $locationData): array { $baseLocale = config('app.locale'); $result = [ 'country_id' => null, 'division_id' => null, 'city_id' => null, 'district_id' => null, ]; // Look up country by name if (!empty($locationData['country_name'])) { $countryLocale = CountryLocale::withoutGlobalScopes() ->where('name', $locationData['country_name']) ->where('locale', $baseLocale) ->first(); $result['country_id'] = $countryLocale?->country_id; } // Look up division by name if (!empty($locationData['division_name'])) { $divisionLocale = DivisionLocale::withoutGlobalScopes() ->where('name', $locationData['division_name']) ->where('locale', $baseLocale) ->first(); $result['division_id'] = $divisionLocale?->division_id; } // Look up city by name if (!empty($locationData['city_name'])) { $cityLocale = CityLocale::withoutGlobalScopes() ->where('name', $locationData['city_name']) ->where('locale', $baseLocale) ->first(); $result['city_id'] = $cityLocale?->city_id; } // Look up district by name if (!empty($locationData['district_name'])) { $districtLocale = DistrictLocale::withoutGlobalScopes() ->where('name', $locationData['district_name']) ->where('locale', $baseLocale) ->first(); $result['district_id'] = $districtLocale?->district_id; } return $result; } public function render() { return view('livewire.posts.backup-restore'); } }