From 7afcbe0e63057fbed3dda521f9b24308f6f5e224 Mon Sep 17 00:00:00 2001 From: Geni Jaho Date: Wed, 10 Jan 2024 23:02:52 +0100 Subject: [PATCH] GPS (#29) * wip * Refactoring --- .../Photos/ExtractLocationFromPhotoAction.php | 85 ++++++++++++ .../Photos/ExtractsLocationFromPhoto.php | 10 ++ .../Controllers/UploadPhotosController.php | 13 +- app/Providers/AppServiceProvider.php | 6 + composer.json | 1 + composer.lock | 121 +++++++++++++++++- ..._10_143605_add_location_info_to_photos.php | 16 +++ storage/app/photo-with-gps.jpg | Bin 0 -> 905 bytes .../FakeExtractLocationFromPhotoAction.php | 14 ++ tests/Feature/Photos/UploadPhotosTest.php | 23 ++++ tests/TestCase.php | 4 + 11 files changed, 289 insertions(+), 4 deletions(-) create mode 100644 app/Actions/Photos/ExtractLocationFromPhotoAction.php create mode 100644 app/Actions/Photos/ExtractsLocationFromPhoto.php create mode 100644 database/migrations/2024_01_10_143605_add_location_info_to_photos.php create mode 100644 storage/app/photo-with-gps.jpg create mode 100644 tests/Doubles/FakeExtractLocationFromPhotoAction.php diff --git a/app/Actions/Photos/ExtractLocationFromPhotoAction.php b/app/Actions/Photos/ExtractLocationFromPhotoAction.php new file mode 100644 index 00000000..305a2ac4 --- /dev/null +++ b/app/Actions/Photos/ExtractLocationFromPhotoAction.php @@ -0,0 +1,85 @@ +read($photo); + $exif = $image->exif('GPS'); + + if ($this->isExifInvalid($exif)) { + return []; + } + + $result = $this->convertArrayToLatLng((array) $exif); + + if ($result['latitude'] === 0.0 && $result['longitude'] === 0.0) { + return []; + } + + return $result; + } + + private function convertArrayToLatLng(array $exif): array + { + $GPSLatitudeRef = $exif['GPSLatitudeRef']; + $GPSLatitude = $exif['GPSLatitude']; + $GPSLongitudeRef = $exif['GPSLongitudeRef']; + $GPSLongitude = $exif['GPSLongitude']; + + $latDegrees = $this->gpsToNumeric($GPSLatitude[0] ?? null); + $latMinutes = $this->gpsToNumeric($GPSLatitude[1] ?? null); + $latSeconds = $this->gpsToNumeric($GPSLatitude[2] ?? null); + + $lonDegrees = $this->gpsToNumeric($GPSLongitude[0] ?? null); + $lonMinutes = $this->gpsToNumeric($GPSLongitude[1] ?? null); + $lonSeconds = $this->gpsToNumeric($GPSLongitude[2] ?? null); + + $latDirection = ($GPSLatitudeRef === 'W' || $GPSLatitudeRef === 'S') ? -1 : 1; + $lonDirection = ($GPSLongitudeRef === 'W' || $GPSLongitudeRef === 'S') ? -1 : 1; + + $latitude = $latDirection * ($latDegrees + ($latMinutes / 60) + ($latSeconds / (60 * 60))); + $longitude = $lonDirection * ($lonDegrees + ($lonMinutes / 60) + ($lonSeconds / (60 * 60))); + + return ['latitude' => $latitude, 'longitude' => $longitude]; + } + + private function gpsToNumeric(?string $coordinates): float + { + if (! $coordinates) { + return 0.0; + } + + $parts = explode('/', $coordinates); + + if ($parts === []) { + return 0.0; + } + + if (count($parts) === 1) { + return (float) $parts[0]; + } + + if ((float) $parts[1] === 0.0) { + return 0.0; + } + + return (float) $parts[0] / (float) $parts[1]; + } + + private function isExifInvalid(mixed $exif): bool + { + return ! $exif || + ! isset($exif['GPSLatitudeRef']) || + ! isset($exif['GPSLatitude']) || + ! isset($exif['GPSLongitudeRef']) || + ! isset($exif['GPSLongitude']); + } +} diff --git a/app/Actions/Photos/ExtractsLocationFromPhoto.php b/app/Actions/Photos/ExtractsLocationFromPhoto.php new file mode 100644 index 00000000..7b6f03cd --- /dev/null +++ b/app/Actions/Photos/ExtractsLocationFromPhoto.php @@ -0,0 +1,10 @@ +user(); $photo = $request->file('photo'); + $location = $extractLocation->run($photo); + $originalFileName = $photo->getClientOriginalName(); Photo::create([ - 'path' => $photo->storeAs('photos', $originalFileName, 'public'), 'user_id' => $user->id, + 'path' => $photo->storeAs('photos', $originalFileName, 'public'), + 'latitude' => $location['latitude'] ?? null, + 'longitude' => $location['longitude'] ?? null, ]); return []; diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 70f1a1f5..6ba685ec 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -2,6 +2,8 @@ namespace App\Providers; +use App\Actions\Photos\ExtractLocationFromPhotoAction; +use App\Actions\Photos\ExtractsLocationFromPhoto; use Illuminate\Database\Eloquent\Model; use Illuminate\Support\Facades\Schema; use Illuminate\Support\ServiceProvider; @@ -24,5 +26,9 @@ public function boot(): void Schema::defaultStringLength(191); Model::unguard(); + + Model::shouldBeStrict(); + + $this->app->bind(ExtractsLocationFromPhoto::class, ExtractLocationFromPhotoAction::class); } } diff --git a/composer.json b/composer.json index 38b0f728..f2284563 100644 --- a/composer.json +++ b/composer.json @@ -12,6 +12,7 @@ "filament/filament": "^3.1", "guzzlehttp/guzzle": "^7.2", "inertiajs/inertia-laravel": "^0.6.8", + "intervention/image": "^3.2", "laravel/framework": "^10.10", "laravel/jetstream": "^3.2", "laravel/sanctum": "^3.2", diff --git a/composer.lock b/composer.lock index e046b6ce..b1ee3a6c 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "65612e8eba481db8755f1da408756b1f", + "content-hash": "f4e0e6dfb0872be50d412553c0db76b3", "packages": [ { "name": "amphp/amp", @@ -2800,6 +2800,125 @@ ], "time": "2023-10-27T10:59:02+00:00" }, + { + "name": "intervention/gif", + "version": "3.0.0", + "source": { + "type": "git", + "url": "https://github.com/Intervention/gif.git", + "reference": "cfececc760862f075a52acf747031bad08c8301b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Intervention/gif/zipball/cfececc760862f075a52acf747031bad08c8301b", + "reference": "cfececc760862f075a52acf747031bad08c8301b", + "shasum": "" + }, + "require": { + "php": "^8.0" + }, + "require-dev": { + "phpstan/phpstan": "^1", + "phpunit/phpunit": "^9" + }, + "type": "library", + "autoload": { + "psr-4": { + "Intervention\\Gif\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Oliver Vogel", + "email": "oliver@intervention.io", + "homepage": "https://intervention.io/" + } + ], + "description": "Native PHP GIF Encoder/Decoder", + "homepage": "https://github.com/intervention/gif", + "keywords": [ + "animation", + "gd", + "gif", + "image" + ], + "support": { + "issues": "https://github.com/Intervention/gif/issues", + "source": "https://github.com/Intervention/gif/tree/3.0.0" + }, + "time": "2023-11-27T18:54:30+00:00" + }, + { + "name": "intervention/image", + "version": "3.2.1", + "source": { + "type": "git", + "url": "https://github.com/Intervention/image.git", + "reference": "3f96a8f752f4db17cde0a152891bc332ea9aa28d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Intervention/image/zipball/3f96a8f752f4db17cde0a152891bc332ea9aa28d", + "reference": "3f96a8f752f4db17cde0a152891bc332ea9aa28d", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "intervention/gif": "^3", + "php": "^8.1" + }, + "require-dev": { + "mockery/mockery": "^1.6", + "phpstan/phpstan": "^1", + "phpunit/phpunit": "^9" + }, + "type": "library", + "autoload": { + "psr-4": { + "Intervention\\Image\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Oliver Vogel", + "email": "oliver@intervention.io", + "homepage": "https://intervention.io/" + } + ], + "description": "PHP image manipulation", + "homepage": "https://image.intervention.io/", + "keywords": [ + "gd", + "image", + "imagick", + "resize", + "thumbnail", + "watermark" + ], + "support": { + "issues": "https://github.com/Intervention/image/issues", + "source": "https://github.com/Intervention/image/tree/3.2.1" + }, + "funding": [ + { + "url": "https://paypal.me/interventionio", + "type": "custom" + }, + { + "url": "https://github.com/Intervention", + "type": "github" + } + ], + "time": "2023-12-30T20:10:28+00:00" + }, { "name": "jaybizzle/crawler-detect", "version": "v1.2.116", diff --git a/database/migrations/2024_01_10_143605_add_location_info_to_photos.php b/database/migrations/2024_01_10_143605_add_location_info_to_photos.php new file mode 100644 index 00000000..aa1f4942 --- /dev/null +++ b/database/migrations/2024_01_10_143605_add_location_info_to_photos.php @@ -0,0 +1,16 @@ +decimal('latitude', 10, 8)->after('path')->nullable(); + $table->decimal('longitude', 11, 8)->after('latitude')->nullable(); + }); + } +}; diff --git a/storage/app/photo-with-gps.jpg b/storage/app/photo-with-gps.jpg new file mode 100644 index 0000000000000000000000000000000000000000..024c76e0a2d76d6b7ba5b0716a0c3db24fe35f7c GIT binary patch literal 905 zcmex=v8VeI#KLe8=P?~`WY&uAE z4$wTHxeQFMuS12>oGf>bdEzZnKSMc<5SMUmObyp~}FwoRdC@f9P$tTD zoi-j64Z8S2#W<;`iIYoATtZSxRZU$(Q_IBE%-q7#%Gt%$&E3P(D>x)HEIcAIDmf)J zEj=SMtGJ}Jth}PKs=1}Lt-YhOYtrN?Q>RUzF>}_U#Y>hhTfSoDs!f}>Y~8kf$Ie}c z4j(ys?D&b3r!HN-a`oEv8#iw~eDwIq(`V0LynOZX)8{W=zkUDl^B2fpj10{1Aj9e} zU?4Cuv9K_+u!H=?$W#u*%z`YeiiT`Lj)Clng~CckjT|CQ6Blkg$f;}`^g%SK=pvVx sipfLOk07sseMX$en#l4Q++zrT-D2QjW&}navmk>#!@qwT%>Qo!0KmT5)&Kwi literal 0 HcmV?d00001 diff --git a/tests/Doubles/FakeExtractLocationFromPhotoAction.php b/tests/Doubles/FakeExtractLocationFromPhotoAction.php new file mode 100644 index 00000000..c56f06c4 --- /dev/null +++ b/tests/Doubles/FakeExtractLocationFromPhotoAction.php @@ -0,0 +1,14 @@ +assertExists('photos/photo.jpg'); }); +test('a user can upload photos with location data', function () { + Storage::fake('public'); + $this->swap(ExtractsLocationFromPhoto::class, new ExtractLocationFromPhotoAction()); + $this->actingAs($user = User::factory()->create()); + + $file = UploadedFile::fake()->createWithContent( + 'photo.jpg', + file_get_contents(storage_path('app/photo-with-gps.jpg')), + ); + + $response = $this->post('/upload', ['photo' => $file]); + + $response->assertOk(); + + expect($user->photos()->count())->toBe(1); + + $photo = $user->photos()->first(); + expect($photo->latitude)->toBe(40.053030045789) + ->and($photo->longitude)->toBe(-77.15449870066); +}); + test('a photo can not be larger than 20MB', function () { Storage::fake('public'); $this->actingAs($user = User::factory()->create()); diff --git a/tests/TestCase.php b/tests/TestCase.php index b533c340..c949f834 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -2,7 +2,9 @@ namespace Tests; +use App\Actions\Photos\ExtractsLocationFromPhoto; use Illuminate\Foundation\Testing\TestCase as BaseTestCase; +use Tests\Doubles\FakeExtractLocationFromPhotoAction; abstract class TestCase extends BaseTestCase { @@ -13,5 +15,7 @@ protected function setUp(): void parent::setUp(); $this->withoutVite(); + + $this->swap(ExtractsLocationFromPhoto::class, new FakeExtractLocationFromPhotoAction()); } }