Skip to content

Commit b180316

Browse files
Add project logo update API route (#3439)
Our infrastructure doesn't support the multi-part requests necessary to handle uploaded files via GraphQL. This commit adds a special `/projects/<id>/logo` POST route which can be used to set the logo for a project.
1 parent e0a2079 commit b180316

File tree

6 files changed

+203
-0
lines changed

6 files changed

+203
-0
lines changed
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
<?php
2+
3+
namespace App\Http\Controllers;
4+
5+
use App\Models\Project;
6+
use App\Services\ProjectService;
7+
use Exception;
8+
use Illuminate\Http\RedirectResponse;
9+
use Illuminate\Http\Request;
10+
use Illuminate\Support\Facades\Gate;
11+
use Illuminate\Validation\ValidationException;
12+
13+
final class UpdateProjectLogoController extends AbstractController
14+
{
15+
/**
16+
* @throws Exception
17+
* @throws ValidationException
18+
*/
19+
public function __invoke(Request $request, int $project_id): RedirectResponse
20+
{
21+
$project = Project::find($project_id);
22+
Gate::authorize('update', $project);
23+
24+
$request->validate([
25+
'logo' => 'required|image',
26+
]);
27+
28+
if ($project === null) {
29+
throw new Exception();
30+
}
31+
32+
ProjectService::setLogo($project, $request->file('logo'));
33+
34+
return response()->redirectTo(url("/projects/{$project->id}/settings"));
35+
}
36+
}

app/Services/ProjectService.php

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,11 @@
88
use App\Models\Project;
99
use App\Models\SubProject;
1010
use App\Models\SubProjectGroup;
11+
use CDash\Model\Image;
12+
use Exception;
13+
use Illuminate\Contracts\Filesystem\FileNotFoundException;
1114
use Illuminate\Database\Eloquent\Collection;
15+
use Illuminate\Http\UploadedFile;
1216
use Illuminate\Support\Carbon;
1317
use Illuminate\Support\Facades\DB;
1418

@@ -28,6 +32,28 @@ public static function create(array $attributes): Project
2832
return $project;
2933
}
3034

35+
/**
36+
* TODO: Rewrite this to use a dedicated project logo workflow instead of sharing the image table.
37+
*
38+
* @throws FileNotFoundException
39+
*/
40+
public static function setLogo(Project $project, UploadedFile $file): void
41+
{
42+
$contents = $file->get();
43+
if ($contents === false) {
44+
throw new Exception();
45+
}
46+
47+
$image = new Image();
48+
$image->Data = $contents;
49+
$image->Checksum = crc32($contents);
50+
$image->Extension = $file->extension();
51+
$image->Save(true);
52+
53+
$project->imageid = (int) $image->Id;
54+
$project->save();
55+
}
56+
3157
/** This method is meant to be temporary, eventually only being called in create() */
3258
public static function initializeBuildGroups(Project $project): void
3359
{

app/cdash/tests/CMakeLists.txt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -187,6 +187,8 @@ add_feature_test_in_transaction(/Feature/GraphQL/BuildTypeTest)
187187

188188
add_feature_test_in_transaction(/Feature/ProjectInvitationAcceptanceTest)
189189

190+
add_feature_test_in_transaction(/Feature/UpdateProjectLogoTest)
191+
190192
add_feature_test_in_transaction(/Feature/GraphQL/FilterTest)
191193

192194
add_feature_test_in_transaction(/Feature/GraphQL/QueryTypeTest)

phpstan-baseline.neon

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2472,6 +2472,12 @@ parameters:
24722472
count: 1
24732473
path: app/Http/Controllers/TimelineController.php
24742474

2475+
-
2476+
rawMessage: 'Parameter #2 $file of static method App\Services\ProjectService::setLogo() expects Illuminate\Http\UploadedFile, (array<int, Illuminate\Http\UploadedFile>|Illuminate\Http\UploadedFile|null) given.'
2477+
identifier: argument.type
2478+
count: 1
2479+
path: app/Http/Controllers/UpdateProjectLogoController.php
2480+
24752481
-
24762482
rawMessage: Access to an undefined property App\Models\Project::$pivot.
24772483
identifier: property.notFound

routes/web.php

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
use App\Http\Controllers\CreateProjectController;
1616
use App\Http\Controllers\GlobalInvitationController;
1717
use App\Http\Controllers\ProjectInvitationController;
18+
use App\Http\Controllers\UpdateProjectLogoController;
1819
use App\Models\DynamicAnalysis;
1920
use App\Models\Project;
2021
use App\Models\Test;
@@ -167,6 +168,9 @@
167168
return redirect("/builds/{$buildid}/errors", 301);
168169
});
169170

171+
Route::post('/projects/{project_id}/logo', UpdateProjectLogoController::class)
172+
->whereNumber('project_id');
173+
170174
Route::get('/projects/{id}/edit', 'EditProjectController@edit')
171175
->whereNumber('id');
172176
Route::permanentRedirect('/project/{id}/edit', url('/projects/{id}/edit'));
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
<?php
2+
3+
namespace Tests\Feature;
4+
5+
use App\Models\Project;
6+
use Illuminate\Foundation\Testing\DatabaseTransactions;
7+
use Illuminate\Http\UploadedFile;
8+
use Illuminate\Support\Facades\Storage;
9+
use Tests\TestCase;
10+
use Tests\Traits\CreatesProjects;
11+
use Tests\Traits\CreatesUsers;
12+
13+
class UpdateProjectLogoTest extends TestCase
14+
{
15+
use CreatesProjects;
16+
use CreatesUsers;
17+
use DatabaseTransactions;
18+
19+
public function testCannotUploadToNonExistentProject(): void
20+
{
21+
$user = $this->makeAdminUser();
22+
$response = $this->actingAs($user)->postJson('/projects/123456789/logo', [
23+
'logo' => UploadedFile::fake()->image('logo.jpg'),
24+
]);
25+
$response->assertForbidden();
26+
}
27+
28+
public function testCannotUseNonIntegerProjectId(): void
29+
{
30+
$user = $this->makeAdminUser();
31+
$response = $this->actingAs($user)->postJson('/projects/abc/logo', [
32+
'logo' => UploadedFile::fake()->image('logo.jpg'),
33+
]);
34+
$response->assertNotFound();
35+
}
36+
37+
public function testCannotUploadAsAnonymousUser(): void
38+
{
39+
$project = $this->makePublicProject();
40+
41+
$response = $this->postJson("/projects/{$project->id}/logo", [
42+
'logo' => UploadedFile::fake()->image('logo.jpg'),
43+
]);
44+
45+
$response->assertForbidden();
46+
self::assertNull($project->fresh()?->logoUrl);
47+
}
48+
49+
public function testCannotUploadAsNormalUser(): void
50+
{
51+
$project = $this->makePublicProject();
52+
$user = $this->makeNormalUser();
53+
54+
$response = $this->actingAs($user)->postJson("/projects/{$project->id}/logo", [
55+
'logo' => UploadedFile::fake()->image('logo.jpg'),
56+
]);
57+
58+
$response->assertForbidden();
59+
self::assertNull($project->fresh()?->logoUrl);
60+
}
61+
62+
public function testCannotUploadAsProjectUser(): void
63+
{
64+
$project = $this->makePublicProject();
65+
$user = $this->makeNormalUser();
66+
$project->users()->attach($user, ['role' => Project::PROJECT_USER]);
67+
68+
$response = $this->actingAs($user)->postJson("/projects/{$project->id}/logo", [
69+
'logo' => UploadedFile::fake()->image('logo.jpg'),
70+
]);
71+
72+
$response->assertForbidden();
73+
self::assertNull($project->fresh()?->logoUrl);
74+
}
75+
76+
public function testCanUploadAsProjectAdmin(): void
77+
{
78+
Storage::fake('public');
79+
$project = $this->makePublicProject();
80+
$user = $this->makeNormalUser();
81+
$project->users()->attach($user, ['role' => Project::PROJECT_ADMIN]);
82+
83+
$response = $this->actingAs($user)->postJson("/projects/{$project->id}/logo", [
84+
'logo' => UploadedFile::fake()->image('logo.jpg'),
85+
]);
86+
87+
$response->assertRedirect(url("/projects/{$project->id}/settings"));
88+
self::assertNotNull($project->fresh()?->logoUrl);
89+
}
90+
91+
public function testCanUploadAsGlobalAdmin(): void
92+
{
93+
$project = $this->makePublicProject();
94+
$user = $this->makeAdminUser();
95+
96+
$response = $this->actingAs($user)->postJson("/projects/{$project->id}/logo", [
97+
'logo' => UploadedFile::fake()->image('logo.jpg'),
98+
]);
99+
100+
$response->assertRedirect(url("/projects/{$project->id}/settings"));
101+
self::assertNotNull($project->fresh()?->logoUrl);
102+
}
103+
104+
public function testCannotUploadNonImage(): void
105+
{
106+
$project = $this->makePublicProject();
107+
$user = $this->makeAdminUser();
108+
109+
$response = $this->actingAs($user)->postJson("/projects/{$project->id}/logo", [
110+
'logo' => UploadedFile::fake()->create('document.pdf'),
111+
]);
112+
113+
$response->assertStatus(422);
114+
self::assertNull($project->fresh()?->logoUrl);
115+
}
116+
117+
public function testCannotUploadSvg(): void
118+
{
119+
$project = $this->makePublicProject();
120+
$user = $this->makeAdminUser();
121+
122+
$response = $this->actingAs($user)->postJson("/projects/{$project->id}/logo", [
123+
'logo' => UploadedFile::fake()->create('logo.svg'),
124+
]);
125+
126+
$response->assertStatus(422);
127+
self::assertNull($project->fresh()?->logoUrl);
128+
}
129+
}

0 commit comments

Comments
 (0)