WIP: storage-fix-local-nas work before playlist controls feature
Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
This commit is contained in:
1 parent
6b3ab5b65e
commit
c160242dbc
46 files changed
+5365
-815
No files matched your search
@@ -313,12 +313,46 @@ class SuperAdminController extends Controller
|
||||
return redirect()->route('admin.users')->with('success', 'User updated successfully!');
|
||||
}
|
||||
|
||||
// Returns true if admin is already within the 30-min verified window
|
||||
private function adminIsVerified(): bool
|
||||
{
|
||||
$admin = auth()->user();
|
||||
if (! $admin->two_factor_enabled || ! $admin->two_factor_secret) {
|
||||
return true;
|
||||
}
|
||||
$verifiedAt = session('admin_2fa_verified_at');
|
||||
return $verifiedAt && now()->timestamp - $verifiedAt < 1800;
|
||||
}
|
||||
|
||||
// Validates OTP and stamps the session on success
|
||||
private function verify2fa(Request $request): bool
|
||||
{
|
||||
$admin = auth()->user();
|
||||
if (! $admin->two_factor_enabled || ! $admin->two_factor_secret) {
|
||||
return true;
|
||||
}
|
||||
if ($this->adminIsVerified()) {
|
||||
return true;
|
||||
}
|
||||
$code = $request->input('otp_code', '');
|
||||
$google2fa = app('pragmarx.google2fa');
|
||||
if ($google2fa->verifyKey(decrypt($admin->two_factor_secret), (string) $code)) {
|
||||
session(['admin_2fa_verified_at' => now()->timestamp]);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Delete user
|
||||
public function deleteUser(User $user)
|
||||
public function deleteUser(Request $request, User $user)
|
||||
{
|
||||
// Prevent deleting yourself
|
||||
if (auth()->id() === $user->id) {
|
||||
return back()->with('error', 'You cannot delete your own account!');
|
||||
return response()->json(['success' => false, 'message' => 'You cannot delete your own account!'], 422);
|
||||
}
|
||||
|
||||
if (! $this->verify2fa($request)) {
|
||||
return response()->json(['success' => false, 'message' => 'Invalid 2FA code. Please try again.'], 422);
|
||||
}
|
||||
|
||||
AuditLog::record('admin.user.deleted', [
|
||||
@@ -343,7 +377,7 @@ class SuperAdminController extends Controller
|
||||
|
||||
$user->delete();
|
||||
|
||||
return redirect()->route('admin.users')->with('success', 'User deleted successfully!');
|
||||
return response()->json(['success' => true, 'message' => 'User deleted successfully!']);
|
||||
}
|
||||
|
||||
// List all videos
|
||||
@@ -530,16 +564,33 @@ class SuperAdminController extends Controller
|
||||
'download_access' => 'nullable|in:disabled,everyone,registered,subscribers',
|
||||
]);
|
||||
|
||||
$oldTitle = $video->title;
|
||||
|
||||
$data = $request->only(['title', 'description', 'visibility', 'type', 'status', 'is_shorts', 'download_access']);
|
||||
|
||||
$video->update($data);
|
||||
|
||||
if (($data['title'] ?? $oldTitle) !== $oldTitle) {
|
||||
try {
|
||||
$nas = app(\App\Services\NasSyncService::class);
|
||||
if ($nas->isEnabled()) {
|
||||
$nas->renameVideoDir($video->fresh());
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
\Illuminate\Support\Facades\Log::warning('NAS video dir rename failed: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
return redirect()->route('admin.videos')->with('success', 'Video updated successfully!');
|
||||
}
|
||||
|
||||
// Delete video
|
||||
public function deleteVideo(Video $video)
|
||||
public function deleteVideo(Request $request, Video $video)
|
||||
{
|
||||
if (! $this->verify2fa($request)) {
|
||||
return response()->json(['success' => false, 'message' => 'Invalid 2FA code. Please try again.'], 422);
|
||||
}
|
||||
|
||||
$videoTitle = $video->title;
|
||||
|
||||
AuditLog::record('admin.video.deleted', [
|
||||
@@ -561,7 +612,7 @@ class SuperAdminController extends Controller
|
||||
|
||||
$video->delete();
|
||||
|
||||
return redirect()->route('admin.videos')->with('success', 'Video "' . $videoTitle . '" deleted successfully!');
|
||||
return response()->json(['success' => true, 'message' => 'Video "' . $videoTitle . '" deleted successfully!']);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -819,7 +870,6 @@ class SuperAdminController extends Controller
|
||||
'gpu_hwaccel' => 'required|in:cuda,none',
|
||||
'gpu_preset' => 'required|in:p1,p2,p3,p4,p5,p6,p7,fast,medium,slow',
|
||||
'ffmpeg_binary' => 'required|string|max:255',
|
||||
'nas_sync_enabled' => 'required|in:true,false',
|
||||
]);
|
||||
|
||||
$binary = trim($request->ffmpeg_binary) ?: '/usr/bin/ffmpeg';
|
||||
@@ -827,13 +877,12 @@ class SuperAdminController extends Controller
|
||||
return back()->withErrors(['ffmpeg_binary' => "FFmpeg binary not found or not executable: {$binary}"]);
|
||||
}
|
||||
|
||||
Setting::set('gpu_enabled', $request->gpu_enabled);
|
||||
Setting::set('gpu_device', (string) $request->gpu_device);
|
||||
Setting::set('gpu_encoder', $request->gpu_encoder);
|
||||
Setting::set('gpu_hwaccel', $request->gpu_hwaccel);
|
||||
Setting::set('gpu_preset', $request->gpu_preset);
|
||||
Setting::set('ffmpeg_binary', $binary);
|
||||
Setting::set('nas_sync_enabled', $request->nas_sync_enabled);
|
||||
Setting::set('gpu_enabled', $request->gpu_enabled);
|
||||
Setting::set('gpu_device', (string) $request->gpu_device);
|
||||
Setting::set('gpu_encoder', $request->gpu_encoder);
|
||||
Setting::set('gpu_hwaccel', $request->gpu_hwaccel);
|
||||
Setting::set('gpu_preset', $request->gpu_preset);
|
||||
Setting::set('ffmpeg_binary', $binary);
|
||||
|
||||
return back()->with('success', 'Settings saved.');
|
||||
}
|
||||
@@ -897,4 +946,433 @@ class SuperAdminController extends Controller
|
||||
$nodes = config('nas-file-manager.schema', []);
|
||||
return view('admin.nas-storage', compact('nodes'));
|
||||
}
|
||||
|
||||
public function nasDelete(Request $request)
|
||||
{
|
||||
$path = trim($request->input('path', ''));
|
||||
$type = $request->input('type', 'dir');
|
||||
|
||||
if ($path === '') {
|
||||
return response()->json(['success' => false, 'message' => 'Path is required.'], 422);
|
||||
}
|
||||
|
||||
$nas = app(\App\Services\NasSyncService::class);
|
||||
|
||||
if (! $nas->isEnabled()) {
|
||||
return response()->json(['success' => false, 'message' => 'NAS not enabled.'], 422);
|
||||
}
|
||||
|
||||
try {
|
||||
if ($type === 'dir') {
|
||||
$nas->deleteNasTree($path);
|
||||
} else {
|
||||
$nas->deleteFile($path);
|
||||
}
|
||||
return response()->json(['success' => true]);
|
||||
} catch (\Throwable $e) {
|
||||
return response()->json(['success' => false, 'message' => $e->getMessage()]);
|
||||
}
|
||||
}
|
||||
|
||||
public function nasRepair(Request $request)
|
||||
{
|
||||
$nas = app(\App\Services\NasSyncService::class);
|
||||
|
||||
if (! $nas->isEnabled()) {
|
||||
return response()->json(['success' => false, 'message' => 'NAS sync is not enabled.'], 422);
|
||||
}
|
||||
|
||||
// ── Collect stuck items ───────────────────────────────────────────────
|
||||
$stuckVideos = $this->collectStuckVideos();
|
||||
$stuckAvatars = $this->collectStuckAvatars();
|
||||
$stuckBanners = $this->collectStuckBanners();
|
||||
$stuckThumbs = $this->collectStuckLegacyThumbnails();
|
||||
$nasOrphans = $nas->scanNasOrphans();
|
||||
|
||||
$totalStuck = $stuckVideos->count() + $stuckAvatars->count()
|
||||
+ $stuckBanners->count() + $stuckThumbs->count()
|
||||
+ count($nasOrphans);
|
||||
|
||||
// Scan-only mode ───────────────────────────────────────────────────────
|
||||
if ($request->boolean('scan_only')) {
|
||||
$details = [];
|
||||
foreach ($stuckVideos as $item) {
|
||||
$details[] = "[video] #{$item['video']->id} {$item['video']->title}: " . implode(', ', $item['files']);
|
||||
}
|
||||
foreach ($stuckAvatars as $item) {
|
||||
$details[] = "[avatar] {$item['user']->username}: {$item['file']}";
|
||||
}
|
||||
foreach ($stuckBanners as $item) {
|
||||
$details[] = "[banner] {$item['user']->username}: {$item['file']}";
|
||||
}
|
||||
foreach ($stuckThumbs as $item) {
|
||||
$details[] = "[{$item['type']}] {$item['file']} (video #{$item['video_id']})";
|
||||
}
|
||||
foreach ($nasOrphans as $orphan) {
|
||||
$label = $orphan['video_id'] ? "video #{$orphan['video_id']}" : 'no meta.json';
|
||||
$details[] = "[nas-orphan] {$orphan['dir']} ({$label} — not in DB)";
|
||||
}
|
||||
|
||||
$cacheBytes = $nas->nasCacheSize();
|
||||
if ($cacheBytes > 0) {
|
||||
$cacheMb = round($cacheBytes / 1048576, 1);
|
||||
$details[] = "[stream-cache] {$cacheMb} MB of on-demand video cache (safe to clear)";
|
||||
$totalStuck++;
|
||||
}
|
||||
|
||||
return response()->json(['stuck' => $totalStuck, 'details' => $details]);
|
||||
}
|
||||
|
||||
// Repair mode ─────────────────────────────────────────────────────────
|
||||
$repaired = 0;
|
||||
$failed = 0;
|
||||
$details = [];
|
||||
|
||||
foreach ($stuckVideos as $item) {
|
||||
$video = $item['video'];
|
||||
try {
|
||||
$nas->syncVideo($video);
|
||||
$nas->deleteLocalAssets($video);
|
||||
if ($video->hls_path || $video->type === 'music') $nas->deleteLocalVideo($video);
|
||||
$nas->pruneLocalVideoDir($video);
|
||||
$repaired++;
|
||||
$details[] = "✓ [video] #{$video->id}: {$video->title}";
|
||||
\Log::info("nas:repair: fixed video #{$video->id}");
|
||||
} catch (\Throwable $e) {
|
||||
$failed++;
|
||||
$details[] = "✗ [video] #{$video->id}: {$e->getMessage()}";
|
||||
\Log::error("nas:repair: failed video #{$video->id}: " . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($stuckAvatars as $item) {
|
||||
try {
|
||||
$nas->syncAvatar($item['user'], $item['path']);
|
||||
$nas->deleteLocalAvatar($item['user']);
|
||||
$repaired++;
|
||||
$details[] = "✓ [avatar] {$item['user']->username}";
|
||||
} catch (\Throwable $e) {
|
||||
$failed++;
|
||||
$details[] = "✗ [avatar] {$item['user']->username}: {$e->getMessage()}";
|
||||
\Log::error("nas:repair: failed avatar user#{$item['user']->id}: " . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($stuckBanners as $item) {
|
||||
try {
|
||||
$nas->syncCover($item['user'], $item['path']);
|
||||
$nas->deleteLocalBanner($item['user']);
|
||||
$repaired++;
|
||||
$details[] = "✓ [banner] {$item['user']->username}";
|
||||
} catch (\Throwable $e) {
|
||||
$failed++;
|
||||
$details[] = "✗ [banner] {$item['user']->username}: {$e->getMessage()}";
|
||||
\Log::error("nas:repair: failed banner user#{$item['user']->id}: " . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($stuckThumbs as $item) {
|
||||
try {
|
||||
if ($item['type'] === 'thumbnail' && $item['video']) {
|
||||
$nas->syncVideo($item['video']);
|
||||
} elseif ($item['type'] === 'slide' && $item['slide'] && $item['video']) {
|
||||
$dir = $nas->resolveVideoDir($item['video']);
|
||||
$ext = pathinfo($item['path'], PATHINFO_EXTENSION) ?: 'jpg';
|
||||
$nas->mkdirp("{$dir}/slides");
|
||||
$nas->putFile($item['path'], "{$dir}/slides/{$item['slide']->position}.{$ext}");
|
||||
}
|
||||
@unlink($item['path']);
|
||||
$repaired++;
|
||||
$details[] = "✓ [{$item['type']}] {$item['file']}";
|
||||
} catch (\Throwable $e) {
|
||||
$failed++;
|
||||
$details[] = "✗ [{$item['type']}] {$item['file']}: {$e->getMessage()}";
|
||||
\Log::error("nas:repair: failed legacy thumb {$item['file']}: " . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
// Delete NAS orphan folders
|
||||
foreach ($nasOrphans as $orphan) {
|
||||
try {
|
||||
$nas->deleteNasTree($orphan['dir']);
|
||||
$repaired++;
|
||||
$details[] = "✓ [nas-orphan] deleted {$orphan['dir']}";
|
||||
\Log::info('nas:repair: deleted NAS orphan', ['dir' => $orphan['dir']]);
|
||||
} catch (\Throwable $e) {
|
||||
$failed++;
|
||||
$details[] = "✗ [nas-orphan] {$orphan['dir']}: {$e->getMessage()}";
|
||||
\Log::error('nas:repair: failed to delete NAS orphan', ['dir' => $orphan['dir'], 'error' => $e->getMessage()]);
|
||||
}
|
||||
}
|
||||
|
||||
// Evict NAS stream cache (24h TTL by default)
|
||||
$evicted = $nas->clearNasCache(24);
|
||||
if ($evicted > 0) {
|
||||
$details[] = "✓ [stream-cache] evicted {$evicted} cached file(s)";
|
||||
$repaired += $evicted;
|
||||
}
|
||||
|
||||
$this->pruneLocalStorageDirs();
|
||||
|
||||
if ($totalStuck === 0) {
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => 'Nothing to repair — no stuck local files and no NAS orphans found.',
|
||||
'repaired' => 0, 'failed' => 0, 'details' => [],
|
||||
]);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => $failed === 0,
|
||||
'message' => $failed === 0
|
||||
? "Repaired {$repaired} item(s) successfully."
|
||||
: "Repaired {$repaired}, failed {$failed} — check logs.",
|
||||
'repaired' => $repaired,
|
||||
'failed' => $failed,
|
||||
'details' => $details,
|
||||
]);
|
||||
}
|
||||
|
||||
private function collectStuckVideos(): \Illuminate\Support\Collection
|
||||
{
|
||||
return \App\Models\Video::with(['user', 'slides'])->get()
|
||||
->filter(fn ($v) => str_starts_with($v->path, 'users/'))
|
||||
->map(function ($video) {
|
||||
$files = [];
|
||||
if (file_exists(storage_path('app/' . $video->path)))
|
||||
$files[] = basename($video->path) . ' (video)';
|
||||
if ($video->thumbnail && str_contains($video->thumbnail, '/') &&
|
||||
file_exists(storage_path('app/' . $video->thumbnail)))
|
||||
$files[] = basename($video->thumbnail) . ' (thumbnail)';
|
||||
foreach ($video->slides as $slide) {
|
||||
if (file_exists($slide->localPath()))
|
||||
$files[] = basename($slide->filename) . " (slide #{$slide->position})";
|
||||
}
|
||||
return $files ? ['video' => $video, 'files' => $files] : null;
|
||||
})
|
||||
->filter();
|
||||
}
|
||||
|
||||
private function collectStuckAvatars(): \Illuminate\Support\Collection
|
||||
{
|
||||
$results = collect();
|
||||
|
||||
// Legacy flat dir
|
||||
$dir = storage_path('app/public/avatars');
|
||||
if (is_dir($dir)) {
|
||||
$flat = collect(scandir($dir) ?: [])
|
||||
->filter(fn ($f) => $f !== '.' && $f !== '..' && is_file("{$dir}/{$f}"))
|
||||
->map(function ($filename) use ($dir) {
|
||||
$user = \App\Models\User::where('avatar', $filename)->first();
|
||||
return $user ? ['user' => $user, 'file' => $filename, 'path' => "{$dir}/{$filename}"] : null;
|
||||
})
|
||||
->filter();
|
||||
$results = $results->merge($flat);
|
||||
}
|
||||
|
||||
// New structured dir: users/{slug}/profile/avatar.*
|
||||
$usersBase = storage_path('app/users');
|
||||
if (is_dir($usersBase)) {
|
||||
foreach (glob("{$usersBase}/*/profile/avatar.*") ?: [] as $path) {
|
||||
$relPath = 'users/' . ltrim(str_replace(storage_path('app/users') . '/', '', str_replace('\\', '/', $path)), '/');
|
||||
$user = \App\Models\User::where('avatar', $relPath)->first();
|
||||
if ($user) {
|
||||
$results->push(['user' => $user, 'file' => basename($path), 'path' => $path]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $results;
|
||||
}
|
||||
|
||||
private function collectStuckBanners(): \Illuminate\Support\Collection
|
||||
{
|
||||
$results = collect();
|
||||
|
||||
// Legacy flat dir
|
||||
$dir = storage_path('app/public/banners');
|
||||
if (is_dir($dir)) {
|
||||
$flat = collect(scandir($dir) ?: [])
|
||||
->filter(fn ($f) => $f !== '.' && $f !== '..' && is_file("{$dir}/{$f}"))
|
||||
->map(function ($filename) use ($dir) {
|
||||
$user = \App\Models\User::where('banner', $filename)->first();
|
||||
return $user ? ['user' => $user, 'file' => $filename, 'path' => "{$dir}/{$filename}"] : null;
|
||||
})
|
||||
->filter();
|
||||
$results = $results->merge($flat);
|
||||
}
|
||||
|
||||
// New structured dir: users/{slug}/profile/cover.*
|
||||
$usersBase = storage_path('app/users');
|
||||
if (is_dir($usersBase)) {
|
||||
foreach (glob("{$usersBase}/*/profile/cover.*") ?: [] as $path) {
|
||||
$relPath = 'users/' . ltrim(str_replace(storage_path('app/users') . '/', '', str_replace('\\', '/', $path)), '/');
|
||||
$user = \App\Models\User::where('banner', $relPath)->first();
|
||||
if ($user) {
|
||||
$results->push(['user' => $user, 'file' => basename($path), 'path' => $path]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $results;
|
||||
}
|
||||
|
||||
private function collectStuckLegacyThumbnails(): \Illuminate\Support\Collection
|
||||
{
|
||||
$dir = storage_path('app/public/thumbnails');
|
||||
if (! is_dir($dir)) return collect();
|
||||
|
||||
$results = [];
|
||||
foreach (scandir($dir) ?: [] as $filename) {
|
||||
if ($filename === '.' || $filename === '..') continue;
|
||||
$path = "{$dir}/{$filename}";
|
||||
if (! is_file($path)) continue;
|
||||
|
||||
$video = \App\Models\Video::where('thumbnail', $filename)->first();
|
||||
if ($video) {
|
||||
$results[] = ['type' => 'thumbnail', 'file' => $filename, 'path' => $path, 'video_id' => $video->id, 'video' => $video, 'slide' => null];
|
||||
continue;
|
||||
}
|
||||
$slide = \App\Models\VideoSlide::where('filename', $filename)->with('video')->first();
|
||||
if ($slide && $slide->video) {
|
||||
$results[] = ['type' => 'slide', 'file' => $filename, 'path' => $path, 'video_id' => $slide->video_id, 'video' => $slide->video, 'slide' => $slide];
|
||||
}
|
||||
}
|
||||
return collect($results);
|
||||
}
|
||||
|
||||
private function pruneLocalStorageDirs(): void
|
||||
{
|
||||
// NAS-mirrored tree
|
||||
$nasRoot = storage_path('app/users');
|
||||
if (is_dir($nasRoot)) {
|
||||
$iter = new \RecursiveIteratorIterator(
|
||||
new \RecursiveDirectoryIterator($nasRoot, \FilesystemIterator::SKIP_DOTS),
|
||||
\RecursiveIteratorIterator::CHILD_FIRST
|
||||
);
|
||||
foreach ($iter as $item) {
|
||||
if (! $item->isDir()) continue;
|
||||
$path = $item->getPathname();
|
||||
$contents = array_diff(scandir($path) ?: [], ['.', '..']);
|
||||
$nonMeta = array_diff($contents, ['meta.json']);
|
||||
if (empty($contents)) {
|
||||
@rmdir($path);
|
||||
} elseif (empty($nonMeta)) {
|
||||
@unlink("{$path}/meta.json");
|
||||
@rmdir($path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Flat asset dirs — remove if empty
|
||||
foreach (['public/avatars', 'public/banners', 'public/thumbnails'] as $rel) {
|
||||
$path = storage_path("app/{$rel}");
|
||||
if (is_dir($path) && empty(array_diff(scandir($path) ?: [], ['.', '..']))) {
|
||||
@rmdir($path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── NAS Disable Flow ──────────────────────────────────────────────────
|
||||
|
||||
public function nasDisable(Request $request)
|
||||
{
|
||||
$mode = $request->input('mode'); // 'migrate' or 'fresh'
|
||||
|
||||
if ($mode === 'migrate') {
|
||||
// Reset progress cache, dispatch job
|
||||
\Cache::put('nas_disable_progress', json_encode([
|
||||
'current' => 0, 'total' => 0,
|
||||
'phase' => 'Starting...', 'done' => false, 'error' => null,
|
||||
]), 3600);
|
||||
\App\Jobs\NasToLocalMigrationJob::dispatch()
|
||||
->onQueue('video-processing')
|
||||
->onConnection('database');
|
||||
return response()->json(['ok' => true]);
|
||||
}
|
||||
|
||||
if ($mode === 'fresh') {
|
||||
// Truncate all media tables, reset user avatars/banners, disable NAS
|
||||
$tables = [
|
||||
'videos','video_slides','video_likes','video_views','video_shares',
|
||||
'video_downloads','playlist_videos','playlists','comments','comment_likes',
|
||||
'posts','post_images','post_reactions','post_videos',
|
||||
'coach_reviews','match_rounds','match_points',
|
||||
'share_accesses','playlist_share_accesses','notifications',
|
||||
];
|
||||
foreach ($tables as $t) {
|
||||
\DB::table($t)->delete();
|
||||
}
|
||||
\DB::table('users')->update(['avatar' => null, 'banner' => null]);
|
||||
Setting::set('nas_sync_enabled', 'false');
|
||||
AuditLog::record('admin.nas_disabled_fresh');
|
||||
return response()->json(['ok' => true]);
|
||||
}
|
||||
|
||||
return response()->json(['ok' => false, 'message' => 'Invalid mode'], 422);
|
||||
}
|
||||
|
||||
public function nasMigrateProgress()
|
||||
{
|
||||
$raw = \Cache::get('nas_disable_progress');
|
||||
if (! $raw) return response()->json(['done' => false, 'current' => 0, 'total' => 0, 'phase' => 'Not started']);
|
||||
return response()->json(json_decode($raw, true));
|
||||
}
|
||||
|
||||
public function backupUsersSettings()
|
||||
{
|
||||
$users = \DB::table('users')->get()->map(function ($u) {
|
||||
return (array) $u;
|
||||
})->toArray();
|
||||
|
||||
$settings = \DB::table('settings')->get()->map(function ($s) {
|
||||
return (array) $s;
|
||||
})->toArray();
|
||||
|
||||
$payload = json_encode([
|
||||
'version' => '1.0',
|
||||
'exported_at' => now()->toIso8601String(),
|
||||
'users' => $users,
|
||||
'settings' => $settings,
|
||||
], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE);
|
||||
|
||||
return response($payload, 200, [
|
||||
'Content-Type' => 'application/json',
|
||||
'Content-Disposition' => 'attachment; filename="takeone-backup-' . now()->format('Ymd-His') . '.json"',
|
||||
]);
|
||||
}
|
||||
|
||||
public function restoreUsersSettings(Request $request)
|
||||
{
|
||||
$request->validate(['backup' => 'required|file|mimes:json|max:10240']);
|
||||
|
||||
$content = file_get_contents($request->file('backup')->getRealPath());
|
||||
$data = json_decode($content, true);
|
||||
|
||||
if (! isset($data['users']) || ! isset($data['settings'])) {
|
||||
return back()->with('toast_error', 'Invalid backup file.');
|
||||
}
|
||||
|
||||
// Restore settings
|
||||
foreach ($data['settings'] as $row) {
|
||||
\DB::table('settings')->updateOrInsert(
|
||||
['key' => $row['key']],
|
||||
['key' => $row['key'], 'value' => $row['value']]
|
||||
);
|
||||
}
|
||||
|
||||
// Restore users (upsert by email)
|
||||
$restored = 0;
|
||||
foreach ($data['users'] as $row) {
|
||||
unset($row['id']); // let DB assign new IDs to avoid PK conflicts
|
||||
\DB::table('users')->updateOrInsert(
|
||||
['email' => $row['email']],
|
||||
$row
|
||||
);
|
||||
$restored++;
|
||||
}
|
||||
|
||||
AuditLog::record('admin.backup_restored', ['users' => $restored]);
|
||||
return back()->with('toast_success', "Backup restored: {$restored} users + settings.");
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user