diff --git a/app/Jobs/GithubAppPermissionJob.php b/app/Jobs/GithubAppPermissionJob.php index d483fe4c2..7cd1b86ac 100644 --- a/app/Jobs/GithubAppPermissionJob.php +++ b/app/Jobs/GithubAppPermissionJob.php @@ -27,19 +27,28 @@ class GithubAppPermissionJob implements ShouldBeEncrypted, ShouldQueue public function handle() { try { - $github_access_token = generate_github_jwt_token($this->github_app); + $github_access_token = generateGithubJwt($this->github_app); + $response = Http::withHeaders([ 'Authorization' => "Bearer $github_access_token", 'Accept' => 'application/vnd.github+json', ])->get("{$this->github_app->api_url}/app"); + + if (! $response->successful()) { + throw new \RuntimeException('Failed to fetch GitHub app permissions: '.$response->body()); + } + $response = $response->json(); $permissions = data_get($response, 'permissions'); + $this->github_app->contents = data_get($permissions, 'contents'); $this->github_app->metadata = data_get($permissions, 'metadata'); $this->github_app->pull_requests = data_get($permissions, 'pull_requests'); $this->github_app->administration = data_get($permissions, 'administration'); + $this->github_app->save(); $this->github_app->makeVisible('client_secret')->makeVisible('webhook_secret'); + } catch (\Throwable $e) { send_internal_notification('GithubAppPermissionJob failed with: '.$e->getMessage()); throw $e; diff --git a/app/Livewire/Project/New/GithubPrivateRepository.php b/app/Livewire/Project/New/GithubPrivateRepository.php index 370d00555..4a81d841f 100644 --- a/app/Livewire/Project/New/GithubPrivateRepository.php +++ b/app/Livewire/Project/New/GithubPrivateRepository.php @@ -105,7 +105,7 @@ class GithubPrivateRepository extends Component $this->page = 1; $this->selected_github_app_id = $github_app_id; $this->github_app = GithubApp::where('id', $github_app_id)->first(); - $this->token = generate_github_installation_token($this->github_app); + $this->token = generateGithubInstallationToken($this->github_app); $this->loadRepositoryByPage(); if ($this->repositories->count() < $this->total_repositories_count) { while ($this->repositories->count() < $this->total_repositories_count) { diff --git a/app/Livewire/Source/Github/Change.php b/app/Livewire/Source/Github/Change.php index 8f4f02f70..fc597748e 100644 --- a/app/Livewire/Source/Github/Change.php +++ b/app/Livewire/Source/Github/Change.php @@ -76,7 +76,7 @@ class Change extends Component // Need administration:read:write permission // https://docs.github.com/en/rest/actions/self-hosted-runners?apiVersion=2022-11-28#list-self-hosted-runners-for-a-repository - // $github_access_token = generate_github_installation_token($this->github_app); + // $github_access_token = generateGithubInstallationToken($this->github_app); // $repositories = Http::withToken($github_access_token)->get("{$this->github_app->api_url}/installation/repositories?per_page=100"); // $runners_by_repository = collect([]); // $repositories = $repositories->json()['repositories']; diff --git a/app/Models/Application.php b/app/Models/Application.php index 56faf6c31..289ef5b0f 100644 --- a/app/Models/Application.php +++ b/app/Models/Application.php @@ -999,7 +999,7 @@ class Application extends BaseModel $fullRepoUrl = "{$this->source->html_url}/{$customRepository}"; $base_command = "{$base_command} {$this->source->html_url}/{$customRepository}"; } else { - $github_access_token = generate_github_installation_token($this->source); + $github_access_token = generateGithubInstallationToken($this->source); if ($exec_in_docker) { $base_command = "{$base_command} $source_html_url_scheme://x-access-token:$github_access_token@$source_html_url_host/{$customRepository}.git"; @@ -1111,7 +1111,7 @@ class Application extends BaseModel $commands->push($git_clone_command); } } else { - $github_access_token = generate_github_installation_token($this->source); + $github_access_token = generateGithubInstallationToken($this->source); if ($exec_in_docker) { $git_clone_command = "{$git_clone_command} $source_html_url_scheme://x-access-token:$github_access_token@$source_html_url_host/{$customRepository}.git {$baseDir}"; $fullRepoUrl = "$source_html_url_scheme://x-access-token:$github_access_token@$source_html_url_host/{$customRepository}.git"; diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 2ce94201c..329482230 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -3,37 +3,68 @@ namespace App\Providers; use App\Models\PersonalAccessToken; -use Illuminate\Support\Facades\Event; +use Illuminate\Database\Eloquent\Model; +use Illuminate\Support\Facades\App; +use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Http; use Illuminate\Support\ServiceProvider; use Illuminate\Validation\Rules\Password; use Laravel\Sanctum\Sanctum; +use Laravel\Telescope\TelescopeServiceProvider; class AppServiceProvider extends ServiceProvider { public function register(): void { - if ($this->app->environment('local')) { - $this->app->register(\Laravel\Telescope\TelescopeServiceProvider::class); + if (App::isLocal()) { + $this->app->register(TelescopeServiceProvider::class); } } public function boot(): void { - Event::listen(function (\SocialiteProviders\Manager\SocialiteWasCalled $event) { - $event->extendSocialite('authentik', \SocialiteProviders\Authentik\Provider::class); - }); - Sanctum::usePersonalAccessTokenModel(PersonalAccessToken::class); + $this->configureCommands(); + $this->configureModels(); + $this->configurePasswords(); + $this->configureSanctumModel(); + $this->configureGitHubHttp(); + } + private function configureCommands(): void + { + if (App::isProduction()) { + DB::prohibitDestructiveCommands(); + } + } + + private function configureModels(): void + { + // Disabled because it's causing issues with the application + // Model::shouldBeStrict(); + } + + private function configurePasswords(): void + { Password::defaults(function () { - $rule = Password::min(8); - - return $this->app->isProduction() - ? $rule->mixedCase()->letters()->numbers()->symbols() - : $rule; + return App::isProduction() + ? Password::min(8) + ->mixedCase() + ->letters() + ->numbers() + ->symbols() + ->uncompromised() + : Password::min(8)->letters(); }); + } - Http::macro('github', function (string $api_url, ?string $github_access_token = null) { + private function configureSanctumModel(): void + { + Sanctum::usePersonalAccessTokenModel(PersonalAccessToken::class); + } + + private function configureGitHubHttp(): void + { + Http::macro('GitHub', function (string $api_url, ?string $github_access_token = null) { if ($github_access_token) { return Http::withHeaders([ 'X-GitHub-Api-Version' => '2022-11-28', diff --git a/bootstrap/helpers/github.php b/bootstrap/helpers/github.php index 529ac82b1..3a3f6e7b2 100644 --- a/bootstrap/helpers/github.php +++ b/bootstrap/helpers/github.php @@ -12,77 +12,108 @@ use Lcobucci\JWT\Signer\Key\InMemory; use Lcobucci\JWT\Signer\Rsa\Sha256; use Lcobucci\JWT\Token\Builder; -function generate_github_installation_token(GithubApp $source) +function generateGithubToken(GithubApp $source, string $type) { - $signingKey = InMemory::plainText($source->privateKey->private_key); - $algorithm = new Sha256; - $tokenBuilder = (new Builder(new JoseEncoder, ChainedFormatter::default())); - $now = CarbonImmutable::now(); - $now = $now->setTime($now->format('H'), $now->format('i')); - $issuedToken = $tokenBuilder - ->issuedBy($source->app_id) - ->issuedAt($now) - ->expiresAt($now->modify('+10 minutes')) - ->getToken($algorithm, $signingKey) - ->toString(); - $token = Http::withHeaders([ - 'Authorization' => "Bearer $issuedToken", - 'Accept' => 'application/vnd.github.machine-man-preview+json', - ])->post("{$source->api_url}/app/installations/{$source->installation_id}/access_tokens"); - if ($token->failed()) { - throw new RuntimeException('Failed to get access token for '.$source->name.' with error: '.data_get($token->json(), 'message', 'no error message found')); + $response = Http::get("{$source->api_url}/zen"); + $serverTime = CarbonImmutable::now()->setTimezone('UTC'); + $githubTime = Carbon::parse($response->header('date')); + $timeDiff = abs($serverTime->diffInSeconds($githubTime)); + + if ($timeDiff > 50) { + throw new \Exception( + 'System time is out of sync with GitHub API time:
'. + '- System time: '.$serverTime->format('Y-m-d H:i:s').' UTC
'. + '- GitHub time: '.$githubTime->format('Y-m-d H:i:s').' UTC
'. + '- Difference: '.$timeDiff.' seconds
'. + 'Please synchronize your system clock.' + ); } - return $token->json()['token']; -} - -function generate_github_jwt_token(GithubApp $source) -{ $signingKey = InMemory::plainText($source->privateKey->private_key); $algorithm = new Sha256; $tokenBuilder = (new Builder(new JoseEncoder, ChainedFormatter::default())); - $now = CarbonImmutable::now(); - $now = $now->setTime($now->format('H'), $now->format('i')); + $now = CarbonImmutable::now()->setTimezone('UTC'); + $now = $now->setTime($now->format('H'), $now->format('i'), $now->format('s')); - return $tokenBuilder + $jwt = $tokenBuilder ->issuedBy($source->app_id) ->issuedAt($now->modify('-1 minute')) - ->expiresAt($now->modify('+10 minutes')) + ->expiresAt($now->modify('+8 minutes')) ->getToken($algorithm, $signingKey) ->toString(); + + return match ($type) { + 'jwt' => $jwt, + 'installation' => (function () use ($source, $jwt) { + $response = Http::withHeaders([ + 'Authorization' => "Bearer $jwt", + 'Accept' => 'application/vnd.github.machine-man-preview+json', + ])->post("{$source->api_url}/app/installations/{$source->installation_id}/access_tokens"); + + if (! $response->successful()) { + $error = data_get($response->json(), 'message', 'no error message found'); + throw new RuntimeException("Failed to get installation token for {$source->name} with error: ".$error); + } + + return $response->json()['token']; + })(), + default => throw new \InvalidArgumentException("Unsupported token type: {$type}") + }; +} + +function generateGithubInstallationToken(GithubApp $source) +{ + return generateGithubToken($source, 'installation'); +} + +function generateGithubJwt(GithubApp $source) +{ + return generateGithubToken($source, 'jwt'); } function githubApi(GithubApp|GitlabApp|null $source, string $endpoint, string $method = 'get', ?array $data = null, bool $throwError = true) { if (is_null($source)) { - throw new \Exception('Not implemented yet.'); + throw new \Exception('Source is required for API calls'); } - if ($source->getMorphClass() === \App\Models\GithubApp::class) { - if ($source->is_public) { - $response = Http::github($source->api_url)->$method($endpoint); + + if ($source->getMorphClass() !== GithubApp::class) { + throw new \InvalidArgumentException("Unsupported source type: {$source->getMorphClass()}"); + } + + if ($source->is_public) { + $response = Http::GitHub($source->api_url)->$method($endpoint); + } else { + $token = generateGithubInstallationToken($source); + if ($data && in_array(strtolower($method), ['post', 'patch', 'put'])) { + $response = Http::GitHub($source->api_url, $token)->$method($endpoint, $data); } else { - $github_access_token = generate_github_installation_token($source); - if ($data && ($method === 'post' || $method === 'patch' || $method === 'put')) { - $response = Http::github($source->api_url, $github_access_token)->$method($endpoint, $data); - } else { - $response = Http::github($source->api_url, $github_access_token)->$method($endpoint); - } + $response = Http::GitHub($source->api_url, $token)->$method($endpoint); } } - $json = $response->json(); - if ($response->failed() && $throwError) { - ray($json); - throw new \Exception("Failed to get data from {$source->name} with error:

".$json['message'].'

Rate Limit resets at: '.Carbon::parse((int) $response->header('X-RateLimit-Reset'))->format('Y-m-d H:i:s').'UTC'); + + if (! $response->successful() && $throwError) { + $resetTime = Carbon::parse((int) $response->header('X-RateLimit-Reset'))->format('Y-m-d H:i:s'); + $errorMessage = data_get($response->json(), 'message', 'no error message found'); + $remainingCalls = $response->header('X-RateLimit-Remaining', '0'); + + throw new \Exception( + 'GitHub API call failed:
'. + "Error: {$errorMessage}
". + 'Rate Limit Status:
'. + "- Remaining Calls: {$remainingCalls}
". + "- Reset Time: {$resetTime} UTC" + ); } return [ 'rate_limit_remaining' => $response->header('X-RateLimit-Remaining'), 'rate_limit_reset' => $response->header('X-RateLimit-Reset'), - 'data' => collect($json), + 'data' => collect($response->json()), ]; } -function get_installation_path(GithubApp $source) +function getInstallationPath(GithubApp $source) { $github = GithubApp::where('uuid', $source->uuid)->first(); $name = str(Str::kebab($github->name)); @@ -90,7 +121,8 @@ function get_installation_path(GithubApp $source) return "$github->html_url/$installation_path/$name/installations/new"; } -function get_permissions_path(GithubApp $source) + +function getPermissionsPath(GithubApp $source) { $github = GithubApp::where('uuid', $source->uuid)->first(); $name = str(Str::kebab($github->name)); diff --git a/composer.json b/composer.json index 055059a56..f01913b5f 100644 --- a/composer.json +++ b/composer.json @@ -47,6 +47,7 @@ "spatie/laravel-ray": "^1.37", "spatie/laravel-schemaless-attributes": "^2.4", "spatie/url": "^2.2", + "stevebauman/purify": "^6.2", "stripe/stripe-php": "^16.2.0", "symfony/yaml": "^7.1.6", "visus/cuid2": "^4.1.0", diff --git a/composer.lock b/composer.lock index 457ec2066..2d7cf461d 100644 --- a/composer.lock +++ b/composer.lock @@ -1883,6 +1883,67 @@ ], "time": "2024-12-27T00:36:43+00:00" }, + { + "name": "ezyang/htmlpurifier", + "version": "v4.18.0", + "source": { + "type": "git", + "url": "https://github.com/ezyang/htmlpurifier.git", + "reference": "cb56001e54359df7ae76dc522d08845dc741621b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/ezyang/htmlpurifier/zipball/cb56001e54359df7ae76dc522d08845dc741621b", + "reference": "cb56001e54359df7ae76dc522d08845dc741621b", + "shasum": "" + }, + "require": { + "php": "~5.6.0 || ~7.0.0 || ~7.1.0 || ~7.2.0 || ~7.3.0 || ~7.4.0 || ~8.0.0 || ~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0" + }, + "require-dev": { + "cerdic/css-tidy": "^1.7 || ^2.0", + "simpletest/simpletest": "dev-master" + }, + "suggest": { + "cerdic/css-tidy": "If you want to use the filter 'Filter.ExtractStyleBlocks'.", + "ext-bcmath": "Used for unit conversion and imagecrash protection", + "ext-iconv": "Converts text to and from non-UTF-8 encodings", + "ext-tidy": "Used for pretty-printing HTML" + }, + "type": "library", + "autoload": { + "files": [ + "library/HTMLPurifier.composer.php" + ], + "psr-0": { + "HTMLPurifier": "library/" + }, + "exclude-from-classmap": [ + "/library/HTMLPurifier/Language/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "LGPL-2.1-or-later" + ], + "authors": [ + { + "name": "Edward Z. Yang", + "email": "admin@htmlpurifier.org", + "homepage": "http://ezyang.com" + } + ], + "description": "Standards compliant HTML filter written in PHP", + "homepage": "http://htmlpurifier.org/", + "keywords": [ + "html" + ], + "support": { + "issues": "https://github.com/ezyang/htmlpurifier/issues", + "source": "https://github.com/ezyang/htmlpurifier/tree/v4.18.0" + }, + "time": "2024-11-01T03:51:45+00:00" + }, { "name": "firebase/php-jwt", "version": "v6.10.2", @@ -8330,6 +8391,72 @@ ], "time": "2024-03-08T11:35:19+00:00" }, + { + "name": "stevebauman/purify", + "version": "v6.2.2", + "source": { + "type": "git", + "url": "https://github.com/stevebauman/purify.git", + "reference": "a449299a3d5f5f8ef177e626721b3f69143890a4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/stevebauman/purify/zipball/a449299a3d5f5f8ef177e626721b3f69143890a4", + "reference": "a449299a3d5f5f8ef177e626721b3f69143890a4", + "shasum": "" + }, + "require": { + "ezyang/htmlpurifier": "^4.17", + "illuminate/contracts": "^7.0|^8.0|^9.0|^10.0|^11.0", + "illuminate/support": "^7.0|^8.0|^9.0|^10.0|^11.0", + "php": ">=7.4" + }, + "require-dev": { + "orchestra/testbench": "^5.0|^6.0|^7.0|^8.0|^9.0", + "phpunit/phpunit": "^8.0|^9.0|^10.0" + }, + "type": "library", + "extra": { + "laravel": { + "aliases": { + "Purify": "Stevebauman\\Purify\\Facades\\Purify" + }, + "providers": [ + "Stevebauman\\Purify\\PurifyServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "Stevebauman\\Purify\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Steve Bauman", + "email": "steven_bauman@outlook.com" + } + ], + "description": "An HTML Purifier / Sanitizer for Laravel", + "keywords": [ + "Purifier", + "clean", + "cleaner", + "html", + "laravel", + "purification", + "purify" + ], + "support": { + "issues": "https://github.com/stevebauman/purify/issues", + "source": "https://github.com/stevebauman/purify/tree/v6.2.2" + }, + "time": "2024-09-24T12:27:10+00:00" + }, { "name": "stripe/stripe-php", "version": "v16.4.0", diff --git a/config/purify.php b/config/purify.php new file mode 100644 index 000000000..66dbbb568 --- /dev/null +++ b/config/purify.php @@ -0,0 +1,115 @@ + 'default', + + /* + |-------------------------------------------------------------------------- + | Config sets + |-------------------------------------------------------------------------- + | + | Here you may configure various sets of configuration for differentiated use of HTMLPurifier. + | A specific set of configuration can be applied by calling the "config($name)" method on + | a Purify instance. Feel free to add/remove/customize these attributes as you wish. + | + | Documentation: http://htmlpurifier.org/live/configdoc/plain.html + | + | Core.Encoding The encoding to convert input to. + | HTML.Doctype Doctype to use during filtering. + | HTML.Allowed The allowed HTML Elements with their allowed attributes. + | HTML.ForbiddenElements The forbidden HTML elements. Elements that are listed in this + | string will be removed, however their content will remain. + | CSS.AllowedProperties The Allowed CSS properties. + | AutoFormat.AutoParagraph Newlines are converted in to paragraphs whenever possible. + | AutoFormat.RemoveEmpty Remove empty elements that contribute no semantic information to the document. + | + */ + + 'configs' => [ + + 'default' => [ + 'Core.Encoding' => 'utf-8', + 'HTML.Doctype' => 'HTML 4.01 Transitional', + 'HTML.Allowed' => 'h1,h2,h3,h4,h5,h6,b,u,strong,i,em,s,del,a[href|title],ul,ol,li,p[style],br,span,img[width|height|alt|src],blockquote', + 'HTML.ForbiddenElements' => '', + 'CSS.AllowedProperties' => 'font,font-size,font-weight,font-style,font-family,text-decoration,padding-left,color,background-color,text-align', + 'AutoFormat.AutoParagraph' => false, + 'AutoFormat.RemoveEmpty' => false, + ], + + ], + + /* + |-------------------------------------------------------------------------- + | HTMLPurifier definitions + |-------------------------------------------------------------------------- + | + | Here you may specify a class that augments the HTML definitions used by + | HTMLPurifier. Additional HTML5 definitions are provided out of the box. + | When specifying a custom class, make sure it implements the interface: + | + | \Stevebauman\Purify\Definitions\Definition + | + | Note that these definitions are applied to every Purifier instance. + | + | Documentation: http://htmlpurifier.org/docs/enduser-customize.html + | + */ + + 'definitions' => Html5Definition::class, + + /* + |-------------------------------------------------------------------------- + | HTMLPurifier CSS definitions + |-------------------------------------------------------------------------- + | + | Here you may specify a class that augments the CSS definitions used by + | HTMLPurifier. When specifying a custom class, make sure it implements + | the interface: + | + | \Stevebauman\Purify\Definitions\CssDefinition + | + | Note that these definitions are applied to every Purifier instance. + | + | CSS should be extending $definition->info['css-attribute'] = values + | See HTMLPurifier_CSSDefinition for further explanation + | + */ + + 'css-definitions' => null, + + /* + |-------------------------------------------------------------------------- + | Serializer + |-------------------------------------------------------------------------- + | + | The storage implementation where HTMLPurifier can store its serializer files. + | If the filesystem cache is in use, the path must be writable through the + | storage disk by the web server, otherwise an exception will be thrown. + | + */ + + 'serializer' => [ + 'driver' => env('CACHE_STORE', env('CACHE_DRIVER', 'file')), + 'cache' => \Stevebauman\Purify\Cache\CacheDefinitionCache::class, + ], + + // 'serializer' => [ + // 'disk' => env('FILESYSTEM_DISK', 'local'), + // 'path' => 'purify', + // 'cache' => \Stevebauman\Purify\Cache\FilesystemDefinitionCache::class, + // ], + +]; diff --git a/resources/views/errors/500.blade.php b/resources/views/errors/500.blade.php index 9e556cbdf..cc672a324 100644 --- a/resources/views/errors/500.blade.php +++ b/resources/views/errors/500.blade.php @@ -1,20 +1,20 @@ @extends('layouts.base') -
-
-

500

-

Wait, this is not cool...

-

There has been an error, we are working on it. -

+
+
+

500

+

Wait, this is not cool...

+

There has been an error with the following error message:

@if ($exception->getMessage() !== '') - Error: {{ $exception->getMessage() }} - +
+ {!! Purify::clean($exception->getMessage()) !!} +
@endif -