From c813386e1882df707a7325dbfdbdc8c5d6229ec7 Mon Sep 17 00:00:00 2001 From: Alexis Bruneteau Date: Wed, 4 Jun 2025 21:53:28 +0200 Subject: [PATCH] working --- ansible/deploy_site.yml | 4 +- app/Console/Commands/DeployStaticSite.php | 129 ++++++++++++++++++ app/Http/Controllers/PortfolioController.php | 118 ++++++++++++++++ app/Http/Controllers/StaticSiteController.php | 12 ++ app/Jobs/DeployStaticSiteJob.php | 49 +++++++ app/Models/Portfolio.php | 26 ++++ config/database.php | 29 ---- config/passport.php | 46 +++++++ ...5_06_03_161157_create_portfolios_table.php | 32 +++++ ...3_230153_create_oauth_auth_codes_table.php | 39 ++++++ ...30154_create_oauth_access_tokens_table.php | 41 ++++++ ...0155_create_oauth_refresh_tokens_table.php | 37 +++++ ...6_03_230156_create_oauth_clients_table.php | 42 ++++++ ...230157_create_oauth_device_codes_table.php | 42 ++++++ deploy/k3s/prod/backend/deployment.yml | 1 + routes/api.php | 2 + 16 files changed, 618 insertions(+), 31 deletions(-) create mode 100644 app/Console/Commands/DeployStaticSite.php create mode 100644 app/Http/Controllers/PortfolioController.php create mode 100644 app/Http/Controllers/StaticSiteController.php create mode 100644 app/Jobs/DeployStaticSiteJob.php create mode 100644 app/Models/Portfolio.php create mode 100644 config/passport.php create mode 100644 database/migrations/2025_06_03_161157_create_portfolios_table.php create mode 100644 database/migrations/2025_06_03_230153_create_oauth_auth_codes_table.php create mode 100644 database/migrations/2025_06_03_230154_create_oauth_access_tokens_table.php create mode 100644 database/migrations/2025_06_03_230155_create_oauth_refresh_tokens_table.php create mode 100644 database/migrations/2025_06_03_230156_create_oauth_clients_table.php create mode 100644 database/migrations/2025_06_03_230157_create_oauth_device_codes_table.php diff --git a/ansible/deploy_site.yml b/ansible/deploy_site.yml index 43a7097..7baef5b 100644 --- a/ansible/deploy_site.yml +++ b/ansible/deploy_site.yml @@ -1,9 +1,9 @@ - hosts: web become: yes vars: - static_site_local_path: "{{ lookup('env', 'PWD') }}/storage/app/private/portfolios/{{ sitename }}/{{ sitehost }}" + static_site_local_path: "{{ lookup('env', 'PWD') }}/storage/app/private/portfolios/{{ sitename }}/{{ siteid }}" kube_manifest_local_path: "{{ lookup('env', 'PWD') }}/storage/app/kube/{{ sitename }}" - target_path: /var/www/{{ sitename }}/{{ sitehost }} + target_path: /var/www/{{ sitename }}/{{ siteid }} remote_kube_path: /tmp/kube/{{ sitename }} tasks: diff --git a/app/Console/Commands/DeployStaticSite.php b/app/Console/Commands/DeployStaticSite.php new file mode 100644 index 0000000..a56c9ef --- /dev/null +++ b/app/Console/Commands/DeployStaticSite.php @@ -0,0 +1,129 @@ +argument('name'); + $host = $this->argument('host'); + $id = $this->argument('id'); + $namespace = 'hosting-deploy'; + $kubeDir = storage_path("app/kube/{$name}"); + File::ensureDirectoryExists($kubeDir); + + // 1. Generate K8s YAMLs + File::put("{$kubeDir}/deployment.yaml", $this->deployment($name,$id, $namespace)); + File::put("{$kubeDir}/service.yaml", $this->service($name,$id, $namespace)); + File::put("{$kubeDir}/ingress.yaml", $this->ingress($name,$host,$id, $namespace)); + + $this->info("✅ K8s manifests generated."); + + // 2. Run Ansible to copy files and apply kube + $ansiblePlaybook = base_path("ansible/deploy_site.yml"); + $process = new Process([ + 'ansible-playbook', + $ansiblePlaybook, + '-i', base_path('ansible/inventory/hosts.ini'), + '--extra-vars', "sitename={$name}",'--extra-vars', "siteid={$id}" + ]); + $process->setTimeout(300); + $process->run(function ($type, $buffer) { + echo $buffer; + }); + + if ($process->isSuccessful()) { + $this->info("🚀 Site '{$name}' deployed successfully."); + } else { + $this->error("❌ Ansible deployment failed."); + } + } + + private function deployment(string $name,string $id, string $namespace): string + { + return <<user()->portfolios; + return ApiResponse::success($portfolios); + } + + public function store(Request $request) + { + $validated = $request->validate([ + 'name' => 'required|string|max:255', + 'domain' => 'required|string|max:255|unique:portfolios,domain', + ]); + + $portfolio = auth()->user()->portfolios()->create($validated); + + return ApiResponse::success($portfolio, 'Portfolio created', 201); + } + + public function show(Portfolio $portfolio) + { + $this->authorizeAccess($portfolio); + + return ApiResponse::success($portfolio); + } + + public function update(Request $request, Portfolio $portfolio) + { + $this->authorizeAccess($portfolio); + + $validated = $request->validate([ + 'name' => 'sometimes|string|max:255', + 'domain' => 'sometimes|string|max:255|unique:portfolios,domain,' . $portfolio->id, + ]); + + $portfolio->update($validated); + + return ApiResponse::success($portfolio, 'Portfolio updated'); + } + + public function destroy(Portfolio $portfolio) + { + $this->authorizeAccess($portfolio); + + $portfolio->delete(); + + return ApiResponse::success(null, 'Portfolio deleted'); + } + + private function authorizeAccess(Portfolio $portfolio) + { + if ($portfolio->user_id !== auth()->id()) { + abort(403, 'Unauthorized'); + } + } + + private function verifyActivation(Portfolio $portfolio) + { + if (!$portfolio->active) + { + abort(403, 'Portfolio unpaid'); + } + } + + public function upload(Request $request, Portfolio $portfolio) + { + $this->authorizeAccess($portfolio); + $this->verifyActivation($portfolio); + + $request->validate([ + 'file' => 'required|file|max:10240', // Max 10MB + ]); + $siteName = $portfolio->getAttribute('name'); + $siteHost = $portfolio->getAttribute('domain'); + $path = $request->file('file')->storeAs( + "portfolios/{$siteName}/{$portfolio->id}", + 'index.html' + ); + + $portfolio->update([ + 'path' => $path, + ]); + return ApiResponse::success(null, 'ZIP uploaded successfully'); + } + + public function deploy(Request $request, Portfolio $portfolio) + { + + $this->authorizeAccess($portfolio); + + + $siteName = $portfolio->getAttribute('name'); + $siteHost = $portfolio->getAttribute('domain'); + + + DeployStaticSiteJob::dispatch($siteName, $siteHost, $portfolio->id); + + return response()->json([ + 'message' => "Async deployment queued for '{$siteName}'." + ]); + } + + public function randomPortfolio() + { + return ApiResponse::success(["host" => Portfolio::inRandomOrder()->first()->domain]); + } +} + diff --git a/app/Http/Controllers/StaticSiteController.php b/app/Http/Controllers/StaticSiteController.php new file mode 100644 index 0000000..648f530 --- /dev/null +++ b/app/Http/Controllers/StaticSiteController.php @@ -0,0 +1,12 @@ +siteName = $siteName; + $this->siteHost = $siteHost; + $this->siteId = $siteId; + } + + public function handle(): void + { + // Run the deployment command (which already handles Ansible) + $exitCode = Artisan::call('deploy:static-site', [ + 'name' => $this->siteName, + 'host' => $this->siteHost, + 'id' => $this->siteId, + ]); + + if ($exitCode !== 0) { + $output = Artisan::output(); // optional: capture for logs + logger()->error("❌ deploy:static-site failed for {$this->siteName}", [ + 'output' => $output, + 'exit_code' => $exitCode, + ]); + + throw new \RuntimeException("deploy:static-site failed with exit code $exitCode"); + } + + logger()->info("✅ deploy:static-site successful for {$this->siteName}"); + } +} diff --git a/app/Models/Portfolio.php b/app/Models/Portfolio.php new file mode 100644 index 0000000..d78956d --- /dev/null +++ b/app/Models/Portfolio.php @@ -0,0 +1,26 @@ +belongsTo(User::class); + } +} diff --git a/config/database.php b/config/database.php index 8910562..0a62fad 100644 --- a/config/database.php +++ b/config/database.php @@ -31,36 +31,7 @@ return [ 'connections' => [ - 'sqlite' => [ - 'driver' => 'sqlite', - 'url' => env('DB_URL'), - 'database' => env('DB_DATABASE', database_path('database.sqlite')), - 'prefix' => '', - 'foreign_key_constraints' => env('DB_FOREIGN_KEYS', true), - 'busy_timeout' => null, - 'journal_mode' => null, - 'synchronous' => null, - ], - 'mysql' => [ - 'driver' => 'mysql', - 'url' => env('DB_URL'), - 'host' => env('DB_HOST', '127.0.0.1'), - 'port' => env('DB_PORT', '3306'), - 'database' => env('DB_DATABASE', 'laravel'), - 'username' => env('DB_USERNAME', 'root'), - 'password' => env('DB_PASSWORD', ''), - 'unix_socket' => env('DB_SOCKET', ''), - 'charset' => env('DB_CHARSET', 'utf8mb4'), - 'collation' => env('DB_COLLATION', 'utf8mb4_unicode_ci'), - 'prefix' => '', - 'prefix_indexes' => true, - 'strict' => true, - 'engine' => null, - 'options' => extension_loaded('pdo_mysql') ? array_filter([ - PDO::MYSQL_ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'), - ]) : [], - ], 'mariadb' => [ 'driver' => 'mariadb', diff --git a/config/passport.php b/config/passport.php new file mode 100644 index 0000000..c3b41b3 --- /dev/null +++ b/config/passport.php @@ -0,0 +1,46 @@ + 'web', + + /* + |-------------------------------------------------------------------------- + | Encryption Keys + |-------------------------------------------------------------------------- + | + | Passport uses encryption keys while generating secure access tokens for + | your application. By default, the keys are stored as local files but + | can be set via environment variables when that is more convenient. + | + */ + + 'private_key' => env('PASSPORT_PRIVATE_KEY'), + + 'public_key' => env('PASSPORT_PUBLIC_KEY'), + + /* + |-------------------------------------------------------------------------- + | Passport Database Connection + |-------------------------------------------------------------------------- + | + | By default, Passport's models will utilize your application's default + | database connection. If you wish to use a different connection you + | may specify the configured name of the database connection here. + | + */ + + 'connection' => env('PASSPORT_CONNECTION'), + +]; diff --git a/database/migrations/2025_06_03_161157_create_portfolios_table.php b/database/migrations/2025_06_03_161157_create_portfolios_table.php new file mode 100644 index 0000000..ed424f0 --- /dev/null +++ b/database/migrations/2025_06_03_161157_create_portfolios_table.php @@ -0,0 +1,32 @@ +id(); + $table->foreignId('user_id')->constrained()->onDelete('cascade'); + $table->string('name'); + $table->string('domain')->unique(); // e.g. alexis.portfolio-host.com + $table->string('path')->nullable(); // for future settings (theme, etc) + $table->boolean('deployed')->default(false); + $table->boolean('active')->default(false); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('portfolios'); + } +}; diff --git a/database/migrations/2025_06_03_230153_create_oauth_auth_codes_table.php b/database/migrations/2025_06_03_230153_create_oauth_auth_codes_table.php new file mode 100644 index 0000000..c700b50 --- /dev/null +++ b/database/migrations/2025_06_03_230153_create_oauth_auth_codes_table.php @@ -0,0 +1,39 @@ +char('id', 80)->primary(); + $table->foreignId('user_id')->index(); + $table->foreignUuid('client_id'); + $table->text('scopes')->nullable(); + $table->boolean('revoked'); + $table->dateTime('expires_at')->nullable(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('oauth_auth_codes'); + } + + /** + * Get the migration connection name. + */ + public function getConnection(): ?string + { + return $this->connection ?? config('passport.connection'); + } +}; diff --git a/database/migrations/2025_06_03_230154_create_oauth_access_tokens_table.php b/database/migrations/2025_06_03_230154_create_oauth_access_tokens_table.php new file mode 100644 index 0000000..3e50f7f --- /dev/null +++ b/database/migrations/2025_06_03_230154_create_oauth_access_tokens_table.php @@ -0,0 +1,41 @@ +char('id', 80)->primary(); + $table->foreignId('user_id')->nullable()->index(); + $table->foreignUuid('client_id'); + $table->string('name')->nullable(); + $table->text('scopes')->nullable(); + $table->boolean('revoked'); + $table->timestamps(); + $table->dateTime('expires_at')->nullable(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('oauth_access_tokens'); + } + + /** + * Get the migration connection name. + */ + public function getConnection(): ?string + { + return $this->connection ?? config('passport.connection'); + } +}; diff --git a/database/migrations/2025_06_03_230155_create_oauth_refresh_tokens_table.php b/database/migrations/2025_06_03_230155_create_oauth_refresh_tokens_table.php new file mode 100644 index 0000000..afb3c55 --- /dev/null +++ b/database/migrations/2025_06_03_230155_create_oauth_refresh_tokens_table.php @@ -0,0 +1,37 @@ +char('id', 80)->primary(); + $table->char('access_token_id', 80)->index(); + $table->boolean('revoked'); + $table->dateTime('expires_at')->nullable(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('oauth_refresh_tokens'); + } + + /** + * Get the migration connection name. + */ + public function getConnection(): ?string + { + return $this->connection ?? config('passport.connection'); + } +}; diff --git a/database/migrations/2025_06_03_230156_create_oauth_clients_table.php b/database/migrations/2025_06_03_230156_create_oauth_clients_table.php new file mode 100644 index 0000000..9794dc8 --- /dev/null +++ b/database/migrations/2025_06_03_230156_create_oauth_clients_table.php @@ -0,0 +1,42 @@ +uuid('id')->primary(); + $table->nullableMorphs('owner'); + $table->string('name'); + $table->string('secret')->nullable(); + $table->string('provider')->nullable(); + $table->text('redirect_uris'); + $table->text('grant_types'); + $table->boolean('revoked'); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('oauth_clients'); + } + + /** + * Get the migration connection name. + */ + public function getConnection(): ?string + { + return $this->connection ?? config('passport.connection'); + } +}; diff --git a/database/migrations/2025_06_03_230157_create_oauth_device_codes_table.php b/database/migrations/2025_06_03_230157_create_oauth_device_codes_table.php new file mode 100644 index 0000000..ea07831 --- /dev/null +++ b/database/migrations/2025_06_03_230157_create_oauth_device_codes_table.php @@ -0,0 +1,42 @@ +char('id', 80)->primary(); + $table->foreignId('user_id')->nullable()->index(); + $table->foreignUuid('client_id')->index(); + $table->char('user_code', 8)->unique(); + $table->text('scopes'); + $table->boolean('revoked'); + $table->dateTime('user_approved_at')->nullable(); + $table->dateTime('last_polled_at')->nullable(); + $table->dateTime('expires_at')->nullable(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('oauth_device_codes'); + } + + /** + * Get the migration connection name. + */ + public function getConnection(): ?string + { + return $this->connection ?? config('passport.connection'); + } +}; diff --git a/deploy/k3s/prod/backend/deployment.yml b/deploy/k3s/prod/backend/deployment.yml index 9203c98..c15bdcc 100644 --- a/deploy/k3s/prod/backend/deployment.yml +++ b/deploy/k3s/prod/backend/deployment.yml @@ -21,6 +21,7 @@ spec: env: - name: FRONTEND_URL value: https://portfolio-host.com + lifecycle: postStart: exec: diff --git a/routes/api.php b/routes/api.php index 7f51692..2b40357 100644 --- a/routes/api.php +++ b/routes/api.php @@ -25,3 +25,5 @@ Route::middleware('auth:api')->group(function () { Route::post('/portfolios/{portfolio}/upload', [PortfolioController::class, 'upload']); Route::post('/deploy', [StaticSiteController::class, 'deploy']); }); + +Route::get('/portfolio/random', [PortfolioController::class, 'randomPortfolio']);