From 4e6491a0800bc92634405821bfcc9289bb56f56d Mon Sep 17 00:00:00 2001 From: Saeed Vaziry <61919774+saeedvaziry@users.noreply.github.com> Date: Thu, 5 Jun 2025 17:01:22 +0200 Subject: [PATCH] Export and Import vito settings (#606) * Export and Import vito settings * fix tests --- .../Controllers/VitoSettingController.php | 131 ++++++++++++++++++ app/Http/Kernel.php | 1 + app/Http/Middleware/MustBeAdminMiddleware.php | 18 +++ resources/js/components/app-logo-icon.tsx | 4 +- resources/js/components/ui/card.tsx | 6 +- resources/js/icons/vito.tsx | 10 ++ resources/js/layouts/settings/layout.tsx | 6 + .../pages/vito-settings/components/export.tsx | 15 ++ .../pages/vito-settings/components/import.tsx | 70 ++++++++++ resources/js/pages/vito-settings/index.tsx | 37 +++++ tests/Feature/VitoSettingsTest.php | 19 +++ 11 files changed, 314 insertions(+), 3 deletions(-) create mode 100644 app/Http/Controllers/VitoSettingController.php create mode 100644 app/Http/Middleware/MustBeAdminMiddleware.php create mode 100644 resources/js/icons/vito.tsx create mode 100644 resources/js/pages/vito-settings/components/export.tsx create mode 100644 resources/js/pages/vito-settings/components/import.tsx create mode 100644 resources/js/pages/vito-settings/index.tsx create mode 100644 tests/Feature/VitoSettingsTest.php diff --git a/app/Http/Controllers/VitoSettingController.php b/app/Http/Controllers/VitoSettingController.php new file mode 100644 index 00000000..2033a22d --- /dev/null +++ b/app/Http/Controllers/VitoSettingController.php @@ -0,0 +1,131 @@ + + */ + protected array $paths = [ + 'storage/database.sqlite' => 'file', + '.env' => 'file', + 'storage/ssh-public.key' => 'file', + 'storage/ssh-private.pem' => 'file', + 'storage/app/key-pairs' => 'directory', + 'storage/app/server-logs' => 'directory', + ]; + + #[Get('/', name: 'vito-settings')] + public function index(): Response + { + return Inertia::render('vito-settings/index'); + } + + /** + * @throws Exception + */ + #[Get('/export', name: 'vito-settings.export')] + public function downloadExport(): BinaryFileResponse + { + $exportName = 'vito-backup-'.date('Y-m-d').'.zip'; + $export = $this->export($exportName); + + return response()->download($export, $exportName)->deleteFileAfterSend(); + } + + /** + * @throws Exception + */ + private function export(string $zipFileName): string + { + $zipPath = Storage::disk('tmp')->path($zipFileName); + + $zip = new ZipArchive; + if ($zip->open($zipPath, ZipArchive::CREATE) !== true) { + throw new Exception('Could not create zip file at '.$zipPath); + } + + foreach ($this->paths as $path => $type) { + $path = base_path($path); + if ($type === 'file' && File::exists($path)) { + $zip->addFile($path, basename($path)); + } elseif ($type === 'directory' && File::exists($path)) { + $this->addDirectoryToZip($zip, $path, basename($path)); + } + } + + $zip->close(); + + return $zipPath; + } + + /** + * @throws ValidationException + * @throws Exception + */ + #[Post('/import', name: 'vito-settings.import')] + public function import(Request $request): RedirectResponse + { + // set session driver to file + config(['session.driver' => 'file']); + + $request->validate([ + 'backup_file' => 'required|file|mimes:zip', + ]); + + $uploadedFile = $request->file('backup_file'); + $extractName = 'vito-backup-import-'.time(); + $extractPath = Storage::disk('tmp')->path($extractName); + + // Create extraction directory + File::makeDirectory($extractPath, 0755, true); + + $zip = new ZipArchive; + if ($zip->open($uploadedFile->getPathname()) !== true) { + throw ValidationException::withMessages(['file' => 'The uploaded file is not a valid zip archive.']); + } + + // Extract files + $zip->extractTo($extractPath); + $zip->close(); + + // Replace files + File::move($extractPath.'/database.sqlite', storage_path('database.sqlite')); + File::copy($extractPath.'/.env', base_path('.env')); + File::move($extractPath.'/ssh-public.key', storage_path('ssh-public.key')); + File::move($extractPath.'/ssh-private.pem', storage_path('ssh-private.pem')); + File::moveDirectory($extractPath.'/key-pairs', storage_path('app/key-pairs')); + File::moveDirectory($extractPath.'/server-logs', storage_path('app/server-logs')); + + return redirect()->route('vito-settings') + ->with('success', 'Settings imported successfully.'); + } + + private function addDirectoryToZip(ZipArchive $zip, string $path, string $zipPath): void + { + $files = File::allFiles($path); + + foreach ($files as $file) { + $relativePath = $zipPath.'/'.$file->getRelativePathname(); + $zip->addFile($file->getRealPath(), $relativePath); + } + } +} diff --git a/app/Http/Kernel.php b/app/Http/Kernel.php index 5bcea704..c520b73c 100644 --- a/app/Http/Kernel.php +++ b/app/Http/Kernel.php @@ -69,5 +69,6 @@ class Kernel extends HttpKernel 'ability' => \Laravel\Sanctum\Http\Middleware\CheckForAnyAbility::class, 'has-project' => \App\Http\Middleware\HasProjectMiddleware::class, 'can-see-project' => \App\Http\Middleware\CanSeeProjectMiddleware::class, + 'must-be-admin' => \App\Http\Middleware\MustBeAdminMiddleware::class, ]; } diff --git a/app/Http/Middleware/MustBeAdminMiddleware.php b/app/Http/Middleware/MustBeAdminMiddleware.php new file mode 100644 index 00000000..6ff8bc2d --- /dev/null +++ b/app/Http/Middleware/MustBeAdminMiddleware.php @@ -0,0 +1,18 @@ +isAdmin()) { + abort(403, 'You must be an admin to perform this action.'); + } + + return $next($request); + } +} diff --git a/resources/js/components/app-logo-icon.tsx b/resources/js/components/app-logo-icon.tsx index 3409f1ac..3a4e7ef0 100644 --- a/resources/js/components/app-logo-icon.tsx +++ b/resources/js/components/app-logo-icon.tsx @@ -1,6 +1,6 @@ -import { SVGAttributes } from 'react'; +import { Ref, SVGAttributes } from 'react'; -export default function AppLogoIcon(props: SVGAttributes) { +export default function AppLogoIcon(props: SVGAttributes & { ref?: Ref }) { return ( diff --git a/resources/js/components/ui/card.tsx b/resources/js/components/ui/card.tsx index c8f99644..3b314549 100644 --- a/resources/js/components/ui/card.tsx +++ b/resources/js/components/ui/card.tsx @@ -26,4 +26,8 @@ function CardFooter({ className, ...props }: React.ComponentProps<'div'>) { return
; } -export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }; +function CardRow({ className, ...props }: React.ComponentProps<'div'>) { + return
; +} + +export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent, CardRow }; diff --git a/resources/js/icons/vito.tsx b/resources/js/icons/vito.tsx new file mode 100644 index 00000000..a151d460 --- /dev/null +++ b/resources/js/icons/vito.tsx @@ -0,0 +1,10 @@ +import React from 'react'; +import { LucideProps } from 'lucide-react'; +import AppLogoIcon from '@/components/app-logo-icon'; +import { cn } from '@/lib/utils'; + +export const VitoIcon = React.forwardRef(({ color = 'currentColor', strokeWidth = 30, className, ...rest }, ref) => { + return ; +}); + +export default VitoIcon; diff --git a/resources/js/layouts/settings/layout.tsx b/resources/js/layouts/settings/layout.tsx index 31ab24b5..1e636ffb 100644 --- a/resources/js/layouts/settings/layout.tsx +++ b/resources/js/layouts/settings/layout.tsx @@ -2,6 +2,7 @@ import { type BreadcrumbItem, type NavItem } from '@/types'; import { BellIcon, CloudIcon, CodeIcon, DatabaseIcon, KeyIcon, ListIcon, PlugIcon, TagIcon, UserIcon, UsersIcon } from 'lucide-react'; import { ReactNode } from 'react'; import Layout from '@/layouts/app/layout'; +import VitoIcon from '@/icons/vito'; const sidebarNavItems: NavItem[] = [ { @@ -54,6 +55,11 @@ const sidebarNavItems: NavItem[] = [ href: route('api-keys'), icon: PlugIcon, }, + { + title: 'Vito Settings', + href: route('vito-settings'), + icon: VitoIcon, + }, ]; export default function SettingsLayout({ children, breadcrumbs }: { children: ReactNode; breadcrumbs?: BreadcrumbItem[] }) { diff --git a/resources/js/pages/vito-settings/components/export.tsx b/resources/js/pages/vito-settings/components/export.tsx new file mode 100644 index 00000000..34a31950 --- /dev/null +++ b/resources/js/pages/vito-settings/components/export.tsx @@ -0,0 +1,15 @@ +import { Button } from '@/components/ui/button'; +import { DownloadIcon } from 'lucide-react'; + +export default function ExportVito() { + const submit = () => { + window.open(route('vito-settings.export'), '_blank'); + }; + + return ( + + ); +} diff --git a/resources/js/pages/vito-settings/components/import.tsx b/resources/js/pages/vito-settings/components/import.tsx new file mode 100644 index 00000000..2b97351e --- /dev/null +++ b/resources/js/pages/vito-settings/components/import.tsx @@ -0,0 +1,70 @@ +import { Button } from '@/components/ui/button'; +import { LoaderCircleIcon, UploadIcon } from 'lucide-react'; +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@/components/ui/dialog'; +import { Form, FormField, FormFields } from '@/components/ui/form'; +import { FormEvent } from 'react'; +import { useForm } from '@inertiajs/react'; +import { Label } from '@/components/ui/label'; +import { Input } from '@/components/ui/input'; +import InputError from '@/components/ui/input-error'; + +export default function ImportVito() { + const form = useForm({ + backup_file: null as File | null, + }); + + const submit = (e: FormEvent) => { + e.preventDefault(); + form.post(route('vito-settings.import')); + }; + + return ( + + + + + + + Import + Import settings to Vito + +
+ + + + form.setData('backup_file', e.target.files?.[0] || null)} + /> + + + +
+ + + + + + +
+
+ ); +} diff --git a/resources/js/pages/vito-settings/index.tsx b/resources/js/pages/vito-settings/index.tsx new file mode 100644 index 00000000..8a713cf4 --- /dev/null +++ b/resources/js/pages/vito-settings/index.tsx @@ -0,0 +1,37 @@ +import SettingsLayout from '@/layouts/settings/layout'; +import { Head } from '@inertiajs/react'; +import Container from '@/components/container'; +import Heading from '@/components/heading'; +import { Card, CardContent, CardRow } from '@/components/ui/card'; +import { Separator } from '@/components/ui/separator'; +import React from 'react'; +import ExportVito from '@/pages/vito-settings/components/export'; +import ImportVito from '@/pages/vito-settings/components/import'; + +export default function Users() { + return ( + + + + +
+ +
+ + + + + Export all data + + + + + Import + + + + +
+
+ ); +} diff --git a/tests/Feature/VitoSettingsTest.php b/tests/Feature/VitoSettingsTest.php new file mode 100644 index 00000000..e2d76ad9 --- /dev/null +++ b/tests/Feature/VitoSettingsTest.php @@ -0,0 +1,19 @@ +actingAs($this->user); + + $this->get(route('vito-settings.export')) + ->assertDownload('vito-backup-'.date('Y-m-d').'.zip'); + } +}