mirror of
https://github.com/projectsend/projectsend.git
synced 2026-10-08 22:31:06 +00:00
1b3f014f5f
The sign-in, password reset, setup and share-link pages drew a custom logo 48 pixels tall, so a square logo was a 48x48 stamp. It now gets a box 80 pixels tall and up to 240 wide, so a square logo reads and a wide one still fits a phone. ProjectSend's own logo is unchanged. The site name appeared nowhere on those pages except as the image's alt text. A new switch on Branding → Logo prints it under the logo. It is off by default, because many logos already say the name and would then say it twice. It is stored on the branding row, gated like the rest of the screen (staff, edit_settings, branding.customize), and a withheld capability takes it off the pages along with the logo. The branding API's logo endpoint reports it as show_site_name. The Logo tab now says what size to use and which formats are accepted. SVG stays refused: it can carry script, and the logo is served from the site's own origin. The crop tool the issue also asks for is not part of this. Reported by @jiits (#1798)
273 lines
12 KiB
PHP
273 lines
12 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
use App\Models\User;
|
|
use Illuminate\Http\UploadedFile;
|
|
use Illuminate\Routing\Route as RouteInstance;
|
|
use Illuminate\Support\Facades\Route;
|
|
use Inertia\Testing\AssertableInertia;
|
|
use Illuminate\Support\Facades\Storage;
|
|
use Inertia\Inertia;
|
|
use App\Modules\Platform\Branding\Models\BrandingSetting;
|
|
|
|
beforeEach(function () {
|
|
Storage::fake('public');
|
|
$this->actingAs(staffWithPermissions(['edit_settings']));
|
|
});
|
|
|
|
test('a staff member can upload a logo, and it is reflected in the shared branding prop', function () {
|
|
$this->get(route('branding.edit'))->assertOk();
|
|
|
|
$this->post(route('branding.store'), [
|
|
'logo' => UploadedFile::fake()->image('logo.png', 200, 80),
|
|
])->assertRedirect();
|
|
|
|
$setting = BrandingSetting::query()->sole();
|
|
expect($setting->logo_path)->not->toBeNull();
|
|
Storage::disk('public')->assertExists($setting->logo_path);
|
|
});
|
|
|
|
test('uploading a new logo replaces and deletes the previous one', function () {
|
|
$this->post(route('branding.store'), ['logo' => UploadedFile::fake()->image('first.png')]);
|
|
$firstPath = BrandingSetting::query()->sole()->logo_path;
|
|
|
|
$this->post(route('branding.store'), ['logo' => UploadedFile::fake()->image('second.png')]);
|
|
$secondPath = BrandingSetting::query()->sole()->logo_path;
|
|
|
|
expect($secondPath)->not->toBe($firstPath);
|
|
Storage::disk('public')->assertMissing($firstPath);
|
|
Storage::disk('public')->assertExists($secondPath);
|
|
});
|
|
|
|
test('a non-image upload is rejected', function () {
|
|
$this->post(route('branding.store'), [
|
|
'logo' => UploadedFile::fake()->create('not-an-image.pdf', 10, 'application/pdf'),
|
|
])->assertSessionHasErrors('logo');
|
|
});
|
|
|
|
test('removing the logo deletes the file and clears the setting', function () {
|
|
$this->post(route('branding.store'), ['logo' => UploadedFile::fake()->image('logo.png')]);
|
|
$path = BrandingSetting::query()->sole()->logo_path;
|
|
|
|
$this->delete(route('branding.destroy'))->assertRedirect();
|
|
|
|
expect(BrandingSetting::query()->sole()->logo_path)->toBeNull();
|
|
Storage::disk('public')->assertMissing($path);
|
|
});
|
|
|
|
test('a client cannot reach the branding screen', function () {
|
|
// The route group carries `staff`, so this is refused before any
|
|
// permission is consulted: a client account holding edit_settings
|
|
// somehow would still not be looking at system settings.
|
|
$this->actingAs(User::factory()->client()->create());
|
|
|
|
// Redirected rather than 403'd: core's `staff` middleware sends a
|
|
// client to their own portal rather than telling them a staff screen
|
|
// exists. The package's isolated harness had no portal to send them
|
|
// to, which is why this read as a 403 there.
|
|
$this->get(route('branding.edit'))->assertRedirect();
|
|
});
|
|
|
|
test('the route 404s when the capability has been taken away', function () {
|
|
// How a hosted plan withholds branding: the key is subtracted from
|
|
// the instance's environment and the screen stops existing. The
|
|
// instance refusing is the enforcement — a portal hiding its own
|
|
// button is presentation.
|
|
config(['projectsend.capabilities_disabled' => 'branding.customize']);
|
|
|
|
$this->get(route('branding.edit'))->assertNotFound();
|
|
});
|
|
|
|
// The row can outlive the capability: an installation moved to the
|
|
// community edition, or a backup restored into one. The screen that would
|
|
// take the logo off is 404 there, so a share that still answered would put
|
|
// a logo on every page with no way to remove it.
|
|
test('the shared logo goes away with the capability, whatever the row says', function () {
|
|
$this->post(route('branding.store'), ['logo' => UploadedFile::fake()->image('logo.png')]);
|
|
|
|
expect(BrandingSetting::query()->sole()->logo_path)->not->toBeNull();
|
|
|
|
$shared = fn (): ?string => value(Inertia::getShared('branding'))['logo_url'];
|
|
|
|
expect($shared())->not->toBeNull();
|
|
|
|
// Taken away the way a hosted plan takes it away. The row is left
|
|
// exactly as it was: a downgrade is usually an expired card rather
|
|
// than a decision, and deleting somebody's branding over a billing
|
|
// event is a loss they would find weeks later with no way to know
|
|
// what it used to be. Hiding reverses; deleting does not.
|
|
config(['projectsend.capabilities_disabled' => 'branding.customize']);
|
|
forgetRequestState();
|
|
|
|
expect($shared())->toBeNull()
|
|
->and(BrandingSetting::query()->sole()->logo_path)->not->toBeNull();
|
|
});
|
|
|
|
// The `image` rule only inspects sniffed content, so a real GIF named
|
|
// ".html" passes it. This disk is web-served (public/storage is symlinked
|
|
// into the document root), so honouring the uploaded filename's extension
|
|
// would store — and serve back — script on the app's own origin.
|
|
//
|
|
// Built as a real UploadedFile rather than UploadedFile::fake(): the fake
|
|
// derives its mime type from the *name*, so it cannot express the very
|
|
// split this guards against (content says GIF, filename says HTML).
|
|
test('the stored extension comes from the content, never from the uploaded filename', function () {
|
|
$temp = tempnam(sys_get_temp_dir(), 'polyglot');
|
|
file_put_contents($temp, "GIF89a=1;\n<html><body><script>alert(document.domain)</script></body></html>");
|
|
|
|
$polyglot = new UploadedFile($temp, 'logo.html', 'image/gif', null, true);
|
|
|
|
// Precondition: this really does pass validation — the fix is about
|
|
// what happens next, not about rejecting the upload.
|
|
expect($polyglot->getClientOriginalExtension())->toBe('html')
|
|
->and($polyglot->guessExtension())->toBe('gif');
|
|
|
|
$this->post(route('branding.store'), ['logo' => $polyglot])->assertRedirect();
|
|
|
|
$path = BrandingSetting::query()->sole()->logo_path;
|
|
|
|
expect($path)->not->toBeNull()
|
|
->and($path)->not->toEndWith('.html')
|
|
->and(pathinfo($path, PATHINFO_EXTENSION))->toBe('gif');
|
|
|
|
Storage::disk('public')->assertExists($path);
|
|
|
|
@unlink($temp);
|
|
});
|
|
|
|
// The logo is a system setting, so it takes the same permission as every
|
|
// other settings surface. `staff` alone previously let any staff role —
|
|
// an Uploader, a Client Manager — replace or delete it.
|
|
test('a staff member without edit_settings cannot see or change the logo', function () {
|
|
$this->actingAs(staffWithPermissions([]));
|
|
|
|
$this->get(route('branding.edit'))->assertForbidden();
|
|
$this->post(route('branding.store'), ['logo' => UploadedFile::fake()->image('logo.png')])->assertForbidden();
|
|
$this->delete(route('branding.destroy'))->assertForbidden();
|
|
|
|
expect(BrandingSetting::query()->count())->toBe(0);
|
|
});
|
|
|
|
/*
|
|
|--------------------------------------------------------------------------
|
|
| The half that did not move
|
|
|--------------------------------------------------------------------------
|
|
|
|
|
| Hiding "Powered by ProjectSend" stayed in cloud-modules. This screen
|
|
| renders the switch, and nothing here can save it: the route that does is
|
|
| registered by the package, and an installation without the package has
|
|
| the column and no code able to act on it.
|
|
*/
|
|
|
|
test('the screen carries the attribution value it cannot change', function () {
|
|
// Written directly, because core has no way to set it — which is the
|
|
// point. The value still has to reach the page, or a hosted customer
|
|
// would open the tab and find the switch always off.
|
|
BrandingSetting::current()->forceFill(['hide_attribution' => true])->save();
|
|
|
|
$this->get(route('branding.edit'))
|
|
->assertOk()
|
|
->assertInertia(fn (AssertableInertia $page) => $page->where('hide_attribution', true));
|
|
});
|
|
|
|
test('core has no route that can change it', function () {
|
|
// The gate is the absence of the code, not a capability check. If this
|
|
// ever passes, the white-label feature has quietly become free.
|
|
expect(collect(Route::getRoutes()->getRoutes())
|
|
->contains(fn (RouteInstance $route): bool => str_contains($route->uri(), 'branding/attribution')))
|
|
->toBeFalse();
|
|
});
|
|
|
|
// The sign-in screens, requested in #1777. One layout serves all of them —
|
|
// login, registration, the reset pair, the two-factor challenge, first-run
|
|
// setup and the page a share link opens — so what is asserted here is that
|
|
// the prop reaches a page nobody has signed in to see, which is the part
|
|
// the layout could not do for itself.
|
|
test('a guest at the sign-in screen is given the branding logo', function () {
|
|
$this->post(route('branding.store'), ['logo' => UploadedFile::fake()->image('logo.png')]);
|
|
|
|
$logoUrl = BrandingSetting::query()->sole()->logoUrl();
|
|
|
|
auth()->logout();
|
|
|
|
$this->get(route('login'))->assertInertia(
|
|
fn (AssertableInertia $page) => $page->component('auth/login')->where('branding.logo_url', $logoUrl),
|
|
);
|
|
});
|
|
|
|
test('the sign-in screen falls back to the product logo when nothing was uploaded', function () {
|
|
auth()->logout();
|
|
|
|
$this->get(route('login'))->assertInertia(
|
|
fn (AssertableInertia $page) => $page->component('auth/login')->where('branding.logo_url', null),
|
|
);
|
|
});
|
|
|
|
test('a withheld capability takes the logo off the sign-in screen too', function () {
|
|
// The screen that would remove it 404s without the capability, so a
|
|
// login page still wearing somebody's logo would have no way back.
|
|
$this->post(route('branding.store'), ['logo' => UploadedFile::fake()->image('logo.png')]);
|
|
|
|
config(['projectsend.capabilities_disabled' => 'branding.customize']);
|
|
forgetRequestState();
|
|
auth()->logout();
|
|
|
|
$this->get(route('login'))->assertInertia(
|
|
fn (AssertableInertia $page) => $page->where('branding.logo_url', null),
|
|
);
|
|
});
|
|
|
|
// Whether the sign-in and download pages print the site name under the
|
|
// logo (#1798). Opt-in, because many logos already say the name.
|
|
test('the site name is off by default, and turning it on reaches the sign-in screen', function () {
|
|
$this->get(route('branding.edit'))->assertInertia(fn (AssertableInertia $page) => $page->where('show_site_name', false));
|
|
|
|
auth()->logout();
|
|
$this->get(route('login'))->assertInertia(fn (AssertableInertia $page) => $page->where('branding.show_site_name', false));
|
|
|
|
$this->actingAs(staffWithPermissions(['edit_settings']));
|
|
$this->patch(route('branding.site-name.update'), ['show_site_name' => true])->assertRedirect();
|
|
|
|
expect(BrandingSetting::query()->sole()->show_site_name)->toBeTrue();
|
|
|
|
auth()->logout();
|
|
$this->get(route('login'))->assertInertia(fn (AssertableInertia $page) => $page->where('branding.show_site_name', true));
|
|
});
|
|
|
|
test('the site-name switch needs edit_settings and the branding capability', function () {
|
|
$this->actingAs(staffWithPermissions([]));
|
|
$this->patch(route('branding.site-name.update'), ['show_site_name' => true])->assertForbidden();
|
|
|
|
$this->actingAs(staffWithPermissions(['edit_settings']));
|
|
config(['projectsend.capabilities_disabled' => 'branding.customize']);
|
|
$this->patch(route('branding.site-name.update'), ['show_site_name' => true])->assertNotFound();
|
|
|
|
expect(BrandingSetting::query()->count())->toBe(0);
|
|
});
|
|
|
|
test('a withheld capability takes the site name off the sign-in screen too', function () {
|
|
$this->patch(route('branding.site-name.update'), ['show_site_name' => true]);
|
|
|
|
config(['projectsend.capabilities_disabled' => 'branding.customize']);
|
|
forgetRequestState();
|
|
auth()->logout();
|
|
|
|
$this->get(route('login'))->assertInertia(fn (AssertableInertia $page) => $page->where('branding.show_site_name', false));
|
|
});
|
|
|
|
test('the API reports the site-name choice beside the logo', function () {
|
|
$staff = staffWithPermissions(['edit_settings']);
|
|
$token = $staff->createToken('Integration', ['edit_settings'])->plainTextToken;
|
|
auth()->logout();
|
|
|
|
$this->withToken($token)->getJson('/api/v1/modules/branding/logo')
|
|
->assertOk()
|
|
->assertJsonPath('data.show_site_name', false);
|
|
|
|
BrandingSetting::current()->update(['show_site_name' => true]);
|
|
|
|
$this->withToken($token)->getJson('/api/v1/modules/branding/logo')
|
|
->assertJsonPath('data.show_site_name', true);
|
|
});
|