From 7b735db8aaee886b19a62a1e295eb2fb21aba4c4 Mon Sep 17 00:00:00 2001 From: William Allen Date: Fri, 6 Feb 2026 09:42:07 -0500 Subject: [PATCH] Add project logo update API route Our infrastructure doesn't support the multi-part requests necessary to handle uploaded files via GraphQL. This commit adds a special `/projects//logo` POST route which can be used to set the logo for a project. --- .../UpdateProjectLogoController.php | 36 +++++ app/Services/ProjectService.php | 26 ++++ app/cdash/tests/CMakeLists.txt | 2 + phpstan-baseline.neon | 6 + routes/web.php | 4 + tests/Feature/UpdateProjectLogoTest.php | 129 ++++++++++++++++++ 6 files changed, 203 insertions(+) create mode 100644 app/Http/Controllers/UpdateProjectLogoController.php create mode 100644 tests/Feature/UpdateProjectLogoTest.php diff --git a/app/Http/Controllers/UpdateProjectLogoController.php b/app/Http/Controllers/UpdateProjectLogoController.php new file mode 100644 index 0000000000..71244680be --- /dev/null +++ b/app/Http/Controllers/UpdateProjectLogoController.php @@ -0,0 +1,36 @@ +validate([ + 'logo' => 'required|image', + ]); + + if ($project === null) { + throw new Exception(); + } + + ProjectService::setLogo($project, $request->file('logo')); + + return response()->redirectTo(url("/projects/{$project->id}/settings")); + } +} diff --git a/app/Services/ProjectService.php b/app/Services/ProjectService.php index 18a5a37909..697f68f485 100644 --- a/app/Services/ProjectService.php +++ b/app/Services/ProjectService.php @@ -8,7 +8,11 @@ use App\Models\Project; use App\Models\SubProject; use App\Models\SubProjectGroup; +use CDash\Model\Image; +use Exception; +use Illuminate\Contracts\Filesystem\FileNotFoundException; use Illuminate\Database\Eloquent\Collection; +use Illuminate\Http\UploadedFile; use Illuminate\Support\Carbon; use Illuminate\Support\Facades\DB; @@ -28,6 +32,28 @@ public static function create(array $attributes): Project return $project; } + /** + * TODO: Rewrite this to use a dedicated project logo workflow instead of sharing the image table. + * + * @throws FileNotFoundException + */ + public static function setLogo(Project $project, UploadedFile $file): void + { + $contents = $file->get(); + if ($contents === false) { + throw new Exception(); + } + + $image = new Image(); + $image->Data = $contents; + $image->Checksum = crc32($contents); + $image->Extension = $file->extension(); + $image->Save(true); + + $project->imageid = (int) $image->Id; + $project->save(); + } + /** This method is meant to be temporary, eventually only being called in create() */ public static function initializeBuildGroups(Project $project): void { diff --git a/app/cdash/tests/CMakeLists.txt b/app/cdash/tests/CMakeLists.txt index e328a27465..02f64829e3 100644 --- a/app/cdash/tests/CMakeLists.txt +++ b/app/cdash/tests/CMakeLists.txt @@ -187,6 +187,8 @@ add_feature_test_in_transaction(/Feature/GraphQL/BuildTypeTest) add_feature_test_in_transaction(/Feature/ProjectInvitationAcceptanceTest) +add_feature_test_in_transaction(/Feature/UpdateProjectLogoTest) + add_feature_test_in_transaction(/Feature/GraphQL/FilterTest) add_feature_test_in_transaction(/Feature/GraphQL/QueryTypeTest) diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index ee9faf43eb..f2855c6357 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -2472,6 +2472,12 @@ parameters: count: 1 path: app/Http/Controllers/TimelineController.php + - + rawMessage: 'Parameter #2 $file of static method App\Services\ProjectService::setLogo() expects Illuminate\Http\UploadedFile, (array|Illuminate\Http\UploadedFile|null) given.' + identifier: argument.type + count: 1 + path: app/Http/Controllers/UpdateProjectLogoController.php + - rawMessage: Access to an undefined property App\Models\Project::$pivot. identifier: property.notFound diff --git a/routes/web.php b/routes/web.php index 8c26f9cd6e..3ef7196c0e 100755 --- a/routes/web.php +++ b/routes/web.php @@ -15,6 +15,7 @@ use App\Http\Controllers\CreateProjectController; use App\Http\Controllers\GlobalInvitationController; use App\Http\Controllers\ProjectInvitationController; +use App\Http\Controllers\UpdateProjectLogoController; use App\Models\DynamicAnalysis; use App\Models\Project; use App\Models\Test; @@ -160,6 +161,9 @@ return redirect("/builds/{$buildid}/coverage/{$fileid}", 301); }); +Route::post('/projects/{project_id}/logo', UpdateProjectLogoController::class) + ->whereNumber('project_id'); + Route::get('/projects/{id}/edit', 'EditProjectController@edit') ->whereNumber('id'); Route::permanentRedirect('/project/{id}/edit', url('/projects/{id}/edit')); diff --git a/tests/Feature/UpdateProjectLogoTest.php b/tests/Feature/UpdateProjectLogoTest.php new file mode 100644 index 0000000000..87f1dfda6c --- /dev/null +++ b/tests/Feature/UpdateProjectLogoTest.php @@ -0,0 +1,129 @@ +makeAdminUser(); + $response = $this->actingAs($user)->postJson('/projects/123456789/logo', [ + 'logo' => UploadedFile::fake()->image('logo.jpg'), + ]); + $response->assertForbidden(); + } + + public function testCannotUseNonIntegerProjectId(): void + { + $user = $this->makeAdminUser(); + $response = $this->actingAs($user)->postJson('/projects/abc/logo', [ + 'logo' => UploadedFile::fake()->image('logo.jpg'), + ]); + $response->assertNotFound(); + } + + public function testCannotUploadAsAnonymousUser(): void + { + $project = $this->makePublicProject(); + + $response = $this->postJson("/projects/{$project->id}/logo", [ + 'logo' => UploadedFile::fake()->image('logo.jpg'), + ]); + + $response->assertForbidden(); + self::assertNull($project->fresh()?->logoUrl); + } + + public function testCannotUploadAsNormalUser(): void + { + $project = $this->makePublicProject(); + $user = $this->makeNormalUser(); + + $response = $this->actingAs($user)->postJson("/projects/{$project->id}/logo", [ + 'logo' => UploadedFile::fake()->image('logo.jpg'), + ]); + + $response->assertForbidden(); + self::assertNull($project->fresh()?->logoUrl); + } + + public function testCannotUploadAsProjectUser(): void + { + $project = $this->makePublicProject(); + $user = $this->makeNormalUser(); + $project->users()->attach($user, ['role' => Project::PROJECT_USER]); + + $response = $this->actingAs($user)->postJson("/projects/{$project->id}/logo", [ + 'logo' => UploadedFile::fake()->image('logo.jpg'), + ]); + + $response->assertForbidden(); + self::assertNull($project->fresh()?->logoUrl); + } + + public function testCanUploadAsProjectAdmin(): void + { + Storage::fake('public'); + $project = $this->makePublicProject(); + $user = $this->makeNormalUser(); + $project->users()->attach($user, ['role' => Project::PROJECT_ADMIN]); + + $response = $this->actingAs($user)->postJson("/projects/{$project->id}/logo", [ + 'logo' => UploadedFile::fake()->image('logo.jpg'), + ]); + + $response->assertRedirect(url("/projects/{$project->id}/settings")); + self::assertNotNull($project->fresh()?->logoUrl); + } + + public function testCanUploadAsGlobalAdmin(): void + { + $project = $this->makePublicProject(); + $user = $this->makeAdminUser(); + + $response = $this->actingAs($user)->postJson("/projects/{$project->id}/logo", [ + 'logo' => UploadedFile::fake()->image('logo.jpg'), + ]); + + $response->assertRedirect(url("/projects/{$project->id}/settings")); + self::assertNotNull($project->fresh()?->logoUrl); + } + + public function testCannotUploadNonImage(): void + { + $project = $this->makePublicProject(); + $user = $this->makeAdminUser(); + + $response = $this->actingAs($user)->postJson("/projects/{$project->id}/logo", [ + 'logo' => UploadedFile::fake()->create('document.pdf'), + ]); + + $response->assertStatus(422); + self::assertNull($project->fresh()?->logoUrl); + } + + public function testCannotUploadSvg(): void + { + $project = $this->makePublicProject(); + $user = $this->makeAdminUser(); + + $response = $this->actingAs($user)->postJson("/projects/{$project->id}/logo", [ + 'logo' => UploadedFile::fake()->create('logo.svg'), + ]); + + $response->assertStatus(422); + self::assertNull($project->fresh()?->logoUrl); + } +}