diff --git a/app/Modules/Platform/Branding/Http/Controllers/BrandingController.php b/app/Modules/Platform/Branding/Http/Controllers/BrandingController.php index bf4ab73e..9d5c6083 100644 --- a/app/Modules/Platform/Branding/Http/Controllers/BrandingController.php +++ b/app/Modules/Platform/Branding/Http/Controllers/BrandingController.php @@ -15,6 +15,7 @@ use Illuminate\Support\Str; use Illuminate\Validation\Rule; use Inertia\Inertia; use Inertia\Response; +use App\Modules\Platform\Branding\LogoCropper; use App\Modules\Platform\Branding\Models\BrandingSetting; use App\Modules\Platform\Branding\Watermark\WatermarkPosition; use App\Modules\Platform\Branding\Watermark\WatermarkSample; @@ -41,6 +42,12 @@ class BrandingController extends Controller return Inertia::render('branding/edit', [ 'logo_url' => $setting->logoUrl(), + // The upload the logo was cut from, and the box it was cut + // with, so the cropper opens on the whole picture with the + // last crop already drawn. + 'logo_source_url' => $setting->logoSourceUrl(), + 'logo_crop' => $setting->logo_crop, + 'logo_cropped' => $setting->logoIsCropped(), // Read, never written here. Hiding attribution is the // white-label half and stays a hosted feature: the switch is // rendered only where Capability::AttributionHide is held, and @@ -60,7 +67,7 @@ class BrandingController extends Controller ]); } - public function store(Request $request): RedirectResponse + public function store(Request $request, LogoCropper $cropper): RedirectResponse { $validated = $request->validate([ 'logo' => ['required', 'image', 'max:2048'], @@ -70,16 +77,64 @@ class BrandingController extends Controller $upload = $validated['logo']; $setting = BrandingSetting::current(); + $previous = $cropper->files($setting); - if ($setting->logo_path !== null) { - Storage::disk('public')->delete($setting->logo_path); - } + // A new upload starts uncropped: the old crop's box describes a + // different picture. + $setting->update([ + 'logo_path' => $this->storeImage($upload), + 'logo_original_path' => null, + 'logo_crop' => null, + ]); - $setting->update(['logo_path' => $this->storeImage($upload)]); + Storage::disk('public')->delete($previous); return back()->with('success', __('Logo updated.')); } + /** + * Cut the logo down to a box drawn on the uploaded image. Optional: an + * uploaded logo is used whole until somebody crops it. + */ + public function cropLogo(Request $request, LogoCropper $cropper): RedirectResponse + { + $validated = $request->validate([ + 'x' => ['required', 'integer', 'min:0'], + 'y' => ['required', 'integer', 'min:0'], + 'width' => ['required', 'integer', 'min:1'], + 'height' => ['required', 'integer', 'min:1'], + ]); + + $setting = BrandingSetting::query()->first(); + + if ($setting === null) { + return back()->withErrors(['logo' => __('Upload a logo before cropping it.')]); + } + + $cropper->crop($setting, [ + 'x' => (int) $validated['x'], + 'y' => (int) $validated['y'], + 'width' => (int) $validated['width'], + 'height' => (int) $validated['height'], + ]); + + return back()->with('success', __('Logo cropped.')); + } + + /** + * Go back to the logo exactly as it was uploaded. + */ + public function restoreLogo(LogoCropper $cropper): RedirectResponse + { + $setting = BrandingSetting::query()->first(); + + if ($setting !== null) { + $cropper->restore($setting); + } + + return back()->with('success', __('Original logo restored.')); + } + /** * Whether the sign-in and download pages print the site name under the * logo. Its own action rather than a field on the logo upload, because @@ -97,13 +152,14 @@ class BrandingController extends Controller return back(); } - public function destroy(): RedirectResponse + public function destroy(LogoCropper $cropper): RedirectResponse { $setting = BrandingSetting::query()->first(); - if ($setting?->logo_path !== null) { - Storage::disk('public')->delete($setting->logo_path); - $setting->update(['logo_path' => null]); + if ($setting !== null && $setting->logo_path !== null) { + $files = $cropper->files($setting); + $setting->update(['logo_path' => null, 'logo_original_path' => null, 'logo_crop' => null]); + Storage::disk('public')->delete($files); } return back()->with('success', __('Logo removed.')); diff --git a/app/Modules/Platform/Branding/LogoCropper.php b/app/Modules/Platform/Branding/LogoCropper.php new file mode 100644 index 00000000..ccf3c185 --- /dev/null +++ b/app/Modules/Platform/Branding/LogoCropper.php @@ -0,0 +1,120 @@ +logoSourcePath(); + $disk = Storage::disk('public'); + + if ($source === null || ! $disk->exists($source)) { + throw ValidationException::withMessages(['logo' => __('Upload a logo before cropping it.')]); + } + + $absolute = $disk->path($source); + $size = @getimagesize($absolute); + + if ($size === false) { + throw ValidationException::withMessages(['logo' => __('This logo cannot be cropped. Upload it again.')]); + } + + [$width, $height] = $size; + + if ($width * $height > self::MAX_PIXELS) { + throw ValidationException::withMessages(['logo' => __('This image is too large to crop. Upload a smaller one.')]); + } + + if ($box['x'] + $box['width'] > $width || $box['y'] + $box['height'] > $height) { + throw ValidationException::withMessages(['width' => __('The crop must stay inside the image.')]); + } + + // The whole picture is the upload itself: no second copy of it. + if ($box['x'] === 0 && $box['y'] === 0 && $box['width'] === $width && $box['height'] === $height) { + $this->restore($setting); + + return; + } + + // Same extension as the upload, which took it from the content + // when it was stored (see BrandingController::storeImage). + $cropped = 'branding/'.Str::uuid().'.'.pathinfo($source, PATHINFO_EXTENSION); + + (new SimpleImage($absolute)) + ->crop($box['x'], $box['y'], $box['x'] + $box['width'], $box['y'] + $box['height']) + ->toFile($disk->path($cropped), $size['mime']); + + $previous = $setting->logoIsCropped() ? $setting->logo_path : null; + + $setting->update([ + 'logo_original_path' => $source, + 'logo_path' => $cropped, + 'logo_crop' => $box, + ]); + + if ($previous !== null) { + $disk->delete($previous); + } + } + + public function restore(BrandingSetting $setting): void + { + if (! $setting->logoIsCropped()) { + return; + } + + $cropped = $setting->logo_path; + + $setting->update([ + 'logo_path' => $setting->logo_original_path, + 'logo_original_path' => null, + 'logo_crop' => null, + ]); + + if ($cropped !== null) { + Storage::disk('public')->delete($cropped); + } + } + + /** + * Every file the logo occupies: the one shown, and the upload behind it. + * + * @return list + */ + public function files(BrandingSetting $setting): array + { + return array_values(array_filter([$setting->logo_path, $setting->logo_original_path])); + } +} diff --git a/app/Modules/Platform/Branding/Models/BrandingSetting.php b/app/Modules/Platform/Branding/Models/BrandingSetting.php index b340ed7d..5f0dd66e 100644 --- a/app/Modules/Platform/Branding/Models/BrandingSetting.php +++ b/app/Modules/Platform/Branding/Models/BrandingSetting.php @@ -14,6 +14,8 @@ use App\Modules\Platform\Branding\Watermark\WatermarkPosition; * * @property int $id * @property string|null $logo_path + * @property string|null $logo_original_path + * @property array{x: int, y: int, width: int, height: int}|null $logo_crop * @property bool $watermark_enabled * @property string|null $watermark_path * @property WatermarkPosition $watermark_position @@ -33,6 +35,7 @@ class BrandingSetting extends Model protected $casts = [ 'watermark_enabled' => 'boolean', 'show_site_name' => 'boolean', + 'logo_crop' => 'array', 'watermark_position' => WatermarkPosition::class, 'watermark_size' => 'integer', 'watermark_opacity' => 'integer', @@ -65,6 +68,28 @@ class BrandingSetting extends Model return $this->logo_path === null ? null : Storage::disk('public')->url($this->logo_path); } + /** + * The file the logo was cut from: the upload itself when it was never + * cropped, since then `logo_path` is that upload. A crop always starts + * from here, never from a previous crop. + */ + public function logoSourcePath(): ?string + { + return $this->logo_original_path ?? $this->logo_path; + } + + public function logoSourceUrl(): ?string + { + $path = $this->logoSourcePath(); + + return $path === null ? null : Storage::disk('public')->url($path); + } + + public function logoIsCropped(): bool + { + return $this->logo_original_path !== null; + } + public function watermarkUrl(): ?string { return $this->watermark_path === null ? null : Storage::disk('public')->url($this->watermark_path); diff --git a/database/migrations/2026_10_03_090000_add_logo_crop_to_branding_settings.php b/database/migrations/2026_10_03_090000_add_logo_crop_to_branding_settings.php new file mode 100644 index 00000000..92752780 --- /dev/null +++ b/database/migrations/2026_10_03_090000_add_logo_crop_to_branding_settings.php @@ -0,0 +1,34 @@ +string('logo_original_path')->nullable()->after('logo_path'); + // The box that crop was cut with, in the original's pixels: + // {x, y, width, height}. So the cropper reopens where it was. + $table->json('logo_crop')->nullable()->after('logo_original_path'); + }); + } + + public function down(): void + { + Schema::table('branding_settings', function (Blueprint $table) { + $table->dropColumn(['logo_original_path', 'logo_crop']); + }); + } +}; diff --git a/package-lock.json b/package-lock.json index cbf5aba0..22bc0323 100644 --- a/package-lock.json +++ b/package-lock.json @@ -47,6 +47,7 @@ "lucide-react": "^0.475.0", "react": "^19.0.0", "react-dom": "^19.0.0", + "react-image-crop": "^11.1.2", "recharts": "^3.10.1", "tailwind-merge": "^3.0.1", "tailwindcss": "^4.0.0", @@ -7278,6 +7279,15 @@ "react": "^19.0.0" } }, + "node_modules/react-image-crop": { + "version": "11.1.2", + "resolved": "https://registry.npmjs.org/react-image-crop/-/react-image-crop-11.1.2.tgz", + "integrity": "sha512-+0Pc2fxpwKL4u4oLmdKBw8XSwUceFbXbKEHvFOlsl/MGB1OVNic4uBlAPmEHGXYgoJIq+b63xHbc/aJMG0AVkA==", + "license": "ISC", + "peerDependencies": { + "react": ">=16.13.1" + } + }, "node_modules/react-is": { "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", diff --git a/package.json b/package.json index bcc5171b..9db893bd 100644 --- a/package.json +++ b/package.json @@ -65,6 +65,7 @@ "lucide-react": "^0.475.0", "react": "^19.0.0", "react-dom": "^19.0.0", + "react-image-crop": "^11.1.2", "recharts": "^3.10.1", "tailwind-merge": "^3.0.1", "tailwindcss": "^4.0.0", diff --git a/resources/js/components/branding/logo-crop-dialog.tsx b/resources/js/components/branding/logo-crop-dialog.tsx new file mode 100644 index 00000000..f37a4603 --- /dev/null +++ b/resources/js/components/branding/logo-crop-dialog.tsx @@ -0,0 +1,154 @@ +import { router } from '@inertiajs/react'; +import { useState } from 'react'; +import ReactCrop, { type PercentCrop } from 'react-image-crop'; +import 'react-image-crop/dist/ReactCrop.css'; + +import InputError from '@/components/input-error'; +import { Button } from '@/components/ui/button'; +import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'; +import { useTranslation } from '@/hooks/use-translation'; + +export interface LogoCropBox { + x: number; + y: number; + width: number; + height: number; +} + +interface LogoCropDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + /** The uploaded image, never a previous crop of it. */ + sourceUrl: string; + /** The last crop, in the upload's pixels, or null to start from the whole image. */ + savedCrop: LogoCropBox | null; +} + +const WHOLE: PercentCrop = { unit: '%', x: 0, y: 0, width: 100, height: 100 }; + +/** + * Draw a box on the uploaded logo and keep only that part of it. + * + * The box is held in percent while it is being drawn, so it survives the + * dialog resizing, and turned into the upload's own pixels only when it is + * saved. The server cuts the new file from the upload: nothing is cropped + * in the browser. + * + * `image-orientation: none` shows the stored pixels exactly as the server + * reads them. A browser would otherwise rotate a phone photo by its + * orientation tag, the server cannot, and the box would land on the wrong + * part of the picture. + */ +export default function LogoCropDialog({ open, onOpenChange, sourceUrl, savedCrop }: LogoCropDialogProps) { + const { t } = useTranslation(); + const [crop, setCrop] = useState(WHOLE); + const [natural, setNatural] = useState<{ width: number; height: number } | null>(null); + const [error, setError] = useState(); + const [saving, setSaving] = useState(false); + + const onImageLoad = (image: HTMLImageElement) => { + const width = image.naturalWidth; + const height = image.naturalHeight; + + setNatural({ width, height }); + setError(undefined); + setCrop( + savedCrop === null + ? WHOLE + : { + unit: '%', + x: (savedCrop.x / width) * 100, + y: (savedCrop.y / height) * 100, + width: (savedCrop.width / width) * 100, + height: (savedCrop.height / height) * 100, + }, + ); + }; + + const toPixels = (box: PercentCrop, size: { width: number; height: number }): LogoCropBox => { + const x = Math.max(0, Math.round((box.x / 100) * size.width)); + const y = Math.max(0, Math.round((box.y / 100) * size.height)); + + return { + x, + y, + width: Math.max(1, Math.min(size.width - x, Math.round((box.width / 100) * size.width))), + height: Math.max(1, Math.min(size.height - y, Math.round((box.height / 100) * size.height))), + }; + }; + + const save = () => { + if (natural === null || crop.width === 0 || crop.height === 0) { + return; + } + + setSaving(true); + + router.patch( + route('branding.logo.crop'), + { ...toPixels(crop, natural) }, + { + preserveScroll: true, + onSuccess: () => onOpenChange(false), + onError: (errors) => setError(errors.logo ?? errors.width ?? errors.x ?? Object.values(errors)[0]), + onFinish: () => setSaving(false), + }, + ); + }; + + return ( + + + + {t('Crop logo')} + + {t( + 'Drag the box and its corners to choose the part of the image to show. The uploaded image is kept, so you can change this later.', + )} + + + +
+ {/* The height limit goes on the crop wrapper: the library's + stylesheet gives the image `max-height: inherit`, so a + limit on the image itself is overridden, and a tall logo + would push the bottom handles out of reach. */} + setCrop(percent)} + keepSelection + minWidth={8} + minHeight={8} + style={{ maxHeight: '56vh' }} + > + onImageLoad(e.currentTarget)} style={{ imageOrientation: 'none' }} /> + +
+ + {natural !== null && ( +

+ {(() => { + const box = toPixels(crop, natural); + + return t(':width × :height pixels', { width: String(box.width), height: String(box.height) }); + })()} +

+ )} + + + + + + + + +
+
+ ); +} diff --git a/resources/js/pages/branding/edit.tsx b/resources/js/pages/branding/edit.tsx index 27f381ae..e2928dcc 100644 --- a/resources/js/pages/branding/edit.tsx +++ b/resources/js/pages/branding/edit.tsx @@ -2,6 +2,7 @@ import { type BreadcrumbItem, type SharedData } from '@/types'; import { Head, useForm, usePage } from '@inertiajs/react'; import { FormEventHandler, useEffect, useRef, useState } from 'react'; +import LogoCropDialog, { type LogoCropBox } from '@/components/branding/logo-crop-dialog'; import Heading from '@/components/heading'; import InputError from '@/components/input-error'; import { Button } from '@/components/ui/button'; @@ -22,6 +23,9 @@ interface Watermark { interface BrandingEditProps { logo_url: string | null; + logo_source_url: string | null; + logo_crop: LogoCropBox | null; + logo_cropped: boolean; hide_attribution: boolean; show_site_name: boolean; watermark: Watermark; @@ -30,7 +34,7 @@ interface BrandingEditProps { type Tab = 'logo' | 'watermark' | 'attribution'; -export default function BrandingEdit({ logo_url, hide_attribution, show_site_name, watermark, watermark_positions }: BrandingEditProps) { +export default function BrandingEdit({ logo_url, logo_source_url, logo_crop, logo_cropped, hide_attribution, show_site_name, watermark, watermark_positions }: BrandingEditProps) { const { t } = useTranslation(); const { capabilities } = usePage().props; const fileInputRef = useRef(null); @@ -135,6 +139,14 @@ export default function BrandingEdit({ logo_url, hide_attribution, show_site_nam removeForm.delete(route('branding.destroy'), { preserveScroll: true, preserveState: true }); }; + // Cropping is optional: an upload is used whole until somebody crops it. + const [cropping, setCropping] = useState(false); + const restoreForm = useForm({}); + + const restore = () => { + restoreForm.delete(route('branding.logo.restore'), { preserveScroll: true, preserveState: true }); + }; + const submitWatermark: FormEventHandler = (e) => { e.preventDefault(); watermarkForm.post(route('branding.watermark.update'), { @@ -212,10 +224,31 @@ export default function BrandingEdit({ logo_url, hide_attribution, show_site_nam - {logo_url && ( - + {logo_url && logo_source_url && ( +
+ + {logo_cropped && ( + + )} + +
+ )} + + {logo_source_url && ( + // Keyed by the upload so a new logo opens on a fresh box. + )}
diff --git a/routes/settings.php b/routes/settings.php index 1f1a0dd4..190342cb 100644 --- a/routes/settings.php +++ b/routes/settings.php @@ -292,6 +292,8 @@ Route::middleware('auth')->group(function () { Route::post('system/settings/branding', [BrandingController::class, 'store'])->name('branding.store'); Route::delete('system/settings/branding', [BrandingController::class, 'destroy'])->name('branding.destroy'); Route::patch('system/settings/branding/site-name', [BrandingController::class, 'updateSiteName'])->name('branding.site-name.update'); + Route::patch('system/settings/branding/logo/crop', [BrandingController::class, 'cropLogo'])->name('branding.logo.crop'); + Route::delete('system/settings/branding/logo/crop', [BrandingController::class, 'restoreLogo'])->name('branding.logo.restore'); // POST rather than PATCH: the form carries a file, so it is // multipart, and PHP only populates $_FILES for POST. diff --git a/tests/Feature/Branding/LogoCropTest.php b/tests/Feature/Branding/LogoCropTest.php new file mode 100644 index 00000000..78ed7adc --- /dev/null +++ b/tests/Feature/Branding/LogoCropTest.php @@ -0,0 +1,234 @@ +actingAs(staffWithPermissions(['edit_settings'])); +}); + +/** + * A 200 × 100 image, red on the left half and blue on the right, so a test + * can tell which part of it a crop actually kept. + */ +function twoColourLogo(string $format = 'png'): UploadedFile +{ + $image = imagecreatetruecolor(200, 100); + imagefilledrectangle($image, 0, 0, 99, 99, imagecolorallocate($image, 255, 0, 0)); + imagefilledrectangle($image, 100, 0, 199, 99, imagecolorallocate($image, 0, 0, 255)); + + $temp = tempnam(sys_get_temp_dir(), 'logo'); + $format === 'jpg' ? imagejpeg($image, $temp, 100) : imagepng($image, $temp); + + return new UploadedFile($temp, "logo.{$format}", $format === 'jpg' ? 'image/jpeg' : 'image/png', null, true); +} + +/** The colour of one pixel of a stored logo, as [r, g, b]. */ +function logoPixel(string $path, int $x, int $y): array +{ + $image = imagecreatefromstring(Storage::disk('public')->get($path)); + $rgb = imagecolorsforindex($image, imagecolorat($image, $x, $y)); + + return [$rgb['red'], $rgb['green'], $rgb['blue']]; +} + +function uploadTwoColourLogo(string $format = 'png'): string +{ + test()->post(route('branding.store'), ['logo' => twoColourLogo($format)])->assertRedirect(); + + return BrandingSetting::query()->sole()->logo_path; +} + +test('an uploaded logo is used whole until somebody crops it', function () { + $upload = uploadTwoColourLogo(); + + $setting = BrandingSetting::query()->sole(); + + expect($setting->logo_original_path)->toBeNull() + ->and($setting->logo_crop)->toBeNull() + ->and($setting->logoSourcePath())->toBe($upload); +}); + +test('cropping keeps the part of the picture the box was drawn on', function () { + $upload = uploadTwoColourLogo(); + + $this->patch(route('branding.logo.crop'), ['x' => 100, 'y' => 0, 'width' => 100, 'height' => 100]) + ->assertRedirect() + ->assertSessionHasNoErrors(); + + $setting = BrandingSetting::query()->sole(); + $size = getimagesizefromstring(Storage::disk('public')->get($setting->logo_path)); + + expect($setting->logo_path)->not->toBe($upload) + ->and($setting->logo_original_path)->toBe($upload) + ->and($setting->logo_crop)->toBe(['x' => 100, 'y' => 0, 'width' => 100, 'height' => 100]) + ->and([$size[0], $size[1]])->toBe([100, 100]) + ->and(logoPixel($setting->logo_path, 5, 50))->toBe([0, 0, 255]) + ->and(logoPixel($setting->logo_path, 95, 50))->toBe([0, 0, 255]); + + // The upload itself is untouched. + Storage::disk('public')->assertExists($upload); + expect(logoPixel($upload, 5, 50))->toBe([255, 0, 0]); +}); + +test('cropping again starts from the upload, not from the last crop', function () { + $upload = uploadTwoColourLogo(); + + $this->patch(route('branding.logo.crop'), ['x' => 100, 'y' => 0, 'width' => 100, 'height' => 100]); + $firstCrop = BrandingSetting::query()->sole()->logo_path; + + // The red half is not in the first crop at all; it is in the upload. + $this->patch(route('branding.logo.crop'), ['x' => 0, 'y' => 0, 'width' => 50, 'height' => 50])->assertSessionHasNoErrors(); + + $setting = BrandingSetting::query()->sole(); + + expect($setting->logo_original_path)->toBe($upload) + ->and(logoPixel($setting->logo_path, 10, 10))->toBe([255, 0, 0]); + + Storage::disk('public')->assertMissing($firstCrop); +}); + +test('restoring puts the upload back and deletes the crop', function () { + $upload = uploadTwoColourLogo(); + $this->patch(route('branding.logo.crop'), ['x' => 100, 'y' => 0, 'width' => 100, 'height' => 100]); + $crop = BrandingSetting::query()->sole()->logo_path; + + $this->delete(route('branding.logo.restore'))->assertRedirect(); + + $setting = BrandingSetting::query()->sole(); + + expect($setting->logo_path)->toBe($upload) + ->and($setting->logo_original_path)->toBeNull() + ->and($setting->logo_crop)->toBeNull(); + + Storage::disk('public')->assertMissing($crop); + Storage::disk('public')->assertExists($upload); +}); + +test('a box covering the whole picture is the same as restoring', function () { + $upload = uploadTwoColourLogo(); + $this->patch(route('branding.logo.crop'), ['x' => 100, 'y' => 0, 'width' => 100, 'height' => 100]); + + $this->patch(route('branding.logo.crop'), ['x' => 0, 'y' => 0, 'width' => 200, 'height' => 100])->assertSessionHasNoErrors(); + + expect(BrandingSetting::query()->sole()->logo_path)->toBe($upload) + ->and(Storage::disk('public')->allFiles('branding'))->toBe([$upload]); +}); + +test('a box reaching outside the picture is refused and changes nothing', function () { + $upload = uploadTwoColourLogo(); + + $this->patch(route('branding.logo.crop'), ['x' => 150, 'y' => 0, 'width' => 100, 'height' => 100]) + ->assertSessionHasErrors('width'); + + $this->patch(route('branding.logo.crop'), ['x' => -1, 'y' => 0, 'width' => 10, 'height' => 10]) + ->assertSessionHasErrors('x'); + + expect(BrandingSetting::query()->sole()->logo_path)->toBe($upload) + ->and(Storage::disk('public')->allFiles('branding'))->toBe([$upload]); +}); + +test('there is nothing to crop before a logo is uploaded', function () { + $this->patch(route('branding.logo.crop'), ['x' => 0, 'y' => 0, 'width' => 10, 'height' => 10]) + ->assertSessionHasErrors('logo'); +}); + +test('a crop keeps the format of the upload', function () { + uploadTwoColourLogo('jpg'); + + $this->patch(route('branding.logo.crop'), ['x' => 0, 'y' => 0, 'width' => 100, 'height' => 100])->assertSessionHasNoErrors(); + + $path = BrandingSetting::query()->sole()->logo_path; + + expect(pathinfo($path, PATHINFO_EXTENSION))->toBe('jpg') + ->and(getimagesizefromstring(Storage::disk('public')->get($path))['mime'])->toBe('image/jpeg'); +}); + +/* + * A small file can declare a huge picture. The size is read from the header + * and refused before GD is asked to hold the pixels. + */ +test('an image too large to hold in memory is refused before it is decoded', function () { + $ihdr = pack('NNCCCCC', 6000, 5000, 8, 2, 0, 0, 0); + $png = "\x89PNG\r\n\x1a\n" + .pack('N', 13).'IHDR'.$ihdr.pack('N', crc32('IHDR'.$ihdr)) + .pack('N', 0).'IEND'.pack('N', crc32('IEND')); + + Storage::disk('public')->put('branding/huge.png', $png); + BrandingSetting::current()->update(['logo_path' => 'branding/huge.png']); + + $this->patch(route('branding.logo.crop'), ['x' => 0, 'y' => 0, 'width' => 100, 'height' => 100]) + ->assertSessionHasErrors('logo'); + + expect(BrandingSetting::query()->sole()->logo_original_path)->toBeNull(); +}); + +test('a new upload after a crop deletes both files and starts uncropped', function () { + $upload = uploadTwoColourLogo(); + $this->patch(route('branding.logo.crop'), ['x' => 100, 'y' => 0, 'width' => 100, 'height' => 100]); + $crop = BrandingSetting::query()->sole()->logo_path; + + $this->post(route('branding.store'), ['logo' => UploadedFile::fake()->image('new.png', 80, 80)]); + + $setting = BrandingSetting::query()->sole(); + + expect($setting->logo_original_path)->toBeNull() + ->and($setting->logo_crop)->toBeNull() + ->and(Storage::disk('public')->allFiles('branding'))->toBe([$setting->logo_path]); + + Storage::disk('public')->assertMissing($upload); + Storage::disk('public')->assertMissing($crop); +}); + +test('removing a cropped logo deletes both files', function () { + uploadTwoColourLogo(); + $this->patch(route('branding.logo.crop'), ['x' => 100, 'y' => 0, 'width' => 100, 'height' => 100]); + + $this->delete(route('branding.destroy'))->assertRedirect(); + + $setting = BrandingSetting::query()->sole(); + + expect($setting->logo_path)->toBeNull() + ->and($setting->logo_original_path)->toBeNull() + ->and(Storage::disk('public')->allFiles('branding'))->toBe([]); +}); + +test('the screen opens the cropper on the upload, with the last box drawn', function () { + $upload = uploadTwoColourLogo(); + $this->patch(route('branding.logo.crop'), ['x' => 100, 'y' => 0, 'width' => 100, 'height' => 100]); + + $this->get(route('branding.edit')) + ->assertOk() + ->assertInertia(fn (AssertableInertia $page) => $page + ->where('logo_source_url', Storage::disk('public')->url($upload)) + ->where('logo_crop', ['x' => 100, 'y' => 0, 'width' => 100, 'height' => 100]) + ->where('logo_cropped', true)); +}); + +test('cropping and restoring need what the rest of the screen needs', function () { + uploadTwoColourLogo(); + + $this->actingAs(staffWithPermissions([])); + $this->patch(route('branding.logo.crop'), ['x' => 0, 'y' => 0, 'width' => 10, 'height' => 10])->assertForbidden(); + $this->delete(route('branding.logo.restore'))->assertForbidden(); + + expect(BrandingSetting::query()->sole()->logo_original_path)->toBeNull(); +}); + +test('cropping and restoring 404 when the capability has been taken away', function () { + // Before any request: the capability set is worked out once, on the + // first one, as the other branding tests rely on. + config(['projectsend.capabilities_disabled' => 'branding.customize']); + Storage::disk('public')->put('branding/logo.png', twoColourLogo()->getContent()); + BrandingSetting::current()->update(['logo_path' => 'branding/logo.png']); + + $this->patch(route('branding.logo.crop'), ['x' => 0, 'y' => 0, 'width' => 10, 'height' => 10])->assertNotFound(); + $this->delete(route('branding.logo.restore'))->assertNotFound(); + + expect(BrandingSetting::query()->sole()->logo_original_path)->toBeNull(); +});