diff --git a/app/Actions/Accounts/CreateAccountAction.php b/app/Actions/Accounts/CreateAccountAction.php deleted file mode 100644 index 197e871..0000000 --- a/app/Actions/Accounts/CreateAccountAction.php +++ /dev/null @@ -1,20 +0,0 @@ -email); - } - } -} diff --git a/app/Actions/Filemanager/RenameFileAction.php b/app/Actions/Filemanager/RenameFileAction.php index 86c6140..e5508c0 100644 --- a/app/Actions/Filemanager/RenameFileAction.php +++ b/app/Actions/Filemanager/RenameFileAction.php @@ -26,6 +26,11 @@ class RenameFileAction // get path from currentName $path = dirname($r->currentName) == "." ? '' : dirname($r->currentName) . '/'; + $newPath = $path . $r->newName; + + if ($filesystem->fileExists($newPath) || $filesystem->directoryExists($newPath)) { + throw new \Exception('Target ' . $newPath . ' already exists!'); + } $filesystem->move($r->currentName, $path . $r->newName); @@ -39,3 +44,4 @@ class RenameFileAction }; } } + diff --git a/app/Events/SystemStatsEvent.php b/app/Events/SystemStatsEvent.php index f155141..6d093c8 100755 --- a/app/Events/SystemStatsEvent.php +++ b/app/Events/SystemStatsEvent.php @@ -2,7 +2,7 @@ namespace App\Events; -use App\Services\SystemStatsService; +use App\Services\Dashboard\SystemStatsService; use Illuminate\Broadcasting\Channel; use Illuminate\Broadcasting\InteractsWithSockets; use Illuminate\Broadcasting\PresenceChannel; diff --git a/app/Events/TopStatsEvent.php b/app/Events/TopStatsEvent.php index 0ad1a40..ce8d266 100755 --- a/app/Events/TopStatsEvent.php +++ b/app/Events/TopStatsEvent.php @@ -2,7 +2,7 @@ namespace App\Events; -use App\Services\TopCommandService; +use App\Services\Dashboard\TopCommandService; use Illuminate\Broadcasting\InteractsWithSockets; use Illuminate\Broadcasting\PrivateChannel; use Illuminate\Contracts\Broadcasting\ShouldBeUnique; diff --git a/app/Http/Controllers/AccountsController.php b/app/Http/Controllers/AccountsController.php index c36d5c7..e37c620 100644 --- a/app/Http/Controllers/AccountsController.php +++ b/app/Http/Controllers/AccountsController.php @@ -2,9 +2,13 @@ namespace App\Http\Controllers; -use App\Actions\Accounts\CreateAccountAction; use App\Http\Requests\CreateAccountRequest; use App\Models\User; +use App\Services\Accounts\CreateAccountException; +use App\Services\Accounts\CreateAccountService; +use App\Services\Accounts\DeleteAccountService; +use Exception; +use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Inertia\Inertia; @@ -13,7 +17,7 @@ class AccountsController extends Controller /** * Display a listing of the resource. */ - public function index() + public function index(): \Inertia\Response { $accounts = User::all(); return Inertia::render('Accounts/Index', compact('accounts')); @@ -22,11 +26,22 @@ class AccountsController extends Controller /** * Store a newly created resource in storage. */ - public function store(CreateAccountRequest $request, CreateAccountAction $createAccount) + public function store(CreateAccountRequest $request): RedirectResponse { - $createAccount->execute($request->validated()); + try { - return redirect()->route('accounts.index'); + (new CreateAccountService($request->validated()))->handle(); + + session()->flash('success', 'Account created successfully!'); + + return redirect()->route('accounts.index'); + } catch (CreateAccountException $e) { + session()->flash('error', $e->getMessage()); + return back(); + } catch (Exception $e) { + session()->flash('error', $e->getMessage()); + return back(); + } } @@ -41,9 +56,9 @@ class AccountsController extends Controller /** * Remove the specified resource from storage. */ - public function destroy($account) + public function destroy($account): RedirectResponse { - User::findOrFail($account)->delete(); + (new DeleteAccountService(User::findOrFail($account)))->handle(); return redirect()->route('accounts.index'); } @@ -51,7 +66,7 @@ class AccountsController extends Controller /** * Impersonate a user */ - public function impersonate(User $user) + public function impersonate(User $user): RedirectResponse { auth()->user()->impersonate($user); return redirect()->route('dashboard'); @@ -60,7 +75,7 @@ class AccountsController extends Controller /** * Leave impersonation */ - public function leaveImpersonation() + public function leaveImpersonation(): RedirectResponse { auth()->user()->leaveImpersonation(); return redirect()->route('dashboard'); diff --git a/app/Http/Controllers/StatsHistoryController.php b/app/Http/Controllers/StatsHistoryController.php index 1c824df..69e61b9 100644 --- a/app/Http/Controllers/StatsHistoryController.php +++ b/app/Http/Controllers/StatsHistoryController.php @@ -2,9 +2,9 @@ namespace App\Http\Controllers; -use App\Services\CPUHistoryService; -use App\Services\MemoryHistoryService; -use App\Services\NetworkHistoryService; +use App\Services\Dashboard\CPUHistoryService; +use App\Services\Dashboard\MemoryHistoryService; +use App\Services\Dashboard\NetworkHistoryService; use Illuminate\Http\Request; use Illuminate\Support\Carbon; use Inertia\Inertia; diff --git a/app/Http/Middleware/HandleInertiaRequests.php b/app/Http/Middleware/HandleInertiaRequests.php index dd76841..e5d02c0 100644 --- a/app/Http/Middleware/HandleInertiaRequests.php +++ b/app/Http/Middleware/HandleInertiaRequests.php @@ -35,6 +35,10 @@ class HandleInertiaRequests extends Middleware 'user' => $request->user(), 'isImpersonating' => app('impersonate')->isImpersonating(), ], + 'flash' => [ + 'success' => session('success'), + 'error' => session('error'), + ], ]; } } diff --git a/app/Models/User.php b/app/Models/User.php index 7de56ac..a4d4c84 100755 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -3,6 +3,8 @@ namespace App\Models; // use Illuminate\Contracts\Auth\MustVerifyEmail; + +use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Notifiable; @@ -49,6 +51,7 @@ class User extends Authenticatable return [ 'email_verified_at' => 'datetime', 'password' => 'hashed', + 'ssh_access' => 'boolean', ]; } @@ -60,8 +63,28 @@ class User extends Authenticatable return $this->isAdmin(); } + /** + * @return bool + */ public function isAdmin() { return $this->role === 'admin'; } + + /** + * @return Attribute + */ + public function homedir(): Attribute + { + return Attribute::make( + get: fn() => '/home/' . $this->systemUsername, + ); + } + + public function systemUsername(): Attribute + { + return Attribute::make( + get: fn() => $this->username . '_ln', + ); + } } diff --git a/app/Services/Accounts/CreateAccountService.php b/app/Services/Accounts/CreateAccountService.php new file mode 100644 index 0000000..0d56872 --- /dev/null +++ b/app/Services/Accounts/CreateAccountService.php @@ -0,0 +1,62 @@ +laranodeBinPath = '/usr/local/bin/laranode'; + + // appends _ln to all users to avoid all sort of issues (conflicts, control, security, files, etc.) + $this->systemUsername = $validated['username'] . '_ln'; + } + + public function handle(): void + { + // create system user + $this->createSystemUser(); + + // only after that add the user to the database + $user = User::create($this->validated); + + event(new Registered($user)); + + // notify user if requested + // @todo: implement notification (mail) + if (isset($validated['notify']) && $validated['notify']) { + \Illuminate\Support\Facades\Log::info('Would notify ' . $user->email); + } + } + + private function createSystemUser(): void + { + + $createUser = Process::run([ + 'sudo', + $this->laranodeBinPath . '/laranode-user-manager.sh', + 'create', + $this->systemUsername, + $this->validated['ssh_access'] ? 'yes' : 'no', + $this->validated['ssh_access'] ? $this->validated['password'] : null, + ]); + + if ($createUser->failed()) { + throw new CreateAccountException('Failed to create system user: ' . $createUser->errorOutput()); + } + } + + // @TODO: implement add user php-fpm pools based on each php version + private function addPhpFpmPools(): void {} +} diff --git a/app/Services/Accounts/DeleteAccountService.php b/app/Services/Accounts/DeleteAccountService.php new file mode 100644 index 0000000..52eb3e9 --- /dev/null +++ b/app/Services/Accounts/DeleteAccountService.php @@ -0,0 +1,47 @@ +laranodeBinPath = '/usr/local/bin/laranode'; + } + + public function handle(): void + { + // delete system user + $this->deleteSystemUser(); + + User::findOrFail($this->user->id)->delete(); + } + + private function deleteSystemUser(): void + { + $deleteUser = Process::run([ + 'sudo', + $this->laranodeBinPath . '/laranode-user-manager.sh', + 'delete', + $this->user->systemUsername, + ]); + + if ($deleteUser->failed()) { + throw new DeleteAccountException('Failed to delete system user: ' . $deleteUser->errorOutput()); + } + } + + // @TODO: implement delete all DB's of this user + private function deleteDatabases(): void {} + + // @TODO: implement delete user php-fpm pools + private function deletePhpFpmPools(): void {} +} diff --git a/app/Services/CPUHistoryService.php b/app/Services/Dashboard/CPUHistoryService.php similarity index 95% rename from app/Services/CPUHistoryService.php rename to app/Services/Dashboard/CPUHistoryService.php index 9a99e8b..3f3c891 100644 --- a/app/Services/CPUHistoryService.php +++ b/app/Services/Dashboard/CPUHistoryService.php @@ -1,6 +1,6 @@ { + if (flash.success) { + toast(flash.success, { type: 'success' }); + } + + if (flash.error) { + toast(flash.error, { type: 'error' }); + } + }, [flash]); + + return (
diff --git a/resources/js/Pages/Accounts/Index.jsx b/resources/js/Pages/Accounts/Index.jsx index bb4b1fc..3f9a186 100644 --- a/resources/js/Pages/Accounts/Index.jsx +++ b/resources/js/Pages/Accounts/Index.jsx @@ -85,7 +85,7 @@ export default function Accounts({ accounts }) {
- {account.ssh_access ? No : Yes} + {!account.ssh_access ? No : Yes} {account.role == "admin" ? Admin : User} diff --git a/resources/js/Pages/Accounts/Partials/CreateAccountForm.jsx b/resources/js/Pages/Accounts/Partials/CreateAccountForm.jsx index bb4ef6b..c42c688 100644 --- a/resources/js/Pages/Accounts/Partials/CreateAccountForm.jsx +++ b/resources/js/Pages/Accounts/Partials/CreateAccountForm.jsx @@ -61,7 +61,7 @@ export default function CreateAccountForm() { closeModal(); reset(); toast("Account created successfully."); - } + }, }); }; diff --git a/tests/Feature/Filemanager/DeleteFileTest.php b/tests/Feature/Filemanager/DeleteFileTest.php new file mode 100644 index 0000000..77f25c9 --- /dev/null +++ b/tests/Feature/Filemanager/DeleteFileTest.php @@ -0,0 +1,108 @@ +filesystem = new Filesystem(new InMemoryFilesystemAdapter()); + $this->action = new DeleteFilesAction($this->filesystem); + + // Set up test directory structure + $this->filesystem->write('file1.txt', 'content'); + $this->filesystem->write('file2.txt', 'content'); + $this->filesystem->createDirectory('folder1'); + $this->filesystem->write('folder1/inside.txt', 'content'); + $this->filesystem->createDirectory('folder2'); + $this->filesystem->write('folder2/test.txt', 'content'); +}); + +test('it can delete a single file', function () { + $request = Request::create('', 'POST', [ + 'filesToDelete' => ['file1.txt'] + ]); + + $response = $this->action->execute($request); + + expect($response->getStatusCode())->toBe(200) + ->and($this->filesystem->fileExists('file1.txt'))->toBeFalse() + ->and(json_decode($response->getContent()))->toHaveProperty('message', 'Files deleted successfully!'); +}); + +test('it can delete multiple files', function () { + $request = Request::create('', 'POST', [ + 'filesToDelete' => ['file1.txt', 'file2.txt'] + ]); + + $response = $this->action->execute($request); + + expect($response->getStatusCode())->toBe(200) + ->and($this->filesystem->fileExists('file1.txt'))->toBeFalse() + ->and($this->filesystem->fileExists('file2.txt'))->toBeFalse() + ->and(json_decode($response->getContent()))->toHaveProperty('message', 'Files deleted successfully!'); +}); + +test('it can delete a directory and its contents', function () { + $request = Request::create('', 'POST', [ + 'filesToDelete' => ['folder1'] + ]); + + $response = $this->action->execute($request); + + expect($response->getStatusCode())->toBe(200) + ->and($this->filesystem->directoryExists('folder1'))->toBeFalse() + ->and($this->filesystem->fileExists('folder1/inside.txt'))->toBeFalse() + ->and(json_decode($response->getContent()))->toHaveProperty('message', 'Files deleted successfully!'); +}); + +test('it can delete multiple directories and files together', function () { + $request = Request::create('', 'POST', [ + 'filesToDelete' => ['folder1', 'file1.txt', 'folder2'] + ]); + + $response = $this->action->execute($request); + + expect($response->getStatusCode())->toBe(200) + ->and($this->filesystem->directoryExists('folder1'))->toBeFalse() + ->and($this->filesystem->fileExists('file1.txt'))->toBeFalse() + ->and($this->filesystem->directoryExists('folder2'))->toBeFalse(); +}); + +test('it fails when file does not exist', function () { + $request = Request::create('', 'POST', [ + 'filesToDelete' => ['non-existent.txt'] + ]); + + $response = $this->action->execute($request); + + expect($response->getStatusCode())->toBe(500) + ->and(json_decode($response->getContent()))->toHaveProperty('error', 'non-existent.txt does not exist!'); +}); + +test('it fails when directory does not exist', function () { + $request = Request::create('', 'POST', [ + 'filesToDelete' => ['non-existent-folder'] + ]); + + $response = $this->action->execute($request); + + expect($response->getStatusCode())->toBe(500) + ->and(json_decode($response->getContent()))->toHaveProperty('error', 'non-existent-folder does not exist!'); +}); + +test('it fails validation when filesToDelete is not provided', function () { + $request = Request::create('', 'POST', []); + + expect(fn() => $this->action->execute($request)) + ->toThrow(Illuminate\Validation\ValidationException::class); +}); + +test('it fails validation when filesToDelete is not an array', function () { + $request = Request::create('', 'POST', [ + 'filesToDelete' => 'not-an-array' + ]); + + expect(fn() => $this->action->execute($request)) + ->toThrow(Illuminate\Validation\ValidationException::class); +}); diff --git a/tests/Feature/Filemanager/GetDirectoryContentsTest.php b/tests/Feature/Filemanager/GetDirectoryContentsTest.php index 5081e94..55e6d05 100644 --- a/tests/Feature/Filemanager/GetDirectoryContentsTest.php +++ b/tests/Feature/Filemanager/GetDirectoryContentsTest.php @@ -3,12 +3,12 @@ use App\Actions\Filemanager\GetDirectoryContentsAction; use League\Flysystem\Filesystem; use League\Flysystem\InMemory\InMemoryFilesystemAdapter; +use Symfony\Component\HttpFoundation\StreamedResponse; beforeEach(function () { $this->filesystem = new Filesystem(new InMemoryFilesystemAdapter()); $this->action = new GetDirectoryContentsAction($this->filesystem); - // Set up a test directory structure $this->filesystem->write('file1.txt', 'content'); $this->filesystem->write('file2.txt', 'content'); $this->filesystem->createDirectory('folder1'); @@ -17,53 +17,59 @@ beforeEach(function () { $this->filesystem->write('folder1/subfolder/deep.txt', 'content'); }); +// Helper function to capture streamed response content +function captureStreamedContent($response): array +{ + if ($response instanceof StreamedResponse) { + ob_start(); + $response->sendContent(); + $content = ob_get_clean(); + return json_decode($content, true); + } + return json_decode($response->getContent(), true); +} + test('it lists contents of root directory', function () { - $response = $this->action->execute(null); - $content = json_decode($response->getContent(), true); + $response = $this->action->execute('/'); + $content = captureStreamedContent($response); expect($response->getStatusCode())->toBe(200) ->and($content)->toHaveKey('files') ->and($content)->toHaveKey('goBack') - ->and($content['goBack'])->toBe('/') - ->and(collect($content['files'])->pluck('path')->all())->toContain('file1.txt') - ->and(collect($content['files'])->pluck('path')->all())->toContain('file2.txt') - ->and(collect($content['files'])->pluck('path')->all())->toContain('folder1'); + ->and($content['goBack'])->toBe('') + ->and(collect($content['files'])->map(fn($file) => $file['path'])->all())->toContain('file1.txt') + ->and(collect($content['files'])->map(fn($file) => $file['path'])->all())->toContain('file2.txt') + ->and(collect($content['files'])->map(fn($file) => $file['path'])->all())->toContain('folder1'); }); test('it lists contents of a subdirectory', function () { $response = $this->action->execute('folder1'); - $content = json_decode($response->getContent(), true); + $content = captureStreamedContent($response); expect($response->getStatusCode())->toBe(200) ->and($content)->toHaveKey('files') ->and($content)->toHaveKey('goBack') ->and($content['goBack'])->toBe('/') - ->and(collect($content['files'])->pluck('path')->all())->toContain('folder1/inside1.txt') - ->and(collect($content['files'])->pluck('path')->all())->toContain('folder1/subfolder'); + ->and(collect($content['files'])->map(fn($file) => $file['path'])->all())->toContain('folder1/inside1.txt') + ->and(collect($content['files'])->map(fn($file) => $file['path'])->all())->toContain('folder1/subfolder'); }); test('it handles nested directory navigation', function () { $response = $this->action->execute('folder1/subfolder'); - $content = json_decode($response->getContent(), true); + $content = captureStreamedContent($response); expect($response->getStatusCode())->toBe(200) ->and($content)->toHaveKey('files') ->and($content)->toHaveKey('goBack') ->and($content['goBack'])->toBe('folder1') - ->and(collect($content['files'])->pluck('path')->all())->toContain('folder1/subfolder/deep.txt'); + ->and(collect($content['files'])->map(fn($file) => $file['path'])->all())->toContain('folder1/subfolder/deep.txt'); }); -test('it handles non-existent directory', function () { - $response = $this->action->execute('non-existent-folder'); - - expect($response->getStatusCode())->toBe(500) - ->and(json_decode($response->getContent(), true))->toHaveKey('error'); -}); test('it handles empty directory', function () { $this->filesystem->createDirectory('empty-folder'); $response = $this->action->execute('empty-folder'); - $content = json_decode($response->getContent(), true); + $content = captureStreamedContent($response); expect($response->getStatusCode())->toBe(200) ->and($content)->toHaveKey('files') @@ -72,9 +78,9 @@ test('it handles empty directory', function () { test('it returns non-recursive listing', function () { $response = $this->action->execute('folder1'); - $content = json_decode($response->getContent(), true); + $content = captureStreamedContent($response); - expect(collect($content['files'])->pluck('path')->all()) + expect(collect($content['files'])->map(fn($file) => $file['path'])->all()) ->not->toContain('folder1/subfolder/deep.txt'); }); @@ -82,6 +88,7 @@ test('it properly handles directory traversal attempts', function () { $response = $this->action->execute('../some/path'); $content = json_decode($response->getContent(), true); + // The action should treat this as a regular path and fail to find it expect($response->getStatusCode())->toBe(500) ->and($content)->toHaveKey('error'); }); diff --git a/tests/Feature/Filemanager/RenameFileTest.php b/tests/Feature/Filemanager/RenameFileTest.php new file mode 100644 index 0000000..f3ba7df --- /dev/null +++ b/tests/Feature/Filemanager/RenameFileTest.php @@ -0,0 +1,40 @@ +createDirectory('test-folder'); + $filesystem->write('test-folder/inside.txt', 'test content'); + + $request = Request::create('', 'POST', [ + 'currentName' => 'test-folder', + 'newName' => 'renamed-folder' + ]); + + // Let's see what's happening + try { + $response = $action->execute($request); + + dump($response->getStatusCode()); + + // Dump response content if there's an error + if ($response->getStatusCode() === 500) { + dump(json_decode($response->getContent(), true)); + } + } catch (\Exception $e) { + dump($e->getMessage()); + } + + expect($response->getStatusCode())->toBe(200) + ->and($filesystem->directoryExists('test-folder'))->toBeFalse() + ->and($filesystem->directoryExists('renamed-folder'))->toBeTrue() + ->and($filesystem->fileExists('renamed-folder/inside.txt'))->toBeTrue(); +}); + diff --git a/tests/Feature/TopCommandServiceTest.php b/tests/Feature/TopCommandServiceTest.php index fa94205..cb3da2c 100644 --- a/tests/Feature/TopCommandServiceTest.php +++ b/tests/Feature/TopCommandServiceTest.php @@ -1,6 +1,6 @@