Create & Delete Accounts - only php fpm pools remaining

This commit is contained in:
Alex Crivion
2025-02-23 07:26:10 +00:00
parent ba64b0be3b
commit d41a9de364
23 changed files with 370 additions and 66 deletions

View File

@@ -1,20 +0,0 @@
<?php
namespace App\Actions\Accounts;
use App\Models\User;
use Illuminate\Auth\Events\Registered;
class CreateAccountAction
{
public function execute(array $validated): void
{
$user = User::create($validated);
event(new Registered($user));
if (isset($validated['notify']) && $validated['notify']) {
\Illuminate\Support\Facades\Log::info('Would notify ' . $user->email);
}
}
}

View File

@@ -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
};
}
}

View File

@@ -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;

View File

@@ -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;

View File

@@ -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');

View File

@@ -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;

View File

@@ -35,6 +35,10 @@ class HandleInertiaRequests extends Middleware
'user' => $request->user(),
'isImpersonating' => app('impersonate')->isImpersonating(),
],
'flash' => [
'success' => session('success'),
'error' => session('error'),
],
];
}
}

View File

@@ -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',
);
}
}

View File

@@ -0,0 +1,62 @@
<?php
namespace App\Services\Accounts;
use App\Models\User;
use Illuminate\Auth\Events\Registered;
use Illuminate\Support\Facades\Process;
use Exception;
class CreateAccountException extends Exception {}
class CreateAccountService
{
private string $laranodeBinPath;
private string $systemUsername;
public function __construct(private array $validated)
{
// path to laranode user manager bin|ssh script
$this->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 {}
}

View File

@@ -0,0 +1,47 @@
<?php
namespace App\Services\Accounts;
use App\Models\User;
use Illuminate\Support\Facades\Process;
use Exception;
class DeleteAccountException extends Exception {}
class DeleteAccountService
{
private string $laranodeBinPath;
public function __construct(private User $user)
{
$this->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 {}
}

View File

@@ -1,6 +1,6 @@
<?php
namespace App\Services;
namespace App\Services\Dashboard;
use Illuminate\Support\Collection;

View File

@@ -1,6 +1,6 @@
<?php
namespace App\Services;
namespace App\Services\Dashboard;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Process;

View File

@@ -1,6 +1,6 @@
<?php
namespace App\Services;
namespace App\Services\Dashboard;
use Illuminate\Support\Collection;

View File

@@ -1,6 +1,6 @@
<?php
namespace App\Services;
namespace App\Services\Dashboard;
use App\Services\Contracts\HistoricStatsContract;
use Illuminate\Support\Carbon;

View File

@@ -1,6 +1,6 @@
<?php
namespace App\Services;
namespace App\Services\Dashboard;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\File;

View File

@@ -1,6 +1,6 @@
<?php
namespace App\Services;
namespace App\Services\Dashboard;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Process;

View File

@@ -1,13 +1,25 @@
import { usePage } from '@inertiajs/react';
import { useState } from 'react';
import { useEffect, useState } from 'react';
import TopNavi from './Partials/TopNavi';
import SidebarNavi from './Partials/SidebarNavi';
import { ToastContainer } from 'react-toastify';
import { ToastContainer, toast } from 'react-toastify';
export default function AuthenticatedLayout({ header, children }) {
const user = usePage().props.auth.user;
const { flash } = usePage().props;
const [showingNavigationDropdown, setShowingNavigationDropdown] = useState(false);
useEffect(() => {
if (flash.success) {
toast(flash.success, { type: 'success' });
}
if (flash.error) {
toast(flash.error, { type: 'error' });
}
}, [flash]);
return (
<div className="min-h-screen flex flex-col flex-auto flex-shrink-0 antialiase bg-gray-100 dark:bg-gray-900">
<ToastContainer theme='dark' />

View File

@@ -85,7 +85,7 @@ export default function Accounts({ accounts }) {
</div>
</td>
<td className="px-6 py-4 font-medium text-gray-900 whitespace-nowrap dark:text-white">
{account.ssh_access ? <span className='bg-amber-200 text-amber-700 px-2 py-1 rounded-lg'>No</span> : <span className='bg-lime-300 text-lime-700 px-2 py-0.5 text-sm rounded-lg'>Yes</span>}
{!account.ssh_access ? <span className='bg-amber-200 text-amber-700 px-2 py-1 rounded-lg'>No</span> : <span className='bg-lime-300 text-lime-700 px-2 py-0.5 text-sm rounded-lg'>Yes</span>}
</td>
<td className="px-6 py-4 font-medium text-gray-900 whitespace-nowrap dark:text-white">
{account.role == "admin" ? <span className='bg-green-300 text-green-700 px-2 py-1 text-sm rounded-lg'>Admin</span> : <span className='bg-gray-300 text-gray-700 px-2 py-1 text-sm rounded-lg'>User</span>}

View File

@@ -61,7 +61,7 @@ export default function CreateAccountForm() {
closeModal();
reset();
toast("Account created successfully.");
}
},
});
};

View File

@@ -0,0 +1,108 @@
<?php
use App\Actions\Filemanager\DeleteFilesAction;
use Illuminate\Http\Request;
use League\Flysystem\Filesystem;
use League\Flysystem\InMemory\InMemoryFilesystemAdapter;
beforeEach(function () {
$this->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);
});

View File

@@ -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');
});

View File

@@ -0,0 +1,40 @@
<?php
use App\Actions\Filemanager\RenameFileAction;
use Illuminate\Http\Request;
use League\Flysystem\Filesystem;
use League\Flysystem\InMemory\InMemoryFilesystemAdapter;
test('it can rename a directory', function () {
$filesystem = new Filesystem(new InMemoryFilesystemAdapter());
$action = new RenameFileAction($filesystem);
// Create a directory with a file inside to test full directory move
$filesystem->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();
});

View File

@@ -1,6 +1,6 @@
<?php
use App\Services\TopCommandService;
use App\Services\Dashboard\TopCommandService;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Process;