This commit is contained in:
Saeed Vaziry 2024-03-24 09:56:34 +01:00 committed by GitHub
parent 884f18db63
commit 4d051330d6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
1055 changed files with 14493 additions and 20278 deletions

View File

@ -2,28 +2,9 @@ APP_NAME=Vito
APP_ENV=local APP_ENV=local
APP_KEY= APP_KEY=
APP_DEBUG=true APP_DEBUG=true
APP_URL=http://vito.test APP_URL=
LOG_CHANNEL=stack
LOG_LEVEL=debug
DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=vito
DB_USERNAME=root
DB_PASSWORD=
BROADCAST_DRIVER=null
CACHE_DRIVER=file
FILESYSTEM_DRIVER=local FILESYSTEM_DRIVER=local
QUEUE_CONNECTION=default
SESSION_DRIVER=database
SESSION_LIFETIME=120
REDIS_HOST=127.0.0.1
REDIS_PASSWORD=null
REDIS_PORT=6379
MAIL_MAILER=smtp MAIL_MAILER=smtp
MAIL_HOST= MAIL_HOST=
@ -33,6 +14,3 @@ MAIL_PASSWORD=null
MAIL_ENCRYPTION=null MAIL_ENCRYPTION=null
MAIL_FROM_ADDRESS=null MAIL_FROM_ADDRESS=null
MAIL_FROM_NAME="${APP_NAME}" MAIL_FROM_NAME="${APP_NAME}"
SSH_PUBLIC_KEY_NAME=ssh-public.key
SSH_PRIVATE_KEY_NAME=ssh-private.pem

View File

@ -4,26 +4,7 @@ APP_KEY=
APP_DEBUG=false APP_DEBUG=false
APP_URL= APP_URL=
LOG_CHANNEL=stack
LOG_LEVEL=debug
DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=
DB_USERNAME=
DB_PASSWORD=
BROADCAST_DRIVER=null
CACHE_DRIVER=file
FILESYSTEM_DRIVER=local FILESYSTEM_DRIVER=local
QUEUE_CONNECTION=default
SESSION_DRIVER=database
SESSION_LIFETIME=120
REDIS_HOST=127.0.0.1
REDIS_PASSWORD=null
REDIS_PORT=6379
MAIL_MAILER=smtp MAIL_MAILER=smtp
MAIL_HOST= MAIL_HOST=
@ -33,6 +14,3 @@ MAIL_PASSWORD=null
MAIL_ENCRYPTION=null MAIL_ENCRYPTION=null
MAIL_FROM_ADDRESS=null MAIL_FROM_ADDRESS=null
MAIL_FROM_NAME="${APP_NAME}" MAIL_FROM_NAME="${APP_NAME}"
SSH_PUBLIC_KEY_NAME=ssh-public.key
SSH_PRIVATE_KEY_NAME=ssh-private.pem

View File

@ -2,28 +2,10 @@ APP_NAME=Vito
APP_ENV=local APP_ENV=local
APP_KEY= APP_KEY=
APP_DEBUG=true APP_DEBUG=true
APP_URL=http://vito.test APP_URL=
APP_PORT=8000
LOG_CHANNEL=stack
LOG_LEVEL=debug
DB_CONNECTION=mysql
DB_HOST=mysql
DB_PORT=3306
DB_DATABASE=vito
DB_USERNAME=sail
DB_PASSWORD=password
BROADCAST_DRIVER=null
CACHE_DRIVER=redis
FILESYSTEM_DRIVER=local FILESYSTEM_DRIVER=local
QUEUE_CONNECTION=default
SESSION_DRIVER=database
SESSION_LIFETIME=120
REDIS_HOST=redis
REDIS_PASSWORD=null
REDIS_PORT=6379
MAIL_MAILER=smtp MAIL_MAILER=smtp
MAIL_HOST= MAIL_HOST=
@ -33,13 +15,3 @@ MAIL_PASSWORD=null
MAIL_ENCRYPTION=null MAIL_ENCRYPTION=null
MAIL_FROM_ADDRESS=null MAIL_FROM_ADDRESS=null
MAIL_FROM_NAME="${APP_NAME}" MAIL_FROM_NAME="${APP_NAME}"
SSH_PUBLIC_KEY_NAME=ssh-public.key
SSH_PRIVATE_KEY_NAME=ssh-private.pem
APP_SERVICE=vito
FORWARD_REDIS_PORT=2060
FORWARD_DB_PORT=2070
APP_PORT=2080
HMR_PORT=2090

View File

@ -1,27 +0,0 @@
APP_NAME=Vito
APP_ENV=local
APP_KEY=base64:d9kZW60V4lFEw2SPn6UiJ0cfi04v80EWP0GZ6kzoxNg=
APP_DEBUG=true
APP_URL=http://localhost:2080
LOG_CHANNEL=stack
LOG_LEVEL=debug
DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=vito_test
DB_USERNAME=root
DB_PASSWORD=
BROADCAST_DRIVER=null
CACHE_DRIVER=array
FILESYSTEM_DRIVER=local
QUEUE_CONNECTION=database
SESSION_DRIVER=array
SESSION_LIFETIME=120
MAIL_MAILER=array
SSH_PUBLIC_KEY_NAME=ssh-public.key
SSH_PRIVATE_KEY_NAME=ssh-private.pem

View File

@ -2,9 +2,9 @@ name: code-style
on: on:
push: push:
branches:
- 1.x
pull_request: pull_request:
schedule:
- cron: '0 0 * * *'
jobs: jobs:
code-style: code-style:
@ -13,7 +13,8 @@ jobs:
strategy: strategy:
fail-fast: true fail-fast: true
matrix: matrix:
php: [ 8.1 ] php: [8.2]
node-version: ["20.x"]
steps: steps:
- uses: actions/checkout@v2 - uses: actions/checkout@v2
@ -31,9 +32,21 @@ jobs:
key: ${{ runner.os }}-php-${{ hashFiles('**/composer.lock') }} key: ${{ runner.os }}-php-${{ hashFiles('**/composer.lock') }}
restore-keys: | restore-keys: |
${{ runner.os }}-php- ${{ runner.os }}-php-
- name: Install dependencies - name: Install dependencies
if: steps.composer-cache.outputs.cache-hit != 'true' if: steps.composer-cache.outputs.cache-hit != 'true'
run: composer install --prefer-dist --no-progress --no-suggest run: composer install --prefer-dist --no-progress --no-suggest
- name: Run pint - name: Run pint
run: ./vendor/bin/pint --test run: ./vendor/bin/pint --test
- name: Setup Node.js
uses: actions/setup-node@v2
with:
node-version: "20.x"
- name: Install NPM Dependencies
run: npm install
- name: Run lint
run: npm run lint

View File

@ -2,30 +2,18 @@ name: tests
on: on:
push: push:
branches:
- 1.x
pull_request: pull_request:
schedule:
- cron: '0 0 * * *'
jobs: jobs:
tests: tests:
runs-on: ubuntu-22.04 runs-on: ubuntu-22.04
services:
mysql:
image: mysql
env:
MYSQL_DATABASE: test_db
MYSQL_USER: user
MYSQL_PASSWORD: password
MYSQL_ROOT_PASSWORD: rootpassword
ports:
- 3306:3306
options: --health-cmd="mysqladmin ping" --health-interval=10s --health-timeout=5s --health-retries=3
strategy: strategy:
fail-fast: true fail-fast: true
matrix: matrix:
php: [ 8.1 ] php: [8.2]
steps: steps:
- uses: actions/checkout@v2 - uses: actions/checkout@v2
@ -47,10 +35,8 @@ jobs:
if: steps.composer-cache.outputs.cache-hit != 'true' if: steps.composer-cache.outputs.cache-hit != 'true'
run: composer install --prefer-dist --no-progress --no-suggest run: composer install --prefer-dist --no-progress --no-suggest
- name: Create sqlite database
run: touch storage/database-test.sqlite
- name: Run test suite - name: Run test suite
run: php artisan test run: php artisan test
env:
DB_HOST: 127.0.0.1
DB_DATABASE: test_db
DB_USERNAME: user
DB_PASSWORD: password

2
.gitignore vendored
View File

@ -4,6 +4,8 @@
/public/storage /public/storage
/storage/*.key /storage/*.key
/storage/*.pem /storage/*.pem
/storage/test-key
/storage/test-key.pub
/vendor /vendor
.env .env
.env.backup .env.backup

16
.prettierrc Normal file
View File

@ -0,0 +1,16 @@
{
"plugins": ["prettier-plugin-blade", "prettier-plugin-tailwindcss"],
"overrides": [
{
"files": ["*.blade.php"],
"options": {
"parser": "blade",
"printWidth": 120,
"htmlWhitespaceSensitivity": "ignore",
"tabWidth": 4,
"quoteProps": "consistent",
"trailingComma": "none"
}
}
]
}

View File

@ -11,6 +11,12 @@ ## About Vito
Vito is a self-hosted web application that helps you manage your servers and deploy your PHP applications into production servers without a hassle. Vito is a self-hosted web application that helps you manage your servers and deploy your PHP applications into production servers without a hassle.
## Quick Start
```sh
bash <(curl -Ls https://raw.githubusercontent.com/vitodeploy/vito/1.x/scripts/install.sh)
```
## Features ## Features
- Provisions and Manages the server - Provisions and Manages the server
@ -38,15 +44,13 @@ ## Useful Links
## Credits ## Credits
- Laravel - Laravel
- Tailwindcss
- Livewire
- Alpinejs
- Vite
- Laravel Enum by BenSampo
- Log Viewer by Arunas Skirius
- PHPSecLib - PHPSecLib
- Laravel Blade Icons - PHPUnit
- Guzzlehttp - Tailwindcss
- Owenvoke for `owenvoke/blade-fontawesome` - Alpinejs
- Axios - HTMX
- Vite
- Toastr by CodeSeven - Toastr by CodeSeven
- Prettier
- Postcss
- Flowbite

View File

@ -3,8 +3,9 @@ # Security Policy
## Supported Versions ## Supported Versions
| Version | Supported | | Version | Supported |
| ------- | ------------------ | | ------- | ----------|
| 0.x | :white_check_mark: | | 0.x | ❌ |
| 1.x | ✅ |
## Reporting a Vulnerability ## Reporting a Vulnerability

View File

@ -19,11 +19,14 @@ public function create(Server $server, array $input): void
'server_id' => $server->id, 'server_id' => $server->id,
'user' => $input['user'], 'user' => $input['user'],
'command' => $input['command'], 'command' => $input['command'],
'frequency' => $input['frequency'], 'frequency' => $input['frequency'] == 'custom' ? $input['custom'] : $input['frequency'],
'status' => CronjobStatus::CREATING, 'status' => CronjobStatus::CREATING,
]); ]);
$cronJob->save(); $cronJob->save();
$cronJob->addToServer();
$server->cron()->update($cronJob->user, CronJob::crontab($server, $cronJob->user));
$cronJob->status = CronjobStatus::READY;
$cronJob->save();
} }
/** /**
@ -40,9 +43,18 @@ private function validate(array $input): void
'in:root,'.config('core.ssh_user'), 'in:root,'.config('core.ssh_user'),
], ],
'frequency' => [ 'frequency' => [
'required',
new CronRule(acceptCustom: true),
],
])->validate();
if ($input['frequency'] == 'custom') {
Validator::make($input, [
'custom' => [
'required', 'required',
new CronRule(), new CronRule(),
], ],
])->validateWithBag('createCronJob'); ])->validate();
}
} }
} }

View File

@ -0,0 +1,16 @@
<?php
namespace App\Actions\CronJob;
use App\Models\CronJob;
use App\Models\Server;
class DeleteCronJob
{
public function delete(Server $server, CronJob $cronJob): void
{
$user = $cronJob->user;
$cronJob->delete();
$server->cron()->update($cronJob->user, CronJob::crontab($server, $user));
}
}

View File

@ -24,15 +24,15 @@ public function create($type, Server $server, array $input): Backup
$backup = new Backup([ $backup = new Backup([
'type' => $type, 'type' => $type,
'server_id' => $server->id, 'server_id' => $server->id,
'database_id' => $input['database'] ?? null, 'database_id' => $input['backup_database'] ?? null,
'storage_id' => $input['storage'], 'storage_id' => $input['backup_storage'],
'interval' => $input['interval'] == 'custom' ? $input['custom'] : $input['interval'], 'interval' => $input['backup_interval'] == 'custom' ? $input['backup_custom'] : $input['backup_interval'],
'keep_backups' => $input['keep'], 'keep_backups' => $input['backup_keep'],
'status' => BackupStatus::RUNNING, 'status' => BackupStatus::RUNNING,
]); ]);
$backup->save(); $backup->save();
$backup->run(); app(RunBackup::class)->run($backup);
return $backup; return $backup;
} }
@ -43,16 +43,16 @@ public function create($type, Server $server, array $input): Backup
private function validate($type, Server $server, array $input): void private function validate($type, Server $server, array $input): void
{ {
$rules = [ $rules = [
'storage' => [ 'backup_storage' => [
'required', 'required',
Rule::exists('storage_providers', 'id'), Rule::exists('storage_providers', 'id'),
], ],
'keep' => [ 'backup_keep' => [
'required', 'required',
'numeric', 'numeric',
'min:1', 'min:1',
], ],
'interval' => [ 'backup_interval' => [
'required', 'required',
Rule::in([ Rule::in([
'0 * * * *', '0 * * * *',
@ -63,13 +63,13 @@ private function validate($type, Server $server, array $input): void
]), ]),
], ],
]; ];
if ($input['interval'] == 'custom') { if ($input['backup_interval'] == 'custom') {
$rules['custom'] = [ $rules['backup_custom'] = [
'required', 'required',
]; ];
} }
if ($type === 'database') { if ($type === 'database') {
$rules['database'] = [ $rules['backup_database'] = [
'required', 'required',
Rule::exists('databases', 'id') Rule::exists('databases', 'id')
->where('server_id', $server->id) ->where('server_id', $server->id)

View File

@ -2,6 +2,7 @@
namespace App\Actions\Database; namespace App\Actions\Database;
use App\Enums\DatabaseStatus;
use App\Models\Database; use App\Models\Database;
use App\Models\Server; use App\Models\Server;
use Illuminate\Support\Facades\Validator; use Illuminate\Support\Facades\Validator;
@ -21,8 +22,9 @@ public function create(Server $server, array $input): Database
'server_id' => $server->id, 'server_id' => $server->id,
'name' => $input['name'], 'name' => $input['name'],
]); ]);
$server->database()->handler()->create($database->name);
$database->status = DatabaseStatus::READY;
$database->save(); $database->save();
$database->createOnServer();
return $database; return $database;
} }

View File

@ -2,6 +2,7 @@
namespace App\Actions\Database; namespace App\Actions\Database;
use App\Enums\DatabaseUserStatus;
use App\Models\DatabaseUser; use App\Models\DatabaseUser;
use App\Models\Server; use App\Models\Server;
use Illuminate\Support\Facades\Validator; use Illuminate\Support\Facades\Validator;
@ -24,8 +25,17 @@ public function create(Server $server, array $input, array $links = []): Databas
'host' => isset($input['remote']) && $input['remote'] ? $input['host'] : 'localhost', 'host' => isset($input['remote']) && $input['remote'] ? $input['host'] : 'localhost',
'databases' => $links, 'databases' => $links,
]); ]);
$server->database()->handler()->createUser(
$databaseUser->username,
$databaseUser->password,
$databaseUser->host
);
$databaseUser->status = DatabaseUserStatus::READY;
$databaseUser->save(); $databaseUser->save();
$databaseUser->createOnServer();
if (count($links) > 0) {
app(LinkUser::class)->link($databaseUser, ['databases' => $links]);
}
return $databaseUser; return $databaseUser;
} }

View File

@ -0,0 +1,15 @@
<?php
namespace App\Actions\Database;
use App\Models\Database;
use App\Models\Server;
class DeleteDatabase
{
public function delete(Server $server, Database $database): void
{
$server->database()->handler()->delete($database->name);
$database->delete();
}
}

View File

@ -0,0 +1,15 @@
<?php
namespace App\Actions\Database;
use App\Models\DatabaseUser;
use App\Models\Server;
class DeleteDatabaseUser
{
public function delete(Server $server, DatabaseUser $databaseUser): void
{
$server->database()->handler()->deleteUser($databaseUser->username, $databaseUser->host);
$databaseUser->delete();
}
}

View File

@ -4,6 +4,9 @@
use App\Models\Database; use App\Models\Database;
use App\Models\DatabaseUser; use App\Models\DatabaseUser;
use App\Models\Server;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\Rule;
use Illuminate\Validation\ValidationException; use Illuminate\Validation\ValidationException;
class LinkUser class LinkUser
@ -11,20 +14,49 @@ class LinkUser
/** /**
* @throws ValidationException * @throws ValidationException
*/ */
public function link(DatabaseUser $databaseUser, array $databases): void public function link(DatabaseUser $databaseUser, array $input): void
{ {
$dbs = Database::query() if (! isset($input['databases']) || ! is_array($input['databases'])) {
->where('server_id', $databaseUser->server_id) $input['databases'] = [];
->whereIn('name', $databases)
->count();
if (count($databases) !== $dbs) {
throw ValidationException::withMessages(['databases' => __('Databases not found!')])
->errorBag('linkUser');
} }
$databaseUser->databases = $databases; $this->validate($databaseUser->server, $input);
$databaseUser->unlinkUser();
$databaseUser->linkUser(); $dbs = Database::query()
->where('server_id', $databaseUser->server_id)
->whereIn('name', $input['databases'])
->count();
if (count($input['databases']) !== $dbs) {
throw ValidationException::withMessages(['databases' => __('Databases not found!')]);
}
$databaseUser->databases = $input['databases'];
// Unlink the user from all databases
$databaseUser->server->database()->handler()->unlink(
$databaseUser->username,
$databaseUser->host
);
// Link the user to the selected databases
$databaseUser->server->database()->handler()->link(
$databaseUser->username,
$databaseUser->host,
$databaseUser->databases
);
$databaseUser->save(); $databaseUser->save();
} }
private function validate(Server $server, array $input): void
{
$rules = [
'databases.*' => [
'required',
Rule::exists('databases', 'name')->where('server_id', $server->id),
],
];
Validator::make($input, $rules)->validate();
}
} }

View File

@ -0,0 +1,39 @@
<?php
namespace App\Actions\Database;
use App\Enums\BackupFileStatus;
use App\Models\BackupFile;
use App\Models\Database;
use Illuminate\Support\Facades\Validator;
class RestoreBackup
{
public function restore(BackupFile $backupFile, array $input): void
{
$this->validate($input);
/** @var Database $database */
$database = Database::query()->findOrFail($input['database']);
$backupFile->status = BackupFileStatus::RESTORING;
$backupFile->restored_to = $database->name;
$backupFile->save();
dispatch(function () use ($backupFile, $database) {
$database->server->database()->handler()->restoreBackup($backupFile, $database->name);
$backupFile->status = BackupFileStatus::RESTORED;
$backupFile->restored_at = now();
$backupFile->save();
})->catch(function () use ($backupFile) {
$backupFile->status = BackupFileStatus::RESTORE_FAILED;
$backupFile->save();
})->onConnection('ssh');
}
private function validate(array $input): void
{
Validator::make($input, [
'database' => 'required|exists:databases,id',
])->validate();
}
}

View File

@ -0,0 +1,32 @@
<?php
namespace App\Actions\Database;
use App\Enums\BackupFileStatus;
use App\Models\Backup;
use App\Models\BackupFile;
use Illuminate\Support\Str;
class RunBackup
{
public function run(Backup $backup): BackupFile
{
$file = new BackupFile([
'backup_id' => $backup->id,
'name' => Str::of($backup->database->name)->slug().'-'.now()->format('YmdHis'),
'status' => BackupFileStatus::CREATING,
]);
$file->save();
dispatch(function () use ($file) {
$file->backup->server->database()->handler()->runBackup($file);
$file->status = BackupFileStatus::CREATED;
$file->save();
})->catch(function () use ($file) {
$file->status = BackupFileStatus::FAILED;
$file->save();
})->onConnection('ssh');
return $file;
}
}

View File

@ -21,10 +21,20 @@ public function create(Server $server, array $input): FirewallRule
'port' => $input['port'], 'port' => $input['port'],
'source' => $input['source'], 'source' => $input['source'],
'mask' => $input['mask'] ?? null, 'mask' => $input['mask'] ?? null,
'status' => FirewallRuleStatus::CREATING,
]); ]);
$server->firewall()
->handler()
->addRule(
$rule->type,
$rule->getRealProtocol(),
$rule->port,
$rule->source,
$rule->mask
);
$rule->status = FirewallRuleStatus::READY;
$rule->save(); $rule->save();
$rule->addToServer();
return $rule; return $rule;
} }
@ -56,6 +66,6 @@ private function validate(Server $server, array $input): void
'mask' => [ 'mask' => [
'numeric', 'numeric',
], ],
])->validateWithBag('createRule'); ])->validate();
} }
} }

View File

@ -0,0 +1,28 @@
<?php
namespace App\Actions\FirewallRule;
use App\Enums\FirewallRuleStatus;
use App\Models\FirewallRule;
use App\Models\Server;
class DeleteRule
{
public function delete(Server $server, FirewallRule $rule): void
{
$rule->status = FirewallRuleStatus::DELETING;
$rule->save();
$server->firewall()
->handler()
->removeRule(
$rule->type,
$rule->getRealProtocol(),
$rule->port,
$rule->source,
$rule->mask
);
$rule->delete();
}
}

View File

@ -0,0 +1,29 @@
<?php
namespace App\Actions\PHP;
use App\Enums\ServiceStatus;
use App\Models\Server;
use Illuminate\Validation\ValidationException;
class ChangeDefaultCli
{
public function change(Server $server, array $input): void
{
$this->validate($server, $input);
$service = $server->php($input['version']);
$service->handler()->setDefaultCli();
$server->defaultService('php')->update(['is_default' => 0]);
$service->update(['is_default' => 1]);
$service->update(['status' => ServiceStatus::READY]);
}
public function validate(Server $server, array $input): void
{
if (! isset($input['version']) || ! in_array($input['version'], $server->installedPHPVersions())) {
throw ValidationException::withMessages(
['version' => __('This version is not installed')]
);
}
}
}

View File

@ -0,0 +1,33 @@
<?php
namespace App\Actions\PHP;
use App\Models\Server;
use Illuminate\Validation\ValidationException;
class GetPHPIni
{
public function getIni(Server $server, array $input): string
{
$this->validate($server, $input);
$php = $server->php($input['version']);
try {
return $php->handler()->getPHPIni();
} catch (\Throwable $e) {
throw ValidationException::withMessages(
['ini' => $e->getMessage()]
);
}
}
public function validate(Server $server, array $input): void
{
if (! isset($input['version']) || ! in_array($input['version'], $server->installedPHPVersions())) {
throw ValidationException::withMessages(
['version' => __('This version is not installed')]
);
}
}
}

View File

@ -28,7 +28,14 @@ public function install(Server $server, array $input): void
'is_default' => false, 'is_default' => false,
]); ]);
$php->save(); $php->save();
$php->install();
dispatch(function () use ($php) {
$php->handler()->install();
$php->status = ServiceStatus::READY;
$php->save();
})->catch(function () use ($php) {
$php->delete();
})->onConnection('ssh');
} }
/** /**
@ -41,12 +48,12 @@ private function validate(Server $server, array $input): void
'required', 'required',
Rule::in(config('core.php_versions')), Rule::in(config('core.php_versions')),
], ],
])->validateWithBag('installPHP'); ])->validate();
if (in_array($input['version'], $server->installedPHPVersions())) { if (in_array($input['version'], $server->installedPHPVersions())) {
throw ValidationException::withMessages( throw ValidationException::withMessages(
['version' => __('This version is already installed')] ['version' => __('This version is already installed')]
)->errorBag('installPHP'); );
} }
} }
} }

View File

@ -2,25 +2,35 @@
namespace App\Actions\PHP; namespace App\Actions\PHP;
use App\Models\Server;
use App\Models\Service; use App\Models\Service;
use Illuminate\Support\Facades\Validator; use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\Rule;
use Illuminate\Validation\ValidationException; use Illuminate\Validation\ValidationException;
class InstallPHPExtension class InstallPHPExtension
{ {
/** public function install(Server $server, array $input): Service
* @throws ValidationException
*/
public function handle(Service $service, array $input): Service
{ {
$this->validate($server, $input);
/** @var Service $service */
$service = $server->php($input['version']);
$typeData = $service->type_data; $typeData = $service->type_data;
$typeData['extensions'] = $typeData['extensions'] ?? []; $typeData['extensions'] = $typeData['extensions'] ?? [];
$typeData['extensions'][] = $input['extension'];
$service->type_data = $typeData; $service->type_data = $typeData;
$service->save(); $service->save();
$this->validate($service, $input); dispatch(function () use ($service, $input) {
$service->handler()->installExtension($input['extension']);
$service->handler()->installExtension($input['name']); })->catch(function () use ($service, $input) {
$service->refresh();
$typeData = $service->type_data;
$typeData['extensions'] = array_values(array_diff($typeData['extensions'], [$input['extension']]));
$service->type_data = $typeData;
$service->save();
})->onConnection('ssh');
return $service; return $service;
} }
@ -28,18 +38,25 @@ public function handle(Service $service, array $input): Service
/** /**
* @throws ValidationException * @throws ValidationException
*/ */
private function validate(Service $service, array $input): void private function validate(Server $server, array $input): void
{ {
Validator::make($input, [ Validator::make($input, [
'name' => [ 'extension' => [
'required', 'required',
'in:'.implode(',', config('core.php_extensions')), 'in:'.implode(',', config('core.php_extensions')),
], ],
])->validateWithBag('installPHPExtension'); 'version' => [
'required',
Rule::in($server->installedPHPVersions()),
],
])->validate();
if (in_array($input['name'], $service->type_data['extensions'])) { /** @var Service $service */
$service = $server->php($input['version']);
if (in_array($input['extension'], $service->type_data['extensions'])) {
throw ValidationException::withMessages( throw ValidationException::withMessages(
['name' => __('This extension already installed')] ['extension' => __('This extension already installed')]
)->errorBag('installPHPExtension'); )->errorBag('installPHPExtension');
} }
} }

View File

@ -2,36 +2,48 @@
namespace App\Actions\PHP; namespace App\Actions\PHP;
use App\Enums\ServiceStatus;
use App\Models\Server; use App\Models\Server;
use App\Models\Service; use App\Models\Service;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\ValidationException; use Illuminate\Validation\ValidationException;
class UninstallPHP class UninstallPHP
{ {
public function uninstall(Server $server, string $version): void public function uninstall(Server $server, array $input): void
{ {
$this->validate($server, $version); $this->validate($server, $input);
/** @var Service $php */ /** @var Service $php */
$php = $server->services()->where('type', 'php')->where('version', $version)->first(); $php = $server->php($input['version']);
$php->status = ServiceStatus::UNINSTALLING;
$php->save();
$php->uninstall(); dispatch(function () use ($php) {
$php->handler()->uninstall();
$php->delete();
})->catch(function () use ($php) {
$php->status = ServiceStatus::FAILED;
$php->save();
})->onConnection('ssh');
} }
/** /**
* @throws ValidationException * @throws ValidationException
*/ */
private function validate(Server $server, string $version): void private function validate(Server $server, array $input): void
{ {
$php = $server->services()->where('type', 'php')->where('version', $version)->first(); Validator::make($input, [
'version' => 'required|string',
])->validate();
if (! $php) { if (! in_array($input['version'], $server->installedPHPVersions())) {
throw ValidationException::withMessages( throw ValidationException::withMessages(
['version' => __('This version has not been installed yet!')] ['version' => __('This version is not installed')]
); );
} }
$hasSite = $server->sites()->where('php_version', $version)->first(); $hasSite = $server->sites()->where('php_version', $input['version'])->first();
if ($hasSite) { if ($hasSite) {
throw ValidationException::withMessages( throw ValidationException::withMessages(
['version' => __('Cannot uninstall this version because some sites are using it!')] ['version' => __('Cannot uninstall this version because some sites are using it!')]

View File

@ -2,8 +2,9 @@
namespace App\Actions\PHP; namespace App\Actions\PHP;
use App\Models\Service; use App\Models\Server;
use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException; use Illuminate\Validation\ValidationException;
use Throwable; use Throwable;
@ -13,14 +14,18 @@ class UpdatePHPIni
/** /**
* @throws ValidationException * @throws ValidationException
*/ */
public function update(Service $service, string $ini): void public function update(Server $server, array $input): void
{ {
$this->validate($server, $input);
$service = $server->php($input['version']);
$tmpName = Str::random(10).strtotime('now'); $tmpName = Str::random(10).strtotime('now');
try { try {
/** @var \Illuminate\Filesystem\FilesystemAdapter $storageDisk */ /** @var \Illuminate\Filesystem\FilesystemAdapter $storageDisk */
$storageDisk = Storage::disk('local'); $storageDisk = Storage::disk('local');
$storageDisk->put($tmpName, $ini); $storageDisk->put($tmpName, $input['ini']);
$service->server->ssh('root')->upload( $service->server->ssh('root')->upload(
$storageDisk->path($tmpName), $storageDisk->path($tmpName),
"/etc/php/$service->version/cli/php.ini" "/etc/php/$service->version/cli/php.ini"
@ -42,4 +47,21 @@ private function deleteTempFile(string $name): void
Storage::disk('local')->delete($name); Storage::disk('local')->delete($name);
} }
} }
public function validate(Server $server, array $input): void
{
Validator::make($input, [
'ini' => [
'required',
'string',
],
'version' => 'required|string',
])->validate();
if (! in_array($input['version'], $server->installedPHPVersions())) {
throw ValidationException::withMessages(
['version' => __('This version is not installed')]
);
}
}
} }

View File

@ -8,11 +8,8 @@
class DeleteProject class DeleteProject
{ {
public function delete(User $user, int $projectId): void public function delete(User $user, Project $project): void
{ {
/** @var Project $project */
$project = $user->projects()->findOrFail($projectId);
if ($user->projects()->count() === 1) { if ($user->projects()->count() === 1) {
throw ValidationException::withMessages([ throw ValidationException::withMessages([
'project' => __('Cannot delete the last project.'), 'project' => __('Cannot delete the last project.'),

View File

@ -26,7 +26,7 @@ private function validate(Project $project, array $input): void
'required', 'required',
'string', 'string',
'max:255', 'max:255',
Rule::unique('projects')->ignore($project->id), Rule::unique('projects')->where('user_id', $project->user_id)->ignore($project->id),
], ],
])->validate(); ])->validate();
} }

View File

@ -29,7 +29,23 @@ public function create(mixed $queueable, array $input): void
'status' => QueueStatus::CREATING, 'status' => QueueStatus::CREATING,
]); ]);
$queue->save(); $queue->save();
$queue->deploy();
dispatch(function () use ($queue) {
$queue->server->processManager()->handler()->create(
$queue->id,
$queue->command,
$queue->user,
$queue->auto_start,
$queue->auto_restart,
$queue->numprocs,
$queue->getLogFile(),
$queue->site_id
);
$queue->status = QueueStatus::RUNNING;
$queue->save();
})->catch(function () use ($queue) {
$queue->delete();
})->onConnection('ssh');
} }
/** /**
@ -60,6 +76,6 @@ protected function validate(array $input): void
], ],
]; ];
Validator::make($input, $rules)->validateWithBag('createQueue'); Validator::make($input, $rules)->validate();
} }
} }

View File

@ -0,0 +1,14 @@
<?php
namespace App\Actions\Queue;
use App\Models\Queue;
class DeleteQueue
{
public function delete(Queue $queue): void
{
$queue->server->processManager()->handler()->delete($queue->id, $queue->site_id);
$queue->delete();
}
}

View File

@ -0,0 +1,13 @@
<?php
namespace App\Actions\Queue;
use App\Models\Queue;
class GetQueueLogs
{
public function getLogs(Queue $queue): string
{
return $queue->server->processManager()->handler()->getLogs($queue->getLogFile());
}
}

View File

@ -0,0 +1,42 @@
<?php
namespace App\Actions\Queue;
use App\Enums\QueueStatus;
use App\Models\Queue;
class ManageQueue
{
public function start(Queue $queue): void
{
$queue->status = QueueStatus::STARTING;
$queue->save();
dispatch(function () use ($queue) {
$queue->server->processManager()->handler()->start($queue->id, $queue->site_id);
$queue->status = QueueStatus::RUNNING;
$queue->save();
})->onConnection('ssh');
}
public function stop(Queue $queue): void
{
$queue->status = QueueStatus::STOPPING;
$queue->save();
dispatch(function () use ($queue) {
$queue->server->processManager()->handler()->stop($queue->id, $queue->site_id);
$queue->status = QueueStatus::STOPPED;
$queue->save();
})->onConnection('ssh');
}
public function restart(Queue $queue): void
{
$queue->status = QueueStatus::RESTARTING;
$queue->save();
dispatch(function () use ($queue) {
$queue->server->processManager()->handler()->restart($queue->id, $queue->site_id);
$queue->status = QueueStatus::RUNNING;
$queue->save();
})->onConnection('ssh');
}
}

View File

@ -2,8 +2,10 @@
namespace App\Actions\SSL; namespace App\Actions\SSL;
use App\Enums\SslStatus;
use App\Enums\SslType; use App\Enums\SslType;
use App\Models\Site; use App\Models\Site;
use App\Models\Ssl;
use Illuminate\Support\Facades\Validator; use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\Rule; use Illuminate\Validation\Rule;
use Illuminate\Validation\ValidationException; use Illuminate\Validation\ValidationException;
@ -17,13 +19,24 @@ public function create(Site $site, array $input): void
{ {
$this->validate($input); $this->validate($input);
if ($input['type'] == SslType::LETSENCRYPT) { $ssl = new Ssl([
$site->createFreeSsl(); 'site_id' => $site->id,
} 'type' => $input['type'],
'certificate' => $input['certificate'] ?? null,
'pk' => $input['private'] ?? null,
'expires_at' => $input['type'] === SslType::LETSENCRYPT ? now()->addMonths(3) : $input['expires_at'],
'status' => SslStatus::CREATING,
]);
$ssl->save();
if ($input['type'] == SslType::CUSTOM) { dispatch(function () use ($site, $ssl) {
$site->createCustomSsl($input['certificate'], $input['private']); $site->server->webserver()->handler()->setupSSL($ssl);
} $ssl->status = SslStatus::CREATED;
$ssl->save();
$site->type()->edit();
})->catch(function () use ($ssl) {
$ssl->delete();
});
} }
/** /**
@ -34,14 +47,15 @@ protected function validate(array $input): void
$rules = [ $rules = [
'type' => [ 'type' => [
'required', 'required',
Rule::in(SslType::getValues()), Rule::in(config('core.ssl_types')),
], ],
]; ];
if (isset($input['type']) && $input['type'] == SslType::CUSTOM) { if (isset($input['type']) && $input['type'] == SslType::CUSTOM) {
$rules['certificate'] = 'required'; $rules['certificate'] = 'required';
$rules['private'] = 'required'; $rules['private'] = 'required';
$rules['expires_at'] = 'required|date_format:Y-m-d|after_or_equal:'.now();
} }
Validator::make($input, $rules)->validateWithBag('createSSL'); Validator::make($input, $rules)->validate();
} }
} }

View File

@ -0,0 +1,14 @@
<?php
namespace App\Actions\SSL;
use App\Models\Ssl;
class DeleteSSL
{
public function delete(Ssl $ssl): void
{
$ssl->site->server->webserver()->handler()->removeSSL($ssl);
$ssl->delete();
}
}

View File

@ -1,41 +0,0 @@
<?php
namespace App\Actions\Script;
use App\Models\Script;
use App\Models\User;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\ValidationException;
class CreateScript
{
/**
* @throws ValidationException
*/
public function handle(User $creator, array $input): Script
{
$this->validateInputs($input);
$script = new Script([
'user_id' => $creator->id,
'name' => $input['name'],
'content' => $input['content'],
]);
$script->save();
return $script;
}
/**
* @throws ValidationException
*/
private function validateInputs(array $input): void
{
$rules = [
'name' => 'required',
'content' => 'required',
];
Validator::make($input, $rules)->validateWithBag('createScript');
}
}

View File

@ -1,17 +0,0 @@
<?php
namespace App\Actions\Script;
use App\Models\User;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
class GetScripts
{
public function handle(User $user): LengthAwarePaginator
{
return $user->scripts()
->orderBy('id', 'desc')
->paginate(6)
->onEachSide(1);
}
}

View File

@ -1,37 +0,0 @@
<?php
namespace App\Actions\Script;
use App\Models\Script;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\ValidationException;
class UpdateScript
{
/**
* @throws ValidationException
*/
public function handle(Script $script, array $input): Script
{
$this->validateInputs($input);
$script->name = $input['name'];
$script->content = $input['content'];
$script->save();
return $script;
}
/**
* @throws ValidationException
*/
private function validateInputs(array $input): void
{
$rules = [
'name' => 'required',
'content' => 'required',
];
Validator::make($input, $rules)->validateWithBag('updateScript');
}
}

View File

@ -0,0 +1,30 @@
<?php
namespace App\Actions\Server;
use App\Facades\Notifier;
use App\Models\Server;
use App\Notifications\ServerDisconnected;
use Throwable;
class CheckConnection
{
public function check(Server $server): Server
{
$status = $server->status;
try {
$server->ssh()->connect();
$server->refresh();
if ($status == 'disconnected') {
$server->status = 'ready';
$server->save();
}
} catch (Throwable) {
$server->status = 'disconnected';
$server->save();
Notifier::send($server, new ServerDisconnected($server));
}
return $server;
}
}

View File

@ -3,13 +3,19 @@
namespace App\Actions\Server; namespace App\Actions\Server;
use App\Enums\FirewallRuleStatus; use App\Enums\FirewallRuleStatus;
use App\Enums\ServerProvider;
use App\Enums\ServerStatus;
use App\Exceptions\ServerProviderError; use App\Exceptions\ServerProviderError;
use App\Jobs\Installation\ContinueInstallation; use App\Facades\Notifier;
use App\Models\Server; use App\Models\Server;
use App\Models\User; use App\Models\User;
use App\Notifications\ServerInstallationFailed;
use App\Notifications\ServerInstallationSucceed;
use App\ValidationRules\RestrictedIPAddressesRule; use App\ValidationRules\RestrictedIPAddressesRule;
use Exception; use Exception;
use Illuminate\Support\Facades\Bus;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Validator; use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use Illuminate\Validation\Rule; use Illuminate\Validation\Rule;
@ -30,7 +36,7 @@ public function create(User $creator, array $input): Server
'user_id' => $creator->id, 'user_id' => $creator->id,
'name' => $input['name'], 'name' => $input['name'],
'ssh_user' => config('core.server_providers_default_user')[$input['provider']][$input['os']], 'ssh_user' => config('core.server_providers_default_user')[$input['provider']][$input['os']],
'ip' => $input['ip'], 'ip' => $input['ip'] ?? '',
'port' => $input['port'] ?? 22, 'port' => $input['port'] ?? 22,
'os' => $input['os'], 'os' => $input['os'],
'type' => $input['type'], 'type' => $input['type'],
@ -71,14 +77,8 @@ public function create(User $creator, array $input): Server
$server->type()->createServices($input); $server->type()->createServices($input);
// install server // install server
if ($server->provider == 'custom') { $this->install($server);
$server->install();
} else {
$server->progress_step = __('Installation will begin in 3 minutes!');
$server->save();
dispatch(new ContinueInstallation($server))
->delay(now()->addMinutes(2));
}
DB::commit(); DB::commit();
return $server; return $server;
@ -88,12 +88,44 @@ public function create(User $creator, array $input): Server
if ($e instanceof ServerProviderError) { if ($e instanceof ServerProviderError) {
throw ValidationException::withMessages([ throw ValidationException::withMessages([
'provider' => __('Provider Error: ').$e->getMessage(), 'provider' => __('Provider Error: ').$e->getMessage(),
])->errorBag('createServer'); ]);
} }
throw $e; throw $e;
} }
} }
private function install(Server $server): void
{
$bus = Bus::chain([
function () use ($server) {
if (! $server->provider()->isRunning()) {
sleep(2);
}
$server->type()->install();
$server->update([
'status' => ServerStatus::READY,
]);
Notifier::send($server, new ServerInstallationSucceed($server));
},
])->catch(function (Throwable $e) use ($server) {
$server->update([
'status' => ServerStatus::INSTALLATION_FAILED,
]);
Notifier::send($server, new ServerInstallationFailed($server));
Log::error('server-installation-error', [
'error' => (string) $e,
]);
});
if ($server->provider != ServerProvider::CUSTOM) {
$server->progress_step = 'waiting-for-provider';
$server->save();
$bus->delay(now()->addMinutes(3));
}
$bus->onConnection('ssh')->dispatch();
}
/** /**
* @throws ValidationException * @throws ValidationException
*/ */
@ -136,7 +168,7 @@ private function validateInputs(array $input): void
*/ */
private function validateType(Server $server, array $input): void private function validateType(Server $server, array $input): void
{ {
Validator::make($input, $server->type()->createValidationRules($input)) Validator::make($input, $server->type()->createRules($input))
->validate(); ->validate();
} }
@ -145,7 +177,7 @@ private function validateType(Server $server, array $input): void
*/ */
private function validateProvider(Server $server, array $input): void private function validateProvider(Server $server, array $input): void
{ {
Validator::make($input, $server->provider()->createValidationRules($input)) Validator::make($input, $server->provider()->createRules($input))
->validate(); ->validate();
} }

View File

@ -35,7 +35,7 @@ public function edit(Server $server, array $input): Server
$server->save(); $server->save();
if ($checkConnection) { if ($checkConnection) {
$server->checkConnection(); return $server->checkConnection();
} }
return $server; return $server;

View File

@ -1,14 +0,0 @@
<?php
namespace App\Actions\Server;
use App\Models\Server;
use Illuminate\Database\Eloquent\Collection;
class GetServers
{
public function handle(): Collection
{
return Server::query()->latest()->get();
}
}

View File

@ -0,0 +1,23 @@
<?php
namespace App\Actions\Server;
use App\Enums\ServerStatus;
use App\Models\Server;
use Throwable;
class RebootServer
{
public function reboot(Server $server): Server
{
try {
$server->os()->reboot();
$server->status = ServerStatus::DISCONNECTED;
$server->save();
} catch (Throwable) {
$server = $server->checkConnection();
}
return $server;
}
}

View File

@ -2,9 +2,9 @@
namespace App\Actions\ServerProvider; namespace App\Actions\ServerProvider;
use App\Contracts\ServerProvider as ServerProviderContract;
use App\Models\ServerProvider; use App\Models\ServerProvider;
use App\Models\User; use App\Models\User;
use App\ServerProviders\ServerProvider as ServerProviderContract;
use Exception; use Exception;
use Illuminate\Support\Facades\Validator; use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\Rule; use Illuminate\Validation\Rule;
@ -28,7 +28,7 @@ public function create(User $user, array $input): ServerProvider
} catch (Exception) { } catch (Exception) {
throw ValidationException::withMessages([ throw ValidationException::withMessages([
'provider' => [ 'provider' => [
__("Couldn't connect to provider. Please check your credentials and try again later."), sprintf("Couldn't connect to %s. Please check your credentials.", $input['provider']),
], ],
]); ]);
} }

View File

@ -0,0 +1,21 @@
<?php
namespace App\Actions\ServerProvider;
use App\Models\ServerProvider;
use Exception;
class DeleteServerProvider
{
/**
* @throws Exception
*/
public function delete(ServerProvider $serverProvider): void
{
if ($serverProvider->servers()->exists()) {
throw new Exception('This server provider is being used by a server.');
}
$serverProvider->delete();
}
}

View File

@ -1,60 +0,0 @@
<?php
namespace App\Actions\Service;
use App\Enums\ServiceStatus;
use App\Models\Server;
use App\Models\Service;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\ValidationException;
class InstallPHPMyAdmin
{
/**
* @throws ValidationException
*/
public function install(Server $server, array $input): Service
{
$this->validate($input);
$phpMyAdmin = $server->defaultService('phpmyadmin');
if ($phpMyAdmin) {
throw ValidationException::withMessages([
'allowed_ip' => __('Already installed'),
]);
}
$phpMyAdmin = new Service([
'server_id' => $server->id,
'type' => 'phpmyadmin',
'type_data' => [
'allowed_ip' => $input['allowed_ip'],
'port' => $input['port'],
'php' => $server->defaultService('php')->version,
],
'name' => 'phpmyadmin',
'version' => '5.1.2',
'status' => ServiceStatus::INSTALLING,
'is_default' => 1,
]);
$phpMyAdmin->save();
$phpMyAdmin->install();
return $phpMyAdmin;
}
/**
* @throws ValidationException
*/
private function validate(array $input): void
{
Validator::make($input, [
'allowed_ip' => 'required',
'port' => [
'required',
'numeric',
'min:1',
'max:65535',
],
])->validate();
}
}

View File

@ -0,0 +1,84 @@
<?php
namespace App\Actions\Service;
use App\Enums\ServiceStatus;
use App\Models\Service;
class Manage
{
public function start(Service $service): void
{
$service->status = ServiceStatus::STARTING;
$service->save();
dispatch(function () use ($service) {
$status = $service->server->systemd()->start($service->unit);
if (str($status)->contains('Active: active')) {
$service->status = ServiceStatus::READY;
} else {
$service->status = ServiceStatus::FAILED;
}
$service->save();
})->onConnection('ssh');
}
public function stop(Service $service): void
{
$service->status = ServiceStatus::STOPPING;
$service->save();
dispatch(function () use ($service) {
$status = $service->server->systemd()->stop($service->unit);
if (str($status)->contains('Active: inactive')) {
$service->status = ServiceStatus::STOPPED;
} else {
$service->status = ServiceStatus::FAILED;
}
$service->save();
})->onConnection('ssh');
}
public function restart(Service $service): void
{
$service->status = ServiceStatus::RESTARTING;
$service->save();
dispatch(function () use ($service) {
$status = $service->server->systemd()->restart($service->unit);
if (str($status)->contains('Active: active')) {
$service->status = ServiceStatus::READY;
} else {
$service->status = ServiceStatus::FAILED;
}
$service->save();
})->onConnection('ssh');
}
public function enable(Service $service): void
{
$service->status = ServiceStatus::ENABLING;
$service->save();
dispatch(function () use ($service) {
$status = $service->server->systemd()->enable($service->unit);
if (str($status)->contains('Active: active')) {
$service->status = ServiceStatus::READY;
} else {
$service->status = ServiceStatus::FAILED;
}
$service->save();
})->onConnection('ssh');
}
public function disable(Service $service): void
{
$service->status = ServiceStatus::DISABLING;
$service->save();
dispatch(function () use ($service) {
$status = $service->server->systemd()->disable($service->unit);
if (str($status)->contains('Active: inactive')) {
$service->status = ServiceStatus::DISABLED;
} else {
$service->status = ServiceStatus::FAILED;
}
$service->save();
})->onConnection('ssh');
}
}

View File

@ -1,30 +0,0 @@
<?php
namespace App\Actions\Site;
use App\Models\Site;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\ValidationException;
class ChangePHPVersion
{
/**
* @throws ValidationException
*/
public function handle(Site $site, array $input): void
{
$this->validate($site, $input);
$site->changePHPVersion($input['php_version']);
}
/**
* @throws ValidationException
*/
protected function validate(Site $site, array $input): void
{
Validator::make($input, [
'php_version' => 'required|in:'.implode(',', $site->server->installedPHPVersions()),
])->validateWithBag('changePHPVersion');
}
}

View File

@ -1,51 +0,0 @@
<?php
namespace App\Actions\Site;
use App\Models\Redirect;
use App\Models\Site;
use Exception;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\ValidationException;
class CreateRedirect
{
/**
* @throws Exception
*/
public function handle(Site $site, array $input): void
{
$this->validate($input);
$redirect = new Redirect([
'site_id' => $site->id,
'mode' => $input['mode'],
'from' => $input['from'],
'to' => $input['to'],
]);
$redirect->save();
$redirect->addToServer();
}
/**
* @throws ValidationException
*/
private function validate(array $input): void
{
$rules = [
'mode' => [
'required',
'in:301,302',
],
'from' => [
'required',
],
'to' => [
'required',
'url',
],
];
Validator::make($input, $rules)->validateWithBag('createRedirect');
}
}

View File

@ -4,8 +4,11 @@
use App\Enums\SiteStatus; use App\Enums\SiteStatus;
use App\Exceptions\SourceControlIsNotConnected; use App\Exceptions\SourceControlIsNotConnected;
use App\Facades\Notifier;
use App\Models\Server; use App\Models\Server;
use App\Models\Site; use App\Models\Site;
use App\Notifications\SiteInstallationFailed;
use App\Notifications\SiteInstallationSucceed;
use App\ValidationRules\DomainRule; use App\ValidationRules\DomainRule;
use Exception; use Exception;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
@ -30,11 +33,11 @@ public function create(Server $server, array $input): Site
'type' => $input['type'], 'type' => $input['type'],
'domain' => $input['domain'], 'domain' => $input['domain'],
'aliases' => isset($input['alias']) ? [$input['alias']] : [], 'aliases' => isset($input['alias']) ? [$input['alias']] : [],
'path' => '/home/'.$server->ssh_user.'/'.$input['domain'], 'path' => '/home/'.$server->getSshUser().'/'.$input['domain'],
'status' => SiteStatus::INSTALLING, 'status' => SiteStatus::INSTALLING,
]); ]);
// fields based on type // fields based on the type
$site->fill($site->type()->createFields($input)); $site->fill($site->type()->createFields($input));
// check has access to repository // check has access to repository
@ -63,8 +66,19 @@ public function create(Server $server, array $input): Site
'content' => '', 'content' => '',
]); ]);
// install server // install site
$site->install(); dispatch(function () use ($site) {
$site->type()->install();
$site->update([
'status' => SiteStatus::READY,
'progress' => 100,
]);
Notifier::send($site, new SiteInstallationSucceed($site));
})->catch(function () use ($site) {
$site->status = SiteStatus::INSTALLATION_FAILED;
$site->save();
Notifier::send($site, new SiteInstallationFailed($site));
})->onConnection('ssh');
DB::commit(); DB::commit();
@ -105,7 +119,7 @@ private function validateInputs(Server $server, array $input): void
*/ */
private function validateType(Site $site, array $input): void private function validateType(Site $site, array $input): void
{ {
$rules = $site->type()->createValidationRules($input); $rules = $site->type()->createRules($input);
Validator::make($input, $rules)->validate(); Validator::make($input, $rules)->validate();
} }

View File

@ -0,0 +1,14 @@
<?php
namespace App\Actions\Site;
use App\Models\Site;
class DeleteSite
{
public function delete(Site $site): void
{
$site->server->webserver()->handler()->deleteSite($site);
$site->delete();
}
}

View File

@ -0,0 +1,51 @@
<?php
namespace App\Actions\Site;
use App\Enums\DeploymentStatus;
use App\Exceptions\DeploymentScriptIsEmptyException;
use App\Exceptions\SourceControlIsNotConnected;
use App\Models\Deployment;
use App\Models\Site;
class Deploy
{
/**
* @throws SourceControlIsNotConnected
* @throws DeploymentScriptIsEmptyException
*/
public function run(Site $site): Deployment
{
if ($site->sourceControl()) {
$site->sourceControl()->getRepo($site->repository);
}
if (! $site->deploymentScript?->content) {
throw new DeploymentScriptIsEmptyException();
}
$deployment = new Deployment([
'site_id' => $site->id,
'deployment_script_id' => $site->deploymentScript->id,
'status' => DeploymentStatus::DEPLOYING,
]);
$lastCommit = $site->sourceControl()->provider()->getLastCommit($site->repository, $site->branch);
if ($lastCommit) {
$deployment->commit_id = $lastCommit['commit_id'];
$deployment->commit_data = $lastCommit['commit_data'];
}
$deployment->save();
dispatch(function () use ($site, $deployment) {
$log = $site->server->os()->runScript($site->path, $site->deploymentScript->content, $site->id);
$deployment->status = DeploymentStatus::FINISHED;
$deployment->log_id = $log->id;
$deployment->save();
})->catch(function () use ($deployment) {
$deployment->status = DeploymentStatus::FAILED;
$deployment->save();
})->onConnection('ssh');
return $deployment;
}
}

View File

@ -1,41 +0,0 @@
<?php
namespace App\Actions\Site;
use App\Models\Site;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\ValidationException;
class EditSite
{
/**
* @throws ValidationException
*/
public function handle(Site $site, array $input): Site
{
// validate type
$this->validateType($site, $input);
// set type data
$site->type_data = $site->type()->data($input);
// save
$site->port = $input['port'] ?? null;
$site->save();
// edit
$site->type()->edit();
return $site;
}
/**
* @throws ValidationException
*/
private function validateType(Site $site, array $input): void
{
$rules = $site->type()->editValidationRules($input);
Validator::make($input, $rules)->validateWithBag('editSite');
}
}

View File

@ -1,14 +0,0 @@
<?php
namespace App\Actions\Site;
use App\Models\Server;
use Illuminate\Database\Eloquent\Collection;
class GetSites
{
public function handle(Server $server): Collection
{
return $server->sites()->orderBy('id', 'desc')->get();
}
}

View File

@ -3,6 +3,7 @@
namespace App\Actions\Site; namespace App\Actions\Site;
use App\Models\Site; use App\Models\Site;
use App\SSH\Git\Git;
use Illuminate\Support\Facades\Validator; use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\ValidationException; use Illuminate\Validation\ValidationException;
@ -14,8 +15,9 @@ class UpdateBranch
public function update(Site $site, array $input): void public function update(Site $site, array $input): void
{ {
$this->validate($input); $this->validate($input);
$site->branch = $input['branch'];
$site->updateBranch($input['branch']); app(Git::class)->checkout($site);
$site->save();
} }
/** /**
@ -25,6 +27,6 @@ protected function validate(array $input): void
{ {
Validator::make($input, [ Validator::make($input, [
'branch' => 'required', 'branch' => 'required',
])->validateWithBag('updateBranch'); ]);
} }
} }

View File

@ -27,6 +27,6 @@ protected function validate(array $input): void
{ {
Validator::make($input, [ Validator::make($input, [
'script' => 'required', 'script' => 'required',
])->validateWithBag('updateDeploymentScript'); ]);
} }
} }

View File

@ -8,11 +8,9 @@ class UpdateEnv
{ {
public function update(Site $site, array $input): void public function update(Site $site, array $input): void
{ {
$typeData = $site->type_data; $site->server->os()->editFile(
$typeData['env'] = $input['env']; $site->path.'/.env',
$site->type_data = $typeData; $input['env']
$site->save(); );
$site->deployEnv();
} }
} }

View File

@ -1,35 +0,0 @@
<?php
namespace App\Actions\Site;
use App\Models\Site;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\Rule;
use Illuminate\Validation\ValidationException;
class UpdateSourceControl
{
/**
* @throws ValidationException
*/
public function update(Site $site, array $input): void
{
$this->validate($input);
$site->source_control_id = $input['source_control'];
$site->save();
}
/**
* @throws ValidationException
*/
protected function validate(array $input): void
{
Validator::make($input, [
'source_control' => [
'required',
Rule::exists('source_controls', 'id'),
],
])->validate();
}
}

View File

@ -13,13 +13,17 @@ class ConnectSourceControl
public function connect(array $input): void public function connect(array $input): void
{ {
$this->validate($input); $this->validate($input);
$sourceControl = new SourceControl([ $sourceControl = new SourceControl([
'provider' => $input['provider'], 'provider' => $input['provider'],
'profile' => $input['name'], 'profile' => $input['name'],
'access_token' => $input['token'],
'url' => Arr::has($input, 'url') ? $input['url'] : null, 'url' => Arr::has($input, 'url') ? $input['url'] : null,
]); ]);
$this->validateProvider($sourceControl, $input);
$sourceControl->provider_data = $sourceControl->provider()->createData($input);
if (! $sourceControl->provider()->connect()) { if (! $sourceControl->provider()->connect()) {
throw ValidationException::withMessages([ throw ValidationException::withMessages([
'token' => __('Cannot connect to :provider or invalid token!', ['provider' => $sourceControl->provider] 'token' => __('Cannot connect to :provider or invalid token!', ['provider' => $sourceControl->provider]
@ -38,20 +42,20 @@ private function validate(array $input): void
$rules = [ $rules = [
'provider' => [ 'provider' => [
'required', 'required',
Rule::in(\App\Enums\SourceControl::getValues()), Rule::in(config('core.source_control_providers')),
], ],
'name' => [ 'name' => [
'required', 'required',
], ],
'token' => [
'required',
],
'url' => [
'nullable',
'url:http,https',
'ends_with:/',
],
]; ];
Validator::make($input, $rules)->validate(); Validator::make($input, $rules)->validate();
} }
/**
* @throws ValidationException
*/
private function validateProvider(SourceControl $sourceControl, array $input): void
{
Validator::make($input, $sourceControl->provider()->createRules($input))->validate();
}
} }

View File

@ -0,0 +1,17 @@
<?php
namespace App\Actions\SourceControl;
use App\Models\SourceControl;
class DeleteSourceControl
{
public function delete(SourceControl $sourceControl): void
{
if ($sourceControl->sites()->exists()) {
throw new \Exception('This source control is being used by a site.');
}
$sourceControl->delete();
}
}

View File

@ -0,0 +1,19 @@
<?php
namespace App\Actions\SshKey;
use App\Enums\SshKeyStatus;
use App\Models\Server;
use App\Models\SshKey;
class DeleteKeyFromServer
{
public function delete(Server $server, SshKey $sshKey): void
{
$sshKey->servers()->updateExistingPivot($server->id, [
'status' => SshKeyStatus::DELETING,
]);
$server->os()->deleteSSHKey($sshKey->public_key);
$server->sshKeys()->detach($sshKey);
}
}

View File

@ -0,0 +1,38 @@
<?php
namespace App\Actions\SshKey;
use App\Enums\SshKeyStatus;
use App\Models\Server;
use App\Models\SshKey;
use App\Models\User;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\Rule;
class DeployKeyToServer
{
public function deploy(User $user, Server $server, array $input): void
{
$this->validate($user, $input);
/** @var SshKey $sshKey */
$sshKey = SshKey::findOrFail($input['key_id']);
$server->sshKeys()->attach($sshKey, [
'status' => SshKeyStatus::ADDING,
]);
$server->os()->deploySSHKey($sshKey->public_key);
$sshKey->servers()->updateExistingPivot($server->id, [
'status' => SshKeyStatus::ADDED,
]);
}
private function validate(User $user, array $input): void
{
Validator::make($input, [
'key_id' => [
'required',
Rule::exists('ssh_keys', 'id')->where('user_id', $user->id),
],
])->validate();
}
}

View File

@ -27,11 +27,18 @@ public function create(User $user, array $input): void
$storageProvider->credentials = $storageProvider->provider()->credentialData($input); $storageProvider->credentials = $storageProvider->provider()->credentialData($input);
try {
if (! $storageProvider->provider()->connect()) { if (! $storageProvider->provider()->connect()) {
throw ValidationException::withMessages([ throw ValidationException::withMessages([
'provider' => __("Couldn't connect to the provider"), 'provider' => __("Couldn't connect to the provider"),
]); ]);
} }
} catch (\Throwable $e) {
throw ValidationException::withMessages([
'provider' => $e->getMessage(),
]);
}
$storageProvider->save(); $storageProvider->save();
} }

View File

@ -0,0 +1,21 @@
<?php
namespace App\Actions\StorageProvider;
use App\Models\StorageProvider;
use Exception;
class DeleteStorageProvider
{
/**
* @throws Exception
*/
public function delete(StorageProvider $storageProvider): void
{
if ($storageProvider->backups()->exists()) {
throw new Exception('This storage provider is being used by a backup.');
}
$storageProvider->delete();
}
}

View File

@ -0,0 +1,74 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\File;
class MigrateFromMysqlToSqlite extends Command
{
protected $signature = 'migrate-from-mysql-to-sqlite';
protected $description = 'Migrate from Mysql to SQLite';
public function handle(): void
{
$this->info('Migrating from Mysql to SQLite...');
File::exists(storage_path('database.sqlite'))
? File::delete(storage_path('database.sqlite'))
: null;
File::put(storage_path('database.sqlite'), '');
config(['database.default' => 'sqlite']);
$this->call('migrate', ['--force' => true]);
$this->migrateModel(\App\Models\Backup::class);
$this->migrateModel(\App\Models\BackupFile::class);
$this->migrateModel(\App\Models\CronJob::class);
$this->migrateModel(\App\Models\Database::class);
$this->migrateModel(\App\Models\DatabaseUser::class);
$this->migrateModel(\App\Models\Deployment::class);
$this->migrateModel(\App\Models\DeploymentScript::class);
$this->migrateModel(\App\Models\FirewallRule::class);
$this->migrateModel(\App\Models\GitHook::class);
$this->migrateModel(\App\Models\NotificationChannel::class);
$this->migrateModel(\App\Models\Project::class);
$this->migrateModel(\App\Models\Queue::class);
$this->migrateModel(\App\Models\Server::class);
$this->migrateModel(\App\Models\ServerLog::class);
$this->migrateModel(\App\Models\ServerProvider::class);
$this->migrateModel(\App\Models\Service::class);
$this->migrateModel(\App\Models\Site::class);
$this->migrateModel(\App\Models\SourceControl::class);
$this->migrateModel(\App\Models\SshKey::class);
$this->migrateModel(\App\Models\Ssl::class);
$this->migrateModel(\App\Models\StorageProvider::class);
$this->migrateModel(\App\Models\User::class);
$env = File::get(base_path('.env'));
$env = str_replace('DB_CONNECTION=mysql', 'DB_CONNECTION=sqlite', $env);
$env = str_replace('DB_DATABASE=vito', '', $env);
File::put(base_path('.env'), $env);
$this->info('Migrated from Mysql to SQLite');
}
private function migrateModel(string $model): void
{
$this->info("Migrating model: {$model}");
config(['database.default' => 'mysql']);
$rows = $model::where('id', '>', 0)->get();
foreach ($rows as $row) {
DB::connection('sqlite')->table($row->getTable())->insert($row->getAttributes());
}
$this->info("Migrated model: {$model}");
}
}

View File

@ -1,49 +0,0 @@
<?php
namespace App\Console\Commands;
use App\Models\Backup;
use Illuminate\Console\Command;
class RunBackup extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'backups:run {interval}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Run backup';
/**
* Create a new command instance.
*
* @return void
*/
public function __construct()
{
parent::__construct();
}
/**
* Execute the console command.
*/
public function handle(): void
{
Backup::query()
->where('interval', $this->argument('interval'))
->where('status', 'running')
->chunk(100, function ($backups) {
/** @var Backup $backup */
foreach ($backups as $backup) {
$backup->run();
}
});
}
}

View File

@ -0,0 +1,33 @@
<?php
namespace App\Console\Commands;
use App\Actions\Database\RunBackup;
use App\Enums\BackupStatus;
use App\Models\Backup;
use Illuminate\Console\Command;
class RunBackupCommand extends Command
{
protected $signature = 'backups:run {interval}';
protected $description = 'Run backup';
public function handle(): void
{
$total = 0;
Backup::query()
->where('interval', $this->argument('interval'))
->where('status', BackupStatus::RUNNING)
->chunk(100, function ($backups) use (&$total) {
/** @var Backup $backup */
foreach ($backups as $backup) {
app(RunBackup::class)->run($backup);
$total++;
}
});
$this->info("{$total} backups started");
}
}

View File

@ -1,10 +0,0 @@
<?php
namespace App\Contracts;
interface SSHCommand
{
public function file(): string;
public function content(): string;
}

View File

@ -2,9 +2,7 @@
namespace App\Enums; namespace App\Enums;
use BenSampo\Enum\Enum; final class BackupFileStatus
final class BackupFileStatus extends Enum
{ {
const CREATED = 'created'; const CREATED = 'created';

View File

@ -2,9 +2,7 @@
namespace App\Enums; namespace App\Enums;
use BenSampo\Enum\Enum; final class BackupStatus
final class BackupStatus extends Enum
{ {
const READY = 'ready'; const READY = 'ready';

View File

@ -2,9 +2,7 @@
namespace App\Enums; namespace App\Enums;
use BenSampo\Enum\Enum; final class CronjobStatus
final class CronjobStatus extends Enum
{ {
const CREATING = 'creating'; const CREATING = 'creating';

View File

@ -2,9 +2,7 @@
namespace App\Enums; namespace App\Enums;
use BenSampo\Enum\Enum; final class Database
final class Database extends Enum
{ {
const NONE = 'none'; const NONE = 'none';
@ -13,4 +11,14 @@ final class Database extends Enum
const MYSQL80 = 'mysql80'; const MYSQL80 = 'mysql80';
const MARIADB = 'mariadb'; const MARIADB = 'mariadb';
const POSTGRESQL12 = 'postgresql12';
const POSTGRESQL13 = 'postgresql13';
const POSTGRESQL14 = 'postgresql14';
const POSTGRESQL15 = 'postgresql15';
const POSTGRESQL16 = 'postgresql16';
} }

View File

@ -2,9 +2,7 @@
namespace App\Enums; namespace App\Enums;
use BenSampo\Enum\Enum; final class DatabaseStatus
final class DatabaseStatus extends Enum
{ {
const READY = 'ready'; const READY = 'ready';

View File

@ -2,9 +2,7 @@
namespace App\Enums; namespace App\Enums;
use BenSampo\Enum\Enum; final class DatabaseUserStatus
final class DatabaseUserStatus extends Enum
{ {
const READY = 'ready'; const READY = 'ready';

View File

@ -2,9 +2,7 @@
namespace App\Enums; namespace App\Enums;
use BenSampo\Enum\Enum; final class DeploymentStatus
final class DeploymentStatus extends Enum
{ {
const DEPLOYING = 'deploying'; const DEPLOYING = 'deploying';

View File

@ -2,9 +2,7 @@
namespace App\Enums; namespace App\Enums;
use BenSampo\Enum\Enum; final class FirewallRuleStatus
final class FirewallRuleStatus extends Enum
{ {
const CREATING = 'creating'; const CREATING = 'creating';

View File

@ -2,9 +2,7 @@
namespace App\Enums; namespace App\Enums;
use BenSampo\Enum\Enum; final class LogType
final class LogType extends Enum
{ {
const SERVER = 'server'; const SERVER = 'server';

View File

@ -2,9 +2,7 @@
namespace App\Enums; namespace App\Enums;
use BenSampo\Enum\Enum; final class NotificationChannel
final class NotificationChannel extends Enum
{ {
const EMAIL = 'email'; const EMAIL = 'email';

View File

@ -2,9 +2,7 @@
namespace App\Enums; namespace App\Enums;
use BenSampo\Enum\Enum; final class OperatingSystem
final class OperatingSystem extends Enum
{ {
const UBUNTU18 = 'ubuntu_18'; const UBUNTU18 = 'ubuntu_18';

View File

@ -2,9 +2,7 @@
namespace App\Enums; namespace App\Enums;
use BenSampo\Enum\Enum; final class QueueStatus
final class QueueStatus extends Enum
{ {
const RUNNING = 'running'; const RUNNING = 'running';

View File

@ -2,9 +2,7 @@
namespace App\Enums; namespace App\Enums;
use BenSampo\Enum\Enum; final class ServerProvider
final class ServerProvider extends Enum
{ {
const CUSTOM = 'custom'; const CUSTOM = 'custom';

View File

@ -2,9 +2,7 @@
namespace App\Enums; namespace App\Enums;
use BenSampo\Enum\Enum; final class ServerStatus
final class ServerStatus extends Enum
{ {
const READY = 'ready'; const READY = 'ready';

View File

@ -2,9 +2,7 @@
namespace App\Enums; namespace App\Enums;
use BenSampo\Enum\Enum; final class ServerType
final class ServerType extends Enum
{ {
const REGULAR = 'regular'; const REGULAR = 'regular';

View File

@ -2,9 +2,7 @@
namespace App\Enums; namespace App\Enums;
use BenSampo\Enum\Enum; final class ServiceStatus
final class ServiceStatus extends Enum
{ {
const READY = 'ready'; const READY = 'ready';
@ -23,4 +21,10 @@ final class ServiceStatus extends Enum
const RESTARTING = 'restarting'; const RESTARTING = 'restarting';
const STOPPED = 'stopped'; const STOPPED = 'stopped';
const ENABLING = 'enabling';
const DISABLING = 'disabling';
const DISABLED = 'disabled';
} }

View File

@ -2,9 +2,7 @@
namespace App\Enums; namespace App\Enums;
use BenSampo\Enum\Enum; final class SiteFeature
final class SiteFeature extends Enum
{ {
const DEPLOYMENT = 'deployment'; const DEPLOYMENT = 'deployment';

View File

@ -2,9 +2,7 @@
namespace App\Enums; namespace App\Enums;
use BenSampo\Enum\Enum; final class SiteStatus
final class SiteStatus extends Enum
{ {
const READY = 'ready'; const READY = 'ready';

View File

@ -2,9 +2,7 @@
namespace App\Enums; namespace App\Enums;
use BenSampo\Enum\Enum; final class SiteType
final class SiteType extends Enum
{ {
const PHP = 'php'; const PHP = 'php';

View File

@ -2,9 +2,7 @@
namespace App\Enums; namespace App\Enums;
use BenSampo\Enum\Enum; final class SourceControl
final class SourceControl extends Enum
{ {
const GITHUB = 'github'; const GITHUB = 'github';

View File

@ -2,9 +2,7 @@
namespace App\Enums; namespace App\Enums;
use BenSampo\Enum\Enum; final class SshKeyStatus
final class SshKeyStatus extends Enum
{ {
const ADDING = 'adding'; const ADDING = 'adding';

View File

@ -2,9 +2,7 @@
namespace App\Enums; namespace App\Enums;
use BenSampo\Enum\Enum; final class SslStatus
final class SslStatus extends Enum
{ {
const CREATED = 'created'; const CREATED = 'created';

View File

@ -2,9 +2,7 @@
namespace App\Enums; namespace App\Enums;
use BenSampo\Enum\Enum; final class SslType
final class SslType extends Enum
{ {
const LETSENCRYPT = 'letsencrypt'; const LETSENCRYPT = 'letsencrypt';

View File

@ -2,9 +2,7 @@
namespace App\Enums; namespace App\Enums;
use BenSampo\Enum\Enum; final class StorageProvider
final class StorageProvider extends Enum
{ {
const DROPBOX = 'dropbox'; const DROPBOX = 'dropbox';

View File

@ -2,9 +2,7 @@
namespace App\Enums; namespace App\Enums;
use BenSampo\Enum\Enum; final class Webserver
final class Webserver extends Enum
{ {
const NONE = 'none'; const NONE = 'none';

View File

@ -1,16 +0,0 @@
<?php
namespace App\Events;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class Broadcast
{
use Dispatchable, InteractsWithSockets, SerializesModels;
public function __construct(public string $type, public array $data)
{
}
}

View File

@ -1,9 +0,0 @@
<?php
namespace App\Exceptions;
use Exception;
class BackupFileException extends Exception
{
}

View File

@ -1,10 +0,0 @@
<?php
namespace App\Exceptions;
use Exception;
class CannotDeployKey extends Exception
{
//
}

View File

@ -1,10 +0,0 @@
<?php
namespace App\Exceptions;
use Exception;
class ComposerInstallFailed extends Exception
{
//
}

View File

@ -0,0 +1,9 @@
<?php
namespace App\Exceptions;
use Exception;
class DeploymentScriptIsEmptyException extends Exception
{
}

View File

@ -1,10 +0,0 @@
<?php
namespace App\Exceptions;
use Exception;
class FailedToInstallWordpress extends Exception
{
//
}

Some files were not shown because too many files have changed in this diff Show More