906 lines
36 KiB
PHP
906 lines
36 KiB
PHP
<?php
|
|
|
|
namespace App\Http\Livewire\Posts;
|
|
|
|
use App\Http\Livewire\Traits\RequiresAdminAuthorization;
|
|
use App\Models\Category;
|
|
use App\Models\Locations\CityLocale;
|
|
use App\Models\Locations\CountryLocale;
|
|
use App\Models\Locations\DistrictLocale;
|
|
use App\Models\Locations\DivisionLocale;
|
|
use App\Models\Locations\Location;
|
|
use App\Models\Meeting;
|
|
use App\Models\Post;
|
|
use App\Models\PostTranslation;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Facades\File;
|
|
use Laravel\Scout\Jobs\MakeSearchable;
|
|
use Livewire\Component;
|
|
use WireUi\Traits\WireUiActions;
|
|
use ZipArchive;
|
|
|
|
class BackupRestore extends Component
|
|
{
|
|
use WireUiActions;
|
|
use RequiresAdminAuthorization;
|
|
|
|
public bool $showRestoreModal = false;
|
|
|
|
// Chunked upload state
|
|
public ?string $uploadId = null;
|
|
public ?string $uploadedFilePath = null;
|
|
public ?string $selectedFileName = null;
|
|
|
|
// Optional: show "Backup selected" button (requires parent component to provide selection)
|
|
public bool $showBackupSelected = false;
|
|
public array $selectedTranslationIds = [];
|
|
|
|
// Restore state
|
|
public array $restorePreview = [];
|
|
public array $restorePostList = []; // Lightweight summaries for selection display
|
|
public array $selectedPostIndices = []; // Selected post indices from backup
|
|
public bool $selectAllPosts = true;
|
|
public bool $isRestoring = false;
|
|
public string $duplicateAction = 'skip'; // skip, overwrite
|
|
public array $restoreStats = [];
|
|
|
|
protected $listeners = [
|
|
'refreshComponent' => '$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');
|
|
}
|
|
}
|